Appearance
第 06 章:Tool Use 工具调用 — 让 LLM 调用函数
LLM 只能生成文本,怎样让它"做事"?
这一章要解决什么问题?
前几章模型只能用文字回答问题。但 Coding Agent 需要读文件、写文件、执行命令。模型自己做不了这些 — 它只是一个文本生成器。
解法是"工具调用"协议:程序告诉模型"你有这些工具可以用",模型想用某个工具时返回一个 toolCall 指令,程序执行工具后把结果传回模型。模型全程不碰文件系统或网络 — 它只是表达意图,程序负责执行。
Tool 的定义是什么?
一个 Tool 就是一段 JSON Schema 声明:名字、描述、参数结构。
typescript
interface Tool {
name: string; // 工具名(模型引用时用这个名字)
description: string; // 告诉模型这个工具干什么、什么时候该用
parameters: object; // JSON Schema — 定义参数的类型和约束
}Tool 是纯声明,不包含执行逻辑。模型看到的是 schema,程序持有 execute 函数。
在我们的项目中,用 MiniTool 把声明和执行放在一起:
typescript
interface MiniTool {
name: string;
description: string;
parameters: object; // JSON Schema
execute: (params: Record<string, any>) => Promise<ToolResult>; // 执行逻辑
}传给模型时只取前三个字段(schema),execute 留在程序这边。
工具调用的完整流程
让我们用一个 get_current_time 工具走一遍完整流程:
第 1 步:定义工具
typescript
const getTimeTool: MiniTool = {
name: "get_current_time",
description: "Get the current date and time in ISO format",
parameters: {
type: "object",
properties: {}, // 不需要参数
},
async execute() {
return { content: new Date().toISOString() };
},
};第 2 步:把工具 schema 传给模型
typescript
const context: Context = {
systemPrompt: "You are a helpful assistant.",
messages: [
{ role: "user", content: "现在几点了?", timestamp: Date.now() }
],
tools: [
{ name: "get_current_time", description: "Get current time in ISO format", parameters: { type: "object", properties: {} } }
],
};模型看到 tools 列表后知道自己可以用 get_current_time。
第 3 步:模型返回 toolCall
模型分析用户的问题"现在几点了?",决定调用 get_current_time 工具:
typescript
// 模型返回的 AssistantMessage
{
role: "assistant",
content: [
{ type: "toolCall", id: "tc_001", name: "get_current_time", arguments: {} }
],
stopReason: "toolUse" // 模型主动停下来,等待工具结果
}注意 stopReason 是 "toolUse" — 模型不是说完了,是在等结果。
第 4 步:程序执行工具
typescript
const tool = tools.find(t => t.name === "get_current_time");
const result = await tool.execute({});
// result = { content: "2024-06-15T14:30:22.000Z" }第 5 步:构造 toolResult 传回模型
typescript
const toolResultMsg: ToolResultMessage = {
role: "toolResult",
toolCallId: "tc_001", // ← 必须与 toolCall.id 配对!
toolName: "get_current_time",
content: [{ type: "text", text: "2024-06-15T14:30:22.000Z" }],
isError: false,
timestamp: Date.now(),
};第 6 步:再次调用模型
把 toolResult 追加到 messages 后再次调用模型:
typescript
messages = [
{ role: "user", content: "现在几点了?" },
{ role: "assistant", content: [toolCall: get_current_time()] },
{ role: "toolResult", toolCallId: "tc_001", content: "2024-06-15T14:30:22.000Z" },
];模型看到时间结果后,生成人类可读的回复:
typescript
{
role: "assistant",
content: [{ type: "text", text: "现在是下午 2:30(2024年6月15日)。" }],
stopReason: "stop" // 正常结束,不再需要工具
}完整的消息时序
整理一下六条消息的因果链:
messages[0]: user("现在几点了?")
↓ 发给 LLM(Context 带上 tools schema)
messages[1]: assistant([toolCall: {id:"tc_001", name:"get_current_time"}])
↓ 程序看到 toolCall,执行 get_current_time()
messages[2]: toolResult({toolCallId:"tc_001", content:"2024-06-15T14:30:22.000Z"})
↓ 再次发给 LLM(messages[0..2] 全部传过去)
messages[3]: assistant([text: "现在是下午 2:30"])四条消息、两次 LLM 调用、一次工具执行。
toolCallId 配对:不能漏、不能错
这是贯穿整个教程的不变量。让我们严肃看一下为什么这个配对这么重要。
模型一次可能返回多个 toolCall:
typescript
// 模型决定同时调两个工具
{
content: [
{ type: "toolCall", id: "tc_001", name: "read_file", arguments: { path: "a.ts" } },
{ type: "toolCall", id: "tc_002", name: "read_file", arguments: { path: "b.ts" } },
]
}程序必须为每个 toolCall 生成一个配对的 toolResult:
typescript
messages.push({ role: "toolResult", toolCallId: "tc_001", ... });
messages.push({ role: "toolResult", toolCallId: "tc_002", ... });如果漏掉 tc_002 的结果,模型下一轮看到的历史是:
- 我请求了两个工具调用
- 只收到了一个结果
- 另一个去哪了?
有些 API(比如 Anthropic)会直接拒绝这种不完整的请求。即使不报错,模型的行为也会变得不可预测。
description 的重要性
模型根据工具的 description 决定要不要用这个工具。description 写得好,模型用得准;写得模糊,模型要么不用要么乱用。
对比:
typescript
// ✗ 模糊的 description — 模型不确定什么时候该用
{ name: "bash", description: "Run a command" }
// ✓ 清晰的 description — 模型知道用途和限制
{ name: "bash", description: "Execute a shell command and return stdout+stderr. Use for running tests, git, package managers. Times out after 30s." }description 是你跟模型沟通的接口。把它当作给同事写的 API 文档 — 说清楚干什么、什么时候用、有什么限制。
手动完成一次完整的 tool use
把上面的流程串成一段可运行的代码:
typescript
import { builtinModels, getBuiltinModel } from "@earendil-works/pi-ai/providers/all";
import type { Context, Message, Tool } from "@earendil-works/pi-ai";
const models = builtinModels();
const model = getBuiltinModel("anthropic", "claude-sonnet-4-20250514");
// 工具 schema(传给模型看的)
const tools: Tool[] = [{
name: "get_current_time",
description: "Get the current date and time in ISO format",
parameters: { type: "object", properties: {} } as any,
}];
// 第 1 次调用
const messages: Message[] = [
{ role: "user", content: "现在几点了?", timestamp: Date.now() }
];
const stream1 = models.streamSimple(model, { systemPrompt: "Reply in Chinese.", messages, tools });
for await (const e of stream1) { /* 消费流 */ }
const reply1 = await stream1.result();
messages.push(reply1);
// 检查是否有 toolCall
const toolCalls = reply1.content.filter(c => c.type === "toolCall");
if (toolCalls.length > 0) {
// 执行工具
for (const tc of toolCalls) {
const result = new Date().toISOString(); // 实际执行
messages.push({
role: "toolResult",
toolCallId: tc.id,
toolName: tc.name,
content: [{ type: "text", text: result }],
isError: false,
timestamp: Date.now(),
});
}
// 第 2 次调用(带工具结果)
const stream2 = models.streamSimple(model, { systemPrompt: "Reply in Chinese.", messages, tools });
for await (const e of stream2) {
if (e.type === "text_delta") process.stdout.write(e.delta);
}
const reply2 = await stream2.result();
messages.push(reply2);
}这段代码手动处理了一次 tool use 的完整流程。但如果模型需要调用两次、三次工具呢?你总不能无限 if/else 下去。下一章用一个 while 循环解决这个问题。
小结
Tool Use 让 LLM 从"只能说话"变成"能做事"。Tool 是纯 JSON Schema 声明(告诉模型有什么工具可以用),模型想用工具时返回 toolCall(表达意图),程序执行后构造 toolResult 传回(告知结果)。toolCallId 配对是协议不变量 — 每个 toolCall 必须有且只有一个对应的 toolResult。模型根据 description 决定何时使用工具,所以 description 要写得清楚具体。
下一章
这一章手动完成了一次工具调用。但现实任务往往需要连续调多次工具。下一章把这个"调用模型 → 有 toolCall?→ 执行 → 再来"的过程包进一个 while 循环 — 这就是 Agent Loop。