Skip to content
LLM Function Calling 原理:结构化输出与工具执行流程
概述
大语言模型本质上是文本生成模型。给它一段上下文,它按概率生成下一个 token,再继续生成后续 token。模型没有直接执行外部函数的能力,它只能把“想调用工具”的意图表达成文本。
在普通的对话接口中,模型只能输出自然语言:
北京今天多云,气温 24 摄氏度。如果应用需要拿到结构化数据,常见的做法是让模型输出 JSON:
{"city":"北京","temperature":24,"condition":"多云"}这种做法不稳定。模型可能输出多余的说明文字,可能漏掉引号,也可能用 Markdown 代码块把 JSON 包起来。下游解析自由文本的成本很高。
Function Calling 改变了输出目标。系统预先给模型一组工具定义;模型判断需要工具时,不再写一段解释文字,而是输出一个结构化的“工具调用”对象,其中包含工具名和参数。应用负责执行这个调用,再把结果送回模型,让模型继续生成最终回复。
这个流程的关键点是:模型不执行函数,只生成调用指令。执行动作永远发生在应用侧。
基本概念:工具定义与 JSON Schema
要让模型知道“有哪些函数可以用”,需要先描述这些函数。工具定义通常包含三个部分:
- 函数名:应用注册表中的唯一标识。
- 函数描述:说明函数的功能,供模型决定何时调用。
- 输入参数:以 JSON Schema 描述参数结构。
一个查询天气的工具定义可以写成:
ts
const weatherTool = {
name: "get_weather",
description: "查询指定城市的当前天气",
inputSchema: {
type: "object",
properties: {
city: { type: "string", description: "城市名,例如 北京" },
unit: { type: "string", enum: ["celsius", "fahrenheit"] },
},
required: ["city"],
},
};inputSchema 是工具参数的核心约束。type: "object" 表示参数是一个对象,properties 描述每个字段的类型,required 列出必填字段。
不同 API 的字段名不完全相同。Anthropic 使用 input_schema,Gemini 在 functionDeclarations 中使用 parameters。它们表达的参数结构基本一致,都是 type、properties、required 这种形式。工具定义的差异主要是协议层面的差异。
工作原理:Prompt 注入与 Tool Choice
工具定义不会凭空进入模型。API 收到 tools 参数后,通常会把工具描述序列化成文本,放进模型上下文。模型实际看到的输入类似于:
你可以使用以下工具:
get_weather(city: string, unit?: string)
查询指定城市的当前天气
User: 北京今天需要带伞吗?因此,工具描述的措辞会影响模型的判断。描述越清晰,模型越容易在合适的场景选择这个工具。
除了工具定义,部分 API 还提供 tool_choice 参数,用来控制“是否调用工具”。它通常有几种语义:
- 不调用工具,只生成普通文本。
- 由模型自己决定。
- 强制调用一次工具。
- 只能调用某个指定工具。
不同实现的具体取值不同。这里需要理解的是:工具定义和工具选择约束最终都会转化为 prompt 文本或解码约束,它们不是模型之外的独立机制。
需要注意:用户消息和工具描述位于同一段上下文中。用户内容可能尝试影响模型去调用某些敏感工具。应用层必须对工具调用做权限校验,不能完全依赖模型的判断。
函数调用输出:tool_call 的形态
当模型决定调用工具时,API 返回的 assistant 消息中会带一个工具调用列表。一个简化后的 OpenAI 兼容格式如下:
ts
{
role: "assistant",
content: null,
toolCalls: [
{
id: "call_1",
name: "get_weather",
argumentsJson: "{\"city\":\"北京\",\"unit\":\"celsius\"}"
}
]
}每个工具调用至少包含:
id:调用 ID,用于把工具结果和这次调用对应起来。name:工具名。arguments:参数内容,通常是一个 JSON 编码的字符串。
有些 API 直接返回对象形式的参数,有些 API 的字段名不同。应用拿到这个结构后,需要先解析参数,再执行工具。
结构化输出约束:JSON Schema、JSON Mode 与 constrained decoding
Function Calling 依赖一个前提:模型必须生成合法的 JSON 参数。但语言模型是以概率生成 token 的,如果完全自由生成,JSON 可能不合法。
为了解决这个问题,推理框架会做 constrained decoding(受约束解码,也叫 guided decoding)。在生成参数时,采样范围不再是整个词表,而是只保留那些“仍然可能形成合法 JSON”的 token。
例如,schema 规定 city 是字符串,解码器就会禁止生成 [ 或 { 这类可能开启数组或嵌套对象的 token,也禁止生成未加引号的字段名。这样生成结果在结构上必然合法。
vLLM 把这种能力称为 structured output 或 guided decoding。用户提供 schema 作为模板,模型输出既符合结构,又保留采样随机性。llama.cpp 使用 GBNF grammar 做类似约束,SGLang 也支持 schema 约束。
JSON Mode、Structured Output 与 Function Calling 的关系可以这样理解:
- JSON Mode:只保证输出是合法 JSON,不校验具体 schema。
- Structured Output / Guided Decoding:保证输出符合给定的 JSON Schema。
- Function Calling:是在结构化输出之上构建的完整工具调用协议,包含工具选择、调用 ID、工具结果回传和多轮循环。
如果应用只需要模型返回一个 JSON 对象,使用结构化输出接口就够了。但如果需要执行工具并把结果送回对话,Function Calling 的必要之处在于工具消息协议,而不是单独一次 JSON 输出。
工具执行闭环:解析、执行与回传
一次函数调用的完整闭环是:
- 向模型发送用户消息和工具定义。
- 模型返回 assistant 消息。
- 如果消息中没有工具调用,这就是最终回答。
- 如果消息中有工具调用,解析参数并执行工具。
- 把带工具调用的 assistant 消息追加到消息历史。
- 为每个工具调用追加一条 tool 消息。
- 用完整消息历史再次请求模型。
- 重复,直到模型不再请求工具,或达到最大轮数。
下面的 TypeScript 代码用一个最小可运行的消息循环演示这个过程。代码中的字段名使用 OpenAI 兼容接口的命名习惯;例如工具消息中的 toolCallId 对应 API 中的 tool_call_id。
ts
import Ajv from "ajv";
type ToolCall = {
id: string;
name: string;
argumentsJson: string;
};
type AssistantMessage = {
role: "assistant";
content: string | null;
toolCalls?: ToolCall[];
};
type Message =
| { role: "user"; content: string }
| AssistantMessage
| { role: "tool"; toolCallId: string; content: string };
type ToolDefinition = {
name: string;
description: string;
inputSchema: Record<string, unknown>;
};
type Model = {
complete(params: {
messages: Message[];
tools: ToolDefinition[];
}): Promise<AssistantMessage>;
};
const weatherTool: ToolDefinition = {
name: "get_weather",
description: "查询指定城市的当前天气",
inputSchema: {
type: "object",
properties: {
city: { type: "string" },
unit: { type: "string", enum: ["celsius", "fahrenheit"] },
},
required: ["city"],
},
};
const ajv = new Ajv();
const validateWeather = ajv.compile(weatherTool.inputSchema);
function parseAndValidate(call: ToolCall): unknown {
const args = JSON.parse(call.argumentsJson);
if (!validateWeather(args)) {
throw new Error(`参数校验失败: ${ajv.errorsText(validateWeather.errors)}`);
}
return args;
}
const toolRegistry = new Map<string, (args: any) => Promise<unknown>>();
async function getWeather(args: { city: string; unit?: string }) {
return { city: args.city, temperature: 24, unit: args.unit ?? "celsius" };
}
toolRegistry.set("get_weather", getWeather);
async function executeTool(call: ToolCall): Promise<unknown> {
const fn = toolRegistry.get(call.name);
if (!fn) {
throw new Error(`未知工具: ${call.name}`);
}
const args = parseAndValidate(call);
return fn(args);
}
async function executeToolCalls(toolCalls: ToolCall[]) {
return Promise.all(
toolCalls.map(async (call) => {
try {
return {
call,
content: JSON.stringify(await executeTool(call)),
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return { call, content: JSON.stringify({ error: message }) };
}
}),
);
}
async function run(model: Model, userContent: string): Promise<string> {
const messages: Message[] = [{ role: "user", content: userContent }];
const maxTurns = 5;
for (let turn = 0; turn < maxTurns; turn++) {
const assistant = await model.complete({
messages,
tools: [weatherTool],
});
messages.push(assistant);
if (!assistant.toolCalls || assistant.toolCalls.length === 0) {
return assistant.content ?? "";
}
const results = await executeToolCalls(assistant.toolCalls);
for (const result of results) {
messages.push({
role: "tool",
toolCallId: result.call.id,
content: result.content,
});
}
}
throw new Error(`超过最大轮数 ${maxTurns}`);
}这个循环做了几件关键事情:
messages.push(assistant)在工具结果之前执行,保证上下文中先有 assistant 的工具调用,再出现工具结果。toolCallId与工具调用的id完全匹配。- 工具结果
content必须是一个字符串,所以对象先经过JSON.stringify。 - 每一轮请求都带上
tools数组,不能只传一次。
参数解析与校验放在 parseAndValidate 中完成。executeTool 先校验参数,再执行工具,避免把非法参数直接传给业务函数。对于多工具注册表,可以把 schema 和函数一并注册,按 call.name 取出对应的校验器。
有些模型在工具调用前会生成推理内容,例如 Kimi K2.5 的 reasoning_content。把 assistant 消息重新放入上下文时,需要保留这些推理内容,否则后续请求可能报错。
并行工具调用与结果聚合
一个 assistant 消息里可以包含多个工具调用。例如用户问“北京和上海分别多少度”,模型可能同时生成两个 get_weather 调用。
上面的 executeToolCalls 使用 Promise.all 并发执行所有工具调用:
ts
async function executeToolCalls(toolCalls: ToolCall[]) {
return Promise.all(
toolCalls.map(async (call) => {
try {
return {
call,
content: JSON.stringify(await executeTool(call)),
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return { call, content: JSON.stringify({ error: message }) };
}
}),
);
}并行执行后的消息顺序不会影响正确性,因为每个结果都通过 toolCallId 对应到具体的调用。
如果多个工具调用之间存在依赖关系,例如先用一个工具拿到用户 ID,再查用户详情,则不能并行,需要拆成多轮串行执行。如果外部 API 有速率限制,应用需要自行控制并发工具调用数量。
错误处理、重试与超时
工具调用过程中可能出现的错误包括:
- JSON 解析失败。
- JSON Schema 校验失败。
- 未知工具名。
- 工具函数本身抛错。
- 外部服务超时。
在工具执行出错时,一种常见做法是把错误信息作为工具结果返回给模型。模型看到错误文本后,可以尝试修正参数或选择其他工具。上面的 executeToolCalls 已经用 try/catch 实现了这一点。
对于超时,可以用 Promise.race 限制工具的执行时间:
ts
async function withTimeout<T>(task: Promise<T>, ms: number): Promise<T> {
let timer: ReturnType<typeof setTimeout>;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error(`tool timeout: ${ms}ms`)), ms);
});
return Promise.race([task, timeout]).finally(() => clearTimeout(timer));
}重试需要谨慎。只有只读工具或幂等工具适合自动重试。写入、支付、发送消息这类操作如果被重复执行,可能产生重复副作用。应用应该设置整体循环的超时或最大轮数,避免模型进入无休止的工具调用。
Function Calling 与 ReAct/Agent 的关系
Function Calling 不是 Agent,也不是 ReAct,它是 ReAct 中“行动”和“观察”两个步骤的通信协议。
ReAct 的循环是:
- Thought:模型决定下一步怎么做。
- Action:调用某个工具。
- Observation:读取工具结果。
- 继续循环直到得到最终答案。
Function Calling 的循环恰好覆盖了 Action 和 Observation:模型生成工具调用,应用执行工具并回传结果。但它本身不包含规划、记忆、终止条件这些 Agent 能力。
LangChain 等 Agent 框架会把工具注册、模型调用、消息历史封装起来,但底层仍然是相同的 assistant -> tool -> assistant 循环。Function Calling 只是其中一个可替换的模块,不是完整的智能体方案。
主流 API 与开源框架的实现差异
不同 API 的消息结构不同,但语义等价。
Anthropic 的接口把 tool_use 块放在 assistant 消息内,工具结果用 tool_result 块放在 user 消息内。Gemini 使用独立的 functionCall 和 functionResponse 格式。OpenAI 兼容接口使用 assistant 消息中的 tool_calls,以及紧随其后的 role 为 tool 的消息。这些差异是序列化形式的差异,不是概念上的差异。
工具定义方面,Anthropic 使用 name、description、input_schema 描述函数;Gemini 的 functionDeclarations 使用 parameters。它们都依赖 JSON Schema 风格的结构来描述参数。
开源推理框架更关注生成约束。vLLM 的 structured output 可以让模型输出符合 schema 的 JSON;llama.cpp 使用 grammar 约束;SGLang 也提供类似的 guided decoding。同时,vLLM、SGLang、llama.cpp 的 server 层通常也提供 OpenAI 兼容的对话补全接口,把 guided decoding 包装成 tool_calls 格式。如果只使用底层采样接口,则需要客户端自己构造 prompt、执行生成并解析工具调用。
可以这样区分:
- 商业 API 通常把工具定义、结构化输出和消息协议封装成一个完整的 Function Calling 接口。
- 开源推理框架往往只提供 constrained decoding 原语,或者额外提供兼容接口,但底层仍是文本生成加解析。
另外,Function Calling 不是模型天然具备的能力。模型需要经过工具调用的指令微调,或者使用支持该能力的商用模型。推理框架只能约束输出格式,不能教会模型在什么时候使用工具。
