自定义工具开发
本教程共 30 篇 · 第 17 篇 · 更新于 2026-08-10 · 约 10 分钟阅读
本节目标:掌握
registerTool()的完整用法——参数定义、返回值格式、错误处理、进度更新和文件安全队列。写出一个能实际运行的天气查询工具。
上一章的 greet 工具只是一个 hello world。真正的生产工具要考虑的事多得多:参数怎么定义才让 AI 理解准确?返回值什么格式?工具执行出错了怎么通知 AI?文件操作怎么防止并发冲突?
这一章把自定义工具开发的每个环节拆开讲清楚。
工具是怎么工作的
先理解流程。当 AI 决定调用一个工具时,pi 的调用链是这样的:
LLM 输出 tool_call
→ prepareArguments() 转换参数(可选)
→ TypeBox Schema 校验参数
→ execute() 执行工具逻辑
→ 返回值(content + details)发给 LLM
你的工作是把后三步写好:定义 Schema 让参数不出错,实现 execute 让逻辑正确,组织返回值让 AI 能理解结果。
工具定义的完整字段
pi.registerTool() 接收一个对象,以下是所有可用字段:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
name | string | ✅ | 工具唯一标识,AI 通过这个名字调用 |
label | string | ✅ | 在 UI 里显示的标签 |
description | string | ✅ | 工具功能描述,AI 据此判断何时用它 |
parameters | TypeBox Schema | ✅ | 参数类型定义 |
execute | async function | ✅ | 工具执行逻辑 |
promptSnippet | string | 否 | 在系统提示词 Available tools 区域显示的单行简介 |
promptGuidelines | string[] | 否 | 追加到系统提示词 Guidelines 区域的规则 |
prepareArguments | function | 否 | 在 Schema 校验前转换参数,用于兼容旧格式 |
Note
promptGuidelines里的每条规则必须点明工具名称。写”使用 weather_query 时……”而不是”使用此工具时……”。AI 并不知道”此”指的是谁。
execute 函数的签名
async execute(
toolCallId: string, // 本次调用的唯一 ID
params: T, // 经过 Schema 校验的参数
signal?: AbortSignal, // 用户按 Escape 时的取消信号
onUpdate?: (update: ToolUpdate) => void, // 发送进度更新
ctx?: ExtensionContext, // 扩展上下文
): Promise<ToolResult>
signal 在用户取消操作时会变为 aborted,工具应该检查这个状态尽早退出。onUpdate 用于在长时间操作中发送进度通知,让用户看到工具在干活。
返回值格式
execute 必须返回一个 ToolResult 对象:
return {
// 发送给 LLM 的内容(必填)
content: [
{ type: "text", text: "查询结果:北京今天晴,25°C" }
],
// 自定义详情数据,用于状态恢复和渲染(可选)
details: {
city: "北京",
temperature: 25,
condition: "晴",
},
// 嵌套 LLM 调用的用量统计(可选)
usage: nestedModelUsage,
// 终止标记:同一批次所有工具都返回 terminate 时跳过后续 LLM 调用(可选)
terminate: true,
};
content 数组里每个元素是 { type: "text", text: string } 格式。details 里的数据会持久化到会话文件里,下次启动可以从中恢复状态。
Note工具执行失败不要返回特殊标记,直接
throw new Error("原因")。只有抛出异常才会把isError设为 true,AI 才能感知到出错了。
参数定义:TypeBox + StringEnum
用 typebox 的 Type.Object() 定义参数结构,用 @earendil-works/pi-ai 的 StringEnum 定义枚举值。
为什么用 StringEnum 而不是 Type.Union?因为 Google 的 API 不兼容 TypeBox 的 Union/Literal 类型。StringEnum 生成的是标准 JSON Schema enum,所有模型都能识别。
import { Type } from "typebox";
import { StringEnum } from "@earendil-works/pi-ai";
// ✅ 正确:所有模型兼容
parameters: Type.Object({
action: StringEnum(["list", "add", "done"] as const),
})
// ❌ 错误:Google API 不认
parameters: Type.Object({
action: Type.Union([Type.Literal("list"), Type.Literal("add"), Type.Literal("done")]),
})
输出截断
工具返回给 LLM 的内容必须在 50KB(约 1 万 token)和 2000 行以内。超过这个量的输出会撑爆上下文窗口,导致压缩失败或模型性能下降。
pi 提供了内置截断工具:
import {
truncateHead,
truncateTail,
formatSize,
DEFAULT_MAX_BYTES,
DEFAULT_MAX_LINES,
} from "@earendil-works/pi-coding-agent";
async execute(_toolCallId, _params) {
const output = await someLargeOperation();
const truncation = truncateHead(output, {
maxLines: DEFAULT_MAX_LINES,
maxBytes: DEFAULT_MAX_BYTES,
});
let result = truncation.content;
if (truncation.truncated) {
result += `\n\n[输出已截断:${truncation.outputLines}/${truncation.totalLines} 行`
+ `(${formatSize(truncation.outputBytes)}/${formatSize(truncation.totalBytes)})]`;
}
return { content: [{ type: "text", text: result }] };
}
truncateHead 保留开头部分,适合文件读取和搜索结果。需要保留尾部时用 truncateTail(比如日志文件的最新片段)。
文件修改安全队列
如果你的工具要修改文件,用 withFileMutationQueue() 包一下。它确保同一个文件的并发修改排队执行,不会互相覆盖:
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const absolutePath = resolve(ctx.cwd, params.path);
return withFileMutationQueue(absolutePath, async () => {
await mkdir(dirname(absolutePath), { recursive: true });
const current = await readFile(absolutePath, "utf8");
const next = current.replace(params.oldText, params.newText);
await writeFile(absolutePath, next, "utf8");
return {
content: [{ type: "text", text: `已更新 ${params.path}` }],
details: {},
};
});
}
这在你的工具和内置 edit 工具同时改同一个文件时特别重要——不然可能互相踩。
工具权限控制
pi 支持 allowedTools 配置,限制哪些工具可以被 AI 调用。在 settings.json 中:
{
"allowedTools": ["read", "bash", "edit", "write", "grep", "weather"]
}
列表里没写的工具不会被 AI 调用。这对自定义工具也适用——想让它可用,名字必须出现在白名单里。
覆盖内置工具
注册一个和内置工具同名的工具,就能覆盖它。
pi -e ./my-read-override.ts # 注册一个自定义 "read" 工具
覆盖时,渲染器(renderCall / renderResult)会继承内置版本。你只改执行逻辑、不改 UI 也完全行。但 promptSnippet 和 promptGuidelines 不会从内置工具继承,需要显式定义。
完整示例:天气查询工具
下面写一个能跑起来的天气查询工具。它会调用一个公开 API,处理网络错误、超时和取消信号,返回结构化的结果。
// ~/.pi/agent/extensions/weather-tool.ts
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
import { StringEnum } from "@earendil-works/pi-ai";
interface WeatherResult {
city: string;
temperature: number;
condition: string;
humidity: number;
windSpeed: number;
}
export default function (pi: ExtensionAPI) {
pi.registerTool({
name: "weather",
label: "天气查询",
description: "查询指定城市的当前天气信息,返回温度、天气状况、湿度和风速",
promptSnippet: "查询城市天气:城市名 + 单位(metric/imperial)",
promptGuidelines: [
"使用 weather 工具查询天气时,城市名请用英文(如 Beijing, Tokyo)",
"weather 工具返回的温度单位由 unit 参数控制,默认 metric(摄氏度)",
],
parameters: Type.Object({
city: Type.String({ description: "城市英文名,如 Beijing, Tokyo, London" }),
unit: Type.Optional(
// buildEnum 在新版中推荐,StringEnum 也兼容
StringEnum(["metric", "imperial"] as const, {
default: "metric",
description: "温度单位:metric 摄氏度,imperial 华氏度",
})
),
}),
async execute(_toolCallId, params, signal) {
const { city, unit = "metric" } = params;
// 检查取消信号
if (signal?.aborted) {
throw new Error("操作已取消");
}
// 使用 Open-Meteo 免费天气 API(无需 API Key)
// 先通过 geocoding API 获取城市坐标
const geoUrl = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(city)}&count=1&language=zh`;
try {
const geoRes = await fetch(geoUrl, { signal });
if (!geoRes.ok) {
throw new Error(`地理编码请求失败:HTTP ${geoRes.status}`);
}
const geoData = await geoRes.json() as {
results?: { latitude: number; longitude: number; name: string; country: string }[];
};
if (!geoData.results || geoData.results.length === 0) {
throw new Error(`未找到城市:${city}`);
}
const { latitude, longitude, name, country } = geoData.results[0];
// 获取天气数据
const weatherUrl =
`https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}`
+ `¤t=temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m`
+ `&temperature_unit=${unit === "imperial" ? "fahrenheit" : "celsius"}`;
const weatherRes = await fetch(weatherUrl, { signal });
if (!weatherRes.ok) {
throw new Error(`天气请求失败:HTTP ${weatherRes.status}`);
}
const weatherData = await weatherRes.json() as {
current: {
temperature_2m: number;
relative_humidity_2m: number;
weather_code: number;
wind_speed_10m: number;
};
};
const current = weatherData.current;
const condition = weatherCodeToText(current.weather_code);
const unitLabel = unit === "metric" ? "°C" : "°F";
const result: WeatherResult = {
city: `${name}, ${country}`,
temperature: current.temperature_2m,
condition,
humidity: current.relative_humidity_2m,
windSpeed: current.wind_speed_10m,
};
return {
content: [{
type: "text",
text:
`📍 ${result.city}\n`
+ `🌡 温度:${result.temperature}${unitLabel}\n`
+ `☁️ 天气:${result.condition}\n`
+ `💧 湿度:${result.humidity}%\n`
+ `🌬 风速:${result.windSpeed} km/h`,
}],
details: result,
};
} catch (err) {
if (err instanceof Error) {
if (err.name === "AbortError") {
throw new Error("天气查询已被取消");
}
throw err;
}
throw new Error("天气查询发生未知错误");
}
},
});
}
// WMO 天气代码转中文描述
function weatherCodeToText(code: number): string {
const map: Record<number, string> = {
0: "晴天", 1: "少云", 2: "多云", 3: "阴天",
45: "雾", 48: "沉积雾凇",
51: "小毛毛雨", 53: "毛毛雨", 55: "大毛毛雨",
61: "小雨", 63: "中雨", 65: "大雨",
71: "小雪", 73: "中雪", 75: "大雪",
80: "阵雨", 81: "中等阵雨", 82: "大阵雨",
95: "雷暴", 96: "雷暴伴小冰雹", 99: "雷暴伴大冰雹",
};
return map[code] ?? `未知(code: ${code})`;
}
测试一下:
pi -e ~/.pi/agent/extensions/weather-tool.ts
然后对 AI 说”查一下北京的天气”,AI 应该会调用你的 weather 工具并返回结果。
这个示例涵盖了哪些要点
- 参数校验:城市名是必填的 string,单位是可选的 enum
- 错误处理:网络异常、城市找不到、请求超时都抛 Error
- 取消信号:检查
signal.aborted,fetch 也传入了 signal - 结构化返回:
content给人/LLM 看,details存结构化数据 - promptGuidelines:明确告诉 AI 城市名用英文、单位默认摄氏度
这一章把工具开发的每个关键环节都走了一遍。下一章进入事件系统——教你监听 pi 的生命周期,在恰当时机插入自定义逻辑。