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 那层的价值——换供应商时,下游不必重写。

九条不变量

把这一课程压缩成一页检查清单:

  1. 工具名是不可信输入,只允许从注册表白名单取出(05)
  2. 模型可见字段与执行器内部字段不重叠(03)
  3. 输入、输出都要过 schema;残缺结果不进上下文(03)
  4. 检查顺序不可交换:注册 → 输入 → 策略 → 执行 → 输出(05)
  5. 每次调用都有 Harness 生成的 call_id,请求/结果/事件/幂等都以它为外键(03、08)
  6. 结果以 tool 身份回到下一轮,错误也可以回去(06)
  7. 重试是否安全由 effect 决定;写操作要有幂等键(07)
  8. 完成顺序不等于请求顺序,按 call_id 归位(08)
  9. 授权来自运行时上下文,不来自模型输出、prompt 或历史记忆(09)

结业练习

第一部分:加一个只读工具 lookup_service_owner(service_name)

  1. 定义 ServiceOwnerArgs 和 ServiceOwnerRecord(记得 extra="forbid");
  2. 在 tools.py 里实现并注册为 read;
  3. 服务不存在时抛 ToolExecutionError("service_not_found", ...);
  4. 用 ScriptedAdapter 写一段三步脚本:查事件 → 查负责人 → 给结论,观察事件流;
  5. 断言 max_steps=2 时这段脚本走到 limit_reached;
  6. 覆盖四条用例:合法、参数错误、服务不存在、同名重复注册被拒。

第二部分:加一个外部副作用工具 notify_service_owner

  1. effect 设为 external,不写任何发送逻辑;
  2. 断言未批准时收到 approval_required,且运行停在 waiting_for_approval、后续步骤未被消费;
  3. 构造一条审批记录(含参数摘要与有效期),用原 call_id 恢复执行;
  4. 这条的验收标准不是"执行成功",而是"没有执行路径能在无审批时生效"。

现在能解释什么(结业检查点)

完成后回到 Agent Harness 课程,把工具边界放进一次完整运行。

当工具已经获准执行,继续学习现实世界执行:那里详细处理幂等、长任务、未知结果、对账和补偿。若要判断动作是否应该获准,进入安全控制:那里展开身份、策略执行点、提示注入、审批绑定和隔离。

上一章:副作用工具怎样进入审批边界? · 返回课程首页

进入 keel 阅读