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 消息必须整条保存,而不是只存工具调用。原因有两个:

  1. 协议配对:多数供应商要求 tool 消息紧跟在提出请求的 assistant 消息之后,并且在同一个请求里没有对应的 assistant.tool_calls 时直接报 400;
  2. 回放:事后要复盘"模型当时为什么选这个工具",只有那条消息里有当时的文本、思考和参数。

看输出:两条可以自己复核的证据

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]

这张图里有两个容易被实现者忽略的细节:

四种结束状态

同样的 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 介入。说不出归属,就是这个代码块还没想清楚。

本章检查点

现在能解释什么

你能画出「提出请求 → 执行 → 结果以 tool 身份回到下一轮 → 再次请求」这条闭环,并知道四种结束状态分别由哪个分支产生。你也看到:把错误放回上下文,模型具备自己修正的能力。下一章把这个能力往深处推——错误为什么不能急着重试。

上一章:Registry 和 Executor 怎样形成白名单? · 下一章:工具失败时为什么不能急着重试?

进入 keel 阅读