Files
openclaw-notes/blog/function-call-101.md

15 KiB
Raw Permalink Blame History

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_resultrole: 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):

{
  "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 和 nameinput 为空)
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 文本追加进 systemtool 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 callagent loop 当普通文本回复处理,直接返回给用户。没有报错,但用户可能拿到的是一个不准确的答案——这正是为什么工具的 description 要写清楚。

情形 处理方式
参数类型小错误 自动类型转换,尽量容忍
参数不符合 schema 错误信息还给模型,让模型重试
模型输出纯文本 tool call 解析并提升为结构化调用
模型完全没调工具 当普通文本回复,loop 结束

几个常见误区

误区一:description 随便写就行

description 是模型决定"要不要用这个工具"的唯一依据,也是它真实读到的文本。写得模糊,模型就可能在不该调用时调用,或者该调用时放弃。工具描述和参数说明值得认真打磨。

误区二:一次只能调一个工具

现代模型支持在一次响应里输出多个 tool call,框架可以并行执行。这对"分头查询再汇总"的任务(比如同时搜索多个关键词)效率提升很大。

误区三:有顺序依赖的操作需要开发者干预

"先创建文件再写入"这类有依赖的操作,LLM 会自然地分轮次处理——它看不到上一步的结果,就不会发出下一步的调用。开发者只需要处理同批次内的并发安全(用 sequential),跨轮次的逻辑顺序不需要干预。


小结

Function Call 的本质是一个协议:开发者用 schema 描述工具,模型用结构化输出表达"想调哪个",代码执行并返回结果,模型用结果继续生成。这个循环让 LLM 从"知识库"变成了"能行动的 agent"。

你现在看到的各种 AI 助手能查天气、写代码、操作文件、发邮件,背后都是这套机制在支撑。如果你对 agent 如何管理多轮工具调用的完整循环感兴趣,可以进一步了解 agent loop 的设计;如果想知道工具列表如何按需过滤,可以看 tool planner 的实现。