15 KiB
Function Call 是什么?LLM 如何学会使用工具
本文以 OpenClaw 开源源码为例(基于 2026.6.2 版本),结合实际代码说明 Function Call 的工作原理。涉及源码路径均可在仓库中直接查阅。
如果你用过 ChatGPT 的联网功能,或者让 AI 帮你查股票、操作文件,你已经在使用 Function Call 的成果了。但它背后是怎么运作的?模型是怎么"调用"工具的?本文从头说清楚。
从一个问题开始
"今天北京天气怎么样?"
把这个问题丢给 LLM,它大概率会回答:
"抱歉,我的训练数据有截止日期,无法获取实时天气信息。"
这暴露了 LLM 的本质局限:它只能生成文本,没有任何与外部世界交互的能力。它不能查数据库、不能调 API、不能执行代码——所有"知识"都封存在训练时的权重里。
Function Call(也叫 Tool Use)就是为了解决这个问题而生的。
Function Call 是什么
一句话:让开发者提前告诉模型"有哪些工具可以用",模型在需要时决定调用哪个工具、传什么参数,再由代码来真正执行它。
有一个关键细节值得提前说清楚:模型本身不执行任何工具。它只是输出一段结构化的"调用请求",真正的执行发生在你的代码里。这个区别很重要,后面会反复提到。
一个完整的例子
还是那个天气问题,看看有了 Function Call 之后发生了什么。
第一步:开发者注册工具
在调用 LLM 之前,把可用的工具告诉模型:
{
"name": "get_weather",
"description": "获取指定城市的实时天气",
"parameters": {
"type": "object",
"properties": {
"location": { "type": "string", "description": "城市名称" }
},
"required": ["location"]
}
}
第二步:用户提问,模型决定调用工具
模型收到"北京今天天气怎么样"后,意识到自己没有实时数据,但有 get_weather 工具可以用。于是它不直接回答,而是输出一个调用请求:
{
"type": "tool_use",
"name": "get_weather",
"input": { "location": "北京" }
}
第三步:代码执行工具,结果还给模型
你的程序接收到这个请求,真正去调天气 API,拿到结果后告诉模型:
{
"type": "tool_result",
"content": "北京今天晴,气温 28℃,东南风 3 级"
}
第四步:模型拿到结果,给出最终回答
"北京今天天气不错,晴天,气温 28℃,东南风 3 级,适合出门。"
整个过程对用户是透明的,感觉就像模型"知道"天气一样。
工作原理
理解了"是什么",再看"怎么做到的"。
工具的定义与注册
在 OpenClaw 里,一个工具用 TypeBox schema 描述参数,并提供一个 execute 函数负责实际执行(packages/agent-core/src/types.ts):
const weatherTool: AgentTool = {
name: "get_weather",
label: "获取天气",
description: "获取指定城市的实时天气信息",
parameters: Type.Object({
location: Type.String({ description: "城市名称,如「北京」" }),
}),
execute: async (toolCallId, args) => {
const data = await fetchWeatherApi(args.location);
return { content: [{ type: "text", text: data }] };
},
};
三个核心字段:name 是模型调用时用的名字,description 是模型决定"要不要用它"的唯一依据,parameters 是模型生成参数时要遵守的 JSON Schema。
工具注册到 AgentContext.tools 后,每次请求 LLM 前经过 provider 的 convertTools() 转换,写入请求 body 的 tools 字段。
各家 API 的格式差异
不同厂商的格式不统一,是接入多个 provider 时最头疼的地方:
| 工具声明字段 | 调用响应格式 | 结果角色 | |
|---|---|---|---|
| Anthropic | input_schema |
{ type: "tool_use", input: {...} } |
tool_result(role: user) |
| OpenAI | parameters |
{ function: { arguments: "字符串" } } |
role: tool |
parametersJsonSchema |
{ functionCall: { args: {...} } } |
functionResponse |
|
| Mistral | parameters |
同 OpenAI,但字段名 camelCase | role: tool |
几个容易踩的坑:
- OpenAI 的
arguments是字符串,不是对象,需要JSON.parse()才能用 - Anthropic 的工具结果 role 是
user,不是独立角色,多个工具结果会合并进同一条 user 消息 - Google 不保证 tool call 有稳定 id,需要在缺失时自动生成
完整请求体长什么样
把上面这些拼在一起,实际发给 Anthropic 的请求体是这样的(src/llm/providers/anthropic.ts):
{
"model": "claude-sonnet-4-6",
"max_tokens": 8096,
"stream": true,
"system": [
{
"type": "text",
"text": "You are a helpful assistant...",
"cache_control": { "type": "ephemeral" }
}
],
"messages": [
{ "role": "user", "content": "北京今天天气怎么样?" }
],
"tools": [
{
"name": "get_weather",
"description": "获取指定城市的实时天气信息",
"input_schema": {
"type": "object",
"properties": {
"location": { "type": "string", "description": "城市名称" }
},
"required": ["location"]
},
"cache_control": { "type": "ephemeral" }
}
]
}
注意 cache_control 只加在 tools 数组的最后一个工具上——这是 Anthropic 的 prompt cache 标记方式,缓存整个 tools 块,下次相同前缀的请求直接命中。
Provider 返回体与 transcript 拼接
请求发出后,Anthropic 以 SSE 流式返回。一次含 tool call 的响应,事件序列大致如下:
// 1. 消息开始,返回 token 用量(含 prompt cache 命中数)
event: message_start
data: { "message": { "id": "msg_01xxx", "usage": { "input_tokens": 312, "cache_read_input_tokens": 280 } } }
// 2. 文本块流式输出
event: content_block_start → data: { "index": 0, "content_block": { "type": "text" } }
event: content_block_delta → data: { "index": 0, "delta": { "type": "text_delta", "text": "我来查一下。" } }
event: content_block_stop → data: { "index": 0 }
// 3. tool_use 块开始(携带 id 和 name,input 为空)
event: content_block_start
data: { "index": 1, "content_block": { "type": "tool_use", "id": "toolu_01xxx", "name": "get_weather", "input": {} } }
// 4. 参数以 JSON 字符串碎片流式输出,需要本地拼接
event: content_block_delta → data: { "index": 1, "delta": { "type": "input_json_delta", "partial_json": "{\"loc" } }
event: content_block_delta → data: { "index": 1, "delta": { "type": "input_json_delta", "partial_json": "ation\": \"北京\"}" } }
event: content_block_stop → data: { "index": 1 }
// 5. 消息结束,stop_reason 说明停止原因
event: message_delta → data: { "delta": { "stop_reason": "tool_use" }, "usage": { "output_tokens": 42 } }
event: message_stop
三个值得注意的细节:
- tool 参数是字符串碎片,需要在本地逐片拼接后解析,解析失败就暂时返回
{},等待更多碎片 stop_reason: "tool_use"告诉框架这轮因调工具而停止,agent loop 据此进入执行流程cache_read_input_tokens: 280说明 system + tools 部分命中了 prompt cache,跳过了 prefill 计算
工具执行完毕后,结果带着原始的 tool_use_id 写回 transcript,下一轮整段重发给 LLM:
[
{ "role": "user", "content": "北京今天天气怎么样?" },
{
"role": "assistant",
"content": [
{ "type": "text", "text": "我来查一下。" },
{ "type": "tool_use", "id": "toolu_01", "name": "get_weather", "input": { "location": "北京" } }
]
},
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01",
"content": [{ "type": "text", "text": "北京今天晴,28℃,东南风 3 级" }],
"is_error": false
}
]
}
]
tool_use_id 的作用是让模型把"这个结果"和"那次调用"对应起来——一次响应里可能有多个 tool call,每个 result 都要通过 id 说明自己是哪次调用的答复。
模型实际"看到"的是什么
API 接收到结构化 JSON 后,服务端会把它展平成一段带特殊 token 的连续文本序列,才是真正喂进 transformer 的内容。各家格式没有完整公开,但大致规律如下。
Anthropic:tools 被序列化成 XML 追加在 system 末尾,含 tool call 的完整对话在 token 层面长这样:
[SYSTEM]
You are a helpful assistant...
<tools><tool_description>...</tool_description></tools>
[/SYSTEM]
[HUMAN]北京今天天气怎么样?[/HUMAN]
[ASSISTANT]我来查一下。
<tool_use>{"name": "get_weather", "input": {"location": "北京"}}</tool_use>
[/ASSISTANT] ← 模型生成到这里停止
[HUMAN]
<tool_result tool_use_id="toolu_01">北京今天晴,28℃,东南风 3 级</tool_result>
[/HUMAN]
[ASSISTANT] ← 服务端追加,模型从这里继续生成
OpenAI:使用 ChatML 格式(<|im_start|> / <|im_end|>),tools 序列化为类 JSON 文本追加进 system,tool result 作为 tool 角色注入。
从模型视角看,它每次看到的都是一段完整历史,完全不知道中间发生过"执行工具"这件事——工具执行是框架层的行为,对模型透明。
这也解释了两件事:description 写得好不好直接影响模型判断(它就是模型读到的那段文字);以及对话越长首 token 延迟越高(每次都要从头 prefill 整段历史),prompt cache 缓存不变的前缀正是为了解决这个问题。
Agent Loop 与执行调度
真实场景中,模型往往不是调一次工具就结束,而是经历多轮。OpenClaw 的 agent loop 核心是一个双层循环(packages/agent-core/src/agent-loop.ts):
while (true) { // 外层:等待用户追加消息
let hasMoreToolCalls = true;
while (hasMoreToolCalls) { // 内层:处理 tool call 批次
const message = await streamAssistantResponse(context);
const toolCalls = message.content.filter(c => c.type === "toolCall");
if (toolCalls.length === 0) {
hasMoreToolCalls = false; // 没有 tool call,本轮结束
break;
}
const results = await executeToolCalls(toolCalls);
context.messages.push(...results); // 带着结果进入下一轮请求
}
}
模型可以一次输出多个 tool call,框架默认并行执行以节省时间。比如"帮我查北京和上海的天气",模型同时发出两个 get_weather 调用,结果都回来后再统一回答。
并行还是串行,由开发者在定义工具时决定,不是 LLM 控制的:
const weatherTool: AgentTool = {
name: "get_weather",
executionMode: "parallel", // 查询类,并发没问题
};
const writeFileTool: AgentTool = {
name: "write_file",
executionMode: "sequential", // 有副作用,强制串行
};
框架降级规则:同一批里只要有一个工具标了 sequential,整批立即降为串行。
| 工具类型 | 推荐模式 | 原因 |
|---|---|---|
| 查询、读取、幂等操作 | parallel |
并发安全,节省时间 |
| 写文件、发消息、调支付 | sequential |
有副作用,避免竞态 |
| 有依赖关系的操作 | LLM 跨轮次自然处理 | 模型看不到上一步结果就不会发下一步调用 |
如果模型"没调对"怎么办
Function Call 依赖模型生成结构化输出,但模型并不总是完美的,实际上有三种失败情形。
情形一:调用了工具,但参数不符合 schema
OpenClaw 的处理分两步:先尝试自动类型转换(比如 schema 要求数字,模型给了字符串 "28",会尝试自动转成 28);转换后仍然不通过,才把详细错误信息作为 tool result 还给模型:
Validation failed for tool "get_weather":
- /location: Expected string
Received arguments: { "city": "北京" }
模型通常能读懂并修正参数重新调用,整个过程用户无感知。
情形二:模型把 tool call 写成了普通文本
部分模型(尤其是较早版本或上下文过长时)会退化,不输出结构化的 tool_use,而是直接在回复文本里写:
[get_weather]
{"location": "北京"}
或者 XML 风格:
<function=get_weather>
<parameter=location>北京</parameter>
</function>
OpenClaw 有专门的 tool-call-repair 模块识别这些格式,解析成功后提升为正式的结构化调用继续执行,同时把这段文本从用户可见的回复里抹掉。
情形三:模型完全没有识别到需要调工具
没有任何 tool call,agent loop 当普通文本回复处理,直接返回给用户。没有报错,但用户可能拿到的是一个不准确的答案——这正是为什么工具的 description 要写清楚。
| 情形 | 处理方式 |
|---|---|
| 参数类型小错误 | 自动类型转换,尽量容忍 |
| 参数不符合 schema | 错误信息还给模型,让模型重试 |
| 模型输出纯文本 tool call | 解析并提升为结构化调用 |
| 模型完全没调工具 | 当普通文本回复,loop 结束 |
几个常见误区
误区一:description 随便写就行
description 是模型决定"要不要用这个工具"的唯一依据,也是它真实读到的文本。写得模糊,模型就可能在不该调用时调用,或者该调用时放弃。工具描述和参数说明值得认真打磨。
误区二:一次只能调一个工具
现代模型支持在一次响应里输出多个 tool call,框架可以并行执行。这对"分头查询再汇总"的任务(比如同时搜索多个关键词)效率提升很大。
误区三:有顺序依赖的操作需要开发者干预
"先创建文件再写入"这类有依赖的操作,LLM 会自然地分轮次处理——它看不到上一步的结果,就不会发出下一步的调用。开发者只需要处理同批次内的并发安全(用 sequential),跨轮次的逻辑顺序不需要干预。
小结
Function Call 的本质是一个协议:开发者用 schema 描述工具,模型用结构化输出表达"想调哪个",代码执行并返回结果,模型用结果继续生成。这个循环让 LLM 从"知识库"变成了"能行动的 agent"。
你现在看到的各种 AI 助手能查天气、写代码、操作文件、发邮件,背后都是这套机制在支撑。如果你对 agent 如何管理多轮工具调用的完整循环感兴趣,可以进一步了解 agent loop 的设计;如果想知道工具列表如何按需过滤,可以看 tool planner 的实现。