第 5 章 工具层:注册表、路由与工具设计¶
第 3 章我们看了循环:模型"要工具→执行→把结果喂回"。但那一章我们把"执行工具"这一步打包成了一个黑盒
run_sampling_request。这一章,我们把这个黑盒打开——模型说"我要调用shell跑个测试",这句话到底怎么变成一次真实的命令执行,结果又怎么回到模型手里。如果说循环是发动机、上下文是燃料,那工具就是 agent 的手。没有手,模型只能"想",不能"做"。
引子:能思考,但碰不到东西¶
把第 1 章那个画面再放一遍:模型很会"想",但它本身碰不到你的文件、跑不了你的测试、看不见报错。它能输出的,只有文本。
让文本变成"动作"的,是工具(tool)。模型在回复里说一句"我要调用 shell,参数是 cargo test",harness 接住这句话,真的去跑 cargo test,再把输出塞回模型。工具,就是模型伸向真实世界的那只手。
这一章要回答三个问题:这只手怎么被装上(注册)、模型的请求怎么被送到对应的手(路由)、以及怎么把手设计得让模型用对(规格)。
一、一次工具调用的一生¶
先看全链路。模型在一轮采样里吐出一个"函数调用",到结果回到模型,中间经过这么几站:
模型输出 function call 模型:"调用 shell,args={cmd: cargo test}"
│
▼
① 路由 ToolRouter 按 ToolName 找到对应的 handler
│
▼
② 前置闸 pre-tool-use hook (可拦截/改写/拒绝,第 9 章)
│
▼
③ 执行 handler(ToolExecutor) 真正干活:跑命令 / 改文件 / 查 MCP……
│ (命令类还要过执行策略 + 沙箱,第 9 章)
▼
④ 后置 post-tool-use hook (记录/补充上下文)
│
▼
⑤ 整理输出 ToolOutput 截断超长输出、附上退出码/耗时
│
▼
结果作为 FunctionCallOutput 回灌 进入下一次采样(回到第 3 章的循环)

图 1:从模型发出 function call 到工具结果回灌,中间依次经过路由、hook、执行与输出整理。
这条链里,本章重点讲①②⑤,③留给后面(命令执行与安全在第 9 章,改代码在第 6 章)。我们先看①——路由是怎么知道该把这次调用送给谁的。
二、三个零件:注册表、路由器、规格¶
Codex 的工具系统,核心就三个零件。
注册表 ToolRegistry:一个"工具名 → 执行器"的登记册。每个工具实现一个统一的执行契约(源码里叫 ToolExecutor / CoreToolRuntime)——给它一次调用,它负责干活并返回结果。注册表把所有工具登记在册,还记着每个工具的元信息(支不支持并发、要不要等取消、有没有 hook)。
路由器 ToolRouter:它握着注册表,外加一份"模型可见的工具规格清单"(model_visible_specs)。模型来一个 ToolCall { tool_name, call_id, payload },路由器按 tool_name 在注册表里找到对应执行器,把调用分发过去。
规格 ToolSpec:一个工具"长什么样"——名字、描述、参数的 JSON Schema。这份规格,就是模型看到的"菜单"。
这三件事在源码里真就是分开的。ToolCall 只描述"模型下了什么单",ToolRouter 则只握两样:后厨登记册和模型可见菜单(model_visible_specs)。核心结构短得几乎像一张名片(core/src/tools/router.rs):
pub struct ToolCall {
pub tool_name: ToolName,
pub call_id: String,
pub payload: ToolPayload,
}
pub struct ToolRouter {
registry: ToolRegistry,
model_visible_specs: Vec<ToolSpec>,
}
短短两段字段,把这一层最重要的边界说透了:下单和怎么执行是两回事,模型看见什么和系统内部注册了什么也是两回事。后面第五节讲延迟加载时,你会看到这条边界为什么值钱。
┌─────────────────────────────┐
模型看见的 │ model_visible_specs(菜单) │ ← 模型据此决定"调用谁、传什么参数"
只是"菜单" │ shell / apply_patch / … │
└──────────────┬──────────────┘
│ 模型下单:ToolCall{ name, args }
▼
┌─────────────────────────────┐
│ ToolRouter(服务员) │ 按 name 找到后厨
└──────────────┬──────────────┘
▼
┌─────────────────────────────┐
│ ToolRegistry(后厨登记册) │ name → ToolExecutor(厨师)
│ shell→… apply_patch→… … │
└─────────────────────────────┘
〔配图 2:注册表 + 路由器 + 规格(餐厅隐喻)〕——画成餐厅:模型是顾客,手里拿一张"菜单"(model_visible_specs,列着 shell / apply_patch / …);服务员是 ToolRouter,接过点单(ToolCall);后厨墙上挂着登记册 ToolRegistry(菜名→厨师 ToolExecutor)。强调"顾客只看菜单,不进后厨"。

图 2:模型只看工具规格这张“菜单”,真正执行动作的是路由器背后的注册表与执行器。
这里有个关键区分,本章后面会再用到:模型看见的是"菜单"(规格),不是"后厨"(执行器)。 菜单上写什么、写多少,直接决定模型点不点得对——这就引出第三件事。
还有一个细节值得记:ToolName 是带命名空间的(namespace + name)。为什么要命名空间?因为工具不只来自内置——MCP 来的、插件来的、扩展来的工具都汇进同一个路由器,命名空间能避免重名打架,也让"这个工具是谁提供的"一目了然。
三、工具规格,就是写给模型的 prompt¶
新手常以为"工具能不能用对"取决于代码写得好不好。错。取决于那份规格(spec)写得好不好。
因为模型唯一能看到的,就是 ToolSpec 里的名字、描述、参数 schema。它什么时候调这个工具、传什么参数,全凭这段描述去"猜"你的意图。描述含糊,模型就乱用;schema 松散,模型就传错参。工具描述,本质上是一段专门写给模型的 prompt。 这正是第 4 章"对 agent 可见的才存在"在工具层的回声——工具的能力,只有写进规格、对模型可见,才真正存在。OpenAI 的 function calling 指南直接把这件事写成 best practice:函数名、参数描述、指令要写得清楚、具体、可执行。
所以 Codex 给大多数工具都单独配了一个 *_spec.rs(比如 shell_spec.rs),专门定义它对模型呈现的样子(少数工具例外:unified_exec 复用 shell 那套 spec,request_permissions 的 spec 则内联在 handler 里)。一份好的工具规格,通常做到:
- 名字即用途:
shell、apply_patch、view_image——看名字就知道干嘛。 - 描述讲清边界:不只说"能做什么",更要说"什么时候用、什么时候别用、有什么坑"。
- schema 收紧:参数用枚举、必填、类型约束,把"能传错的方式"尽量堵死。
- 错误信息会"教"模型:调用失败时,返回的报错本身要能指导模型下一步怎么改(这点在第 9 章的执行策略里尤其明显——Codex 的自定义 lint 报错会把"该怎么修"直接写进给模型的反馈)。
〔配图 3:好工具 vs 坏工具的规格〕——左右对照两张"菜单卡片"。左(坏):工具名
do_stuff,描述一句"does things",参数input: any,旁边模型一脸问号、乱点。右(好):工具名shell,描述写明"运行一条命令;只读沙箱下不能写文件;超时 N 秒",参数command: string[]、workdir?: string,模型点得精准。底部一行字:"模型只能看见菜单——菜单写不清,再强的后厨也白搭。"

图 3:工具规格本身就是写给模型的 prompt;名字、描述和参数约束越清晰,模型越不容易乱点。
Anthropic 专门写过一篇《Writing effective tools for agents》讲这件事。里面有三条和这一节直接对应:工具名要有边界感(namespacing)、返回给模型的上下文要高信号、规格描述本身要像 prompt 一样精修。一个反直觉的经验是:与其加一个新工具,不如把已有工具的描述写得更好——工具太多,菜单本身就会反过来成为上下文负担(这点第六节再说)。
四、内置工具巡礼¶
顺着菜单看一眼 Codex 都给模型配了哪些"手"(都在 core/src/tools/handlers/ 下,大多数配有一个 *_spec.rs):
| 工具 | 干什么 | 备注 |
|---|---|---|
shell / unified_exec |
跑命令 | 最常用的手;要过执行策略 + 沙箱(第 9 章) |
apply_patch |
改代码 | coding agent 的核心动作(第 6 章专讲) |
view_image |
看图片 | 把图喂进多模态上下文 |
plan |
列/更新计划 | 让模型把多步任务的计划显式化 |
request_user_input |
反问用户 | 拿不准时主动问你(而不是瞎猜) |
request_permissions |
申请提权 | 主动请求更高权限(第 9 章) |
tool_search |
搜工具 | 延迟加载:工具太多时,先搜再用(第六节) |
更妙的是:MCP 来的工具、插件 / 扩展提供的工具、运行时动态注册的工具,全都汇进同一个 ToolRouter(看 ToolRouterParams 就知道,它同时接收 mcp_tools、discoverable_tools、extension_tool_executors、dynamic_tools)。对模型来说,不管这只"手"来自哪里,点单方式完全一样。这种"内置与外部工具一视同仁"的设计,是工具系统可扩展的关键(第 8 章 MCP 会接着讲)。
〔配图 4:工具来源大汇流〕——四条入水管(内置 built-in / MCP / 插件·扩展 / 动态 dynamic)汇进同一个水池「ToolRouter」,池子出口接到模型。配一句:"来源不同,点单方式相同。"

图 4:无论工具来自内置、MCP、插件还是动态注册,对模型来说都统一通过 ToolRouter 点单。
五、并发、生命周期,与"别撑爆上下文"¶
把工具做到生产级,还有三件"边角"的事,每件都对应一个真实的坑。
并发:模型有时一口气要调好几个工具(比如同时读三个文件)。ToolRouter 会问每个工具"支不支持并发"(tool_supports_parallel),支持的就并行跑(tools/parallel.rs),省时间。但像改文件这种有副作用、可能互相打架的,就老老实实串行。
生命周期:每次工具调用开始/结束,都会发事件(lifecycle.rs 的 notify_tool_start / notify_tool_finish),这样 UI 能实时显示"正在跑 cargo test…",遥测也能记账(第 13 章)。还有前置/后置 hook(工具侧的 pre_tool_use_payload / post_tool_use_payload,由 run_pre_tool_use_hooks / run_post_tool_use_hooks 触发),是你往工具调用里插一脚的标准位置——拦截、改写、补充上下文、甚至拒绝,都在这里。
别撑爆上下文:工具输出可能巨大(一次 cargo test 刷几千行)。如果原样塞回模型,上下文瞬间爆掉(回想第 4 章:上下文是预算)。所以 Codex 在把输出回灌前会截断——format_exec_output_for_model 给输出限长、附上退出码和耗时;连给遥测用的预览都有 2 KiB / 64 行的硬上限。工具的输出,也是要花预算买的,所以也得省着花。
这事在源码里也很直白。format_exec_output_for_model 不是把命令输出原样回灌,而是强行补上元信息,再决定正文留多少(core/src/tools/mod.rs):
sections.push(format!("Exit code: {}", exec_output.exit_code));
sections.push(format!("Wall time: {duration_seconds} seconds"));
if total_lines != formatted_output.lines().count() {
sections.push(format!("Total output lines: {total_lines}"));
}
sections.push("Output:".to_string());
sections.push(formatted_output);
这段很有代表性:模型真正需要的,不是“所有输出”,而是“足够判断下一步的输出”。退出码、耗时、总行数这些元信息,往往比第 913 行那句重复日志更值钱。Anthropic 那篇工具文章也把这条单独拎出来讲:工具返回给 agent 的内容,要优先追求高信号与 token 效率,该分页、过滤、截断时就截断。
〔配图 5:工具输出的"减压阀"〕——一股巨大的命令输出(几千行)冲向模型,中间有一道"截断阀"把它压成"头部 + 尾部 + [已截断] + 退出码/耗时"的小块,才放进模型的上下文预算条。呼应第 4 章。

图 5:工具输出不是原样回灌,而是先压缩成“足够判断下一步”的高信号片段,再放进上下文预算。
六、菜单也有预算:延迟加载工具¶
最后一个伏笔。前面说"模型看见的是菜单(model_visible_specs)"。但如果你接了几十个 MCP server、上百个工具,把这上百份规格全塞进菜单,菜单本身就把上下文撑爆了——又是第 4 章那个预算问题,只不过这次超支的是"工具说明"。
Codex 的解法,和它对付技能(第 7 章)是同一招:不是所有工具都进菜单。 一部分工具被注册成 ToolExposure::Deferred;菜单上先只放一个 tool_search,模型需要时用它去搜那些"已注册、但没上桌"的工具。源码主干几乎就是明牌(core/src/tools/spec_plan.rs):
.filter(|executor| executor.exposure() == ToolExposure::Deferred)
.filter_map(|executor| executor.search_info())
...
planned_tools.add(ToolSearchHandler::new(search_infos));
这里最容易讲错的一点是:不是“搜到以后把所有工具永久塞回初始菜单”。更准确地说,tool_search 会把可加载的 spec 作为搜索结果回给模型;后续请求依赖这条搜索结果历史来调用命中的工具,而不是把几百份 schema 一次性重新注回第一轮菜单。也正因为如此,Codex 才能把"已注册的工具很多"和"第一轮菜单依然很瘦"同时成立。
这就是"工具名先入、schema 后取"——和第 4 章 skill"只放名录、正文按需加载"、第 8 章 MCP 的延迟加载,是同一个省预算的思想,在三个层面各来一次。记住这个模式,后面会反复见到。
本章小结¶
- 工具是 agent 的手:模型只能输出文本,工具让文本变成动作;一次工具调用要经过路由 → 前置 hook → 执行 → 后置 hook → 整理输出 → 回灌。
- 三个零件:
ToolRegistry(名字→执行器的后厨登记册)、ToolRouter(按名分发的服务员 + 模型可见的菜单)、ToolSpec(模型看见的菜单项)。ToolName带命名空间,让内置/MCP/插件/动态工具汇进同一路由器。 - 工具规格就是写给模型的 prompt:模型只看得见菜单,描述与 schema 写得好不好,直接决定它用不用得对——"与其加工具,不如把描述写好"。
- 生产级的边角:并发执行、生命周期事件与 pre/post hook(插一脚的标准位)、以及对超长输出的截断(工具输出也要花上下文预算)。
- 菜单也有预算:工具太多时用
tool_search延迟加载——"工具名先入、schema 后取",与第 7 章 skill、第 8 章 MCP 同一套省预算思想。
下一章,我们把所有工具里最特殊、对 coding agent 最核心的那一个单拎出来:apply_patch——"改代码"这件事,是怎么被工程化成一个安全、可审查、可回滚的工具的。
参考来源¶
解剖标本(codex-rs 源码)
core/src/tools/registry.rs—ToolRegistry与 hook payloadcore/src/tools/router.rs—ToolRouter、ToolCall、ToolRouterParamscore/src/tools/mod.rs— 子模块与format_exec_output_for_modelcore/src/tools/parallel.rs— 工具并发core/src/tools/lifecycle.rs— 工具生命周期core/src/tools/handlers/— 各 handler 与*_spec.rscore/src/tools/spec_plan.rs—ToolExposure::Deferred与tool_search拼装protocol/src/tool_name.rs—ToolNamecodex_toolscrate —ToolSpec、ToolExecutor、ToolExposure
方法论
- Anthropic《Writing effective tools for agents》
- OpenAI《Harness engineering》
- OpenAI Developers《Function calling》
注:工具系统还涉及
spec_plan::build_tool_router、runtimes/、sandboxing/、动态工具等细节;本章为讲清"注册—路由—规格"主干做了取舍,符号以你 clone 的版本为准。