Skip to content

2.2 工具(Tool)系统

工具是 Agent 与外部世界交互的"手"。pi 把工具定义成一套带 Schema 校验的接口,让模型能安全地调用任意功能。这一节讲清楚 AgentTool 契约、执行生命周期,以及如何写一个自己的工具。

先连回 2.1:工具在循环里被用在哪一步

在进入 AgentTool 细节前,先想清楚:工具在 2.1 核心循环被用在哪一步

回顾 2.1 的循环,工具出现在两处

ts
// ==== 位置 A:发给模型前的"工具清单" ====
const llmContext: Context = {
	systemPrompt,
	messages: llmMessages,
	tools: context.tools,          // ← 这里!把 AgentTool 列表"介绍"给模型
};
// 模型看到 tools,才知道"我可以调用这些工具"(见 2.1 的 Function Calling 一课)

// ==== 位置 B:模型发出 toolCall 后"真正执行" ====
const toolCalls = message.content.filter((c) => c.type === "toolCall");
if (toolCalls.length > 0) {
	hasMoreToolCalls = !executedToolBatch.terminate;
	// 进入 executeToolCalls → 最终调到 tool.execute() ← 工具真正干活的地方
}

所以在前面的循环里,工具扮演两个角色:

循环里的位置工具的角色对应 2.2 的内容
位置 A(发请求前)作为 Context.tools介绍给模型AgentToolname / description / parameters
位置 B(收到 toolCall 后)作为执行体,真正干活AgentTool.execute + 执行生命周期

一句话:2.1 的循环负责"模型提议调工具",2.2 的工具负责"真正执行"。两者通过 Context.tools(介绍)和 toolCall(请求)对接。

AgentTool 契约

packages/agent/src/types.ts:380

ts
export interface AgentTool<TParameters extends TSchema, TDetails = any> extends Tool<TParameters> {
	label: string;   // 显示名
	prepareArguments?: (args: unknown) => Static<TParameters>;  // 参数兼容垫片
	execute: (
		toolCallId: string,
		params: Static<TParameters>,   // 已经过 Schema 校验的参数
		signal?: AbortSignal,
		onUpdate?: AgentToolUpdateCallback<TDetails>,  // 流式进度回调
	) => Promise<AgentToolResult<TDetails>>;
	executionMode?: "sequential" | "parallel";
}

它继承自 @pi/aiToolpackages/ai/src/types.ts:491):

ts
export interface Tool<TParameters extends TSchema> {
	name: string;
	description: string;      // 交给模型的说明
	parameters: TParameters;  // typebox Schema,用于生成 JSON Schema 与校验
	constrainedSampling?: ...;
}

核心:parameters 是 typebox Schema。pi 用它做两件事:

  1. 生成 JSON Schema 发给模型,让模型知道"该用什么参数"。
  2. 在模型发来参数后,校验参数是否合法。

发给模型的 tool 数据长什么样

Context.tools 里的 AgentTool运行时对象,但发给模型时不会原样发,而是被 API 实现层转换成 JSON Schema 格式。假设这样一个工具:

ts
const getWeatherTool: AgentTool = {
	name: "get_weather",
	description: "查询指定城市的天气",
	parameters: Type.Object({
		city: Type.String({ description: "城市名" }),
		unit: Type.Optional(Type.Union([Type.Literal("celsius"), Type.Literal("fahrenheit")])),
	}),
};

模型真正收到的(以 OpenAI tools 参数为例):

json
{
  "tools": [
    {
      "type": "function",
      "function": {
        "name": "get_weather",
        "description": "查询指定城市的天气",
        "parameters": {
          "type": "object",
          "properties": {
            "city": { "type": "string", "description": "城市名" },
            "unit": { "type": ["string", "null"], "enum": ["celsius", "fahrenheit"] }
          },
          "required": ["city"]
        }
      }
    }
  ]
}

关键点

  1. parameters(typebox Schema)被编译成标准 JSON Schema —— 模型靠它知道"该传哪些参数、什么类型、哪些必填"。
  2. 只发 name + description + parameters,不发 execute
AgentTool 字段发给模型?原因
name模型用它"点名"要调哪个工具
description模型据此判断"什么时候该用"
parameters✅(转 JSON Schema)模型据此生成合法参数
execute这是你的执行函数,不能发给模型
label纯 UI 概念
executionMode / 钩子运行时逻辑,与模型无关
  1. parameters 是"可选性"的载体:Type.Optional(...) 决定字段是否出现在 required 里。模型生成的参数必须满足 required,否则参数校验会拒绝。

各家厂商的差异(api 字段为什么重要):不同协议对工具的编码略有不同,@pi/ai 的 API 实现层负责把 Context.tools(统一形式)转成各家协议要求的格式,也把模型回复的 toolCall 反向转回 pi 统一形式:

协议工具格式
OpenAI openai-completions / openai-responsestools: [{type:"function", function:{...}}]
Anthropic anthropic-messagestools: [{name, description, input_schema}]
Google google-generative-aitools: [{functionDeclarations: [...]}]

一句话:发给模型的 tools 是"介绍信",只含 name + description + parameters(JSON Schema),不含 execute。模型据此生成合法的 toolCall 参数;@pi/ai 的 API 实现层负责格式转换。

工具执行的生命周期

一个工具调用经历 prepareToolCallexecutefinalize 三个阶段(agent-loop.ts):

mermaid
flowchart LR
    TC[assistant 消息里的 toolCall] --> PREP[参数校验/准备]
    PREP -->|beforeToolCall 返回 block| BLK[发错误结果]
    PREP -->|通过| EXEC[执行 execute]
    EXEC --> FIN[afterToolCall 改写结果]
    FIN --> RES[toolResult 消息回填]
prepareToolCall (agent-loop.ts:600)
  ├─ 找到同名工具;找不到 → 发"Tool not found"错误结果
  ├─ prepareArguments 垫片
  ├─ validateToolArguments 用 Schema 校验
  ├─ beforeToolCall 拦截(可 block)
  └─ 返回 { kind: "prepared", tool, args }

executePreparedToolCall (agent-loop.ts:666)
  └─ tool.execute(toolCallId, args, signal, onUpdate)
       ├─ 正常返回 → { result, isError: false }
       └─ 抛错     → { result: 错误结果, isError: true }

finalizeExecutedToolCall (agent-loop.ts:709)
  └─ afterToolCall 可部分改写 content / isError / usage / terminate

注意一个细节:工具应通过"抛错"报告失败,循环会把它转成错误结果消息,而不是让 Agent 崩溃。

工具的顺序依赖怎么表达

工具之间的依赖要分两种情况,pi 分别用不同机制处理。

情况一:只是"不要并发跑"(避免冲突 / 保证先后)

如果多个工具调用不该同时执行(比如都写同一个文件、共享某资源),用声明来约束:

ts
// 方式 A:单个工具声明串行
const writeTool: AgentTool = {
	name: "write",
	description: "写入文件",
	parameters: Type.Object({ path: Type.String(), content: Type.String() }),
	executionMode: "sequential",   // ★ 这个工具必须串行执行
	async execute(toolCallId, params) { ... },
};

// 方式 B:全局设置所有工具串行
const agent = new Agent({ streamFn, toolExecution: "sequential", ... });

循环里的选择逻辑(agent-loop.ts:411):

ts
const hasSequentialToolCall = toolCalls.some(
	(tc) => currentContext.tools?.find((t) => t.name === tc.name)?.executionMode === "sequential",
);
if (config.toolExecution === "sequential" || hasSequentialToolCall) {
	return executeToolCallsSequential(...);   // 一个执行完再执行下一个
}
return executeToolCallsParallel(...);        // 都准备好后并发执行

sequential 只保证"同一批 toolCall 一个接一个执行",但参数相互独立 —— 它不解决"B 需要 A 的结果"。

情况二:真正的数据依赖(B 需要 A 的结果当参数)

这种情况工具定义层面不需要、也无法表达,而是靠模型自己决定。因为工具结果(toolResult)都回填进了上下文,模型能看到前面工具的结果:

第 1 轮:模型发出 toolCall A
  → 循环执行 A → toolResult_A 回填
第 2 轮:模型看到 toolResult_A,据此发出 toolCall B(参数带上 A 的结果)
  → 循环执行 B → toolResult_B 回填
第 3 轮:模型给出最终答案
ts
// 你不需要写任何"依赖声明",只需提供两个独立工具:
const readTool = { name: "read", execute: ({ path }) => 读文件... };
const editTool = { name: "edit", execute: ({ path, replacement }) => 改文件... };
// 模型会自己先调 read,看到内容后再调 edit(把内容带进参数)

两种机制怎么选

需求用什么是否需要声明
多个工具共享资源,不能并发executionMode: "sequential" 或全局 toolExecution: "sequential"需要(声明串行)
B 的参数依赖 A 的结果不用声明,靠模型多轮调用不需要
两者都有串行模式 + 模型多轮声明串行即可

一句话:"只是别并发"用 executionMode: "sequential"(或全局 toolExecution)声明;"B 依赖 A 的结果"则无需声明,靠模型在多轮循环里"看到 A 的结果后再调 B"自然达成。 pi 不提供"工具间参数依赖声明",因为模型本身就具备这种顺序推理能力。

工具结果的形状

AgentToolResult<TDetails>types.ts:355):

ts
export interface AgentToolResult<TDetails> {
	content: (TextContent | ImageContent)[];  // 返回给模型的内容
	details: TDetails;                        // 结构化细节(UI/日志用)
	usage?: Usage;
	addedToolNames?: string[];                // 动态新增的工具
	terminate?: boolean;                      // 提示"执行完这批就停"
}

content 会被循环转成 toolResultMessage.content 回填给模型;details 则供 UI 渲染(例如 bash 的退出码、耗时)。

连回 2.1terminate 字段直接控制循环的 hasMoreToolCalls!在 2.1 里我们见到 hasMoreToolCalls = !executedToolBatch.terminate。这里 terminate 就是某个工具结果里带的 terminate: true。若该批次所有工具结果都设了 terminate: true,即使模型还想调工具,内层循环也会停止 —— 这是"工具主动要求收工"的机制。

写一个自己的工具

ts
import { Type } from "@earendil-works/pi-ai";

const echoTool: AgentTool<{ message: string }> = {
	name: "echo",
	label: "回显",
	description: "把输入的文本原样返回",
	parameters: Type.Object({ message: Type.String() }),
	async execute(_toolCallId, params) {
		return { content: [{ type: "text", text: params.message }], details: {} };
	},
};

然后把它挂到 Agent 上:

ts
const agent = new Agent({ streamFn, initialState: { tools: [echoTool], ... } });
// 或运行时赋值
agent.state.tools = [echoTool];

动态工具:addedToolNames

pi 支持工具在执行后动态引入新工具(例如 bash 工具执行时发现用户装了某个包管理器)。工具只需在结果里返回:

ts
return {
	content: [...],
	details: {},
	addedToolNames: ["npm"],
};

toolResultMessage.addedToolNames 会携带这些名字,供支持原生延迟加载的 provider 使用(见 packages/ai/src/types.ts:439)。

内置工具一览(coding-agent)

@pi/coding-agent 基于这套契约提供了真实工具:readbasheditwritegrepfindls(见 6.3 内置工具)。它们证明了这套契约足以支撑一个生产级编码 Agent。

小结

  • 工具 = name + description + parameters(JSON Schema) + execute
  • 生命周期:校验 →(拦截)→ 执行 →(改写)→ 回填。
  • 失败用"抛错"表达,循环会优雅转成错误结果。
  • addedToolNames 支持动态引入工具。
真实源码位置
  • AgentToolpackages/agent/src/types.ts:380
  • Toolpackages/ai/src/types.ts:491
  • 执行生命周期:packages/agent/src/agent-loop.ts:600-711

面试角度:为什么这样设计工具系统

Q1:为什么工具参数要用 JSON Schema(typebox)校验? 因为工具参数是模型(不可信)生成的。模型可能胡说参数、类型不对、丢字段。用 Schema 校验,能在执行前拦截非法参数,避免把脏数据传给真实函数(如 bash、edit)。校验失败返回错误结果,让模型自己修正 —— 这是"安全边界"的关键。

Q2:为什么工具用"抛错"报告失败,而不是返回一个错误对象? 因为抛错是 JS 天然的失败信号,能让调用方(executePreparedToolCall)统一捕获并转成 error 结果消息,而不用写一堆 if (result.error) 判断。同时它能保证"工具失败不会让 Agent 崩溃",失败被优雅地转述给模型。

Q3:为什么要有 beforeToolCall / afterToolCall 钩子?beforeToolCall 让宿主在工具执行前可拦截(比如危险命令 bash 需要用户确认);afterToolCall 让宿主可改写结果(比如脱敏、附加信息)。这两个钩子把"安全策略"和"输出加工"从工具实现里抽出来,工具本身保持纯粹。

Q4:为什么执行模式分 parallelsequential 因为工具调用之间可能有依赖或资源冲突。默认 parallel 提升吞吐(多个独立工具同时跑);sequential 适合有顺序依赖、或工具本身不该并发(如写同一文件)的场景。AgentTool 还能为单个工具单独声明 executionMode 覆盖全局策略。

下一步:2.3 生命周期事件