手把手教你从零开发一个 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.json、tokenizer.json 中找到;有些项目还会单独提供 chat_template.jinja。
以我写作时查看的 Qwen3.6 为例,打开它的 tokenizer_config.json 就能看到:

图里框出的就是模型定义的特殊 Token。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;
}
|

这才是真实执行的函数。接下来还要准备一份给模型看的内容,因为模型不能直接读取并运行我本地的 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 总结为五步:
- 带着可用工具向模型发起请求;
- 接收模型返回的 Tool Call;
- 在应用侧执行对应代码;
- 把工具结果再次发送给模型;
- 接收最终回复,或者继续处理新的 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 响应,会看到一个包含 name、arguments 和 call_id 的 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);
|

这里最容易混淆的点是:OpenAI SDK 会帮我们序列化请求、发送 HTTP 和解析响应,但不会替我们执行 getWeather。工具路由、参数校验、权限控制、实际执行和结果回传,仍然是 Agent 应用自己的工作。
当模型收到 function_call_output 后,它才补全了缺失的信息,可以根据最新的深圳天气组织自然语言回复。至此,一次完整的工具调用结束。
上期我们说,一次对话容易,多轮对话加个循环就行了。现在工具在一轮里也可能不只执行一次:模型执行完一个工具后,还可能判断需要再调用另一个工具,甚至一次返回多个可以并行执行的调用。
OpenAI 官方文档也明确说明,Responses 的工具调用流程可以持续任意多次,直到模型返回最终消息,或者我们的预算、轮数与安全策略要求停止。
简单啊,再加个循环吧。
我们下期再讲。
继续阅读#
参考资料#