返回首页
05
工具系统Schema验证错误处理并行调度Operations抽象

工具系统:Agent 的手脚是怎么被管住的

三层工具类型、五步执行管道,以及"错误即消息"的防线设计

带着问题读

读完本章,你应该能回答这三个问题:

  1. 1模型把数组参数序列化成字符串传过来(如 edits: \"[{...}]\"),Pi 在哪一步、用什么机制修正这类参数怪癖?答案见正文中对应的「面试题 1」气泡
  2. 2一批工具调用里有两个 edit 改同一个文件,Pi 怎么防止并行执行互相覆盖?答案见正文中对应的「面试题 2」气泡
  3. 3工具 execute 抛出未捕获异常时,为什么 Pi 选择把它编码成 isError 消息而不是让异常穿透打断循环?答案见正文中对应的「面试题 3」气泡

第 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"。

并行 vs 串行 三阶段设计

永不抛出:错误防线

回看五步管道,每一步都可能出错,但规律惊人地统一: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 会看到"工具先报错、再吐出最后一行进度"的乱序画面)。

错误防线:6 种错误 1 种产物

为什么"伪装成消息"是最佳处理方式

异常和消息的区别不在内容,而在接收者是谁:异常的接收者是调用栈,会打断循环;消息的接收者是模型,会消化错误然后继续。看几个真实场景:

错误场景 模型看到错误消息后的合理反应
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——每个工具只声明自己需要的方法,不多不少。

方法论提炼

  1. 分层接口递进法:基础层管"能描述"(Tool),运行时层加"能执行"(AgentTool),产品层加"能展示和扩展"(ToolDefinition),包装器桥接层间差异。
  2. 管道 + 钩子模式:核心流程一条管道(预处理 → 验证 → 执行),前后各一个钩子(拦截 / 修改),管道内任何一步出错都不抛异常。
  3. 错误即消息原则:所有工具错误统一编码为 isError: true 的 ToolResultMessage 发给模型,让模型自己决定下一步;未知异常用 String(error) 兜底,绝不让原始异常穿透打断循环。
  4. 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 许可的开源教程。