10 min read教程

PiAgent 05|工具调用:从 Schema 到执行结果

理解工具如何被模型发现、被宿主校验、被安全执行,并把结果重新接回 Agent Loop

PiAgent 05|工具调用:从 Schema 到执行结果#

理解工具如何被模型发现、被宿主校验、被安全执行,并把结果重新接回 Agent Loop

模型说“请调用 query_data”,不等于程序就可以直接执行一个函数。模型输出是不可信的结构化数据,而工具可能读写文件、运行命令、访问数据库或发起网络请求。Pi 的工具系统把“描述能力”和“执行副作用”拆开,目的是让每一个风险点都有明确的检查位置。

1. 工具首先是模型可理解的契约#

一个工具至少有四个组成部分:

text
name        稳定的调用名称
description 用自然语言说明何时使用、不能做什么
parameters 结构化参数 Schema
execute     接收已验证参数并产生结果

模型依靠前三项决定是否调用以及传什么参数;宿主依靠 execute 连接真实系统。描述写得再好也不能替代校验,校验通过也不能替代权限判断。

2. 三层工具对象#

在源码中可以用三个层次理解工具:

  • Tool:名称、描述和参数定义,解决“模型能看到什么”。
  • AgentTool:再加上异步 execute,解决“运行时如何执行”。
  • ToolDefinition:产品层的标签、渲染提示和额外 UI 行为,解决“人如何看到执行过程”。

底层执行器不应该知道终端如何显示一张工具卡片;UI 也不应该绕过 schema 直接调用 execute。这条边界让同一个工具既可以被 TUI 使用,也可以被 Web 或测试环境复用。

3. 一个真实工具的最小形态#

下面是一个只读数据查询工具的示意。示例中的 queryDatabase 是业务层函数,真正接入时还要加入租户、权限、超时和审计信息。

ts
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. 五段执行管道#

一次工具调用可以拆成五个阶段:

text
模型 tool call
  → 解析与合并增量参数
  → Schema 验证
  → beforeToolCall / 权限判断
  → tool.execute
  → afterToolCall / 结果投影
  → ToolResultMessage

每一段承担不同责任:

  1. 解析:把流式 tool call 片段合并成完整的名称和参数。
  2. Schema 验证:拒绝缺字段、非法枚举、额外参数或超长输入。
  3. 策略检查:判断用户、会话、工作目录和当前运行是否允许这个操作。
  4. 执行:在超时、取消和资源边界内调用业务函数。
  5. 结果包装:把结果转成模型能理解的结构,同时生成日志和 UI 事件。

5. 工具结果为什么必须回到上下文#

工具的返回值不是旁路日志,而是下一次模型请求的输入:

text
assistant: tool_call(query_data, {地区=华东})
tool:     result = {销售额: 120000, ...}
assistant: 根据工具结果生成结论

如果只把结果打印到终端,不追加到 transcript,模型下一轮看不到刚刚查到的数据,只能重复调用或凭空回答。反过来,如果把巨大原始结果全部塞回上下文,又会迅速消耗窗口,应该在工具层做裁剪、摘要和可追溯的完整输出保存。

6. 错误协议:让模型能修正,让人能追踪#

工具失败至少要区分:

  • 参数错误:模型可以修正输入后重试。
  • 权限拒绝:不应自动重试,应该明确告知用户。
  • 业务无结果:是有效结果,不等于执行失败。
  • 外部系统超时:可能允许有限次数重试。
  • 程序异常:对模型返回稳定错误,对日志保留堆栈。
ts
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、文件写入、部署、删除和外部发送等操作,至少要有:

  1. 输入校验:限制路径、命令、参数长度和允许的枚举。
  2. 权限校验:把用户身份和资源范围传入执行器。
  3. 审批或 dry-run:危险动作先展示计划,再由人确认。
  4. 运行隔离:限制工作目录、环境变量、网络和系统调用。
  5. 审计记录:记录调用 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_starttool_execution_updatetool_execution_end

相关文章