从零手写到框架抽象:Tool Calling 完全实战指南
从零手写到框架抽象:Tool Calling 完全实战指南
4 天时间,从手写 JSON Schema 到 ReAct Agent,再到并行调用与思考模式——一篇文章讲透 LLM 工具调用的底层原理和工程实践。
写在前面
Tool Calling 是 AI Agent 能力的基石。没有它,LLM 只是一个"会聊天的文字生成器";有了它,LLM 变成了一个能查天气、能算数学、能读文件、能操作 API 的"智能体"。
这篇文章记录了我在 4 天内(Day 4 ~ Day 7)从零实现 Tool Calling 全链路的过程。不是理论综述,是一行一行代码写出来、跑通的实战总结。每一段代码都可以直接运行。
一、Tool Calling 的本质:LLM 不执行代码
很多初学者的第一个误解是:"LLM 调用了工具"。不对。准确地说,LLM 声明了它想调用什么工具、传什么参数,然后由你的代码真正执行,再把结果告诉 LLM。
整个生命周期是这样的:
┌─────────┐ ┌─────────┐ ┌──────────┐ ┌─────────┐ ┌─────────┐
│ User │───▶│ LLM │───▶│ 你的代码 │───▶│ LLM │───▶│ User │
│ 提问 │ │ 决策调用 │ │ 真正执行 │ │ 总结结果 │ │ 看到答案 │
└─────────┘ └─────────┘ └──────────┘ └─────────┘ └─────────┘
tool_calls tool result 最终回答
LLM 的角色是"决策者"而非"执行者"。它通过你提供的 JSON Schema 理解有哪些工具可用,然后在合适的时机选择调用。
二、Day 4:手写完整流程——理解每一个字节
2.1 用 Zod 定义工具 Schema
工具的核心是"接口契约"——一份描述"这个工具叫什么、接受什么参数"的 JSON Schema。LLM 靠这份契约决定什么时候调用、传什么参数。
用 Zod 定义 schema 有三个好处:类型安全、自动转 JSON Schema、运行时参数校验。
import { z } from "zod";
import { zodToJsonSchema } from "zod-to-json-schema";
// 天气工具的参数 schema
const getWeatherSchema = z.object({
location: z.string().describe("城市名称,例如 '北京'、'上海'"),
unit: z
.enum(["celsius", "fahrenheit"])
.optional()
.default("celsius")
.describe("温度单位,默认摄氏度"),
});
// 类型推导——写 execute 函数时参数自动有类型
type GetWeatherParams = z.infer<typeof getWeatherSchema>;
// 工具元数据
const getWeatherTool = {
name: "get_weather",
description: "获取指定城市的当前天气信息。当用户询问天气时调用此工具。",
parameters: getWeatherSchema,
};
几个设计原则值得注意。description 字段是给 LLM 看的决策依据,它决定了模型在什么场景下选择这个工具。参数名要语义化——location 而非 loc。每个参数的 .describe() 帮助 LLM 理解该传什么值。enum 约束了可选范围,避免模型乱填。
转换成 OpenAI API 格式的函数也很简单:
function zodToOpenAIFunction(toolDef: {
name: string;
description: string;
parameters: z.ZodType;
}) {
return {
type: "function" as const,
function: {
name: toolDef.name,
description: toolDef.description,
parameters: zodToJsonSchema(toolDef.parameters, { $refStrategy: "none" }),
},
};
}
2.2 手写 Tool Calling 循环
有了 schema,接下来是核心:一个 while 循环,不断在 LLM 和工具之间传递消息,直到 LLM 决定不再调用工具。
async function toolCallingLoop(userMessage: string) {
const messages: OpenAI.Chat.ChatCompletionMessageParam[] = [
{ role: "system", content: "你是一个有用的助手..." },
{ role: "user", content: userMessage },
];
while (true) {
const response = await client.chat.completions.create({
model: "deepseek-chat",
messages,
tools: OPENAI_TOOLS,
tool_choice: "auto",
});
const choice = response.choices[0];
// 情况 1:LLM 直接回答(不需要工具)
if (choice.finish_reason === "stop") {
return choice.message.content;
}
// 情况 2:LLM 要调用工具
if (choice.message.tool_calls?.length) {
messages.push(choice.message); // 把 assistant 的调用意图加入历史
for (const toolCall of choice.message.tool_calls) {
const result = executeTool(
toolCall.function.name,
toolCall.function.arguments
);
// 关键:tool 消息必须关联 tool_call_id
messages.push({
role: "tool",
tool_call_id: toolCall.id,
content: result,
});
}
continue; // 继续循环,LLM 会看到工具结果
}
}
}
这段代码揭示了 Tool Calling 的消息模型:messages 数组中出现了第三种角色——tool。它的 tool_call_id 对应 assistant 消息中的某个 tool_calls[i].id,形成一一关联。LLM 看到工具结果后,可能继续调用另一个工具(比如先查天气再算华氏度),也可能直接总结回答。
工具调度器(switch-case 分发)负责把 LLM 的字符串参数解析、校验、执行:
function executeTool(name: string, args: string): string {
const parsedArgs = JSON.parse(args);
switch (name) {
case "get_weather":
return getWeather(getWeatherSchema.parse(parsedArgs));
case "calculate":
return calculate(calculateSchema.parse(parsedArgs));
default:
return JSON.stringify({ error: `未知工具: ${name}` });
}
}
2.3 Day 4 核心认知
手写一遍完整流程后,五个关键点刻入脑子。LLM 不执行代码——它只返回调用意图,你的代码负责执行。tool_choice 控制行为——"auto" 让模型自己决定,"required" 强制调用,"none" 禁止调用。可能需要多轮——LLM 看到一个工具的结果后可能接着调另一个工具。Zod 做了三件事——定义 schema、转 JSON Schema、运行时校验参数。循环终止条件——finish_reason === "stop" 意味着模型不再需要工具。
三、Day 5:从手动挡到自动挡——AI SDK 的 tool() 抽象
Day 4 写了约 150 行代码。Day 5 用 Vercel AI SDK 的 tool() 函数,同样的功能压缩到 30 行。
3.1 tool() 三合一
import { generateText, tool } from "ai";
import { createOpenAI } from "@ai-sdk/openai";
import { z } from "zod";
const model = createOpenAI({
apiKey: process.env.OPENAI_API_KEY,
baseURL: process.env.OPENAI_BASE_URL,
compatibility: "compatible",
})("deepseek-chat");
const weatherTool = tool({
description: "获取指定城市的当前天气信息",
parameters: z.object({
location: z.string().describe("城市名称"),
unit: z.enum(["celsius", "fahrenheit"]).optional().default("celsius"),
}),
execute: async ({ location, unit }) => {
// 参数已经过 Zod 验证,类型安全
const data = await fetchWeather(location);
return { location, temperature: data.temp, condition: data.condition };
},
});
tool() 把 Day 4 的三层(schema 定义 + 执行逻辑 + 参数校验)合为一体。description 给 LLM 看,parameters 用 Zod(自动转 JSON Schema + 自动验证),execute 是异步执行函数,参数类型由 Zod 推导。
3.2 maxSteps:一个参数替代手写 while 循环
const result = await generateText({
model,
system: "你是一个有用的助手",
prompt: "上海现在多少度?帮我把温度乘以 2 再加 10",
tools: { weather: weatherTool, calculate: calculateTool },
maxSteps: 5, // 最多 5 步 LLM ↔ 工具交互
});
console.log(result.text); // 直接拿到最终答案
maxSteps 就是 Day 4 那个 while 循环的声明式替代品。设为 5 意味着最多允许 5 轮"LLM 决策 → 工具执行 → 结果回传"。SDK 内部帮你管理 messages 数组、分发执行、回传结果,直到 LLM 返回最终文字回答或者达到步数上限。
不设 maxSteps(默认为 1)时,SDK 只执行一次工具就停了,不会有第二轮 LLM 总结——result.text 会是空的。这是初学者常踩的坑。
3.3 result.steps:完整的决策轨迹
for (let i = 0; i < result.steps.length; i++) {
const step = result.steps[i];
console.log(`Step ${i + 1}: finishReason=${step.finishReason}`);
for (const call of step.toolCalls) {
console.log(` toolCall: ${call.toolName}(${JSON.stringify(call.args)})`);
}
}
console.log(`Token: prompt=${result.usage.promptTokens}, completion=${result.usage.completionTokens}`);
steps 数组记录了 Agent 每一步做了什么——调了哪个工具、传了什么参数、得到什么结果、消耗了多少 token。可以用来调试、审计、做可视化。
3.4 错误处理策略
在 execute 函数内部 try-catch,把错误信息作为正常结果返回给 LLM,而不是抛出异常:
const riskyTool = tool({
description: "查询股票价格",
parameters: z.object({ symbol: z.string() }),
execute: async ({ symbol }) => {
try {
const data = await fetchStockPrice(symbol);
return { symbol, price: data.price };
} catch (error: any) {
// 返回错误信息而非抛出——让 LLM 决定如何处理
return { error: `查询失败: ${error.message}`, symbol };
}
},
});
这样 LLM 看到错误后可以决定重试、换参数、或者告诉用户"服务暂时不可用"。如果直接 throw,整个 generateText 调用就失败了。
3.5 接入真实 API
execute 函数里可以做任何异步操作。接入 GitHub API 查询仓库信息只需要一个 fetch:
const githubTool = tool({
description: "查询 GitHub 仓库信息(stars、language 等)",
parameters: z.object({
owner: z.string().describe("仓库所有者"),
repo: z.string().describe("仓库名称"),
}),
execute: async ({ owner, repo }) => {
const res = await fetch(`https://api.github.com/repos/${owner}/${repo}`);
if (!res.ok) return { error: `GitHub API 返回 ${res.status}` };
const data = await res.json();
return {
full_name: data.full_name,
stars: data.stargazers_count,
language: data.language,
};
},
});
工具就是 Agent 连接外部世界的"手和脚"。你可以 fetch API、查数据库、读文件、发邮件——只要能写成一个异步函数,就能变成工具。
四、Day 6:ReAct Agent——思考与行动交替进行
4.1 什么是 ReAct
ReAct(Reasoning + Acting)是 2022 年提出的 Agent 范式。核心思想很简单:让 LLM 交替进行"推理"和"行动"。
Thought: 我需要先查北京的天气...
Action: search("北京天气")
Observation: 28°C, 晴朗
Thought: 现在要把 28 转换成华氏度...
Action: calculate("28 * 9/5 + 32")
Observation: 82.4
Thought: 我现在有了所有信息,可以回答了。
Answer: 北京今天 28°C(82.4°F),天气晴朗。
ReAct 和单纯的 Tool Calling 有什么区别?区别在于 System Prompt 的设计和可观测性。ReAct 通过 prompt 显式引导模型进行分步推理,并且每一步都有"思考痕迹"可以追踪。
4.2 构建 ReAct Agent
工具集独立为模块(agent-tools.ts),包含 search、calculate、readFile、writeFile、getCurrentTime 五个工具:
export const agentTools = {
search: searchTool,
calculate: calculateTool,
readFile: readFileTool,
writeFile: writeFileTool,
getCurrentTime: getCurrentTimeTool,
};
ReAct Agent 本身是一个类,封装了运行逻辑和可观测性:
class ReActAgent {
private maxSteps: number;
private steps: AgentStep[] = [];
async run(userQuery: string): Promise<string> {
const result = await generateText({
model,
system: REACT_SYSTEM_PROMPT,
prompt: userQuery,
tools: agentTools,
maxSteps: this.maxSteps,
onStepFinish: (event) => {
this.recordStep(event); // 记录每一步的决策
},
});
return result.text;
}
}
4.3 System Prompt 的设计
ReAct 的 System Prompt 不是随便写的,它需要明确告诉模型工作流程:
你采用 ReAct 模式工作:
1. 思考:分析问题,决定下一步行动
2. 行动:调用合适的工具获取信息
3. 观察:分析工具返回的结果
4. 重复:如果信息不够,继续思考和行动
5. 回答:收集到足够信息后,给出最终答案
工具使用原则:
- 每次只做一件事
- 先搜索/查询事实,再进行计算
- 数学运算必须用 calculate 工具,不要自己算
4.4 onStepFinish:Agent 的可观测性
onStepFinish: (event) => {
if (event.toolCalls?.length > 0) {
for (const call of event.toolCalls) {
console.log(`🔧 行动: ${call.toolName}(${JSON.stringify(call.args)})`);
}
}
if (event.toolResults?.length > 0) {
for (const result of event.toolResults) {
console.log(`👁 观察: ${JSON.stringify(result.result).slice(0, 100)}`);
}
}
}
onStepFinish 回调在每一步完成时触发。生产环境中这类日志是必须的——没有它,Agent 就是一个黑盒,出了问题无从定位。
4.5 终止条件设计
Agent 的终止有两种情况:LLM 自主决定回答(finish_reason: "stop"),或者达到 maxSteps 上限被强制终止。两者结合是最安全的策略——既给模型自主决策空间,又防止无限循环。
五、Day 7:并行调用与工程优化
5.1 并行 Tool Calls
LLM 可以在一次响应中返回多个 tool_calls。AI SDK 会用 Promise.all 并行执行这些工具。
const result = await generateText({
model,
system: "当需要查询多个城市时,请同时调用多个搜索",
prompt: "告诉我北京、上海、深圳三个城市现在的天气",
tools: { search: searchWithDelay },
maxSteps: 3,
});
如果三个搜索各需要 500ms,串行执行需要 1500ms,并行执行只需要约 500ms(取最慢的那个)。通过 System Prompt 引导模型"同时查询"可以显著提升效率。
实测对比:
🐢 串行模式:强制 LLM 逐个查询
总耗时 ≈ 1500ms + LLM 推理时间 × 3
🚀 并行模式:鼓励 LLM 同时查询
总耗时 ≈ 500ms + LLM 推理时间 × 1
注意:工具的物理执行是并行的,但 LLM 本身的推理仍然是串行的。并行优化的收益取决于工具执行的延迟占比。
5.2 对话历史压缩
Agent 在多轮对话中会遇到一个实际问题:messages 越来越长,每次 API 调用的 prompt_tokens 越来越多。解决方案是对话压缩——当历史超过阈值时,用 LLM 对旧消息做 summarize。
class CompressibleConversation {
private messages: CoreMessage[] = [];
private compressionThreshold: number;
async maybeCompress(): Promise<boolean> {
if (this.messages.length < this.compressionThreshold) return false;
// 取前半部分做摘要
const halfPoint = Math.floor(this.messages.length / 2);
const toCompress = this.messages.slice(0, halfPoint);
const toKeep = this.messages.slice(halfPoint);
// 用 LLM 生成摘要
const summaryResult = await generateText({
model,
system: "用 2-3 句话总结以下对话的关键信息",
prompt: toCompress.map((m) => `${m.role}: ${m.content}`).join("\n"),
});
// 替换为:[摘要] + [最近的消息]
this.messages = [
{ role: "assistant", content: `[历史摘要] ${summaryResult.text}` },
...toKeep,
];
return true;
}
}
压缩前 20 条消息,压缩后变为 11 条(1 条摘要 + 最近 10 条)。Token 开销从线性增长变为可控。实际使用时 compressionThreshold 设为 10-20 比较合适。
5.3 DeepSeek 思考模式与 Tool Calling 的兼容
DeepSeek V4 系列模型默认开启"思考模式"——模型会先进行内部推理(reasoning_content),再输出回答或工具调用。这带来了一个工程挑战:reasoning_content 必须在下一轮请求中原样回传,否则 API 直接报错。
AI SDK 的 maxSteps 机制无法处理这个字段——它内部管理 messages 时不认识 reasoning_content,构建下一轮请求时会丢弃它。因此需要手写 Agent Loop:
interface ThinkingMessage {
role: "system" | "user" | "assistant" | "tool";
content: string | null;
reasoning_content?: string | null; // 思考模式特有
tool_calls?: Array<{...}>;
tool_call_id?: string;
}
async function thinkingAgentLoop(userMessage: string) {
const messages: ThinkingMessage[] = [
{ role: "system", content: "你是一个有用的助手..." },
{ role: "user", content: userMessage },
];
while (true) {
const { message, finishReason } = await callDeepSeek(messages, true);
// 关键:把完整的 assistant 消息(含 reasoning_content)加入历史
messages.push(message);
if (finishReason === "stop") {
return message.content;
}
if (message.tool_calls) {
for (const toolCall of message.tool_calls) {
const result = executeTool(toolCall.function.name, ...);
messages.push({ role: "tool", content: result, tool_call_id: toolCall.id });
}
}
}
}
这本质上就是回到了 Day 4 的手动循环,但多了对 reasoning_content 字段的完整保留。
5.4 思考模式的三种设置
DeepSeek 支持三种思考模式配置:
{ type: "enabled" }— 强制开启思考,适合复杂推理{ type: "disabled" }— 关闭思考,适合简单工具调用{ type: "adaptive" }— 模型自己决定是否思考
通过实测对比,简单工具调用场景(查天气算温度)下,关闭思考比开启思考快 30-50% 且省 token。但对于需要多步推理的复杂问题,思考模式能提供更可靠的结果。
如果使用 AI SDK 但想关闭思考模式(避免 reasoning_content 兼容问题),可以通过 custom fetch 注入:
const deepseek = createOpenAI({
apiKey: process.env.OPENAI_API_KEY,
baseURL: process.env.OPENAI_BASE_URL,
compatibility: "compatible",
fetch: async (url, options) => {
const body = JSON.parse(options!.body as string);
body.thinking = { type: "disabled" };
return fetch(url, { ...options, body: JSON.stringify(body) });
},
});
六、完整知识图谱
Day 4: Tool Calling 原理
└─ LLM 不执行代码,只返回调用意图
└─ messages 中新增 tool 角色
└─ Zod → JSON Schema → LLM 理解工具
Day 5: AI SDK tool() 声明式
└─ tool() = description + parameters + execute 三合一
└─ maxSteps 替代手写循环
└─ result.steps 完整决策轨迹
Day 6: ReAct Agent
└─ 思考 → 行动 → 观察 → 循环
└─ System Prompt 引导推理策略
└─ onStepFinish 可观测性
Day 7: 工程优化
└─ 并行 tool calls(一次返回多个调用意图)
└─ 对话历史压缩(summarize 旧消息)
└─ 思考模式下的手写 Agent Loop
└─ reasoning_content 回传机制
七、关键决策指南
选 AI SDK 还是手写循环?
如果你的模型不需要思考模式(或者可以关闭思考模式),用 AI SDK 的 tool() + maxSteps——代码量少 80%,类型安全,错误处理完善。如果需要保留思考模式(reasoning_content),或者需要对 messages 做精细控制(如压缩、过滤、注入额外字段),手写循环是更好的选择。
maxSteps 设多少?
一般设 5-10。太小可能让复杂任务无法完成,太大浪费 token 且有无限循环风险。
串行还是并行?
通过 System Prompt 引导模型一次性返回多个 tool_calls。如果工具执行有外部网络延迟(API 调用、数据库查询),并行收益显著。如果工具是本地计算,并行意义不大。
什么时候开思考模式?
复杂推理任务(多步数学、代码分析、策略规划)开启。简单工具调用场景关闭。拿不准时用 adaptive。
八、可运行的代码
本文所有代码都在 agent-lab 项目的 src/02-tool-calling/ 目录下,可以直接运行:
# Day 4:手写完整流程
npx tsx src/02-tool-calling/tools-basic.ts
# Day 5:AI SDK tool() 抽象
npx tsx src/02-tool-calling/ai-sdk-tools.ts
# Day 6:ReAct Agent
npx tsx src/02-tool-calling/react-agent.ts
# Day 7:并行调用 + 历史压缩
npx tsx src/02-tool-calling/parallel-agent.ts
# Day 7 进阶:思考模式 Agent Loop
npx tsx src/02-tool-calling/thinking-agent.ts
依赖安装:pnpm install。需要在 .env 中配置 OPENAI_API_KEY 和 OPENAI_BASE_URL。
写在最后
回顾这 4 天的学习路径,有一条清晰的进化线:
Day 4 手写完整流程,理解每个消息角色、每次循环在干什么。Day 5 切换到框架抽象,同样的逻辑只需 30 行。Day 6 在框架之上叠加 Agent 范式(ReAct),让 LLM 不只是"调一次工具",而是"推理-行动-观察"地解决复杂问题。Day 7 面对工程现实——性能优化(并行)、成本控制(压缩)、模型兼容性(思考模式)。
Tool Calling 是 Agent 能力的地基。地基打牢了,后面的 RAG、MCP、多 Agent 协作才有根基可言。