# Function Call 是什么?LLM 如何学会使用工具 > 本文以 [OpenClaw](https://github.com/openclaw/openclaw) 开源源码为例(基于 2026.6.2 版本),结合实际代码说明 Function Call 的工作原理。涉及源码路径均可在仓库中直接查阅。 如果你用过 ChatGPT 的联网功能,或者让 AI 帮你查股票、操作文件,你已经在使用 Function Call 的成果了。但它背后是怎么运作的?模型是怎么"调用"工具的?本文从头说清楚。 --- ## 从一个问题开始 > "今天北京天气怎么样?" 把这个问题丢给 LLM,它大概率会回答: > "抱歉,我的训练数据有截止日期,无法获取实时天气信息。" 这暴露了 LLM 的本质局限:**它只能生成文本,没有任何与外部世界交互的能力**。它不能查数据库、不能调 API、不能执行代码——所有"知识"都封存在训练时的权重里。 Function Call(也叫 Tool Use)就是为了解决这个问题而生的。 --- ## Function Call 是什么 一句话:**让开发者提前告诉模型"有哪些工具可以用",模型在需要时决定调用哪个工具、传什么参数,再由代码来真正执行它。** 有一个关键细节值得提前说清楚:**模型本身不执行任何工具**。它只是输出一段结构化的"调用请求",真正的执行发生在你的代码里。这个区别很重要,后面会反复提到。 --- ## 一个完整的例子 还是那个天气问题,看看有了 Function Call 之后发生了什么。 **第一步:开发者注册工具** 在调用 LLM 之前,把可用的工具告诉模型: ```json { "name": "get_weather", "description": "获取指定城市的实时天气", "parameters": { "type": "object", "properties": { "location": { "type": "string", "description": "城市名称" } }, "required": ["location"] } } ``` **第二步:用户提问,模型决定调用工具** 模型收到"北京今天天气怎么样"后,意识到自己没有实时数据,但有 `get_weather` 工具可以用。于是它不直接回答,而是输出一个调用请求: ```json { "type": "tool_use", "name": "get_weather", "input": { "location": "北京" } } ``` **第三步:代码执行工具,结果还给模型** 你的程序接收到这个请求,真正去调天气 API,拿到结果后告诉模型: ```json { "type": "tool_result", "content": "北京今天晴,气温 28℃,东南风 3 级" } ``` **第四步:模型拿到结果,给出最终回答** > "北京今天天气不错,晴天,气温 28℃,东南风 3 级,适合出门。" 整个过程对用户是透明的,感觉就像模型"知道"天气一样。 --- ## 工作原理 理解了"是什么",再看"怎么做到的"。 ### 工具的定义与注册 在 OpenClaw 里,一个工具用 TypeBox schema 描述参数,并提供一个 `execute` 函数负责实际执行(`packages/agent-core/src/types.ts`): ```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` | | **Google** | `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`): ```json { "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: ```json [ { "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... ... [/SYSTEM] [HUMAN]北京今天天气怎么样?[/HUMAN] [ASSISTANT]我来查一下。 {"name": "get_weather", "input": {"location": "北京"}} [/ASSISTANT] ← 模型生成到这里停止 [HUMAN] 北京今天晴,28℃,东南风 3 级 [/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`): ```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 控制的:** ```ts 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 风格: ``` 北京 ``` 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 的实现。