跳转至

第 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 里)。一份好的工具规格,通常做到:

  • 名字即用途shellapply_patchview_image——看名字就知道干嘛。
  • 描述讲清边界:不只说"能做什么",更要说"什么时候用、什么时候别用、有什么坑"。
  • schema 收紧:参数用枚举、必填、类型约束,把"能传错的方式"尽量堵死。
  • 错误信息会"教"模型:调用失败时,返回的报错本身要能指导模型下一步怎么改(这点在第 9 章的执行策略里尤其明显——Codex 的自定义 lint 报错会把"该怎么修"直接写进给模型的反馈)。

〔配图 3:好工具 vs 坏工具的规格〕——左右对照两张"菜单卡片"。左(坏):工具名 do_stuff,描述一句"does things",参数 input: any,旁边模型一脸问号、乱点。右(好):工具名 shell,描述写明"运行一条命令;只读沙箱下不能写文件;超时 N 秒",参数 command: string[]workdir?: string,模型点得精准。底部一行字:"模型只能看见菜单——菜单写不清,再强的后厨也白搭。"

好工具 vs 坏工具的规格

图 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_toolsdiscoverable_toolsextension_tool_executorsdynamic_tools)。对模型来说,不管这只"手"来自哪里,点单方式完全一样。这种"内置与外部工具一视同仁"的设计,是工具系统可扩展的关键(第 8 章 MCP 会接着讲)。

〔配图 4:工具来源大汇流〕——四条入水管(内置 built-in / MCP / 插件·扩展 / 动态 dynamic)汇进同一个水池「ToolRouter」,池子出口接到模型。配一句:"来源不同,点单方式相同。"

工具来源大汇流

图 4:无论工具来自内置、MCP、插件还是动态注册,对模型来说都统一通过 ToolRouter 点单。

五、并发、生命周期,与"别撑爆上下文"

把工具做到生产级,还有三件"边角"的事,每件都对应一个真实的坑。

并发:模型有时一口气要调好几个工具(比如同时读三个文件)。ToolRouter 会问每个工具"支不支持并发"(tool_supports_parallel),支持的就并行跑(tools/parallel.rs),省时间。但像改文件这种有副作用、可能互相打架的,就老老实实串行。

生命周期:每次工具调用开始/结束,都会发事件(lifecycle.rsnotify_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.rsToolRegistry 与 hook payload
  • core/src/tools/router.rsToolRouterToolCallToolRouterParams
  • core/src/tools/mod.rs — 子模块与 format_exec_output_for_model
  • core/src/tools/parallel.rs — 工具并发
  • core/src/tools/lifecycle.rs — 工具生命周期
  • core/src/tools/handlers/ — 各 handler 与 *_spec.rs
  • core/src/tools/spec_plan.rsToolExposure::Deferredtool_search 拼装
  • protocol/src/tool_name.rsToolName
  • codex_tools crate — ToolSpecToolExecutorToolExposure

方法论

注:工具系统还涉及 spec_plan::build_tool_routerruntimes/sandboxing/、动态工具等细节;本章为讲清"注册—路由—规格"主干做了取舍,符号以你 clone 的版本为准。

留言