KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
06. 工具结果为什么要回到下一轮? — keel 龙骨
执行器已经给出了结构化结果,事情到这里结束了吗?没有。工具返回的是事实,不是用户要的答案——把 severity: high 和三条证据翻译成一句「先去看数据库连接池」,还得模型再参与一次。
执行器已经给出了结构化结果,事情到这里结束了吗?没有。工具返回的是事实,不是用户要的答案——把 severity: high 和三条证据翻译成一句「先去看数据库连接池」,还得模型再参与一次。
现场:一次"看起来完成了"的回答
值班同学在对话框里问「INC-001 现在什么情况」。链路一切正常:
tool.finished lookup_incident ok=true
data = {"incident_id":"INC-001","service":"auth-api","severity":"high",
"symptom":"登录接口持续返回 502",
"evidence":["auth-api timeout","database pool exhausted"]}
但屏幕上出现的是一句干巴巴的「找到了」。原因是那次实现把工具结果写进了日志,没有放回给模型——没有第二次模型请求,就不会有一句基于证据的结论。
工具调用的价值不在「调到了」,而在「调完之后有人看得懂」。这就是为什么要把它放回 messages,而不是塞进日志了事。
role 是协议语义,不是分类标签
| role | 谁产生 | 含义 |
|---|---|---|
user |
用户 | 目标和补充信息 |
assistant |
模型 | 文本、思考,或若干工具请求 |
tool |
Harness / 执行器 | 对某次工具请求的应答 |
tool 不能写成 user。真实的失败案例:某次实现为了"让模型看得懂",把工具结果拼成字符串塞进 user 消息,模型立刻把它当成用户新下达的指令——因为在它眼里,来自 user 的文本就是要求。运行舱里出现了「用户要求:连接池耗尽」这种荒唐的复述。
反过来,assistant 消息必须整条保存,而不是只存工具调用。原因有两个:
- 协议配对:多数供应商要求
tool消息紧跟在提出请求的assistant消息之后,并且在同一个请求里没有对应的assistant.tool_calls时直接报 400; - 回放:事后要复盘"模型当时为什么选这个工具",只有那条消息里有当时的文本、思考和参数。
看输出:两条可以自己复核的证据
python courses/foundation/tool-calling/course/project/examples/08_offline_loop.py
单工具那一组:
--- ① 单工具 → 最终回答 ---
status = completed answer/error = INC-001 是 auth-api 的高级别故障,证据指向数据库连接池耗尽。
[tool.requested] run-c7f7c72020cd:step-0:call-0
[tool.finished ] run-c7f7c72020cd:step-0:call-0
第二轮模型看到的最后一条消息 role = tool
这条消息携带的工具名 = lookup_incident
同一轮提两个请求时,下一轮收到的序列是:
下一轮消息序列 = ['user', 'assistant', 'tool', 'tool']
这两行输出回答了本章标题的问题——结果确实回到了下一轮,而且是以 tool 身份回去的。注意 answer 里的结论引用了 data 里的证据,这不是模型"记得",是因为它第二轮真的读到了那条消息。
错误也可以回到下一轮
这是最容易被跳过的一步。工具失败≠运行失败,很多失败是模型能解释的事实:
--- ③ 自修复:错误回传 → 模型换参数 ---
status = completed
第 1 轮回传给模型的 tool 结果:ok=False code=incident_not_found
第 2 轮模型手里已经有的工具结果条数 = 2
两次调用的结果 ok = [False, True]
最终回答 = INC-404 不存在;INC-001 是 auth-api 的高级别故障。
(出自 examples/09_offline_advanced.py 第 ③ 组)
模型第一次查了不存在的 INC-404,拿到 incident_not_found,第二轮换个编号重试成功。这次修正没有任何一行代码参与判断——它完全由「把结构化错误放回上下文」这个动作产生。这也解释了第 03 章为什么要在 ToolError 里放稳定的 code:模型要能读懂,然后决定怎么办。
哪些错误不该回去?第 08 章会给出 Runner 的分类:不可恢复的(unknown_tool、tool_internal_error、invalid_tool_output)会让整次运行失败,而不是交给模型自己兜。
循环的形状
flowchart TD
A[messages] --> B[模型]
B --> C[保存完整 assistant 消息]
C --> D{有 tool_calls?}
D -->|否| E[completed: 最终回答]
D -->|是| F[转换内部 ToolCall]
F --> G[Registry + Executor]
G --> H[以 role=tool 追加结果]
H --> I{审批/致命错误?}
I -->|waiting_for_approval / failed| J[结束运行]
I -->|否则| K{预算允许继续?}
K -->|是| A
K -->|否| L[limit_reached]
这张图里有两个容易被实现者忽略的细节:
messages是累加的。每多一轮就多若干条消息,成本随轮数增长;什么时候压缩、丢掉什么,属于上下文工程的范围,不属于工具调用。- 停止条件是运行时安全边界。
max_steps、deadline、取消信号必须留在代码里,不能写进 prompt 指望模型自律。同样一组脚本第 ⑤ 组实验里,模型 20 次都请求工具,max_steps=3把运行停在了第 3 步。
四种结束状态
同样的 Runner,不同脚本输进去,会得到四种不同的结局:
| status | 触发条件 | 示例输出中出现位置 |
|---|---|---|
completed |
模型给出不带工具请求的回答 | ① ② ③ |
waiting_for_approval |
副作用工具卡在审批边界 | ④(steps_used = 1,脚本第二步没被消费) |
failed |
出现不可恢复错误 | 离线示例没覆盖到,见 runner.py 里对 unknown_tool 等的分支 |
limit_reached |
用尽 max_steps |
⑤ |
第 ④ 组里有个耐人寻味的细节:暂停之后,脚本第二步从未被消费。也就是说「审批通过后再继续」不是脚本里那一行"会被执行",而是需要一次新的运行状态——这是第 09 章的主题。
失败注入:三种"看起来没问题"的写法
改 runner.py 后重跑 08_offline_loop.py 和 09_offline_advanced.py:
| 改法 | 观察什么 | 为什么危险 |
|---|---|---|
把 tool 结果写成 {"role":"user", ...} |
模型是否把工具输出当指令执行 | role 语义被破坏,第 06 章开头的荒唐复述就是这么来的 |
只保存 response.message.tool_calls,不保存整条 assistant 消息 |
第二轮开始是否被协议拒绝 | 丢失回放数据,且供应商侧协议不配对 |
工具一失败就 return failed |
自修复实验能否走到第二轮 | 把「模型可以解释的事实」当成系统故障,白白丢掉一次修正机会 |
生产替换点
| 教学实现 | 生产替换 |
|---|---|
messages 列表常驻内存 |
可持久化的会话存储,支持恢复与回放 |
max_steps=6 硬编码 |
按租户/任务类型配置的预算:步数、token、金额、deadline |
同步 ToolRunner.run() |
可取消的运行,取消后迟到结果不能把状态改回成功 |
| print 事件 | 结构化事件流(run_id + sequence),供审计和前端渲染 |
| 脚本化假模型 | 录制回放的 LLM 响应,作为集成测试基线 |
练习与验收
练习:把自修复脚本改成「模型第二次仍然给错编号」,观察运行走到什么状态,并说明这个结果是好是坏。
验收标准:能明确说出「这次应该由谁结束运行」——是模型放弃,还是 Harness 介入。说不出归属,就是这个代码块还没想清楚。
本章检查点
- 工具结果为什么不能用
user身份回传?举一个具体的失败表现。 - 为什么必须保存整条
assistant消息,而不只是它携带的工具请求? incident_not_found为什么可以让模型知道?举一个不应该让模型知道的错误,并说明区别。- 第 ④ 组实验里
steps_used = 1,如果把它改成 2,说明链路发生了什么变化?
现在能解释什么
你能画出「提出请求 → 执行 → 结果以 tool 身份回到下一轮 → 再次请求」这条闭环,并知道四种结束状态分别由哪个分支产生。你也看到:把错误放回上下文,模型具备自己修正的能力。下一章把这个能力往深处推——错误为什么不能急着重试。