Day 32 / 共 68 天 · 阶段 5 工具与 MCP

多步工具链与失败处理

昨天(Day 31)你懂了 MCP——工具怎么统一接进来。但真实任务很少"一个工具搞定":要先查库、再算数、最后发邮件,还得应付网络抽风、接口报错。今天学怎么把工具串成一条链连续调用、把前一步结果喂给下一步,更重要的是补上转行必备的工程素养:失败重试、降级兜底、设循环上限防止 Agent 无限打转。学完你就跨过了"从能调一个工具到能干成一件事"的分水岭;明天(Day 33)正式进入 Agent 核心概念。

📍 你在阶段 5(工具与 MCP D29-32)的位置
D29 函数调用原理 D30 写安全工具 D31 MCP 入门 D32 多步工具链
💡 用一个类比兜住今天(今天全程沿用「快递接力送包裹」的世界观) 一个包裹从下单到签收要过好几个手:分拣→干线运输→派件小哥。多步工具链 = 每个环节干一段、把包裹交给下一环(前一步的产出是后一步的输入);结果传递 = 上一站的运单信息随包裹一起交到下一站;重试 = 派件没人签收,小哥改天再来一次;降级/兜底 = 电梯坏了就走楼梯、实在送不到就放代收点(次优但不失败);循环上限 = "最多重派 3 次,再不行就退回",绝不让包裹在系统里无限空转。你今天要当的是"物流调度中心"。
L01

一个工具不够用:真实任务是接力

🤔 痛点用户说:"看看北京明天下不下雨,如果下雨就发邮件提醒团队带伞。"这一句话里藏着好几步:查天气 → 判断 → (可能)发邮件。单个工具都做不到,得连着调、还要看情况
💡 本质多步工具链 = 把若干工具按需要连续调用,像快递接力。关键有两点:一是顺序(先查后发,不能乱);二是依赖(要不要发邮件,取决于查天气的结果)。谁来决定顺序和依赖?——大模型自己。它每一轮看着当前情况,决定"下一步调哪个工具、还是收工"。
📝 举个例子:一句话拆成一条链 用户:"帮我查上周订单总额,超过 1 万就在群里发喜报。"
Agent 的链条:①调 query_orders("上周") → 得到 12300;②模型判断 12300 > 10000;③调 send_message("群", "上周破万!") → 完成。
若结果是 8000,链条会在第②步后直接收工,不调发消息。同一套工具,走哪条路由模型临场决定
L02

工具调用循环:Agent 的"心跳"

🤔 痛点Day 29 我们调一次工具就结束了。可上面的任务要调好几次,代码怎么组织?难道写死"先调 A 再调 B"?那换个任务就得重写。
💡 本质不写死顺序,而是套一个循环:反复问模型"下一步干嘛"→ 若它要调工具就执行、把结果塞回去 → 再问 → 直到它说"我不用工具了,这是最终答案"就跳出。这个 while 循环就是所有 Agent 的心脏(Day 38 会亲手写一遍)。
工具调用循环(转到模型说"够了"才停) ① 问模型:下一步? ② 要调工具? 是→执行 / 否→跳出 ③ 执行工具 结果塞回,再问 ④ 输出答案 否→
图注:执行完一个工具不直接结束,而是把结果交回模型"再问一遍",像快递每到一站都回中心销一次单再派下一程。
👶 谁决定"够了没"?模型自己。当它认为信息已经足够回答用户,就不再请求调工具、直接给文字答案,循环随之结束。所以你不用写"该调几次"——你只需保证"它想调时能调到、循环有出口"。
L03

结果如何传递:随包裹一起交到下一站

🤔 痛点第一步查到"余额 12300",第二步发消息怎么知道这个数?模型执行完 A 就"忘了"吗?
💡 本质不会忘——因为每次调完工具,你要把结果作为一条新消息追加进对话历史,再连着历史一起发给模型。模型下一轮看到的是"完整运单":用户原话 + 我调了 A + A 返回了 12300。于是它自然能拿 12300 去发消息。对话历史就是那张随包裹流转的运单
# 极简示意:一轮工具调用后,把结果"贴回"历史,再进入下一轮
messages = [{"role": "user", "content": "查上周订单额,超1万发喜报"}]

# —— 第 1 轮:模型说要调 query_orders ——
# (伪代码,省略真实 SDK 细节,重点看数据怎么流)
result = query_orders("上周")            # 真正执行工具,拿到 12300

# 关键一步:把"工具返回值"作为一条消息追加进历史
messages.append({"role": "tool", "content": f"query_orders 结果: {result}"})
#                          ↑ 'tool' 角色专门放工具结果(Day11 学过 role)

# —— 第 2 轮:带着更新后的 messages 再问模型 ——
# 此时模型能看到 12300,于是决定调 send_message(...),或直接回答
# ...循环继续,直到模型不再要工具
核心记住一句:工具的返回值必须回灌进对话历史,模型才"看得见"。忘了这步,是新手写多步 Agent 最常见的 bug——表现为"它好像调了工具却没用上结果"。
L04

失败重试:派件没成,改天再来

🤔 痛点调外部接口(查库、发邮件)会偶发失败:网络抖一下、对方服务器忙一秒。这种"抖动"往往再试一次就好了,但一失败就整条链崩掉太亏。
💡 本质重试(retry) = 快递员今天没人签收,不是直接退货,而是改天再送几次。做法:失败后等一小会儿再试,试几次都不行才放弃。等待时间常用"越等越久"(1 秒、2 秒、4 秒),叫指数退避——避免大家一起猛敲一个正忙的服务。
import time

def call_with_retry(func, max_tries=3):   # 给任意函数套上"重试外壳"
    for attempt in range(1, max_tries + 1):    # 最多试 max_tries 次
        try:
            return func()                       # 成功就直接返回,结束
        except Exception as e:                  # 抓住任何失败(Day05 的 try/except)
            print(f"第 {attempt} 次失败: {e}")
            if attempt == max_tries:            # 已是最后一次,别再等了
                raise                           # 把错误往外抛,交给上层处理(见 L05)
            time.sleep(2 ** attempt)            # 指数退避:等 2、4 秒……再试

# 用法:把"可能抖动的调用"包起来
data = call_with_retry(lambda: query_orders("上周"))  # lambda 是"临时打包一小段调用"

👶 小白:那是不是所有失败都该重试?

👨‍🏫 老师:只重试"再试可能会好"的错——网络超时、对方 503 繁忙、限流。像"参数写错了""没权限""余额不足"这种确定性错误,重试一百次也一样,只会白白浪费时间和钱,该直接停下报错。分清"暂时性故障"和"根本性错误",是可靠性设计的第一课。

L05

降级与兜底:电梯坏了就走楼梯

🤔 痛点重试几次还是不行怎么办?难道给用户甩一句"系统错误"就完了?那体验太差,也显得不专业。
💡 本质降级(fallback) = 首选方案不行,退而求其次给个次优但可用的结果,而不是彻底失败。快递类比:电梯坏了走楼梯(慢点但送到)、家里没人放代收点(不是原地退回)。对 Agent 而言:实时接口挂了就用缓存的旧数据并声明"数据可能不新";发邮件失败就改发一条站内提醒;都不行,至少返回一句清楚的人话告诉用户发生了什么、他能怎么办。
失败处理三层:重试 → 降级 → 兜底 ① 重试抖一下?再试几次 ② 降级走次优路:用缓存/换渠道 ③ 兜底都不行:说清人话 原则:能降级不失败,要失败也失败得"体面、可解释"
图注:这三层是面试高频考点,也呼应 Day05 说的"try/except 让一个工具挂了不拖垮整个 Agent"。Day 54 会深入"失败闸门与护栏"。
📝 举个例子:降级前后的用户体验对比 不降级:"❌ Error 500"——用户一脸懵。
会降级:"实时汇率接口暂时不可用,已用今早 9 点的缓存汇率(可能略有偏差):6.85。如需最新值请稍后再试。"——虽是次优,但信息完整、用户能决策。这就是"专业 Agent"和"玩具 Demo"的差距。
L06

循环上限:绝不让它无限打转

🤔 痛点L02 的循环靠"模型说够了"才停。可万一模型犯轴,反复调同一个工具、或两个工具来回踢皮球,永远不说停?那就是烧钱又卡死的灾难。
💡 本质给循环加一道硬闸:最多转 N 圈(比如 10)。到了上限还没完成,就强制跳出、走兜底。就像物流规定"一个包裹最多重派 3 次,超了自动退回",绝不允许它在系统里无限空转。这是安全底线,任何上线的 Agent 都必须有
MAX_STEPS = 10                     # 硬上限:最多转 10 圈
messages = [{"role": "user", "content": "用户的任务"}]

for step in range(MAX_STEPS):      # for 天然带上限,转满自动结束
    reply = ask_model(messages)    # 问模型下一步(伪函数)
    if not reply.wants_tool:       # 模型不再要工具 → 完成,正常退出
        print("完成:", reply.text)
        break
    result = run_tool(reply.tool, reply.args)      # 执行它点名的工具
    messages.append({"role": "tool", "content": result})  # 结果回灌历史(L03)
else:
    # 注意:for...else 里的 else 只在"没 break、跑满 10 圈"时执行
    print("已达步数上限,转兜底:把已知信息整理成一句话回给用户")
for...else 是 Python 一个冷门但好用的语法:else 分支只在循环"自然跑完(没被 break)"时执行——正好用来接"转满上限还没完成"这种情况,不用另设计数器判断。看不惯可以先用普通计数,认得出即可。
👶 三条"防失控"红线一起记做多步 Agent,这三样缺一不可:①步数上限(防无限循环);②单步超时(某个工具卡住太久就掐断);③预算上限(累计花的 token/钱到顶就停,Day 13 提过成本)。它们是 Agent 的"三根保险丝",Day 54 会当专题讲。
L07

今日小结 + 动手 10 分钟

🧠 今天你应该能回答

  • 为什么真实任务需要"多步工具链"?顺序和依赖由谁决定?
  • Agent 的"工具调用循环"长什么样?它什么时候停?
  • 工具的返回结果怎么让模型下一轮"看得见"?(回灌进对话历史)
  • 重试适合哪类失败?什么错不该重试?"指数退避"是啥?
  • 降级/兜底是什么?为什么"体面地失败"很重要?
  • 为什么循环必须设上限?三根"保险丝"分别是什么?

✋ 动手 10 分钟:用假工具跑通一条"带上限的链"

不接真 LLM 也能练透今天的骨架——用 input() 假装模型决策,把循环 + 结果回灌 + 上限 + 兜底串起来,存成 chain.py 跑:

MAX_STEPS = 5
history = []                                  # 当作对话历史/运单

def fake_tool(name):                          # 假工具:随便返回点结果
    return f"[{name} 的结果]"

for step in range(MAX_STEPS):
    print(f"\n--- 第 {step+1} 步,历史: {history} ---")
    cmd = input("下一步调哪个工具?(直接回车=完成) ")  # 你来扮演"模型决策"
    if cmd == "":                             # 空 = 模型说"够了"
        print("✅ 完成!")
        break
    result = fake_tool(cmd)                   # 执行工具
    history.append(result)                    # 结果回灌历史(今天的核心!)
else:
    print("⛔ 到达步数上限,走兜底:整理已知信息回复用户")

print("最终掌握的信息:", history)

玩法:先连按几次工具名看历史怎么累积,再故意一直不回车,验证第 5 步会触发上限兜底(而不是无限问下去)。跑一遍,你就把"循环 + 传递 + 上限"焊进肌肉记忆了。

明日预告 · Day 33:到这里,阶段 5(工具与 MCP)收官——你已经能让模型调工具、串多步、扛失败。明天进入阶段 6:Agent 核心,回答那个终极问题:Agent 到底是什么?你会发现,它其实就是今天这个"循环 + 工具 + 会看结果再想"的组合有了名字。我们还会去 Claude Code 源码里看真实的 Agent 主循环长什么样。
← Day 31 · MCP 协议入门 Day 33 · Agent 到底是什么 →