从零手写到框架抽象: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_KEYOPENAI_BASE_URL

写在最后

回顾这 4 天的学习路径,有一条清晰的进化线:

Day 4 手写完整流程,理解每个消息角色、每次循环在干什么。Day 5 切换到框架抽象,同样的逻辑只需 30 行。Day 6 在框架之上叠加 Agent 范式(ReAct),让 LLM 不只是"调一次工具",而是"推理-行动-观察"地解决复杂问题。Day 7 面对工程现实——性能优化(并行)、成本控制(压缩)、模型兼容性(思考模式)。

Tool Calling 是 Agent 能力的地基。地基打牢了,后面的 RAG、MCP、多 Agent 协作才有根基可言。