手把手教你从零开发一个 Agent(1)。本期主要深入讲:什么是 Function Calling,什么是 tools,以及 LLM 实际看到了什么。

工具调用不是模型直接执行了你的函数。模型做的是读取工具说明,生成一段结构化的调用请求;真正的函数由应用侧执行,结果再回传给模型,由模型组织最终回复。

前言

经过上一期,你应该清楚 LLM 看到的消息是什么样子的。这个消息列表就是它的 Context——上下文,我在公众号之前的扫盲系列里也简单讲过。

我们发送的 messages 列表不会直接以 JSON 原样喂给模型,而会经过模型服务中的 Tokenizer 和 Chat Template,转换成带有特殊 Token 的文本序列。

比如我们发送:

json
1
{"role": "user", "content": "你好,我叫小安落滢"}

到了模型那里,会被转换成类似下面的内容。不同模型的格式会有所区别:

text
1
2
3
<|im_start|>user
你好,我叫小安落滢
<|im_end|>

特殊 Token

对于大多数 Hugging Face 开源对话模型,特殊 Token 和对话格式通常可以在 tokenizer_config.jsontokenizer.json 中找到;有些项目还会单独提供 chat_template.jinja

以我写作时查看的 Qwen3.6 为例,打开它的 tokenizer_config.json 就能看到:

Qwen3.6 tokenizer_config.json 中定义的特殊 Token

图里框出的就是模型定义的特殊 Token。chat_template 则是一段 Jinja 模板:

Qwen3.6 Chat Template 中的 Jinja 模板

例如处理 user 消息时,核心代码就是:

jinja
1
2
3
<|im_start|>{{ message.role }}
{{ content }}
<|im_end|>

也就是说,发送上面的例子时,实际到达模型的 Prompt 大致会是:

text
1
2
3
4
<|im_start|>user
你好,我叫小安落滢
<|im_end|>
<|im_start|>assistant

最后那个 <|im_start|>assistant 就是在告诉模型:接下来轮到 Assistant 说话了,请开始续写。LLM 的本质还是接龙,有兴趣可以回头看看扫盲篇。

模型的爪子——工具

Agent 的一个核心能力就是调用工具。不管是某某 claw,还是某某 paw,名字都取得很形象,要么有爪子,要么干脆就叫爪子。

那我们最关心的问题应该是:在对话中,模型怎么知道自己可以调用工具?它怎么决定?它究竟看到了什么?

接下来用一个经典例子,让上期做的 chatbot 变成一个可以帮我查天气的 Agent。

第一步:准备真实执行的函数

首先,我们要准备一个查天气的函数。

这里我直接用了手头已有的高德天气 API。它使用城市的 adcode,所以为了让演示足够简单,我把工具限制为只能查询北上广深,再在代码里完成城市名到 adcode 的转换。

我写作时,高德控制台显示个人开发者有免费调用额度。具体额度可能调整,请以高德天气查询 API 文档和你自己的控制台为准。

[javascript] 显示已折叠代码(36 行)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// 只允许查询这四个城市,code 来自 AMap_adcode_citycode.xlsx
const cityCodes = {
    北京: "110000",
    上海: "310000",
    广州: "440100",
    深圳: "440300",
};

// 模型请求 get_weather 时,真正执行的函数
export async function getWeather({ city }) {
    const cityCode = cityCodes[city];

    if (!cityCode) {
        return { error: "目前只支持查询北京、上海、广州和深圳。" };
    }

    const url = new URL("https://restapi.amap.com/v3/weather/weatherInfo");
    url.search = new URLSearchParams({
        key: process.env.AMAP_API_KEY,
        city: cityCode,
        extensions: "base",
        output: "JSON",
    });

    const response = await fetch(url);
    if (!response.ok) {
        throw new Error(`天气接口请求失败:${response.status}`);
    }

    const data = await response.json();
    if (data.status !== "1") {
        throw new Error(data.info || "天气接口返回错误");
    }

    return data.lives?.[0] ?? data;
}

调用高德天气 API 的函数与执行结果

这才是真实执行的函数。接下来还要准备一份给模型看的内容,因为模型不能直接读取并运行我本地的 JavaScript 函数。

第二步:准备给模型看的工具描述

[javascript] 显示已折叠代码(19 行)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// 传给模型的工具描述
export const weatherTool = {
    type: "function",
    name: "get_weather",
    description: "查询北京、上海、广州或深圳的天气",
    parameters: {
        type: "object",
        properties: {
            city: {
                type: "string",
                enum: Object.keys(cityCodes),
                description: "城市名称,只能是北京、上海、广州或深圳",
            },
        },
        required: ["city"],
        additionalProperties: false,
    },
    strict: true,
};

这里有四个关键部分:

  • name:模型要返回的函数名;
  • description:告诉模型什么时候应该使用它;
  • parameters:用 JSON Schema 描述参数;
  • strict: true:约束模型生成符合 Schema 的参数。

strict: true 约束的是调用参数的结构,并不意味着模型一定会在正确的时机选择正确的工具,也不意味着工具已经被执行。

第三步:发起带工具的请求

现在第一个 Responses 请求长这样:

javascript
1
2
3
4
5
6
7
8
const input = [{ role: "user", content: userInput }];

let response = await client.responses.create({
    model: "gpt-5.5",
    instructions: "你是天气助手。用户询问天气时,使用 get_weather 工具。",
    tools: [weatherTool],
    input,
});

我写本文时还没有启动本地部署的 Qwen3.6,所以运行示例一直使用 gpt-5.5。下面关于 Chat Template 的具体格式来自可检查源码的 Qwen 等开源模型,用它解释底层更容易复现;OpenAI 闭源模型内部究竟怎样拼接模板无法直接检查,不能断言它使用完全相同的 XML 或 Prompt 格式。

那么,在可以观察的 Qwen 实现里,模型会看到什么?

tools 会由 Chat Template 转换成工具说明。按照上面的例子,Qwen3 系列的模板会生成类似下面的内容:

[text] 显示已折叠代码(42 行)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
<|im_start|>system

# Tools

You have access to the following functions:

<tools>
{
  "type": "function",
  "name": "get_weather",
  "description": "查询北京、上海、广州或深圳的天气",
  "parameters": {
    ...
  }
}
</tools>

If you choose to call a function ONLY reply in the following format with NO suffix:

<tool_call>
<function=example_function_name>
<parameter=example_parameter_1>
value
</parameter>
</function>
</tool_call>

<IMPORTANT>
Reminder:
- Function calls MUST follow the specified format
- Required parameters MUST be specified
...
</IMPORTANT>

你是天气助手。用户询问天气时,使用 get_weather 工具。
<|im_end|>

<|im_start|>user
深圳天气怎么样?
<|im_end|>

<|im_start|>assistant

到这一步,所谓的模型调用工具就很清楚了:模型先输出一段符合约定格式的内容,再由模型服务或解析器把它转换成 API 对外提供的标准 function_call Item。

这里要把事实边界说清楚:上面这段 XML 风格格式来自 Qwen 等可观察的开源 Chat Template,不能推广成所有模型供应商都使用相同内部实现。对于 OpenAI,我们能从公开 API 确认的是:请求中传入了工具定义,响应中会返回标准的 function_call,但闭源服务内部怎样组织 Prompt 不能直接下结论。

早期模型能力不足时,经常无法稳定遵循指令,输出不了标准的 XML 或 JSON,所以服务层和应用层都需要做更多校验。现在有了更成熟的 Tool Calling 训练和 strict Schema 约束,稳定性高了很多,但应用侧仍然应该校验参数和权限。

所以,模型并不是凭空“知道”自己可以调用工具。更准确地说,是我们在每次请求中把工具说明显式传给模型服务,模型依据这些说明决定要不要生成一次 Tool Call。

执行工具和执行之后

OpenAI 官方把 Function Calling 总结为五步:

  1. 带着可用工具向模型发起请求;
  2. 接收模型返回的 Tool Call;
  3. 在应用侧执行对应代码;
  4. 把工具结果再次发送给模型;
  5. 接收最终回复,或者继续处理新的 Tool Call。

第一步:读取模型返回的函数调用

我们发送刚才的请求后,可以从 response.output 中找到 function_call

[javascript] 显示已折叠代码(15 行)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
const toolCall = response.output.find(
    (item) => item.type === "function_call"
);

if (!toolCall) {
    throw new Error("模型本轮没有返回函数调用");
}

const args = JSON.parse(toolCall.arguments);

console.log("\nAI 输出 tool use 的内容:");
console.log({
    name: toolCall.name,
    arguments: args,
});
text
1
2
AI 输出 tool use 的内容:
{ name: 'get_weather', arguments: { city: '深圳' } }

如果直接查看 HTTP 响应,会看到一个包含 nameargumentscall_idfunction_call Item:

Responses API 返回的 function_call Item

其中 arguments 是 JSON 字符串,需要解析后再交给真实函数;call_id 则是这次工具请求的关联 ID,回传结果时必须原样带回。

第二步:路由到真实工具

javascript
1
2
3
4
5
6
7
async function callFunction(name, args) {
    if (name === "get_weather") {
        return getWeather(args);
    }

    throw new Error(`未知工具:${name}`);
}

这个路由做的事情很简单:根据模型返回的函数名和参数,调用我们真正允许执行的函数。

实际工具结果如下:

javascript
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
{
    province: '广东',
    city: '深圳市',
    adcode: '440300',
    weather: '阴',
    temperature: '27',
    winddirection: '东南',
    windpower: '≤3',
    humidity: '87',
    reporttime: '2026-07-29 17:00:17',
    temperature_float: '27.0',
    humidity_float: '87.0'
}

第三步:回传工具结果,再请求一次模型

公众号发布时,这里只写了 input.push(toolOutput),但没有展示 toolOutput 的结构,也漏掉了第二次请求。完整代码应该是:

[javascript] 显示已折叠代码(22 行)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
const result = await callFunction(toolCall.name, args);

// 保留模型本轮返回的所有 Items,包括 function_call。
// 对推理模型来说,这也能保留可能同时返回的 reasoning Items。
input.push(...response.output);

// 用同一个 call_id 告诉模型:这是刚才那次函数调用的执行结果。
input.push({
    type: "function_call_output",
    call_id: toolCall.call_id,
    output: JSON.stringify(result),
});

// 把工具结果再次发给模型,让它生成面向用户的最终回复。
response = await client.responses.create({
    model: "gpt-5.5",
    instructions: "你是天气助手。用户询问天气时,使用 get_weather 工具。",
    tools: [weatherTool],
    input,
});

console.log(response.output_text);

通过 HTTP 回传 function_call_output 的请求示例

这里最容易混淆的点是:OpenAI SDK 会帮我们序列化请求、发送 HTTP 和解析响应,但不会替我们执行 getWeather。工具路由、参数校验、权限控制、实际执行和结果回传,仍然是 Agent 应用自己的工作。

当模型收到 function_call_output 后,它才补全了缺失的信息,可以根据最新的深圳天气组织自然语言回复。至此,一次完整的工具调用结束。

循环

上期我们说,一次对话容易,多轮对话加个循环就行了。现在工具在一轮里也可能不只执行一次:模型执行完一个工具后,还可能判断需要再调用另一个工具,甚至一次返回多个可以并行执行的调用。

OpenAI 官方文档也明确说明,Responses 的工具调用流程可以持续任意多次,直到模型返回最终消息,或者我们的预算、轮数与安全策略要求停止。

简单啊,再加个循环吧。

我们下期再讲。

继续阅读

参考资料