← 返回目录
ap3.7实操⏱ 约 12 分钟

多步工具与控制:让它连着调,但别让它停不下来

实测 4 步累积 2110 输入 token;以及"它这次自己停了"为什么不能当保证

🔎 最后验证 2026-08📚 来源:本节多步循环实测为 ECS 实测(2026-08,deepseek-chat,三组轨迹,脚本与原始输出见本节代码)🧰 Python、DeepSeek API
为什么学这个

上一节模型只调了一次工具。真实场景很少这么简单——用户一句话里常常含着两件事:

"订单 SN20260804555 到哪了?如果还没发货,就直接帮我退了,原因写质量问题。"

这需要:查 → 看结果 → 再决定退不退 → 退 → 汇总回复。模型要连着调,你要接住这个循环。

实测这条请求的完整轨迹:

    第1步 get_order_status  {"order_id": "SN20260804555"}   输入  389 tok  输出  66
    第2步 apply_refund      {"order_id": "…", "reason": "质…  输入  495 tok  输出 102
    第3步 答复                                                输入  645 tok  输出 112

注意输入 token 那一列:389 → 495 → 645。每一步都要把之前所有消息和工具结果重发一遍——多步的成本不是线性的,是累积的。

💡 打个比方

这像让助理去办一件连环事:先去档案室查,查到了再去财务室办。

你必须给他两样东西:一个最多跑几趟的上限(否则他可能一直在两个房间之间来回),和一个跑不通时该怎么回来交代的规矩(否则他会自己编一个结果)。

循环的骨架:30 行

msgs = [{"role": "system", "content": SYS}, {"role": "user", "content": user_msg}]

for step in range(MAX_STEPS):                       # ← 上限,不能省
    r = call(msgs, tools=TOOLS)
    msg = r["choices"][0]["message"]
    tc = msg.get("tool_calls")

    if not tc:                                      # 没有工具调用 = 它要给最终答复了
        return msg["content"]

    msgs.append({"role": "assistant", "content": msg.get("content") or "",
                 "tool_calls": tc})                 # ← 助手这一轮要原样放回去
    for t in tc:
        args = json.loads(t["function"]["arguments"])
        result = execute(t["function"]["name"], args)          # 你执行
        msgs.append({"role": "tool", "tool_call_id": t["id"],  # ← id 必须对上
                     "content": result})

return "(达到 %d 步上限,强制停止)" % MAX_STEPS

三个容易写错的地方:

  1. 助手带 tool_calls 的那条消息要原样放回 msgs——漏了它,模型会不知道自己刚才调过什么;
  2. tool_call_id 必须对上——一轮里可能有多个调用,对不上就串了;
  3. 循环出口有两个:模型不再调工具(正常),或撞上步数上限(异常)——两个出口的处理必须都写

实验二:它这次自己停了,但你不能赌

我造了一个永远返回"未找到,请确认订单号后重试" 的工具,并且在用户提示里明确说"查不到就多试几次,一定要查到":

    第1步 get_order_status  {"order_id": "SN99999999999"}   输入  388 tok
    第2步 get_order_status  {"order_id": "SN99999999999"}   输入  486 tok
    第3步 get_order_status  {"order_id": "SN99999999999"}   输入  575 tok
    第4步 答复                                                输入  661 tok

  工具被调用了 3 次;是否撞上 8 步上限:否 —— 它自己停了
  最终答复:我已经多次尝试查询订单 SN99999999999 的状态,但系统始终返回"未找到该订单"…

它试了 3 次就放弃并如实说明了——这是好消息,而且比我预期的好。

但它仍然只是倾向,不是机制(和 ap3.6 的结论一模一样)。而且代价已经发生:4 步下来输入 token 累积到 2110,而同样一个查询单步只要 377。

所以 MAX_STEPS 是必须的兜底,不是可选的优化。参数怎么定:

场景建议上限
单一工具的问答2-3(一次调用 + 一次答复,留一步余量)
多工具编排(查→算→写)5-8
探索型任务10 以上要配成本上限而不是步数上限(ap6.4 的熔断)

更实用的一条:除了步数,同一个工具用同样的参数连续调用 2 次以上,直接掐断——这几乎总是死循环的前兆,而且检测起来只要三行代码。

实验三:工具报错时,它编不编

  工具返回:{"error": "DB_CONNECTION_FAILED", "message": "数据库连接超时"}

  最终答复:抱歉,我暂时无法查询到订单 SN20260804555 的状态信息。
            系统在查询时出现了数据库连接超时的问题…请您稍等片刻后再次尝试查询…

  → 是否编造了订单状态:否 ✅ —— 如实转达了失败

这次它表现很好。但要注意它为什么能表现好:因为我返回的是明确的 error 字段

三条工具返回值的规矩(工具返回值也是"资料",AP4 的规矩在这里同样成立):

情况该返回什么别返回什么
查到了结构化数据
没查到{"found": false, "reason": "订单号不存在"}空数组 / null——模型会以为"这是个空结果"然后自由发挥
出错了{"error": "DB_TIMEOUT", "message": "..."}抛异常让循环崩掉,或返回一个假的默认值
权限不足{"error": "FORBIDDEN"}假装查不到(会让模型继续换参数重试)

"没查到"和"出错了"要分开——它们对应的用户话术完全不同:前者是"没有这个订单,请确认订单号",后者是"系统繁忙,请稍后再试"。混在一起,用户会收到误导性的答复。

多步的三笔账

① 成本账:实验一的 3 步花了 0.00094 元,而实验三里同样开头的单步查询(2 步)只要 0.00066 元。每加一步,前面所有内容都要重发一遍——这和 ap7.2 的多轮对话是同一个机制,前缀缓存在这里同样是主要的省钱手段(系统提示和工具声明固定不变,新内容只追加在尾部)。

② 延迟账:实验一 5.7 秒、实验二 7.0 秒——用户要盯着转圈等这么久。做法:把每一步的进展流式告诉用户("正在查询订单…"→"正在发起退款…"),ap7.3 的空窗期反馈在这里尤其重要。

③ 可观测账:多步失败时,"哪一步错了"是唯一有用的信息。把整条轨迹记进 ap1.6 的流水:

{"request_id": "req_x", "step": 2, "tool": "apply_refund",
 "args_hash": "…", "ok": True, "latency_ms": 120, "tokens_in": 495}

request_id 能把一条轨迹串起来——没有这个,多步系统的排查基本靠猜。

🔧 动手做:把循环接起来(20 分钟)

  1. 跑本节代码,看三条轨迹的步数和 token 累积,尤其是实验二那 4 步。
  2. 把上一节的单次调用改成循环,MAX_STEPS 先设 5。
  3. 加两道刹车:步数上限 + "同工具同参数连续 2 次即掐断"
  4. 规范工具返回值:区分「查到」「没查到」「出错」「无权限」四种,别用空值表示失败
  5. 加进展反馈:每一步开始时给前端推一句"正在做什么"(ap7.3)。
  6. 记轨迹:每一步一条流水,用 request_id 串起来(ap1.6 / ap7.4)。
  7. 测三种坏情况:工具超时、工具返回错误、用户要求"一直试到成功"——看你的循环会不会失控。

做到这里你应该有:一个带双重刹车的工具循环 + 四类规范化的工具返回值 + 进展反馈 + 可按 request_id 串起来的轨迹流水。

为什么:🎉 AP3 到这里完整了——从"能解析的 JSON"(3.1)、"能自动修复的输出"(3.2)、"能入库的数据"(3.3)、"会说不确定"(3.4)、"能被字段级评测"(3.5),到"能调工具"(3.6)和"能连着调"(3.7)。

这七节合起来解决的是同一件事:让模型的输出从「一段文字」变成「系统能接住的东西」。而多步循环是其中最容易失控的一环——因为它同时放大了成本、延迟和风险。两道刹车、四类返回值、一条轨迹,是它能安全上线的最低配置。

下一章开始给模型"知识":AP4 RAG 知识库

❓ 测验
实测里我造了一个「永远查不到」的工具,并在提示里说「一定要查到」,模型试了 3 次就自己放弃并如实说明。那还需要 MAX_STEPS 吗?
⚠️ 避坑「没查到」用空值表示,是多步系统最常见的坑

工具查不到数据时返回 []null 或者空字符串,看起来很自然。但模型收到的信号是"这是个空结果",而不是"这次失败了"。

后果有两种,都很难看:① 模型换个参数再试一遍(死循环的常见来源);② 模型当作"该用户没有订单"来回答,而实际上是数据库没连上。

正确做法是把四种情况显式区分:

{"found": true,  "data": {...}}                      # 查到了
{"found": false, "reason": "订单号不存在"}            # 确实没有
{"error": "DB_TIMEOUT", "message": "..."}            # 系统故障
{"error": "FORBIDDEN", "message": "无权访问该订单"}   # 权限不足

每一种对应完全不同的用户话术,也对应完全不同的监控告警(ap7.4:business_ok 要能区分这几类)。多花五分钟规范返回值,能省掉后面几十次"为什么它这么答"的排查。

🤖 让 AI 帮你写工具循环
我的工具集是【贴工具声明】,典型的用户请求是【举 3 个例子,含需要多步的】。请帮我:1) 写完整的多步循环(含 assistant 的 tool_calls 消息回填、tool_call_id 对应、两个出口);2) 加两道刹车:步数上限和「同工具同参数连续 N 次即掐断」,给出建议参数;3) 规范化工具返回值,区分查到/没查到/出错/无权限四类,并给出每类对应的用户话术;4) 设计每一步的进展反馈文案;5) 设计轨迹日志字段,让我能按 request_id 复现整条链路;6) 列出该测的失控场景和预期表现。
✅ 小结

多步三句话:循环有两个出口(它不调了 / 撞上限),两个都要写两道刹车:步数上限 + 同工具同参数连续 2 次即掐断工具返回值要区分「没查到」和「出错」——用空值表示失败是多步系统最常见的坑

三笔账要记住:成本累积(4 步 2110 tok)、延迟感知(要有进展反馈)、轨迹可查(按 request_id 串)

🎉 AP3 完整了。下一章给模型知识:RAG——而第一节仍然是先证伪,量出"不给资料时它到底编多少"。

下一节 → AP4 RAG 知识库:先量化裸问的幻觉率
🔎 来源与核验· 3 条,点开核对
本节每个关键论断都对应一个可追溯的来源 —— 这是本课程"靠谱、不过时"的底线。
「多步轨迹实测:「查订单 + 未发货则退款」共 3 步,每步输入 token 389/495/645(累积),总成本 0.00094 元,耗时 5.7s」
📚 ECS 实测 tool_loop.py 实验一(2026-08,deepseek-chat,temperature=0)✓ 已核验 2026-08
「死循环压力测试:工具永远返回「未找到」且用户要求「一定要查到」,模型调用 3 次后自行放弃并如实说明,未撞上 8 步上限;4 步累计输入 token 2110」
📚 ECS 实测 tool_loop.py 实验二(2026-08)✓ 已核验 2026-08
「工具报错实测:返回 DB_CONNECTION_FAILED 时,模型如实转达查询失败,未编造订单状态」
📚 ECS 实测 tool_loop.py 实验三(2026-08)✓ 已核验 2026-08
智图软件的赞赏码
都看到这了,打个赏呗!
接下来 · ap4.1
先量化幻觉:裸问你的业务,它编造 5/8
继续读下一节 →