Skip to content

第 10 章 工具系统:从“会调用函数”到“可靠地执行一批操作” ​

本章要解决的问题

第 3~9 章已经搭出一个能读、能改、能运行命令且有安全边界的单 Agent 骨架。但它主要按“一次执行一个已知工具”理解问题。当工具数量、调用数量和任务时长增长时,这副骨架还不够。本章先解决第一个规模问题:真正的工具系统还要回答:

  • 一轮到底给模型看哪些工具,工具太多时怎么办;
  • 参数不完整、权限被拒、调用被取消时,消息怎样保持合法;
  • 多个调用能否并行,写同一文件时怎样避免互相覆盖;
  • 工具已经产生副作用、最终却返回错误时,系统怎样报告事实。

本章先用检索、计划和询问说明“工具变多”会带来哪些不同运行要求,再沿着一次工具调用的完整流水线拆开上述问题。它们不是三项功能展示,而是后续调度、状态和取消问题的具体入口。

10.1 工具变多以后,先出现三类新需求 ​

第 4、5 章只需要一个 read_file 就能讲清调用协议。真实任务很快会要求三类新能力:先找到信息、持续记住计划、必要时停下来问用户。选择这三类作为起点,是因为它们分别把工具系统推向三个不同方向:

新需求代表工具对运行时提出的新要求
从大量文件中缩小范围grep / glob只读并行、结果截断、按需呈现
跨多轮保存当前目标todo / plan状态持久化、恢复、与执行权限分离
缺信息或授权时等待用户ask / approvalpending 状态、取消、超时和断线恢复

先把这三类差异看清,后面才知道为什么“工具就是一个函数”不够。

检索:grep 与 glob ​

glob 按文件名找候选,grep 按内容找位置。工具本身不难,难的是在“通用 Shell”和“专用检索工具”之间取舍。

  • codex 主要让模型用 Shell 运行 rg、find 等命令。工具少、组合能力强,但权限判断和输出解析更难细分。
  • grok-build、dsh 提供结构化 grep、glob、LSP 等工具。返回值更稳定,也容易给只读操作单独授权,代价是更多 schema 和维护成本。
  • pi 默认 coding 工具面很小,保留 read、bash、edit、write;只读模式再提供 grep、find、ls,其他能力交给扩展。

不要从这里得出“Shell 一定更好”或“专用工具一定更安全”。选择标准很实际:若操作需要稳定字段、细权限或专门的结果展示,就做专用工具;若只是现有命令的薄包装,优先复用 Shell。怎样逐步检索大型代码库留到第 11 章。

计划:把目标从聊天记录里拿出来 ​

长任务常见三种事故:漏做一项、在局部问题上打转、被用户中途补充的问题带偏。计划工具的价值不是“让模型显得有条理”,而是把当前目标放到一块不容易被上下文冲走的任务板上。

四家的取舍主要在于:计划状态保存在哪里,以及每轮给模型看多少。

  • codex 把计划更新写成会话事件,UI 展示完整计划,工具结果只给简短确认。省 token,但模型更多依赖历史和界面状态。
  • grok-build 更新 todo 后把清单重新带回上下文,并用有限次数的提醒纠正偏航。目标更醒目,代价是重复占用 token。
  • dsh 采用整表替换并写入会话事件,重放时以最新状态为准;工具结果返回待办数量等摘要。
  • pi 核心不内置 todo 或 plan mode。应用可以用扩展补上,但持久化、恢复和审批的一致性也随之交给扩展作者。

Plan 模式又多一层含义:规划阶段只读,用户确认后才执行。它不是 todo 的别名。todo 解决“别忘了做什么”,Plan 模式解决“方向未经确认前不要产生副作用”。

询问:等待用户,不等于默认批准 ​

codex、grok-build 和 dsh 都有结构化提问能力,可以展示问题、选项和说明;pi 主要依赖用户的 steering/follow-up 消息,结构化问答由扩展实现。

阻塞式问题至少要保存四个状态:pending / answered / cancelled / expired。超时或断线后不能把 pending 当成 answered,更不能为高风险操作选择“同意”作为默认值。恢复会话时还要判断原问题是否仍然有效:文件、分支或权限环境已经变化,就应撤回旧问题并重新判断。

到这里,工具之间的差异已经不只是名字和参数:检索可以并行,计划要持久保存,提问会暂停执行。下一节把这些差异放进同一条调用流水线,明确每一步由谁负责。

10.2 工具不是一个函数,而是一条流水线 ​

10.1 的三类工具分别要求并行、持久化和暂停等待,但运行时不应为每个工具重写一套主流程。先把共同的执行链抽出来,后面才能在固定位置加可见性、校验、调度、取消和 hook。教程常把工具调用写成:

text
result = tools[name](arguments)

这行代码隐藏了生产环境真正会出错的地方。一次调用至少经过下面几层:

工具调用从装配到结果提交的完整流水线

关键点是:前半段失败通常没有副作用,进入执行以后就不能再这样假设。 例如工具完成了文件写入,但结果观察器、UI hook 或后续调用失败;此时最终状态可以是 error,磁盘却已经改变。错误记录必须同时描述“调用状态”和“已确认副作用”,不能用一个布尔值覆盖两件事。

一套实用的调用状态可以很简单:

text
received → parsed → approved → started → settled → committed
     └──────── rejected / cancelled-before-start ────────┘
                              started ─→ outcome-unknown

cancelled-before-start 可以安全地重新规划;outcome-unknown 必须先检查外部状态。第 14 章会继续讨论这些状态怎样写入会话并在崩溃后恢复。

10.3 一轮给模型看哪些工具 ​

10.2 的流水线从“装配与呈现”开始。第一个决定不是怎样执行,而是本轮根本允许模型看见什么。工具越多,不只是 schema 占 token;名称相近、参数相似还会增加选错工具的概率。四种常见办法对应四种产品边界:

  1. 直接暴露:当前轮把完整 schema 给模型。适合高频、小而稳定的核心工具。
  2. 延迟发现:先只给目录或搜索入口,命中后再加载完整工具。codex 把工具区分为直接提供、可搜索但不直接提供、完全隐藏三种状态;延迟工具通过 tool_search 被发现。
  3. 按会话装配:dsh 按 preset、scope 和限制规则组装当轮工具;子作用域可以有自己的工具,同时收窄继承来的能力。
  4. 扩展注册:pi 保持小核心,让扩展增加或替换工具;Skill 先给名称和简介,需要时再读取正文。

这不是纯粹的上下文优化,还决定了安全边界。一个工具从“隐藏”变成“可调用”,意味着它的 schema、审批策略、超时、输出大小和恢复语义都进入当前会话。动态加载后应重新生成当轮工具集,不能让模型调用一个策略层尚未认识的名字。

注册冲突不能悄悄覆盖 ​

插件生态里最危险的错误之一,是外部工具用相同名称覆盖内置工具。模型仍然看到熟悉的名字,实际执行的却是另一段代码。

  • codex 区分可信内置注册和外部注册:内置重复是配置错误;外部重名会被跳过,不能覆盖内置能力。
  • dsh 在同一注册层拒绝重名;子作用域可以显式遮蔽父作用域,但保留受控的层级语义,并保护代码执行入口等保留名称。
  • grok-build 的注册表允许 MCP 工具动态加入和移除,因此注销、重连和同名处理也是运行时状态的一部分。
  • pi 允许扩展包装或替换工具,灵活性最高;应用应把扩展来源和最终工具表展示出来,否则用户很难知道同名工具来自哪里。

自建时最简单的规则是:最终工具名必须唯一;覆盖只能通过显式配置;会话记录要保存“工具名 + 实现来源 + 版本”,不能只存名字。

10.4 参数和结果都要有契约 ​

10.3 先把当轮可见的工具集合固定下来。模型真正选中某个工具后,流水线的下一段才是检查调用输入,并约束执行后返回的结果。

参数:能解析不等于完整 ​

参数处理至少要区分三类错误:

text
不是合法 JSON       → syntax_error
JSON 合法但字段不对  → schema_error
字段合法但当前不可执行 → policy_error / stale_state

dsh 先收集整批调用。空参数先变成 {},损坏的 JSON 暂时保留原文,再由工具或 schema 层返回更具体的错误;这样模型能看到“缺哪个字段”,而不是只收到模糊的解析失败。它还会在策略和 hook 运行前保存参数快照,避免某个监听器偷偷修改共享对象。

pi 的边界更保守:如果 assistant 消息因长度上限被截断,本轮所有工具调用都拒绝执行。即使某个半截 JSON 恰巧可以解析,也无法证明尾部没有被截掉一个危险参数。牺牲一次可用调用,换来“被截断的输出绝不产生副作用”。

结果:成功值也要校验 ​

工具结果至少包含三部分:

text
给模型的内容        解释结果和下一步
给程序的结构化值    状态、错误码、资源标识、用量
给 UI 的私有细节     diff、进度、图片、耗时

dsh 要求成功值通过输出 schema,并且能无损转成 JSON,之后才渲染成给模型看的内容;UI diff 等私有元数据随事件保存但不必塞进模型上下文。这样可以防止“实现返回了一个对象,渲染器却按另一种形状解释”。

流式进度也有一个小坑:最终结果到达前,要先排空已经接收的进度更新;最终结果之后到达的迟到更新必须丢弃。pi 明确采用这条规则,避免界面出现“已完成后又回到 80%”。

10.5 多个调用不是简单的 Promise.all ​

10.4 解决了单个调用能不能执行。同一轮出现多个合法调用时,“每个都没问题”仍不能推出“一起执行没问题”。假设模型同一轮发出四个调用:

text
A 读 package.json
B 修改 package.json
C 读 src/index.ts
D 运行测试

全部串行会慢;全部并行又可能让 A/B 竞态、D 在修改前启动。调度器至少要知道:工具是否允许并行、访问哪个资源、是否依赖前一个结果。

四种实际调度取舍 ​

批次形成并行规则结果顺序
codex可在单个工具调用完整后交给调度层并行安全调用共享读锁;独占调用拿写锁,形成屏障每个调用保留自己的身份和事件
grok-build先按模型顺序完成解析、权限和 hook,再启动获批调用获批调用并发;只要同一路径有写操作,该路径相关调用共享文件锁完成一个处理一个,但不会混淆调用身份
dsh收集并解析整批后调度并行工具进入有上限的滚动池;独占工具形成屏障;未启动调用在真正启动前会重新判定按模型原始顺序提交,不是按完成顺序
pi等完整 assistant 消息结束配置要求串行,或批次中任一工具声明串行,则整批串行;否则整批并行Promise.all 保持模型顺序

dsh 的“启动前重新判定”解决了动态工具系统里的细节:前一个调用可能修改注册表或执行策略,后一个原本可并行的调用到启动时已经需要独占。只在批次开始时判定一次会用过期策略执行。

grok-build 的按路径锁比“所有写操作全局串行”更细:写 A 文件不妨碍读 B 文件;但同一路径只要出现写操作,本批次里连读操作也一起排队,避免读到写一半的状态。

pi 的规则最容易实现和解释,但也最保守:一个串行工具会让整批失去并行机会。适合小工具集;工具多以后,可逐步升级成“独占屏障 + 资源锁”,不要一开始就构建复杂依赖图。

执行顺序和提交顺序是两回事 ​

工具可以并发完成,但写回模型历史时必须保持协议合法。若第二个调用先完成就先写结果,有些 provider 会接受,有些日志重放和 UI 会变得难以理解。dsh 选择“执行可以乱序,结果按模型顺序提交”;grok-build 选择及时处理已完成结果,同时用调用 ID 和预分配槽位保持对应关系。

这两种都成立。真正不能做的是仅靠数组位置猜结果属于哪个调用。每条请求、开始、进度和结果都必须携带稳定的 toolCallId。

10.6 取消时最容易破坏消息协议 ​

用户点击停止时,系统需要同时处理三类调用:

text
尚未启动  → 不再启动,并记录“未执行”
已经启动  → 发出取消信号,等待工具真正停稳
已经完成  → 保留真实结果,不能改写成“已取消”

“发出 AbortSignal”不等于工具已经停了。工具可能正在写文件、持有锁或执行不响应取消的系统调用。若调度器立即释放资源并启动下一次操作,旧调用稍后完成就会覆盖新结果。

dsh 的做法很值得借鉴:取消后停止启动新调用,等待已经启动的调用达到稳定结束,再按原顺序提交结果;尚未启动的调用补一条合成的错误结果,让 provider 历史仍然保持“每个 tool call 都有回应”。它的工具超时也是协作式的:超时先触发取消,但要等工具真正 settle 后才释放执行链。

grok-build 在准备阶段如果遇到权限拒绝、取消或用户插话,会给后续尚未执行的调用补“因前一调用取消”结果;运行 Shell 时,终端句柄与注册表锁分开保存,所以即使注册调用仍占锁,取消路径也能终止子进程。

pi 在串行批次中收到取消后直接停止后续调用,没有同样的合成补齐策略;这让核心更简单,但接入严格要求 tool-call/result 成对的 provider 时,适配层需要处理尾部调用。

内部故障不能伪造业务结果 ​

dsh 还区分“用户取消”和“调度器自身崩了”。用户取消时,可以确定尚未启动,所以补 not_executed;内部调度失败时,已经写入日志的调用究竟执行到哪里可能不清楚,此时只排空已启动任务并上抛故障,不编造工具结果。恢复器再根据有没有 tool/start 判断 TOOL_NOT_STARTED 或 TOOL_OUTCOME_UNKNOWN。

这条边界非常重要:为了让消息看起来完整而伪造“失败”,会把未知副作用错误地描述成没有发生,恢复后就可能重复付款、重复发布或重复写入。

10.7 hook、权限和观察器的失败怎样处理 ​

10.3~10.6 已经覆盖了工具本身和调度器的主流程,但生产系统还会在这条流水线前后挂权限检查、扩展 hook 和只读观察器。它们的失败不应共享一个模糊的“插件错误”语义:

  • 执行前 hook 可以改参数、请求审批或阻止调用;阻止时工具不能启动。
  • 执行包装器 可以加超时、日志、缓存,但必须把上游取消继续传给真实工具。
  • 执行后 hook 可以补充或替换展示结果;若文件已经写入,它不能把事实改成“没有执行”。
  • 只读观察器 只做遥测和 UI 更新;观察器失败应被隔离,不能把一个已经成功的业务调用翻成失败。

dsh 把这些阶段明确分开,并规定“拒绝是单向的”:前面的安全守卫已经拒绝,后面的监听器不能再强行放行。pi 的 before hook 可以阻止并要求结束循环,after hook 可以改写呈现内容和用量。codex 会分别记录调度等待、处理器耗时和总耗时,便于判断慢在排队、审批还是工具本身。

10.8 自建起步:先实现一个保守调度器 ​

第一版不用复刻四家的全部机制。按下面顺序增加,比较不容易走偏:

text
第 1 步  工具名唯一;参数做 schema 校验;每个调用都有 toolCallId
第 2 步  默认串行;请求、开始、结果写入追加日志
第 3 步  只把明确标记为 pure/read-only 的工具放入并行池
第 4 步  为文件路径、会话或终端增加资源锁
第 5 步  取消后停止启动新调用,等待已启动调用停稳并补齐未执行结果
第 6 步  再加入延迟发现、动态注册、hook 和代码模式

不要把 readOnly: true 当成唯一安全证明。读取也可能消耗昂贵额度、访问隐私或与写操作存在顺序关系。并行标记描述的是“实现能否安全重叠”,权限标记描述的是“用户是否允许”,二者要分开。

【对比小结】 ​

关键问题codexgrok-builddshpi
工具呈现直接 / 延迟发现 / 隐藏宽工具集,可动态接 MCPnative / code / both,按作用域装配小核心 + 扩展
注册冲突外部不能覆盖可信内置动态注册与注销同层拒绝,子层显式遮蔽扩展可包装或替换,应用负责可见性
批次并发读写锁式并行/独占屏障准备串行、执行并发、同路径锁有上限并行池 + 动态独占屏障任一串行则整批串行
结果提交稳定调用身份按完成处理 + 槽位身份按模型顺序提交批次结果保持模型顺序
取消未启动调用调度前取消可终止后续补取消结果补合成未执行结果串行模式直接停止尾部
截断工具参数不执行未形成的完整调用依赖解析/准备边界解析与 schema 分层报错length 截断整批拒绝

没有一家在所有维度都“最强”。codex 重视工具装配、策略和审批的组合;grok-build 为宽工具集和文件并发做了更细的批处理;dsh 把调度、结果契约和崩溃恢复接得最完整;pi 保持核心规则少而清楚,把产品化差异留给扩展。自建时应按自己的故障模型选保证,而不是照搬工具数量。

工具系统现在能稳定执行一批操作了,但“有 grep、glob 和 read”不等于模型会在大型仓库里找到正确代码。下一章展开 10.1 的检索需求,说明怎样从目录和匹配行逐步缩小到当前任务所需文件;第 12、13 章再接着处理这些信息如何进入上下文、以及累积后怎样清理。

基于 MIT 协议发布,欢迎转载与贡献。