第 12 章 Function Calling:结构化工具调用协议
|
本章导读:实现最小可用的 Function Calling Agent——模型学习输出 结构化工具调用,由 C++ dispatcher 完成解析、执行与结果回喂的多轮循环。 理解它的关键只有一条:Function Calling 是一个文本协议循环。 学完本章你将能够:
产出与代码: |
12.1 原理概述:Function Calling 的文本协议本质
「AI 帮你订机票、查天气」这类演示很常见,模型看起来能直接执行操作。但 模型本身不执行函数。OpenAI 官方文档也是这个口径——模型只输出结构化的调用请求,真正执行函数的永远是外部程序。
整个「调用」是一个文本协议循环。模型读到带工具列表的 prompt,生成一段结构化文本,写明「请调 get_stock_price,参数 company=Apple」。C++ dispatcher 解析这段文本,执行对应的函数。结果再拼回对话、喂给模型,模型才能生成面向用户的回答。模型管判断,程序管执行,两边靠一段格式化文本传话:
实现这个循环,有四个设计决策要确定(README 取舍表的浓缩):
| 决策 | 选择 | 理由 |
|---|---|---|
触发标记 |
|
纯 ASCII、BPE 友好;裸 JSON 容错差(字符串值多一个未转义引号就解析失败) |
多轮处理 |
C++ dispatcher( |
模型只加载一次,多轮循环进程内完成;不需要每轮 spawn 子进程 |
数据格式 |
Hermes 多轮 → Alpaca 拍平 |
每个 assistant turn 是一条样本(history → 下一条回复) |
训练 |
复用 |
C++ 训练代码一字不改(沿用第 11 章已验证的复用路径) |
第一条决策值得展开。主流的 Function Calling 实现,如 OpenAI 与 Anthropic 的版本,都是闭源 API。模型内部如何触发调用、参数如何约束,外界无法复现。开源生态的通行做法是用文本标记约定协议。Hermes 格式(NousResearch)是事实标准之一,Qwen、Llama 系微调模型广泛兼容。
XML 标签包 JSON 这个组合有两个好处。标签给 BPE 一个稳定、纯 ASCII 的开关:<tool_call> 会切成固定的几个 token,模型容易学会何时打开、何时关闭。内层 JSON给程序一个可直接解析的载荷。不用裸 JSON 做开关,是因为它的语法容错差:字符串值里出现一个未转义的引号,解析就会失败。相比之下,判断「</tool_call> 有没有出现」对 tokenizer 来说几乎不会出错。所以标签管触发,JSON 管载荷。
12.1.1 协议归代码,能力归模型:本章为什么必须训练
读完上面的时序图,容易冒出一个问题:既然执行工具的是 C++ 代码,那换一个现成的大模型,不训练是不是也能调工具?能——前提是这个模型本来就懂这套协议。Function Calling 的能力可以拆成两层,两层的来源完全不同:
| 层 | 负责什么 | 从哪来 |
|---|---|---|
执行层 |
渲染 prompt、stop 串停机、解析 |
纯代码,零训练(12.3 的 dispatcher 全部属于这一层) |
决策层 |
这句话该不该调工具、调哪个、参数填什么、什么时候收尾说人话 |
模型能力:要么基座自带,要么 SFT 教出来 |
执行层永远不需要训练,换任何权重都能直接复用,本章 12.3 的所有代码都在这一层。决策层按基座分三种情况:
-
强 instruct 基座(GPT-4 / DeepSeek / Qwen 官方版等):这些模型在后训练阶段就见过大量工具调用数据。把工具定义塞进 prompt,它就会照着定义输出调用请求,零训练可用。这正是 OpenAI Function Calling 的日常用法——开发者从不训练模型,只写解析与执行的代码。厂商通常还会叠加受限解码,用语法约束保证输出的 JSON 合法。但这是代码侧的增强:能管住格式,管不住模型该不该调、调哪个。
-
中等基座 + 手写示例(ReAct 风格的 Thought / Action / Action Input):这条路零训练也能跑通,但是十分脆弱。格式会漂移,该停的时候停不下来,参数容易填错。
-
纯预训练基座(本书的 gpt2-medium):只有续写能力,没有「输出结构化调用」的先验,必须 SFT。
本书属于第三种,所以第 12 章要走完整训练流程。这条分界的证据就在代码里:scripts/run.sh smoke 找不到 data/toolcall-sft-medium.pth 时,会自动回落到未训练的 gpt2-medium.safetensors,并打印「预期输出接近乱码即 OK」(smoke 分支见 scripts/run.sh)。同一套 dispatcher、同一份模板,换个权重就从「能对话的 Agent」变回「乱续写的文本生成器」。代码一行没动,掉下去的是模型那一层。
|
手上另有可换的模型时,用 12.4 的四个指标判断走哪条路:先跑一遍零训练基线。 |
12.2 数据:多轮对话拍平为单轮 SFT 样本
第 11 章 nl2bash 是单进单出,本章要处理多轮对话。训练器 sft_train 从第 5 章沿用至今,只接受单轮样本:一条 instruction 配一条 output。好在训练器不需要改。先明确多轮对话的本质:每一次生成,都是「看到 history 预测下一条 assistant 回复」。模型发 tool_call 时看前几轮;生成自然语言时,看到的还包括工具结果在内的全部历史。
想通这一点,转换方案就定了:把每次生成时的 history 渲染成 instruction,把那条 assistant 回复作为 output。一段多轮对话就此拍平为一组单轮样本,训练代码无需任何修改。
语料是 NousResearch/hermes-function-calling-v1(约 58 MB JSON),每条记录是一段多轮对话:
{"conversations": [
{"from": "system", "value": "...<tools>[...工具定义...]</tools>"},
{"from": "human", "value": "What is the stock price of Apple?"},
{"from": "gpt", "value": "<tool_call>{...}</tool_call>"},
{"from": "tool", "value": "<tool_response>{\"name\": \"get_stock_price\", \"content\": {\"stock_price\": \"$150.75\"}}</tool_response>"},
{"from": "gpt", "value": "Apple is trading at $150.75."}
]}
转换规则(tools/src/tool_convert.cpp):对每个 assistant turn,把它
之前的全部 history 渲染成 instruction,该 turn 的内容作为 output,
整段对话就退化为一组单轮 SFT 样本:
# tool_convert 的产物形态(标准 Alpaca,可直接喂 sft_train)
{"instruction": "<system + tools + user + 之前所有 turn 的渲染>",
"input": "",
"output": "<gpt 的下一段:tool_call 或自然语言>"}
这个方案有一个前提:推理时渲染出的 prompt,必须和训练数据里的 instruction 完全一致。训练时模型见到的是 A 形态,推理时也要喂 A 形态。差一个空格、换一次行,模型面对的就是没见过的输入。因此模板必须单源:chat_template.h 是训练侧转换与推理侧 dispatcher 共用的唯一模板源。两边渲染逐字节一致(执行细节见 12.3 的回喂协议)。
转换器(tools/src/tool_convert.cpp)还要处理两件事:parse_hermes_tools 从 system 文本里提取 <tools>…</tools> 工具定义块(兼容 JSON 数组与 Hermes XML <tool>{…}</tool> 两种格式);to_tool_def 把 OpenAI 风格的 {type:function, function:{…}} 拍平成 {name, description, parameters}。语料的 8995 条对话最终拍平得到约 1.38 万条样本(instruction 超过 2048 字符的长尾对话被丢弃,为 1024 token 训练窗口留出余量)。
内置注册表的两个工具(get_stock_price / get_movie_details)也不是随手挑的:这个两工具组合是语料里真实出现的 <tools> 块(17 个样本的家族,两个工具在族内都有调用),定义原文逐字节取自该家族——注意 get_stock_price 的参数是 company(公司名)而非股票代码,不同对话家族的定义变体并不相同。经同一模板源渲染后,这两个定义在训练数据的 <tools> 块里分别逐字节出现 343/79 次——注册表本身落在训练分布内。另一个如实的边界是语言:语料 16326 条 human 轮没有一句中文,本章的演示与 smoke 用例因此全部用英文。
|
我们沿"注册表与训练分布的关系"做过两组实测,都写进了本章的数字里: +
. *把语料里没出现过的工具塞进注册表:模型照样学会 |
12.3 dispatcher:多轮循环与工具注册(chat/dispatcher.cpp)
工具注册:一个静态注册表
dispatcher 收到「What is the stock price of Apple?」,会调 get_stock_price 而不是 get_movie_details。依据就在每轮 prompt 里:模型能看到的信息,全在 <tools> 块那几段定义文本里——name、description、parameters。工具描述是写给模型的使用说明。
|
工具定义(description + parameters)是 prompt 工程的一部分,而且容易被 低估:模型选哪个工具、填哪些参数,取决于它每一轮 prompt 里读到的定义 文本。description 写「Get the current stock price」,与把 symbol 该填什么 代码、返回什么币种写清楚,选工具的准确率可能相差一个量级。这与第 7 章 Alpaca 模板措辞影响微调效果是同一件事:训练分布里有什么,模型就学会 什么。练习 1 让你亲手验证这一点。 |
|
模型能「看到」哪些工具,完全由注册进 |
这个设计针对一个具体风险:定义表和函数表若分开维护,两边不同步时会出现「模型看得见、程序没法执行」的幽灵工具。这种缺陷在 prompt 里没有痕迹,只有运行到那一轮才暴露。配对登记就是冲着它去的——定义和函数写在同一处,想只登记一半都做不到:
每个工具是「一个定义 + 一个函数」的配对(chat/tools.cpp):
// chat/tools.cpp —— 内置工具注册表(定义逐字节取自语料家族原文)
ToolDef stock_def() {
ToolDef d;
d.name = "get_stock_price";
d.description = "Get the current stock price of a company";
d.parameters = json::parse(R"({
"properties": {
"company": {"type": "string", "description": "The name of the company"}
},
"required": ["company"],
"type": "object"
})");
return d;
}
void ToolRegistry::register_builtin_tools() {
register_tool(stock_def(), [](const ToolCall& call) -> std::string {
std::string company = stock_company(call); // company 为主,兼容变体
json r = json::object();
r["stock_price"] = "$" + fmt2(stock_price(company)); // 固定价格表,确定性
return r.dump();
});
// get_movie_details 同款配对登记
}
要新增一个工具,写一个 ToolDef、一个 json(const json&) 函数,再调用
register_tool(def, fn) 配对登记即可,dispatcher 与模型零改动。工具函数
内部自己负责沙箱:例如 get_movie_details 用影片白名单拒绝一切未收录的
影片,思路与第 11 章 deny-by-default 一致:
register_tool(d, [](const ToolCall& call) -> std::string {
std::string title = call.arguments.value("title", std::string(""));
if (title.empty()) throw std::runtime_error("缺少参数 title");
// 沙箱:影片走白名单,未收录的直接拒绝,不编造信息
static const std::map<std::string, json> kMovies = {
{"Inception", ...}, {"The Dark Knight", ...}, {"Titanic", ...},
};
auto it = kMovies.find(title);
if (it == kMovies.end()) throw std::runtime_error("未收录的影片: " + title);
return it->second.dump();
});
沙箱放在哪一层有讲究:不在 dispatcher,而在每个工具函数内部。只有工具自己知道什么输入合法,dispatcher 无法替所有工具做判断——白名单放在离输入最近的地方,才挡得住。
多轮循环:stop 串精确停机
循环主干是:渲染 prompt → 生成 → 有 tool_call 就执行并回喂,没有就结束。「停止」有两个关键点——什么时候停止本次生成,什么时候停止整个会话:
// chat/dispatcher.cpp —— run_loop:模型只加载一次,循环在进程内完成
for (int turn = 0; turn < opt.max_turns; ++turn) {
const std::string prompt = render_full_prompt(messages, tools, opt.system);
auto out = gen.generate(prompt, opt.max_new, stop_on); // 贪心解码
auto call = extract_tool_call(out.text);
if (!call) {
result.answer = out.text; // 自然语言 → 结束
return result;
}
const std::string tool_result = registry.call(*call); // 真执行
messages.push_back({"assistant", out.text});
messages.push_back({"tool", wrap_tool_result(*call, tool_result)}); // 回喂
result.answer = out.text;
}
result.truncated = true; // max_turns 兜底
两个关键机制:
-
stop 串:默认
{"</tool_call>", "<tool_response>", "<|im_end|>"}——生成 文本尾部一旦命中就立即返回。</tool_call>出现说明 JSON 已完整,继续解码只是 浪费时间,还可能在后面续出多余内容、干扰解析;<tool_response>出现说明模型在替 工具「作答」(越界),同样截断;<|im_end|>是 ChatML 的轮次边界——语料里部分 assistant 轮以它结尾,模型答完一轮后会续写「下一轮对话」的幻觉文本,命中边界立即停, 答案才干净(chat/dispatcher.h)。 -
回喂协议:assistant 的 tool_call 原文,加上
<tool_response>包裹的{"name","content"}工具结果(语料里 7860 个工具轮一律是这个形状), 一起拼回 messages。下一轮render_full_prompt渲染出的 prompt 形态与 训练数据严格一致——chat_template.h要当唯一模板源(训练/推理共用), 就是为了这一点。训练与推理的分布对齐是 Agent 稳定工作的前提:差一个 空格,模型就可能把输入当成没见过的局面。
循环退出条件有三类:自然语言(正常结束)、<tool_response> 命中
(模型越界,异常路径)、max_turns 兜底(truncated=true)。把整个
循环看成一个协议状态机更清晰:
[渲染 prompt] → 生成 ─┬─ 无 tool_call ────→ 结束(返回自然语言)
├─ 合法 tool_call ──→ 执行 → 结果回喂 → 下一轮
└─ 越界/超轮数 ─────→ 异常退出或截断返回
max_turns 默认 4(chat/dispatcher.h),这个上限有两个作用。一是成本护栏:
每轮都要一次 GPU forward。二是失控护栏:模型若陷入「调工具→失败→再调」的循环,
没有上限就会一直消耗算力。贪心解码(temperature=0)让同一 query 的调用
路径可复现,便于调试。路径不可复现的系统出了问题,无法区分该查模型
还是查代码。
12.4 评测:4 维命中率(tool_eval)
工具调用的评测可以完全客观(eval/src/tool_eval.cpp,逐样本渲染
prompt → 生成 → 与真值对比,每条 0/1):
| 指标 | 定义 |
|---|---|
|
期望 tool_call 时,输出含合法 |
|
工具名与期望一致 |
|
arguments 的 JSON keys 集合一致(值不查——参数值千变万化,键集合体现「会不会用这个工具」) |
|
期望自然语言时,输出不含 tool_call(不乱调工具) |
correct_args 只查键不查值,是刻意的取舍。参数值千变万化,「city 填
北京还是 beijing」都是对的,强行查值会误伤合理答案。键集合测的才是
「会不会用这个工具」。
评测里还有零训练的 smoke 验证:12 条手写 prompt,直接喂未微调的 gpt2-medium。期望输出接近乱码,但整条链路(渲染 → 生成 → JSON 解析 → 评分)不崩。先跑通管道,再谈模型能力。顺序反了,出了问题无法区分 是模型还是管道的问题。训练三天、最后崩在一个没测过的解析分支上,这类 浪费完全可以避免。
这条基线还有第二个用途:它和训练后的 ./scripts/run.sh eval 用同一份数据、同一套指标、同一个二进制,只是权重不同。两份结果一对比,SFT 到底买到了什么就清楚了:
./scripts/run.sh smoke --out logs/smoke-base.json # 未训练基线
./scripts/run.sh eval --out logs/eval-sft.json # 训练后(需 data/toolcall-sft-medium.pth)
# 全量 1.38 万条要逐条生成上万次;先抽样看趋势可加 --limit 300
对照两份 JSON 的 summary 字段即可。12.1.1 的分界在这里也对得上:涨上去的应该是决策层指标(correct_tool / correct_args / natural_language),执行层两边的代码一模一样。
12.5 编译运行与预期输出
按顺序执行以下命令,完成数据准备、训练与对话验证:
cd chapters/chapter12_toolcalling
./scripts/run.sh download # Hermes 数据集 ~58 MB(→ data/raw/*.json)
./scripts/run.sh download-weights # GPT-2 medium 官方权重(~1.4 GiB,已存在则跳过)
./scripts/run.sh merge # 合并 data/raw/*.json → all-merged.json
./scripts/run.sh convert # → data/toolcall-data.json(~13.8k 条)
./scripts/run.sh build # 产出 tool_chat/tool_dispatch/tool_eval/tool_convert/tool_merge
SIZE=medium EPOCHS=3 BATCH=1 ./scripts/train-background.sh
# 输出 data/toolcall-sft-medium.pth(约 1.6 GB:406M 参数 × 4B,含未共享的 50257 维输出头)。
# batch 必须=1:工具描述拉长序列后,batch=2/4 实测 OOM(15.59 GiB 卡上分别占到 14.6/14.8 GiB)
./scripts/run.sh chat --query "Hi, can you tell me the current stock price of Apple?"
chat 默认 verbose,每轮打印 prompt、生成文本与工具执行结果。下面是实测输出
(system + <tools> 块约 0.8 KB,以 … 略去):
[turn 0] prompt (816 chars):
…(<|im_start|>system + <tools> 块 + <|im_start|>user + 查询)…
[turn 0] generated (87 chars, stopped_on=</tool_call>, 0.79 s):
<tool_call>
{"name": "get_stock_price", "arguments": {"company": "Apple"}}
</tool_call>
[turn 0] tool -> {"stock_price":"$150.75"}
[turn 1] prompt (1061 chars):
…(上一轮全部内容 + assistant 调用 + <tool_response> 结果回喂)…
[turn 1] generated (55 chars, stopped_on=<|im_end|>, 0.51 s):
The current stock price of Apple is $150.75.
<|im_end|>
--- 结果 ---
轮数: 2
[turn 0] 调用工具 (0.79 s)
工具结果: {"stock_price":"$150.75"}
[turn 1] 自然语言 (0.51 s)
答案: The current stock price of Apple is $150.75.
对照 12.1 的时序图读这段输出:模型输出 <tool_call>,dispatcher 执行
查询并把结果回喂,模型再生成自然语言回答。两轮循环,就是一个完整的
Agent。
本章小结
-
Function Calling 的本质是文本协议循环:模型只输出结构化标记,工具由 dispatcher 执行。
-
Hermes XML 标记对 BPE 友好;多轮对话经「拍平」退化为单轮 SFT 样本,训练代码完全复用。
-
dispatcher 是带成本与失控护栏的协议状态机:自然语言退出、tool_call 回喂、轮数上限截断。
-
工具注册表决定模型可见的工具集合;描述质量直接影响选工具与填参数的准确率。
-
执行层(dispatcher)零训练,换任何权重都能复用;决策层(该不该调、调哪个、填什么)看基座——强 instruct 模型写 prompt 就有,纯预训练基座必须 SFT(12.1.1)。
自测清单
能不回看就答出这些,本章就算过关:
-
convert 产物为标准 Alpaca JSON,sft_train 零修改可训
-
smoke 通过:未微调权重跑通全链路,输出乱码但解析不崩
-
微调后:语料原生的查股价 query 走满「tool_call → 执行 → 回答」循环;用
--tools把 get_movie_details 排到块首后,影片 query 同样走满(12.2 TIP 的位置偏置) -
纯聊天 query 不触发 tool_call(natural_language 维度高分)
-
tool_eval四指标 JSON 落盘,correct_tool 明显高于随机 -
自定义工具:在
make_builtin_registry()加一个 ToolDef + ToolFn,模型就能调到它 -
能说出 stop 串两个字符串各自拦住什么,以及为什么
correct_args只查键不查值 -
能说清执行层与决策层的分界:为什么本章换权重就必须重新训练,而 dispatcher 代码不用动
习题
-
在
chat/tools.cpp的make_builtin_registry()里加一个「查数据库」工具 (mock 数据即可),验证模型能否按你的 description 正确选工具—— 工具描述是 prompt 的一部分,写清楚与写含糊,选工具准确率差多少? -
把
--max-turns从默认的 4 改成 1,观察哪些 query 会失败——单轮 Agent 与 多轮 Agent 的能力边界在哪? -
给
extract_tool_call(chat/chat_template.cpp)加 JSON 修复逻辑 (缺引号/尾逗号),对比valid_tc指标变化——量化「容错解析」的收益。 -
dispatcher 默认就打印每轮 prompt;加
--json后改为输出机器可读的{"query","answer","turns","truncated"}。用两种模式各跑一次, 手动核对第二轮 prompt 与训练数据中「第二轮样本」的 instruction 渲染是否逐字节一致。 -
(思考)为什么
correct_args只查键不查值?如果要查值,评测脚本 该怎么设计才不会把「合理但不同」的参数值判成错的? -
(思考)把 get_movie_details 的影片白名单搬到 dispatcher 统一做,会有什么 问题?提示:考虑语料里没出现过的新影片该由谁放行。
-
tool_dispatch的--system参数能把任意文本插进 system 提示词。 把两三条手写示例(用户问句 →<tool_call>原文)塞进去,对未微调 的 gpt2-medium 重跑tool_eval,看valid_tc能涨多少:少样本提示 能把格式教会吗?能把「该不该调、调哪个」也教会吗?对照 12.1.1 的分界回答。 -
tool_dispatch --tools get_movie_details,get_stock_price能按给定顺序重排 注册表。分别用两种顺序跑同一批 query(12.2 TIP),记录「被调用的工具」: 块首位置与 query 语义,哪个才是模型选工具的依据?语料里被调用工具 85.7% 排在块首——你测出的比例是多少?
拓展阅读
-
上一章:第 11 章 NL → Bash —— 同样复用 sft_train 的前作
-
OpenAI Function Calling 官方文档(模型不执行函数、由开发者执行的官方口径): https://platform.openai.com/docs/guides/function-calling
-
NousResearch/hermes-function-calling-v1 数据集卡(Hermes 格式出处,Apache-2.0): https://huggingface.co/datasets/NousResearch/hermes-function-calling-v1
-
NousResearch/Hermes-Function-Calling 官方仓库(格式与用法): https://github.com/NousResearch/Hermes-Function-Calling