第 5 章:工具系统 —— Agent 的手脚是怎么被管住的
当模型的回复里出现这样一条指令:
{ "type": "toolCall", "id": "call_abc123", "name": "read", "arguments": { "path": "src/main.ts" } }
从这条指令到文件内容回到模型面前,中间经历了什么?直觉答案是"找到 read 工具、读文件、塞回消息"。但现实没这么简单——模型可能传错误类型的参数(path: 12345),可能要求执行危险命令(rm -rf /),工具执行时可能抛异常(文件不存在)。
Pi 用一条五步管道解决这些问题:参数预处理 → Schema 验证 → 权限拦截 → 工具执行 → 结果后处理。每一步职责明确,每一步的错误都不会"炸掉"整个循环。但讲管道之前,先得搞清楚一个更基础的问题:工具到底是怎么定义的?
三层类型:为什么"一个工具"要分三层定义
第一层 Tool(pi-ai)——一张名片:
// packages/ai/src/types.ts:433-437
export interface Tool<TParameters extends TSchema = TSchema> {
name: string; // 工具名,如 "read"、"bash"
description: string; // 给 LLM 看的描述
parameters: TParameters; // 参数的 JSON Schema(TypeBox 定义)
}
它唯一关心的是"怎么把工具信息告诉模型"。能描述自己,但不能执行任何操作。
第二层 AgentTool(pi-agent-core)——加上执行能力:
// packages/agent/src/types.ts:371-394
export interface AgentTool<TParameters, TDetails> extends Tool<TParameters> {
label: string; // 给人看的标签(模型看 name,UI 看 label)
prepareArguments?: (args: unknown) => Static<TParameters>; // 兼容性垫片
execute: (toolCallId: string, params: Static<TParameters>,
signal?: AbortSignal, onUpdate?: AgentToolUpdateCallback<TDetails>,
) => Promise<AgentToolResult<TDetails>>;
executionMode?: "sequential" | "parallel";
}
第三层 ToolDefinition(pi-coding-agent)——产品层能力:execute 多了第 5 个参数 ctx: ExtensionContext(访问会话状态),还有 promptSnippet(系统提示词片段)、renderCall / renderResult(终端渲染)等 UI 字段。
两层之间靠一个十几行的包装器桥接——wrapToolDefinition 通过闭包捕获 ctxFactory,调用时动态注入第 5 个参数:
// packages/coding-agent/src/core/tools/tool-definition-wrapper.ts
execute: (toolCallId, params, signal, onUpdate) =>
definition.execute(toolCallId, params, signal, onUpdate, ctxFactory?.()),
Agent Loop 永远不知道 ExtensionContext 的存在。
为什么非要分三层?因为每层有独立的依赖范围:如果 pi-ai 的 Tool 里加了 renderCall(返回终端 UI 组件),pi-ai 就得依赖终端渲染库——但它是纯模型适配层,不该知道 UI 长什么样。三层递进的本质是:每一层只加自己这个层级需要的能力,不越界。
五步管道:工具调用不是"调个函数就完了"
LLM 输出 ToolCall
▼
第 1 步:参数预处理(prepareArguments)
处理 LLM 的参数怪癖,如把字符串化的数组解析回真正的数组
▼
第 2 步:Schema 验证(validateToolArguments)
用 TypeBox 做运行时类型检查:path 是 string,不是 number
▼
第 3 步:权限拦截(beforeToolCall 前置钩子)
产品层拦截:返回 { block: true, reason } 则阻止执行
▼
第 4 步:工具执行(tool.execute)
真正干活,支持 onUpdate 流式进度回调
▼
第 5 步:结果后处理(afterToolCall 后置钩子)
可替换 content / details / isError,或返回 { terminate: true }
▼
ToolResultMessage
第 1 步,参数预处理——兼容性垫片。 不同模型序列化参数时有怪癖:Edit 工具期望 edits 是数组,某些模型却把数组序列化成字符串 "[{...}]" 传过来。prepareArguments 负责解析回真正的数组;工具没定义就直接透传。为什么不和 Schema 验证合并?因为关注点不同——预处理是"我知道某个模型会犯什么错"的兼容层,验证是"不管谁调我都得验"的安全层,混在一起没法维护。
第 2 步,Schema 验证。 TypeBox 运行时类型检查。验证失败被 try-catch 捕获,生成错误 ToolResultMessage——工具永远不会收到类型错误的参数。
第 3 步,权限拦截。 beforeToolCall 返回 undefined 放行,返回 { block: true, reason: "危险命令" } 阻止。注意:即使被阻止,产物仍是一条正常的 ToolResultMessage(isError: true),模型看到后自己决定下一步——换命令或向用户解释。不抛异常,不打断循环。
第 4 步,工具执行。 四个参数中 onUpdate 解决"长任务的进度感知":Bash 每 100ms 推一次终端输出,Grep 每找到一批匹配推一次,都包装成 tool_execution_update 事件流向 UI。没有 onUpdate,工具执行是黑盒;有了它,执行是可观察的。 还有个防御性细节:execute 返回后内部可能还有没结束的异步操作(如 Bash 子进程还在打印最后几行),Pi 用 acceptingUpdates 标志位在 settle 后关闭闸门,之后的 onUpdate 调用一律静默丢弃。
第 5 步,结果后处理。 afterToolCall 可以脱敏(替换敏感内容)、审计(读 result 写日志后返回 undefined)、修错(把错误结果修正为正常结果)、早停({ terminate: true })。合并语义是字段级覆盖。
五步走完,不管中间出了什么状况,最终产物都是一条 ToolResultMessage,追加到对话历史,下一轮作为上下文发给模型。
并行 vs 串行:一票否决与三阶段设计
模型的一次回复经常包含多个 ToolCall。直觉是 Promise.all 一起跑,但如果两个 edit 改同一个文件,并行就会互相覆盖。Pi 的调度策略是一票否决——只要批次中有一个工具声明 executionMode: "sequential",整批串行:
const hasSequentialToolCall = toolCalls.some(
(tc) => tools?.find((t) => t.name === tc.name)?.executionMode === "sequential",
);
if (config.toolExecution === "sequential" || hasSequentialToolCall) {
return executeToolCallsSequential(...);
}
return executeToolCallsParallel(...);
为什么一票否决而不是只串行冲突的工具?因为"哪些工具会冲突"很难精确判断——edit 不同文件就安全吗?万一文件间有依赖关系呢?宁可多等,不可出错。
判定可并行时也不是无脑 Promise.all,而是三阶段设计:
阶段 1 - 准备(顺序):A 准备 → B 准备 → C 准备
prepareArguments + validate + beforeToolCall 必须顺序——钩子可能有副作用,
万一 B 被拦截,C 就不该执行
阶段 2 - 执行(并行):A、B、C 同时 execute(Promise.all)——只有这一步真并行
阶段 3 - 事件(有序):end 按完成顺序发;result 按调用顺序发——
模型先要求 read 再要求 grep,消息就得按这个顺序排
细节:v0.80.2 的 7 个内置工具都没有显式声明
executionMode,默认全部并行。Edit 怎么保证文件安全?靠工具内部的withFileMutationQueue(file-mutation-queue.ts:32-61)——对同一文件的编辑串行化,这是第二道防线。扩展工具如需串行,可显式声明executionMode: "sequential"。
永不抛出:错误防线
回看五步管道,每一步都可能出错,但规律惊人地统一:6 种错误来源,1 种产物——一条 isError: true 的 ToolResultMessage。
| 哪一步出错 | 最终产物 |
|---|---|
| 工具未找到 | "Tool xxx not found" |
| prepareArguments 抛异常 | 异常信息 |
| Schema 验证失败 | 验证错误描述 |
| beforeToolCall 阻止 | 阻止原因 |
| tool.execute 抛异常 | 异常信息(框架兜底 catch) |
| afterToolCall 抛异常 | 异常信息 |
没有一种错误会以"抛异常"的形式逃出管道。最关键的一层防护在 executePreparedToolCall(),它包住最容易出错的 tool.execute():
// packages/agent/src/agent-loop.ts:628-669(节选)
async function executePreparedToolCall(prepared, signal, emit) {
const updateEvents: Promise<void>[] = [];
let acceptingUpdates = true;
try {
const result = await prepared.tool.execute(..., (partialResult) => {
if (!acceptingUpdates) return; // settle 后的孤儿回调直接忽略
updateEvents.push(/* 发 tool_execution_update */);
});
acceptingUpdates = false;
await Promise.all(updateEvents);
return { result, isError: false };
} catch (error) {
acceptingUpdates = false;
await Promise.all(updateEvents); // 进度事件先发完,再编码错误
return { result: createErrorToolResult(...), isError: true };
} finally {
acceptingUpdates = false; // 兜底:无论如何都关闸门
}
}
三个工程决策:异常被 catch 不穿透(ENOENT、EACCES、超时、SyntaxError 统统止步于此);异常被翻译成正常形态的结果(catch 后它不再是"异常",而是"一条带错误标记的消息");进度事件先发完再编码错误(否则 UI 会看到"工具先报错、再吐出最后一行进度"的乱序画面)。
为什么"伪装成消息"是最佳处理方式
异常和消息的区别不在内容,而在接收者是谁:异常的接收者是调用栈,会打断循环;消息的接收者是模型,会消化错误然后继续。看几个真实场景:
| 错误场景 | 模型看到错误消息后的合理反应 |
|---|---|
read 报"文件不存在" |
先 ls 看目录里有什么,找到正确文件名再读 |
edit 报"oldText 找不到匹配" |
先 read 文件查看实际内容,调整后重试 |
bash("npm run build") 报"模块未找到" |
先 npm install 再 build |
rm -rf / 被 beforeToolCall 阻止 |
换安全写法或向用户解释 |
每种场景的正确下一步都不同,只有模型有足够的上下文判断该走哪条路。框架抛异常打断循环,等于放弃模型的全部自我纠错能力;错误编码成消息,错误就成为下一步决策的输入——这是 Agent 比传统脚本更"智能"的关键之一。
两层分工:工具主动包装,框架被动兜底
错误描述越具体,模型纠错能力越强。"Read failed" 只能让模型盲目重试;"Offset 200 is beyond end of file (100 lines total)" 能让模型立刻明白该给 offset: 50。Pi 的内置工具绝不靠框架兜底,而是工具内部就写得具体——read.ts:275 附上文件总行数;bash.ts:390-407 是教科书级示范:主动识别中止、超时、非零退出码,用 appendStatus(text, ...) 把"已输出的内容 + 具体原因"打包成新 Error,识别不了的才 throw err 原样交给框架。
框架的兜底 catch 只做搬运——createErrorToolResult 函数体只有三行,工具写的 message 是什么,模型就看到什么。写自定义工具时记住两条:能识别的错误一定要包装("文件 /a.ts 不存在,目录下有 [b.ts, c.ts]"比"操作失败"强一百倍);识别不了的不要硬编码笼统描述,直接 throw err 让框架透传 err.message。
进阶:Operations 抽象——工具执行不等于系统调用
Pi 的每个工具都不直接调 fs、child_process,而是依赖一个按需求裁剪的最小接口:
export interface ReadOperations {
readFile: (absolutePath: string) => Promise<Buffer>;
access: (absolutePath: string) => Promise<void>;
detectImageMimeType?: (absolutePath: string) => Promise<string | null>;
}
// 测试时注入 Mock,远程时注入 SSH 实现,工具代码一行不改
const tool = createReadToolDefinition(cwd, {
operations: {
readFile: () => Buffer.from("mock file content"),
access: () => {},
}
});
接口按工具裁剪:Read 没有 writeFile,Bash 只有一个 exec,Ls 有 exists / stat / readdir——每个工具只声明自己需要的方法,不多不少。
方法论提炼
- 分层接口递进法:基础层管"能描述"(Tool),运行时层加"能执行"(AgentTool),产品层加"能展示和扩展"(ToolDefinition),包装器桥接层间差异。
- 管道 + 钩子模式:核心流程一条管道(预处理 → 验证 → 执行),前后各一个钩子(拦截 / 修改),管道内任何一步出错都不抛异常。
- 错误即消息原则:所有工具错误统一编码为
isError: true的 ToolResultMessage 发给模型,让模型自己决定下一步;未知异常用String(error)兜底,绝不让原始异常穿透打断循环。 - Operations 抽象法:工具不直接调系统 API,测试可 Mock、远程可 SSH,不改工具代码。
收尾
回到开场的问题:"当模型说'读取这个文件',到底发生了什么?"现在答案完整了——ToolCall 经过参数预处理、Schema 验证、权限拦截、工具执行(通过 Operations 接口而非直接调 fs)、结果后处理五步,产出一条 ToolResultMessage 回到对话历史。参数验证挡住垃圾数据,钩子拦截危险操作,Operations 抽象让同一份代码既能本地跑也能远程跑,错误防线保证循环永远不崩。
至此,Agent 的"思考"(Loop)、"表达"(模型调用)和"行动"(工具系统)都已拆开。剩下最后一个核心问题:这些在循环里流转的消息——用户输入、模型回复、工具结果——到底以什么结构存储和传递?Agent 内部的消息和发给模型的消息一样吗?下一章,消息系统。
本章关键源码索引:
ai/src/types.ts:433-437(Tool)、agent/src/types.ts:371-394(AgentTool)、agent-loop.ts:562-626(管道前 3 步)、agent-loop.ts:628-669(execute + 兜底 catch)、agent-loop.ts:716-721(createErrorToolResult)。本文改编自 CC-BY-SA-4.0 许可的开源教程。