PiAgent 05|工具调用:从 Schema 到执行结果#
理解工具如何被模型发现、被宿主校验、被安全执行,并把结果重新接回 Agent Loop
模型说“请调用 query_data”,不等于程序就可以直接执行一个函数。模型输出是不可信的结构化数据,而工具可能读写文件、运行命令、访问数据库或发起网络请求。Pi 的工具系统把“描述能力”和“执行副作用”拆开,目的是让每一个风险点都有明确的检查位置。
1. 工具首先是模型可理解的契约#
一个工具至少有四个组成部分:
name 稳定的调用名称
description 用自然语言说明何时使用、不能做什么
parameters 结构化参数 Schema
execute 接收已验证参数并产生结果模型依靠前三项决定是否调用以及传什么参数;宿主依靠 execute 连接真实系统。描述写得再好也不能替代校验,校验通过也不能替代权限判断。
2. 三层工具对象#
在源码中可以用三个层次理解工具:
- Tool:名称、描述和参数定义,解决“模型能看到什么”。
- AgentTool:再加上异步
execute,解决“运行时如何执行”。 - ToolDefinition:产品层的标签、渲染提示和额外 UI 行为,解决“人如何看到执行过程”。
底层执行器不应该知道终端如何显示一张工具卡片;UI 也不应该绕过 schema 直接调用 execute。这条边界让同一个工具既可以被 TUI 使用,也可以被 Web 或测试环境复用。
3. 一个真实工具的最小形态#
下面是一个只读数据查询工具的示意。示例中的 queryDatabase 是业务层函数,真正接入时还要加入租户、权限、超时和审计信息。
import { Type } from "typebox";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
const queryDataTool = {
name: "query_data",
label: "查询业务数据",
description:
"按允许的字段和条件查询销售数据。只读,不接受任意 SQL。",
parameters: Type.Object({
column: Type.Union([
Type.Literal("地区"),
Type.Literal("产品"),
Type.Literal("月份"),
]),
operator: Type.Literal("="),
value: Type.String({ minLength: 1, maxLength: 100 }),
}),
async execute(_toolCallId: string, params: {
column: string;
operator: "=";
value: string;
}) {
const rows = await queryDatabase(params);
return {
content: [{ type: "text", text: JSON.stringify(rows) }],
details: { rowCount: rows.length },
};
},
};
export default function (pi: ExtensionAPI) {
pi.registerTool(queryDataTool);
}这里最值得注意的是 parameters:它不是给 TypeScript 编译器看的类型注释,而是发送给模型的能力说明,并且应在执行前再次验证。模型可能生成缺字段、错误枚举或超长字符串,不能只依赖模型“应该会遵守 schema”。
4. 五段执行管道#
一次工具调用可以拆成五个阶段:
模型 tool call
→ 解析与合并增量参数
→ Schema 验证
→ beforeToolCall / 权限判断
→ tool.execute
→ afterToolCall / 结果投影
→ ToolResultMessage每一段承担不同责任:
- 解析:把流式 tool call 片段合并成完整的名称和参数。
- Schema 验证:拒绝缺字段、非法枚举、额外参数或超长输入。
- 策略检查:判断用户、会话、工作目录和当前运行是否允许这个操作。
- 执行:在超时、取消和资源边界内调用业务函数。
- 结果包装:把结果转成模型能理解的结构,同时生成日志和 UI 事件。
5. 工具结果为什么必须回到上下文#
工具的返回值不是旁路日志,而是下一次模型请求的输入:
assistant: tool_call(query_data, {地区=华东})
tool: result = {销售额: 120000, ...}
assistant: 根据工具结果生成结论如果只把结果打印到终端,不追加到 transcript,模型下一轮看不到刚刚查到的数据,只能重复调用或凭空回答。反过来,如果把巨大原始结果全部塞回上下文,又会迅速消耗窗口,应该在工具层做裁剪、摘要和可追溯的完整输出保存。
6. 错误协议:让模型能修正,让人能追踪#
工具失败至少要区分:
- 参数错误:模型可以修正输入后重试。
- 权限拒绝:不应自动重试,应该明确告知用户。
- 业务无结果:是有效结果,不等于执行失败。
- 外部系统超时:可能允许有限次数重试。
- 程序异常:对模型返回稳定错误,对日志保留堆栈。
try {
const result = await executeBusinessOperation(params, signal);
return { content: [{ type: "text", text: result }] };
} catch (error) {
return {
content: [{ type: "text", text: "查询失败,请检查条件后重试。" }],
isError: true,
details: { code: classifyError(error) },
};
}isError 是 Loop 和 UI 的稳定信号;details 可以供应用日志使用;面向模型的文本则应简短、可操作,避免把内部路径、密钥或完整堆栈泄漏出去。
7. 并行与串行不是单纯的性能选项#
多个 tool call 是否并行执行,取决于副作用关系:
- 两个独立的只读查询可能并行,缩短等待时间。
- 同一个文件的读写、数据库事务或有顺序依赖的操作应该串行。
- 涉及额度、库存或权限变更的操作不能仅因为模型同时提出就并行。
Pi 的 Agent 配置允许选择工具执行策略,但产品层仍需要根据工具性质决定是否接受并行。并行执行也会改变事件顺序、取消语义和审计记录,不能只看平均耗时。
8. 高风险工具要加第二道闸#
对 bash、文件写入、部署、删除和外部发送等操作,至少要有:
- 输入校验:限制路径、命令、参数长度和允许的枚举。
- 权限校验:把用户身份和资源范围传入执行器。
- 审批或 dry-run:危险动作先展示计划,再由人确认。
- 运行隔离:限制工作目录、环境变量、网络和系统调用。
- 审计记录:记录调用 ID、工具名、参数摘要、结果和操作者。
不要把“请模型自己小心”当成安全机制。模型是决策参与者,不是权限系统。
常见误区
- 只写 TypeScript 参数类型,没有发送给模型的 JSON Schema。
- 允许工具接收任意 SQL、任意路径或任意 shell 字符串。
- 把业务函数直接暴露给模型,没有
beforeToolCall的策略边界。 - 工具抛异常后直接结束 Agent,让模型失去修正机会。
- 只记录工具最终结果,不记录
toolCallId,无法和开始事件配对。
小结
Pi 的工具系统把“模型可见能力”和“真实副作用”之间插入了完整的契约:schema 描述能力,校验过滤不可信参数,钩子执行策略,execute 运行受控动作,结果再以 ToolResultMessage 回到 Loop。工具设计得越清楚,Agent 的行为越可预测;工具边界越模糊,提示词再长也救不了系统。
源码定位
- 工具协议与执行:
packages/agent/src/agent-loop.ts - Agent 工具类型:
packages/agent/src/types.ts - 扩展注册工具:
packages/coding-agent/src/extensions/ - 内置工具:
packages/coding-agent/src/core/tools/ - 工具事件:
tool_execution_start、tool_execution_update、tool_execution_end