本文最后更新于 35 天前 (2026-07-29),部分内容可能已经过时。
手把手教你从零开发一个 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 的工具调用流程可以持续任意多次,直到模型返回最终消息,或者我们的预算、轮数与安全策略要求停止。
简单啊,再加个循环吧。
我们下期再讲。
继续阅读# 参考资料#