工具调用 Function Calling,让 Agent 真正能动手

2026-11-08 19 min 6594 字 -- 次阅读
摘要

Function Calling 用 JSON Schema 把工具调用从口头约定变成接口契约:模型返回结构化的 tool_calls,工具名和参数直接可用,Agent 不再靠字符串匹配猜意图。

上一篇我们用 [GET_TIME] 这种约定标记,让 Agent 调用了获取时间的工具。代码确实能跑,但有个隐藏的致命问题。

模型是概率生成的,它可能输出 [get_time],可能输出 GET_TIME,甚至一开心给整个 [GET_WEATHER]。字符串匹配不上,整个 Agent 就废了。

上篇的方式是在用自然语言约定跟模型沟通,而自然语言是模糊的。这一篇我们用 Function Calling 来解决这个问题,用结构化的 schema 告诉模型工具箱有哪些工具、每个工具要什么参数,模型返回的也是结构化的调用指令。如此一来,我们就把口头约定变成接口契约。但是 Function Calling 解决的是工具调用格式的脆弱性,不是保证模型一定不会犯错。有了 Function Calling,模型仍然可能不调工具、选错工具、参数填错。这个设计只是把猜文本变成了读结构化的数据,但不能代替循环设计、description 编写和错误处理。

为什么结构化输出能缓解幻觉

上篇的方式是这样的:在 prompt 里写到“如果需要时间就回复 [GET_TIME]”,模型自由生成一段文本,之后再用 reply.includes("[GET_TIME]") 去匹配。这里的问题在于,模型的输出是不受约束的,它可能输出任何东西。

Function Calling 则是换了个思路,通过 API 的 tools 参数传入一份 JSON Schema,让模型最后返回的是结构化的 tool_calls 数组,里面有工具名和参数,直接可用。

区别就是:

  • 自然语言约定:请你按这个格式输出(模型可能漂移,就是不听指令)

  • 结构化输出:工具名和参数按 schema 返回(格式稳定得多,但工具选择和参数语义仍可能出错)

    这就是为什么 Function Calling 能显著降低格式解析失败的原因。

用 tools 参数定义一个工具

我们用上一篇的时间工具,但这次用 tools 参数来定义。把 index.ts 改成:

typescript
import OpenAI from "openai";
import "dotenv/config";

const client = new OpenAI({
  baseURL: process.env.OPENAI_BASE_URL,
  apiKey: process.env.OPENAI_API_KEY,
});

// 工具定义
const tools = [
  {
    type: "function",
    function: {
      name: "getCurrentTime",
      description: "获取当前的日期和时间",
      parameters: {
        type: "object",
        properties: {},
        required: [],
      },
    },
  },
];

const messages = [
  { role: "user", content: "现在几点了?" },
];

const res = await client.chat.completions.create({
  model: process.env.MODEL,
  messages,
  tools,
});

const msg = res.choices[0].message;
console.log(msg);

运行:

bash
node --experimental-strip-types index.ts

你会看到输出里不再是普通的文本回复,而是一个 tool_calls 数组,大概长这样:

json
{
  content: '',
  reasoning_content: 'The user is asking what time it is now. I should use the getCurrentTime function to get the current date and time.',
  role: 'assistant',
  tool_calls: [
    {
      function: [Object],
      id: 'call_3f98e3257d3842e486021961',
      type: 'function'
    }
  ]
}

模型没有输出任何自然语言,而是返回了一个结构化的调用指令:我要调用 getCurrentTime,参数是空对象。

这就是结构化输出的力量,我们拿到的不是需要解析的文本,而是可以直接用的数据。

执行工具并回填结果

光拿到调用指令还不够,我们还要让 Agent 真的去执行这个工具,然后把结果告诉模型,让它基于结果给出最终回答。现在把代码补完整:

typescript
import OpenAI from "openai";
import "dotenv/config";

const client = new OpenAI({
  baseURL: process.env.OPENAI_BASE_URL,
  apiKey: process.env.OPENAI_API_KEY,
});

const tools = [
  {
    type: "function",
    function: {
      name: "getCurrentTime",
      description: "获取当前的日期和时间,当用户询问现在几点、今天几号时使用",
      parameters: {
        type: "object",
        properties: {},
        required: [],
      },
    },
  },
];

function getCurrentTime() {
  return new Date().toLocaleString("zh-CN");
}

const toolMap: Record<string, (...args: any[]) => string> = {
  getCurrentTime,
};

const messages: any[] = [
  { role: "user", content: "现在几点了?" },
];

const MAX_ROUNDS = 5;

for (let round = 0; round < MAX_ROUNDS; round++) {
  const res = await client.chat.completions.create({
    model: process.env.MODEL!,
    messages,
    tools,
  });

  const msg = res.choices[0].message;

  // 只保留标准字段,不要把供应商特有字段原样塞回上下文
  const assistantMessage = {
    role: "assistant" as const,
    content: msg.content ?? null,
    ...(msg.tool_calls ? { tool_calls: msg.tool_calls } : {}),
  };
  messages.push(assistantMessage);

  // 没有 tool_calls,就是最终回答
  if (!msg.tool_calls?.length) {
    console.log(msg.content);
    break;
  }

  // 如果模型要调用工具,逐个执行并回填
  for (const call of msg.tool_calls) {
    const fn = toolMap[call.function.name];

    if (!fn) {
      messages.push({
        role: "tool",
        tool_call_id: call.id,
        content: `未知工具:${call.function.name}`,
      });
      continue;
    }

    let args: any;
    try {
      args = JSON.parse(call.function.arguments || "{}");
    } catch (e) {
      messages.push({
        role: "tool",
        tool_call_id: call.id,
        content: `参数解析失败:${(e as Error).message}`,
      });
      continue;
    }

    let result: string;
    try {
      result = fn(args);
    } catch (e) {
      result = `工具执行失败:${(e as Error).message}`;
    }

    messages.push({
      role: "tool",
      tool_call_id: call.id,
      content: result,
    });
  }
}

代码运行后,模型先调用 getCurrentTime,拿到时间后,再用自然语言回答“现在是 2026年10月30日 12:59:52,快到下午1点了。”。

对比一下上一篇,我们要写 reply.includes("[GET_TIME]") 去猜模型的意思,而现在则是直接读 msg.tool_calls,干净利落。

加一个带参数的工具

时间工具不需要参数,体现不出 schema 的价值。我们加一个计算器工具,需要接收数学表达式:

typescript
const tools = [
  {
    type: "function",
    function: {
      name: "getCurrentTime",
      description: "获取当前的日期和时间",
      parameters: {
        type: "object",
        properties: {},
        required: [],
      },
    },
  },
  {
    type: "function",
    function: {
      name: "calculate",
      description: "执行基础数学计算,支持加减乘除",
      parameters: {
        type: "object",
        properties: {
          expression: {
            type: "string",
            description: "要计算的数学表达式,如 2 + 3 * 4",
          },
        },
        required: ["expression"],
      },
    },
  },
];

function calculate({ expression }: { expression: string }) {
  // 安全起见,只允许数字和运算符
  if (!/^[\d\s+\-*/().]+$/.test(expression)) return "不支持的表达式";
  return String(eval(expression));
}

const toolMap = { getCurrentTime, calculate };

然后把用户问题改成:

typescript
const messages = [
  { role: "user", content: "现在几点了?另外帮我算一下 128 乘以 256 等于多少" },
];

运行。你会看到模型可能一轮里同时调用两个工具,也可能分两轮调用,然后整合结果给出最终回答。

description 是写给模型看的说明书

注意 tools 里的 description 字段,不管是工具本身的 description,还是参数里的 description,它们都是写给模型看的。模型靠这些文字来判断什么时候该用这个工具、这个参数该填什么。

写得越清楚,模型调用越准确。比如 calculate 的 description 写成执行基础数学计算就比只写一个计算好,写成要计算的数学表达式,如 2 + 3 * 4就比只写表达式更好。

tool_calls 的消息格式

这是最容易写错的地方,我认为有必要单独拿出来说一下。

模型返回 tool_calls 后,你需要往 messages 里加两类消息:

  1. 模型那条 assistant 消息必须完整地包含 tool_calls 字段,不能只存 content。因为下一轮模型需要看到自己之前调了哪些工具。

  2. 工具结果消息,每条工具调用对应一条,role 固定是 tool,必须带 tool_call_id 关联回具体的那次调用。一定要注意:如果模型一轮返回多个 tool_calls,必须为每一个 call.id 都回填一条 role: "tool" 消息,不能只处理其中一个,而且这些 tool 消息要紧跟在当前 assistant 消息之后,中间不能插入其他消息,同时tool 消息的顺序最好与 tool_calls 的顺序一致。

    如果你漏了 tool_call_id,或者没把 assistant 的 tool_calls 存进上下文,下一轮调用要么直接报错,要么模型行为完全失控。

    另外,不要把模型返回的 msg 原模原样 push 进上下文。不同供应商可能返回 reasoning_content 等非标准字段,部分 API 在多轮工具调用中要求移除或特殊处理这些字段。比较稳妥做法是像上面的代码一样,显式构造只包含 role、content、tool_calls 的 assistant 消息。

    这个格式是 OpenAI Chat Completions 协议的规范。主流兼容模型通常会遵循这个规范,但像并行 tool_calls、strict、推理字段等细节的支持程度,仍要看具体供应商文档。

常见问题

  1. 模型不调工具,直接回答

    现象:问“现在几点了”,模型直接说“我不知道当前时间”,没调用 getCurrentTime。

    原因:要么问题确实不需要工具(比如问“1+1等于几”),要么工具的 description 写得太模糊,模型没意识到该用它。

    解法:优化 description,明确写出工具的适用场景。也可以在 system prompt 里加一句引导,比如“当用户询问当前时间时,必须使用 getCurrentTime 工具”。

  2. 参数解析报错

    现象:JSON.parse(call.function.arguments) 抛出异常。

    原因:虽然概率很低,但模型偶尔会返回不合法的 JSON。

    解法:加 try-catch,把错误信息作为工具结果回填给模型,让它自己修正:

    typescript
    let args: any;
    try {
      args = JSON.parse(call.function.arguments || "{}");
    } catch (e) {
      messages.push({
        role: "tool",
        tool_call_id: call.id,
        content: `参数解析失败:${(e as Error).message}`,
      });
      continue;
    }
    
    let result: string;
    try {
      result = fn(args);
    } catch (e) {
      result = `工具执行失败:${(e as Error).message}`;
    }
    
    messages.push({ role: "tool", tool_call_id: call.id, content: result });

    这里把参数解析和工具执行分成了两个 try-catch。JSON.parse 失败回填“参数解析失败”,fn(args) 抛错回填“工具执行失败”。如果合成一个 try,工具执行抛错会被误报成 JSON 问题,模型就不知道真正的错误在哪。

  3. 多工具时模型选错工具

    现象:现在有 getCurrentTime 和 calculate两个工具,但模型问时间时跑去调了 calculate。

    原因:两个工具的 description 区分度不够。

    解法:每个工具的 description 要写出明确的适用场景。比如 getCurrentTime 写“获取当前的日期和时间,当用户询问现在几点、今天几号时使用”,calculate 写“执行数学计算,当用户需要计算数值时使用”。

总结

这一篇实践,我们做到了这些:

  1. 用 Function Calling 替代了脆弱的标记法,不再靠字符串匹配猜测模型意图。

  2. 实现了带参数的工具调用,通过 JSON Schema 约束模型的输入格式。

  3. 理解了结构化输出的本质,把约定变成契约,显著减少格式解析失败。

    现在的 Agent 已经能可靠地调用工具了。但还有一个问题悬而未决:每一轮都把所有 messages 完整发给模型,对话轮数多了, token 只增不减,迟早会爆炸。

    我们将在下一篇解决这个问题,记忆系统。看看怎么能让 Agent 记住该记的,忘掉该忘的。

下一篇预告

今天的 Agent 每轮都把完整对话历史塞给模型,几轮之后就会超过上下文窗口限制。

下一篇我们讲记忆系统:短期记忆怎么管理、长期记忆怎么用向量检索、怎么在记得全面和 token 不爆炸之间做取舍。

评论