多步工具与控制:让它连着调,但别让它停不下来
实测 4 步累积 2110 输入 token;以及"它这次自己停了"为什么不能当保证
上一节模型只调了一次工具。真实场景很少这么简单——用户一句话里常常含着两件事:
"订单 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
三个容易写错的地方:
- 助手带
tool_calls的那条消息要原样放回msgs——漏了它,模型会不知道自己刚才调过什么; tool_call_id必须对上——一轮里可能有多个调用,对不上就串了;- 循环出口有两个:模型不再调工具(正常),或撞上步数上限(异常)——两个出口的处理必须都写。
实验二:它这次自己停了,但你不能赌
我造了一个永远返回"未找到,请确认订单号后重试" 的工具,并且在用户提示里明确说"查不到就多试几次,一定要查到":
第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 分钟)
- 跑本节代码,看三条轨迹的步数和 token 累积,尤其是实验二那 4 步。
- 把上一节的单次调用改成循环,
MAX_STEPS先设 5。 - 加两道刹车:步数上限 + "同工具同参数连续 2 次即掐断"。
- 规范工具返回值:区分「查到」「没查到」「出错」「无权限」四种,别用空值表示失败。
- 加进展反馈:每一步开始时给前端推一句"正在做什么"(ap7.3)。
- 记轨迹:每一步一条流水,用
request_id串起来(ap1.6 / ap7.4)。 - 测三种坏情况:工具超时、工具返回错误、用户要求"一直试到成功"——看你的循环会不会失控。
✅ 做到这里你应该有:一个带双重刹车的工具循环 + 四类规范化的工具返回值 + 进展反馈 + 可按 request_id 串起来的轨迹流水。
为什么:🎉 AP3 到这里完整了——从"能解析的 JSON"(3.1)、"能自动修复的输出"(3.2)、"能入库的数据"(3.3)、"会说不确定"(3.4)、"能被字段级评测"(3.5),到"能调工具"(3.6)和"能连着调"(3.7)。
这七节合起来解决的是同一件事:让模型的输出从「一段文字」变成「系统能接住的东西」。而多步循环是其中最容易失控的一环——因为它同时放大了成本、延迟和风险。两道刹车、四类返回值、一条轨迹,是它能安全上线的最低配置。
下一章开始给模型"知识":AP4 RAG 知识库。
工具查不到数据时返回 []、null 或者空字符串,看起来很自然。但模型收到的信号是"这是个空结果",而不是"这次失败了"。
后果有两种,都很难看:① 模型换个参数再试一遍(死循环的常见来源);② 模型当作"该用户没有订单"来回答,而实际上是数据库没连上。
正确做法是把四种情况显式区分:
{"found": true, "data": {...}} # 查到了
{"found": false, "reason": "订单号不存在"} # 确实没有
{"error": "DB_TIMEOUT", "message": "..."} # 系统故障
{"error": "FORBIDDEN", "message": "无权访问该订单"} # 权限不足
每一种对应完全不同的用户话术,也对应完全不同的监控告警(ap7.4:business_ok 要能区分这几类)。多花五分钟规范返回值,能省掉后面几十次"为什么它这么答"的排查。
我的工具集是【贴工具声明】,典型的用户请求是【举 3 个例子,含需要多步的】。请帮我:1) 写完整的多步循环(含 assistant 的 tool_calls 消息回填、tool_call_id 对应、两个出口);2) 加两道刹车:步数上限和「同工具同参数连续 N 次即掐断」,给出建议参数;3) 规范化工具返回值,区分查到/没查到/出错/无权限四类,并给出每类对应的用户话术;4) 设计每一步的进展反馈文案;5) 设计轨迹日志字段,让我能按 request_id 复现整条链路;6) 列出该测的失控场景和预期表现。
多步三句话:循环有两个出口(它不调了 / 撞上限),两个都要写、两道刹车:步数上限 + 同工具同参数连续 2 次即掐断、工具返回值要区分「没查到」和「出错」——用空值表示失败是多步系统最常见的坑。
三笔账要记住:成本累积(4 步 2110 tok)、延迟感知(要有进展反馈)、轨迹可查(按 request_id 串)。
🎉 AP3 完整了。下一章给模型知识:RAG——而第一节仍然是先证伪,量出"不给资料时它到底编多少"。
🔎 来源与核验· 3 条,点开核对
