KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
10. 把工具子系统放回 Harness — keel 龙骨
九章跑下来,工具链路已经被拆成了九个可以单独检查的部分。这一章做三件事:把它们压回一次运行、放回它所属的更大系统、然后给你一个需要自己补两次能力的结业练习。
九章跑下来,工具链路已经被拆成了九个可以单独检查的部分。这一章做三件事:把它们压回一次运行、放回它所属的更大系统、然后给你一个需要自己补两次能力的结业练习。
一次完整运行长什么样
ScriptedAdapter 的好处是每一步都可控,于是能把各种情形压进同一次运行:第一轮两个请求(一个失败),第二轮一小部分成功,第三轮给出结论。
python courses/foundation/tool-calling/course/project/examples/09_offline_advanced.py
--- ⑦ 一次完整运行的事件流 ---
run_id = run-34c559713956 status = completed
seq event call_id/说明
1 model.requested step=0
2 model.responded step=0
3 tool.requested run-34c559713956:step-0:call-0
4 tool.finished run-34c559713956:step-0:call-0
5 tool.requested run-34c559713956:step-0:call-1
6 tool.finished run-34c559713956:step-0:call-1
7 model.requested step=1
8 model.responded step=1
9 tool.requested run-34c559713956:step-1:call-0
10 tool.failed run-34c559713956:step-1:call-0
11 model.requested step=2
12 model.responded step=2
13 run.completed step=2
十三条事件里藏着前面九章的每一个决定:
| 事件 | 对应章节 | 那个决定是什么 |
|---|---|---|
| 1–2 | 01 / 02 | 请求由模型提出,但必须转成内部 ToolCall 才被执行器认 |
| 3–6 | 05 / 08 | 同轮两个请求各有 call_id,结果按它归位而不是按位置 |
| 9–10 | 03 / 06 | tool.failed 不等于运行失败;结构化错误回到下一轮 |
| 11–12 | 06 | 整条 assistant 消息被保存,协议才能配对、事后才能回放 |
| 13 | 06 / 08 | 停止由 completed 表达,是 Harness 的状态而非模型的决定 |
注意这张表的最后一行:整个工具子系统没有一处自己决定"这次运行到此为止"。它只在四个边界上给出判断,Harness 决定怎么接。这就是下一节那张边界表要说的事。
两层系统的边界
| Harness 负责 | Tool Calling 负责 |
|---|---|
| 管理整次运行的状态 | 管理工具子系统内部协议 |
| 事件序列化、预算、停止条件 | 工具契约、注册表、校验、执行器 |
| 取消、恢复、服务化 | 幂等、并发、流式、副作用分级 |
| 调度多个子系统(记忆、计划、模型) | 对外只提供 ToolCall / ToolResult 两个形状 |
反过来验证这条边界:把工具子系统拿掉,Harness 仍然需要有状态机、事件和预算;把 Harness 拿掉,工具子系统还能独立执行固定调用(第 05 章那四个实验就是)。两边可以各自单测,也可以各自替换实现。
反例是「工具自己决定重试三次」——重试要看预算和 deadline,那是 Harness 的信息,工具不知道。所以 ToolError.retryable 只能是建议。
项目模块怎么对应
ollama_adapter.py -> 供应商响应 -> 内部 ToolCall
scripted_adapter.py -> 同一份接口的离线实现(用于验证与测试)
contracts.py -> ToolCall / ToolResult / ToolError / ExecutionContext
resolution.py -> 静态数据库行 -> callable(限定名 import / 动态转发器)
registry.py -> 工具定义与白名单
executor.py -> schema、策略、执行、输出校验(固定顺序)
runner.py -> 有界多轮循环与停止状态
tools.py -> 领域工具实现
模块可以合并(很多项目里 adapter 和 runner 就写在一起),但每行右侧的责任不能合并。判断标准:能不能在不打开另一个模块的情况下解释这一行的所有行为?
scripted_adapter.py 是整组里最不像"生产代码"的一个,却是最能证明架构的一个:它替换掉了链路最上游的模型,runner.py 一行没改。这说明了 adapter 那层的价值——换供应商时,下游不必重写。
九条不变量
把这一课程压缩成一页检查清单:
- 工具名是不可信输入,只允许从注册表白名单取出(05)
- 模型可见字段与执行器内部字段不重叠(03)
- 输入、输出都要过 schema;残缺结果不进上下文(03)
- 检查顺序不可交换:注册 → 输入 → 策略 → 执行 → 输出(05)
- 每次调用都有 Harness 生成的
call_id,请求/结果/事件/幂等都以它为外键(03、08) - 结果以
tool身份回到下一轮,错误也可以回去(06) - 重试是否安全由 effect 决定;写操作要有幂等键(07)
- 完成顺序不等于请求顺序,按
call_id归位(08) - 授权来自运行时上下文,不来自模型输出、prompt 或历史记忆(09)
结业练习
第一部分:加一个只读工具 lookup_service_owner(service_name)
- 定义
ServiceOwnerArgs和ServiceOwnerRecord(记得extra="forbid"); - 在
tools.py里实现并注册为read; - 服务不存在时抛
ToolExecutionError("service_not_found", ...); - 用
ScriptedAdapter写一段三步脚本:查事件 → 查负责人 → 给结论,观察事件流; - 断言
max_steps=2时这段脚本走到limit_reached; - 覆盖四条用例:合法、参数错误、服务不存在、同名重复注册被拒。
第二部分:加一个外部副作用工具 notify_service_owner
effect设为external,不写任何发送逻辑;- 断言未批准时收到
approval_required,且运行停在waiting_for_approval、后续步骤未被消费; - 构造一条审批记录(含参数摘要与有效期),用原
call_id恢复执行; - 这条的验收标准不是"执行成功",而是"没有执行路径能在无审批时生效"。
现在能解释什么(结业检查点)
- 给一段事件流,你能指出哪几条违反了不变量,并说出违反的是哪一条;
- 说明为什么工具子系统不能自己决定停止;
- 说明换一个模型供应商时,需要改哪几个文件、哪几个不用改;
- 说明同一个缺陷(例如结果错位)在事件流里会呈现为什么形态。
完成后回到 Agent Harness 课程,把工具边界放进一次完整运行。
当工具已经获准执行,继续学习现实世界执行:那里详细处理幂等、长任务、未知结果、对账和补偿。若要判断动作是否应该获准,进入安全控制:那里展开身份、策略执行点、提示注入、审批绑定和隔离。