第五章我们盯着 LOOP 看了一整章,结论是:模型每一轮只有两种输出——说话,或者调工具。 说话是 assistant 消息,改变的只是历史;调工具才会真正改变世界:跑命令、改文件、发请求、问用户、派分身。 工具是 agent 与外部世界之间唯一的行动接口,LOOP 的每一圈都由它驱动。 本章拆开工具系统:工具从哪里来、模型怎么知道有哪些工具可用、一次调用如何穿过层层关卡落地执行、结果又以什么形态回到历史里。
6.1 工具是什么:一份 spec,一个 runtime
先建立一个最核心的拆分:每个工具同时是两样东西。
- 对模型,它是一份 spec(规格说明):一个名字、一段描述、一份参数的 JSON Schema。这是模型选择工具、填写参数的全部依据。
- 对 harness,它是一个 runtime(运行时):一段真正干活的代码——起进程、发网络请求、弹审批框、调用另一个服务。
这两样东西在代码里由同一个契约绑定:工具实现者必须同时回答”我叫什么、长什么样”(spec)和”调用我时执行什么”(handle)。为什么要强制绑定?因为第五章那个步骤快照的不变量——模型在 spec 里看到的工具,和调用时真正能执行的工具,必须是同一份。如果 spec 和 runtime 是两张各管各的表,“模型看到了工具、调用时却不存在”这种错配就只是时间问题。把两者焊在同一个对象上,注册一次,两处同时生效。
类比:工具像餐厅里的一道菜。菜单(spec)给顾客(模型)看:菜名、食材、大概什么样;后厨(runtime)真有这道菜的做法。一家靠谱的餐厅,菜单上有的后厨一定能做,后厨会做的也才印上菜单——spec 与 runtime 永远同步。顾客从菜单点菜(工具调用),后厨按单做菜(执行),菜端上桌(结果回灌)。
一次工具调用的完整生命周期,在第二章的流上已经露过面:模型在回复里输出一个函数调用 item(名字 + 参数 JSON)→ 它在流上成形的那一刻被 spawn 执行 → 产出一个工具结果 item 回灌历史。本章要展开的,是这个 item 从”成形”到”回灌”之间发生的一切。
6.2 工具的五种形态
先看 spec 这一侧。工具在请求体长什么样?不是一种,而是五种形态,各自对应一类”行动”:
| 形态 | 典型工具 | 参数形态 | 谁来执行 |
|---|---|---|---|
| function(函数) | 绝大多数工具:exec_command、view_image、spawn_agent | JSON Schema 约束的参数 | harness / 扩展 / MCP |
| freeform(自由格式) | apply_patch(打补丁改文件) | 一整段人类可读文本,用文法(grammar)约束而非 JSON | harness |
| namespace(命名空间) | MCP 工具组:mcp__calendar 下挂一批函数 | 组内工具仍是 JSON 参数 | MCP 服务 / 扩展 |
| tool_search(工具搜索) | tool_search 本身 | JSON(查询词 + 数量) | harness(本地执行) |
| web_search(服务端托管) | 联网搜索 | 几乎无参数 | 模型服务端自己执行 |
两个形态值得单独解释。
为什么 apply_patch 是 freeform 而不是 function? 补丁是逐行的文本(“在这个文件第几行删掉什么、加上什么”),一次改动可能几千行。如果把它塞进一个 JSON 字符串参数,参数里的每一个换行、引号都要转义,模型生成又长又容易出错;更重要的是第二章讲过的:freeform 工具的参数是流式可读的——补丁每生成一行,harness 就能边解析边把”将要改动哪些文件”预览出来,而 function 工具的半截 JSON 毫无意义,只能等整体成形。freeform 用一套 Lark 文法(类似 EBNF 的语法描述)告诉模型”补丁文本必须长这样”,既松绑了 JSON 的枷锁,又保留了结构性约束。选 function 还是 freeform,取决于参数的原子是”结构化数据”还是”流式文本”。
web_search 为什么不归 harness 执行? 它是服务端托管工具:spec 出现在请求里,但调用的执行完全发生在模型服务端,harness 只在历史里看到一个搜索调用 item 和它的结果。这是”工具”概念最宽的边界——工具不一定要在你自己的进程里执行,它只是”模型可以采取、且结果会回到上下文里的一个行动”。同理,命名空间(namespace)是较新的协议特性:把一批相关工具收进一个命名空间对象里(比如一个 MCP 服务器提供的二十个工具),而不是在工具清单顶层平铺二十个名字,减少顶层命名的拥挤。模型不支持命名空间时(第三章的能力卡片),harness 会把同一批工具改写成扁平的 function 形态。
6.3 工具的四大来源
再看 runtime 这一侧:真正注册进注册表的工具来自四个方向,另有一类连 runtime 都不在本地的托管工具(见下图 ⑤)。
graph TD
subgraph REQ["每步冻结的工具箱(ToolRouter)"]
REG["注册表 ToolRegistry
名字 → runtime + 暴露面"]
VIS["模型可见清单 model_visible_specs
注册表的一份过滤投影"]
end
S1["① 内置工具
shell / 补丁 / 看图 / 计划 /
时间 / 预算 / 问人 / 派分身"] --> REG
S2["② MCP 工具
外部 MCP 服务器提供
命名空间 mcp__ 加服务名"] --> REG
S3["③ 扩展工具
插件 / 扩展通过贡献点注册"] --> REG
S4["④ 动态工具
前端程序提供 spec,前端执行"] --> REG
S5["⑤ 托管工具
web_search:服务端执行"] --> VIS
REG -->|"按暴露面过滤"| VIS
① 内置工具是 harness 自带的”手和脚”,大致分四类:
- 操作环境的:
exec_command(在终端跑命令,基于 PTY,长命令会返回一个会话 ID,配套的write_stdin可以继续给它喂输入——模型因此能驱动交互式进程)、apply_patch(改文件)、view_image(看图); - 管理自身工作流的:
update_plan(维护任务计划)、get_context_remaining(查剩余 token)、new_context(主动开新上下文窗口)——注意后两个正是第四章三道防线交给模型自助使用的开关,上下文管理本身也被建模成了工具; - 与人协作的:
request_user_input(向用户提问)、request_permissions(主动申请更高权限); - 组织协作的:
spawn_agent/send_message/wait(派生子 agent、收发消息),这是第七章的主题。
② MCP 工具来自外部 MCP 服务器(日历、数据库、内部平台……),每个服务器的工具收在 mcp__<服务名> 命名空间下。MCP 是第 11 章可扩展性的主题,这里只需要知道:MCP 服务可以随时连接、断开,工具清单是动态变化的。
③ 扩展工具由插件/扩展通过贡献点注册,与内置工具走同一套契约,harness 不区分对待。
④ 动态工具最特别:它的 spec 由前端程序提供,执行也发生在前端。IDE 插件可以把”读取当前选中的代码""操作编辑器标签页”这类只有前端才有的能力,以工具形式暴露给模型;模型调用时,harness 通过第二章那个”反转的请求-应答”把调用转发给前端,前端干完活把结果还回来。工具箱因此可以延伸到 harness 进程之外,长到前端的能力边界上。
图中的 ⑤ 托管工具(web_search)则是唯一不进注册表的来源:它没有本地 runtime,注册表里也查不到,执行完全发生在模型服务端,harness 只把它的 spec 放进可见清单、在历史里记下调用与结果。它的存在说明 spec 清单和执行路由是两个可以独立存在的面——清单描述”模型能做什么”,注册表回答”harness 怎么执行”,两者在绝大多数工具上重合,在托管工具这里刻意分离。
这里浮现出本章第一个重要洞察:注册 ≠ 可见,可见 ≠ 可执行——但可执行的一定已注册。 注册表里躺着所有来源的工具 runtime;而发给模型的可见清单只是注册表按”暴露面”过滤后的一份投影。一个工具可以已注册、能执行,却不在当前清单里(马上会讲的 deferred / hidden 工具);但模型绝不可能调用一个注册表里不存在的工具——查无此工具时,调用会得到一条错误回灌,而不是 panic。
内置工具的注册还遵循按身份裁剪:不是每个会话都拿到全套工具。子 agent 拿不到”向用户提问”这种根线程专属的工具;派分身的工具在达到 spawn 深度上限时不注册;一种叫 guardian 的受管审查线程只给最小工具集(跑命令、喂输入、看图);模型能力卡片(第三章)声明不支持的实验性工具(如 send_user_message_async、test_sync)也不注册。工具清单是”这个 agent 在这个步骤、这个模型下能做什么”的完整表达,权限边界从”模型能看到什么”就开始划了,而不是等到执行时才拦。
6.4 spec 即提示词:让模型”用得对”的工程
新手写工具,容易把 spec 当成”给代码看的接口定义”——名字和参数类型对上就行。在 agent harness 里这是严重的误解:spec 是提示词(prompt)的一部分,而且是最贵的那类内容之一。它逐字节出现在每一次采样请求里,直接决定模型能否在正确的时机、用正确的参数、选择正确的工具。
看几个真实的设计决策:
描述是写给模型的使用说明,不是写给人的文档。 tool_search 的描述里有一句非常直白的话:“发现 MCP 工具时,始终用 tool_search,而不是 list_mcp_resources 或 list_mcp_resource_templates。” 这是在直接劝阻模型的一个已知错误倾向——功能重叠的两个工具,模型可能选错,光靠命名区分不可靠,就在描述里把优先级讲死。exec_command 的 yield_time_ms 参数描述则精确到默认值和取值范围(“默认等待 10000 毫秒,有效范围 250–30000”),因为参数填错的代价是模型对”命令为什么还没返回”产生误判。
参数 Schema 承载行为引导。 哪些参数必填、哪些有默认值、哪些是枚举,都通过 JSON Schema 表达;Schema 还可以声明 strict(严格模式),要求模型输出的参数必须完全符合结构,不许多余字段。部分工具还带 output schema,告诉模型结果会长什么样。工具描述和参数描述里写的每一句话,都是在做”注意力编程”——和第四章 AGENTS.md 以用户角色注入是同一种思路:模型对上下文里的自然语言指令高度敏感,工具用法说明就应该大方地写进 spec。
形态选择本身也是引导。 补丁用 freeform 换取流式可生成;MCP 工具用 namespace 换取分组可发现;连”这个工具能不能并行调用”(下一节展开)都是 runtime 声明的一个属性。
spec 的成本是真金白银。 一个工具的完整 spec 序列化后可能几百到几千 token,几十个工具就是几万 token 的固定开销——每次请求都付。这直接催生了下一节的暴露面设计,也解释了为什么 spec 要处处节俭:命名空间描述有字节预算上限(MCP 命名空间描述 512KB 封顶、插件类更只有 1KB),扩展工具超预算直接不注册。工具清单是一份既怕不全(模型没手可用)、又怕太全(token 爆炸 + 选择困难)的菜单,这个张力贯穿全章。
6.5 暴露面:先给模型看哪几个工具
设想一个重度用户:五个 MCP 服务器、十几个插件、若干前端动态工具,加起来两三百个工具。全量塞进每次请求会怎样?token 账单爆炸是其一;更隐蔽的危害是选择过载——模型要在两三百个选项里挑出正确的那个,准确率随清单长度下降,就像让人在一本三百页的菜单上点菜。
Codex 的解法是给每个工具标注一个暴露面(exposure),回答”这个工具出现在哪个模型可见面上”:
| 暴露面 | 初始工具清单 | tool_search 可发现 | code mode 可嵌套调用 |
|---|---|---|---|
| Direct(直接) | ✅ | — | ✅ |
| Deferred(延迟) | ❌ | ✅ | ✅ |
| DirectModelOnly | ✅ | ❌ | ❌ |
| DeferredModelOnly | ❌ | ✅ | ❌ |
| CodeModeOnly | ❌ | ❌ | ✅ |
| Hidden(隐藏) | ❌ | ❌ | ❌(但仍注册、仍可执行) |
于是模型面前实际上有三个可见面:
- 直接清单:每步请求里完整携带 spec 的工具。高频核心工具(shell、补丁、计划……)常驻这里。
- 延迟发现面:spec 不进请求,模型需要时通过
tool_search搜索加载。MCP 工具在搜索功能开启时默认全部 deferred——它们数量大、单次任务往往只用其中一两个。 - code mode 嵌套面:code mode 开启时,工具被收进一个”代码执行”工具内部——模型不是直接调工具,而是写一段可以批量调用工具的代码交给执行器跑。这是面向复杂多步操作的第三种形态,暴露面标注决定哪些工具能被嵌套进去。
Hidden 是个容易被忽略但很关键的姿态。 举个真实例子:新模型面前用的是统一的 exec_command 工具,但旧的 shell_command 工具仍然注册在表里、只是 Hidden。为什么?因为历史 item 里可能躺着旧格式的调用,某些兼容路径也可能发出旧调用——注册着就能正常执行,不展示则保证模型不会再新发起它。Hidden 让”下线一个工具”变成”藏起来”而不是”删掉”,新调用看不到、旧调用能善后。 这与第四章”上下文只追加不重写”、第二章”协议事件只增不废”是同一种演进哲学。
暴露面不是工具的固定属性,而是每步策略计算的结果:MCP 服务器可以在配置里声明 omit_tools_from(把自己的某些工具从某些面上撤下);code mode 配置可以把指定命名空间强制降为直接模式;能力探测发现模型不支持命名空间/搜索时,deferred 会回退成 direct。每步构建工具箱时,这些策略统一应用一遍——同一个 MCP 工具,在这个模型面前是 deferred,换个不支持搜索的模型就自动变成 direct。
6.6 发现机制:tool_search 与按需加载
deferred 工具不进初始清单,那模型怎么知道世界上存在这些工具?两层”广告”:
- 世界状态片段:第四章讲过世界状态按区段差分注入。工具箱里的 deferred 命名空间(名字 + 一句话描述,比如”calendar: 管理日历事件”)就是一个区段,模型由此知道”有一类日历工具存在,但细节要自己搜”;
- tool_search 工具描述:搜索工具自己的描述里列出当前可用的工具来源(Google Drive、内部平台……),同样是有预算上限的。
模型决定搜索时,tool_search 在本地执行一次检索:
sequenceDiagram
participant M as 模型
participant H as harness(tool_search)
participant Reg as 注册表(含 deferred 工具元数据)
M->>H: tool_search(query="create calendar event")
H->>Reg: 在全部 deferred 工具的搜索文本上跑 BM25
Note over Reg: 搜索文本 = 工具名 + 描述 + 命名空间
Reg-->>H: 命中 mcp__calendar.create_event 等 spec
H-->>M: 结果:匹配工具的完整 spec(带 defer_loading 标记)
Note over M: 下一轮起,这些工具"已加载",可以直接调用
M->>H: mcp__calendar.create_event({...})
Note over H: 注册表里本就有 runtime → 正常执行
几个设计细节值得驻足:
检索是本地的 BM25,不是再问一次模型。 所有 deferred 工具的”搜索文本”(名字、描述、命名空间描述拼成的文档)在工具箱构建时建好一个 BM25 索引,搜索就是纯本地的关键词相关性排序,零网络开销、结果确定。搜索处理器本身还按注册表内容缓存——工具集没变就复用,变了才重建。
搜索结果返回的是完整 spec,且同一命名空间的工具会合并返回。 搜到日历服务的一个工具,往往把同命名空间下相关工具一起带上,省得模型一个一个搜。
“加载”改变的是模型侧可见性,注册表从未变化。 这呼应 6.3 的洞察:deferred 工具的 runtime 从一开始就在注册表里,tool_search 做的只是把 spec 交到模型手里。模型随后发起调用时,路由查注册表一击即中。加载状态由模型服务端/harness 配合标记,已加载工具在后续请求中进入直接清单。
tool_search 自己也是形态为 tool_search 的特殊工具,标记为”客户端执行”(execution: client)——它由 harness 本地处理,不经过模型服务端。这与 web_search 的服务端执行形成有趣的对称:同是”工具”,执行点可以在模型服务端、harness、扩展、前端的任何一处。
6.7 注册与冻结:步骤快照里的工具箱
工具从各来源汇集后,要在步骤边界完成一次”装箱”。第五章讲过步骤快照(StepContext)冻结本轮的模型、工具、环境;工具这一侧冻结的产物是 ToolRouter,它由两部分组成:
- 注册表:
工具名 → (runtime, 暴露面)的全量表,执行时按名查 runtime; - 模型可见清单:注册表按暴露面过滤、合并命名空间后得到的 spec 数组,随请求发出。
装箱过程有一套严格的命名与冲突规则:
- 内置工具是可信注册:重名直接 panic——内置工具重名是程序 bug,不该在运行时凑合;
- 外部工具(MCP、扩展、动态)是外部注册:占用保留名(如
shell_command)直接拒绝;重名则跳过后来者并记警告,同时记录”首次冲突”;配置可以把冲突升级为致命错误(严格模式); - 命名空间有归属:一个命名空间只能由一个来源拥有(同一个
mcp__calendar不能由两个服务器同时提供),同一命名空间的描述也必须一致,否则按冲突处理。
每步重建工具箱听起来昂贵,实际上大部分组件是缓存的:MCP 工具的 handler 按 MCP 绑定缓存(绑定没变就复用旧 handler)、tool_search 索引按注册表内容缓存、不可变 spec 直接共享引用。第五章那个细节在这套结构里闭环:如果用户插话提到了一个尚未启动的 MCP 服务,harness 会先把服务拉起来、等它的工具到齐,再冻结快照——绝不让快照里宣称的工具和真正能路由到的 runtime 出现差集。
冻结之后,本轮采样期间工具箱静止:工具的增删、MCP 断连都要到下一个步骤边界才反映。这正是第五章”变化只发生在边界”原则在工具系统的体现。
6.8 一次工具调用的旅程:六道关卡
现在跟随一个工具调用 item,从流上成形走到结果回灌。第五章已经讲了外层的”成形即 spawn、FuturesOrdered 按序回收”,这里钻进单次调用内部——它要穿过一串关卡:
flowchart TD
A["工具调用 item 成形
(名字 + 参数)"] --> B["① 路由查表
注册表里有这个名字吗?"]
B -->|"没有"| X1["回灌错误:unsupported call
模型可见的失败,可改道"]
B -->|"有"| C["② PreToolUse hooks
扩展可拦截 / 修改参数"]
C -->|"拦截"| X2["回灌 hook 给出的解释"]
C -->|"放行(可带改写后的参数)"| D["③ 策略判定
Skip / NeedsApproval / Forbidden"]
D -->|"Forbidden"| X3["回灌拒绝原因"]
D -->|"NeedsApproval"| E["④ 审批关卡
发审批事件 → oneshot 挂起
等用户应答(结果按会话缓存)"]
D -->|"Skip"| F["⑤ 沙箱内执行"]
E -->|"批准"| F
E -->|"拒绝"| X4["回灌拒绝说明"]
F -->|"沙箱拒绝且可升级"| G["升级重试
(放宽沙箱,审批缓存命中不再追问)"]
G --> F
F --> H["⑥ PostToolUse hooks
可否决结果 / 替换反馈 / 注入上下文"]
H -->|"否决"| X5["回灌 hook 反馈"]
H -->|"放行"| I["产出工具结果 item
success 标志 + 输出文本 → 回灌历史"]
逐关说明:
① 路由查表。 调用按名字在注册表查 runtime。查不到不 panic,而是回灌一条”unsupported call: <工具名>“——这是给模型的可恢复错误:它可能用错了名字、或调用了一个被隐藏的工具,读到错误后可以换工具重来。参数形态与工具不匹配(比如把 freeform 工具当 function 调)才是 harness 级错误。
② PreToolUse hooks。 扩展挂载点(第 11 章)。hook 有两种干预方式:拦截(返回一条消息,调用不执行,消息作为结果回灌——自动审查、合规拦截挂在这里);改写参数(比如给命令自动补上安全参数)。注意 hook 改的是这次调用的输入,且要通过同一套契约反向构造,不能塞裸数据。
③ 策略判定。 对 shell/补丁这类有副作用的工具,先判定审批需求:Skip(策略允许直接跑,比如只读命令在沙箱内)、NeedsApproval(需要授权)、Forbidden(明确禁止,直接回灌原因)。判定依据是命令内容、当前权限画像、审批策略——第 8 章的主题。
④ 审批关卡。 需要授权时,走第一章那个”发事件 → oneshot 挂起 → 应答 Op 唤醒”的模式:harness 发出审批请求事件(命令详情、理由),工具 future 安静地挂在并发队列里等待,不阻塞其他工具、也不阻塞提交循环处理中断。用户的批准决定会按键缓存(命令、补丁文件集都是缓存键):选择”本次会话始终允许”后,同类调用后续直接放行。连审批关卡本身也挂了 hook——扩展可以自动应答审批请求。
⑤ 沙箱内执行 + 升级重试。 命令先在沙箱约束内尝试;如果沙箱拒绝了它要做的事(比如要写工作区外的路径),且策略允许升级,harness 会用放宽一级的沙箱重试——因为升级审批在第 ④ 关已经拿到并缓存,重试不再打扰用户。命令成功走捷径,失败才升级,这是”默认最小权限、按需升级”的完整体现(第 8 章展开)。
⑥ PostToolUse hooks。 工具跑完、结果回灌前,扩展还有一次干预机会:否决结果(回灌反馈消息)、替换模型可见的输出(原始结果仍保留在日志里,模型读到的是 hook 改写版——比如给结果附加解释)、或注入额外上下文片段。
整个旅程中,harness 还在两端发出工具生命周期通知(开始 / 结束,结束带结果:成功/失败/被拦截/被中止),扩展的可观测性和自动化(比如”所有工具调用记账”)挂在这里;内置控制类工具(如扩展注册的目标管理工具)还有专门的分析守卫,记录被拒/失败/完成。
6.9 两类错误,两种命运
旅程中处处可能出错,而错误的类型决定它的命运。工具系统把错误严格分成两类:
| 错误类型 | 含义 | 典型场景 | 命运 |
|---|---|---|---|
| RespondToModel(回灌模型) | 这次调用没成功,但世界没问题 | 工具不存在、hook 拦截、审批被拒、命令退出码非零、文件不存在、网络失败、等待审批时被取消 | 变成 success=false 的工具结果 item,错误文本就是输出内容,模型下一轮自行决定怎么办 |
| Fatal(致命) | harness 自身的契约被破坏 | 参数形态与工具不匹配、结果序列化失败、内部状态错乱 | 上抛为轮次级致命错误,轮次结束、线程存活(第五章 5.6) |
这正是第五章”工具错误是观察,不是异常”的落地实现。判断标准很清晰:模型能对这个错误做出理性反应的,回灌;模型无能为力、说明 harness 自己坏了的,致命。 “命令退出码 1”是观察——模型可以读报错、改命令、换方案;“工具参数 JSON 解析后和 Schema 对不上且类型错乱”是 harness 的 bug,模型再聪明也修不了。
被用户中断的工具走的也是观察路径:回灌一条 "aborted by user"(shell 类工具还附上已运行时长),模型下一轮读到”这个动作被用户叫停了”,自然会停下来等指示或换方向——中断不产生错误,只产生一条特殊观察(第一章的协作式取消)。
6.10 执行与并发:并行闸门
第五章讲过工具调用”并行执行、按序回灌”(FuturesOrdered)。这里补上循环内部看不到的一层——并行闸门:并不是所有工具都被允许并行。
每个工具 runtime 声明自己是否支持并行调用。执行队列里有一把读写锁:
- 支持并行的工具拿读锁:多个读锁互不排斥,可以同时执行——绝大多数只读、无副作用冲突的工具如此;
- 不支持并行的工具拿写锁:写锁与一切锁互斥——它开始前要等所有在跑的工具结束,它跑的时候其他工具(包括另一个写锁工具)都得排队。
为什么需要这个?有些工具并发执行会互相破坏:交互式 shell 会话共享终端、某些改动共享状态、向用户提问的对话框不该同时弹三个。并行度由工具自己声明,闸门在派发处统一执行,调用方(LOOP)不需要知道谁能并行——它只管把所有调用 spawn 出去。
此外还有两个执行细节:
- 就绪等待(wait_until_ready):个别工具在真正执行前需要等待前置条件(典型是 MCP 服务器还在启动)。这个等待发生在并行闸门之前,不占执行位;
- 取消的两种姿态:中断信号到达时(第一章的取消令牌树),正在执行的工具被分成两类——多数工具直接中止执行(句柄 drop,子进程收到终止信号);少数声明需要”等待运行时清理”的工具,harness 会等它自己把拆除工作做完,再回灌 aborted。无论哪种,回灌的都是那条”aborted by user”观察,且生命周期通知通过一个原子标志保证只发一次(完成和中止不会重复记账)。
工具计时也在这里埋点:一次工具调用的耗时被拆成”派发等待”(在闸门/就绪/审批排队上花的时间)和”处理器执行”两段,分别上报——第 12 章可观测性会看到这对区分”工具慢”还是”工具在排队等审批”至关重要。
6.11 结果回灌:输出有预算,形态要统一
工具跑完产出的结果,要变成历史里的一个标准 item。这里有三个设计点。
统一的结果形态。 无论哪种来源的工具,结果都归一化成”工具结果 item”:一个 success 标志 + 一段输出文本(或结构化内容)。shell 工具的输出还会包一层标准信封——Exit code(退出码)、Wall time(耗时)、Output(输出正文),模型读 shell 结果的方式因此跨平台一致。扩展、动态、MCP 工具的结果也都适配进同一形态,历史里的工具调用-结果对永远是齐整的(第四章规范化所依赖的配对不变量)。
双重输出截断。 第四章讲过工具结果在入库时就按”留头留尾挖中间”截断。在那之前还有一道工具自带的参数级预算:exec_command 有 max_output_tokens 参数(默认约 1 万 token),命令输出超过预算时执行侧就先收一刀,模型还可以在调用时主动调大或调小。两道截断的分工是:参数预算让模型按任务预期控制单次输出(跑测试时可以调大),入库截断是全局硬保险,防止任何一个工具结果撑爆窗口。
hook 可替换模型可见结果。 6.8 第⑥关提到 PostToolUse hook 能改写输出。实现上是一个装饰器:原始结果保留(日志、审计看到的是真相),模型读到的是 hook 提供的反馈文本。这保证”权威记录”和”模型感知”可以不同而不互相污染。
还有一个安全向的细节:如果工具结果包含外部不可信内容(比如网页、MCP 返回),harness 会给线程打上”外部上下文污染”标记,记忆巩固等功能据此关闭——防止外部内容通过工具结果间接注入。工具结果是新信息进入上下文的主要通道,也是提示注入的主要入口,第 8 章会回到这条信任边界。
6.12 工具是万能行动面
把全章串起来,会浮现一个比”工具=函数调用”大得多的图景:在 Codex 里,模型对外部世界的一切影响都被建模成工具调用。
- 操作机器是工具:跑命令、改文件、看图;
- 管理自己是工具:查 token 余额、开新窗口、写计划;
- 问人要权限是工具:
request_user_input、request_permissions,以及 shell/补丁/MCP 调用触发的审批——全部复用第二章的”反转请求-应答”:发事件、挂起、等应答、唤醒。模型不需要区分”我在跑命令”和”我在问用户”,它只是调用了一个返回得慢一点的工具; - 组织分身是工具:
spawn_agent把”创建一个子 agent”也变成一次工具调用(第七章); - 连前端的专属能力都是工具:动态工具把 IDE 的编辑器操作暴露给模型。
这个选择的回报是机制的极致统一。LOOP 不需要为”问用户”开特殊分支——它和等一个慢命令没有结构区别;人机协同不是一套平行系统,而是工具调用的一种自然结果(第一章 1.3 埋下的伏笔在此闭合);中断、审批、并发、超时、重试、记账、hook……所有这些机制只需围绕”工具调用”这一个概念实现一次,就自动覆盖了手脚、提问、派活、委托的全部场景。
类比:工具系统像一个公司给员工配的统一办事窗口。无论是领用电脑(操作环境)、申请预算(问权限)、咨询人事(问用户)、还是外包任务(派子 agent),员工都填同一张”申请单”(工具调用),窗口后面按单子类型走不同流程,结果以同一种回执单返回(工具结果)。员工不需要记住每个部门的门在哪、流程是什么——他只要会看服务目录(spec)、会填单子(参数)。
6.13 小结:工具系统的六条设计原则
-
spec 与 runtime 绑定,看到即可执行。 工具同时是给模型的 spec 和给 harness 的 runtime,注册一次、两处生效;步骤边界冻结成 ToolRouter(注册表 + 可见清单投影),采样期间静止,从结构上杜绝”模型看到的工具”和”能执行的工具”差集。
-
工具是万能行动面。 操作环境、管理自身、问人要权、派生子 agent、委托前端,全部建模为工具调用。人机协同、前端能力扩展因此不是平行机制,而是”发事件→挂起→应答唤醒”模式在工具上的自然复用。
-
spec 即提示词,暴露面分层。 工具描述是写给模型的行为指令,每个 token 都计费;Direct / Deferred / CodeModeOnly / Hidden 等暴露面把工具分到直接清单、tool_search 发现、code mode 嵌套三个可见面,token 花在高频工具上,低频工具按需加载;Hidden 让工具下线变成”藏起来”而非”删掉”。
-
调用走关卡,错误走回灌。 路由查表 → PreToolUse hook → 策略判定 → 审批挂起 → 沙箱执行(可升级重试)→ PostToolUse hook,六道关卡各司其职;错误分两类——模型能处理的回灌为
success=false观察,harness 自身的故障才致命。中断也是一条观察。 -
并行有闸门,回灌有顺序。 工具声明可否并行,读写锁闸门统一执行(可并行者共享、独占者排他);外层 FuturesOrdered 保证结果按调用顺序回灌(第五章)。并行拿延迟收益,闸门与顺序保正确性。
-
一切来源,同一契约。 内置、MCP、扩展、动态(前端执行)四类来源的 runtime 全部进入同一注册表、走同一套关卡与回灌流程;托管工具(服务端执行)虽不注册,也共用同一份 spec 清单和历史 item 形态。命名有保留、冲突有规则、命名空间有归属、结果有预算。新能力接入的默认动作是”注册一个新工具”,而不是”开一条新通路”。
留给读者思考的几个问题:
- deferred 工具靠模型主动
tool_search发现。如果模型”不知道自己不知道”——压根没想到某类工具存在,世界状态片段里只有命名空间名字和一句话描述,这个提示粒度够吗?在”广告不足(漏用工具)“和”广告过度(token 膨胀)“之间,最优平衡点在哪? - 两个工具功能重叠时(如
tool_search与list_mcp_resources),Codex 的做法是在工具描述里明文写”用我而不是它”。这是提示词工程的胜利还是接口设计的妥协?如果让你重新设计,会从 spec、暴露面还是命名上消除重叠? - 并行闸门用一把读写锁实现”可并行工具共享、独占工具排他”。如果两个独占工具其实操作互不相关的资源(比如不同的远程环境),这把粗粒度锁白白损失了什么?要把它细化成”按资源加锁”,工具契约需要增加什么表达?(→ 第 8 章)
- 审批被用户拒绝和命令执行失败,回灌给模型的都是一条
success=false文本。模型若把两者一视同仁地重试会怎样?错误文本里需要携带什么信号,模型才能区分”这条路不通”和”用户不允许走这条路”?(→ 第 8 章) - 动态工具把执行委托给前端,harness 既看不到它的副作用、也无法用沙箱约束它。这对权限模型和审批策略意味着什么?前端提供的 spec 本身可不可信?(→ 第 8、11 章)
- 工具集在步骤边界冻结,但 MCP 服务可能在轮次中途断开。模型这一轮已经发出的调用、历史里已回灌的结果,在下一轮各自会怎样?快照冻结与”外部资源会消失”这两个事实如何调和?(→ 第 10、11 章)
下一章我们进入多 agent 与编排:当 spawn_agent 这个工具被调用,一个新的 agent 线程如何被派出去、父子之间如何通信与等待、并发与深度如何受控——工具系统由此从”一个人干活”扩展到”一支队伍协作”。