跟着LAP啥都学 跟着LAP啥都学

第 12 章 Function Calling:结构化工具调用协议

      +

      本章导读:实现最小可用的 Function Calling Agent——模型学习输出 结构化工具调用,由 C++ dispatcher 完成解析、执行与结果回喂的多轮循环。 理解它的关键只有一条:Function Calling 是一个文本协议循环。

      学完本章你将能够:

      1. 说明 Function Calling 的文本协议本质,及其与闭源 API 实现的差异;

      2. 把 Hermes 多轮对话数据拍平为单轮 SFT 样本;

      3. 实现带护栏的协议状态机(自然语言退出、结果回喂、轮数上限);

      4. 设计工具注册机制与四维客观评测;

      5. 判断一个模型该走「写好 prompt 就能调工具」还是「必须 SFT」(12.1.1)。

      产出与代码:chapters/chapter12_toolcalling/(chat/chat_template.h/.cpp 唯一模板源、 chat/tools.cpp 工具注册、chat/dispatcher.cpp + chat/src/tool_dispatch.cpp 多轮 dispatcher、tools/src/tool_convert.cpp 数据转换、eval/src/tool_eval.cpp 评测;训练复用 chapter10_serving;单轮 worker chat/src/tool_chat.cpp)。

      12.1 原理概述:Function Calling 的文本协议本质

      「AI 帮你订机票、查天气」这类演示很常见,模型看起来能直接执行操作。但 模型本身不执行函数。OpenAI 官方文档也是这个口径——模型只输出结构化的调用请求,真正执行函数的永远是外部程序。

      整个「调用」是一个文本协议循环。模型读到带工具列表的 prompt,生成一段结构化文本,写明「请调 get_stock_price,参数 company=Apple」。C++ dispatcher 解析这段文本,执行对应的函数。结果再拼回对话、喂给模型,模型才能生成面向用户的回答。模型管判断,程序管执行,两边靠一段格式化文本传话:

      Diagram

      实现这个循环,有四个设计决策要确定(README 取舍表的浓缩):

      决策 选择 理由

      触发标记

      <tool_call> XML 标签包 JSON

      纯 ASCII、BPE 友好;裸 JSON 容错差(字符串值多一个未转义引号就解析失败)

      多轮处理

      C++ dispatcher(tool_dispatch)

      模型只加载一次,多轮循环进程内完成;不需要每轮 spawn 子进程
      写临时文件(本项目曾以 Python dispatcher 起步,现已全部改为 C++, chat_template.h 的头注保留了这段历史)

      数据格式

      Hermes 多轮 → Alpaca 拍平

      每个 assistant turn 是一条样本(history → 下一条回复)

      训练

      复用 chapters/chapter10_serving/train

      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 串停机、解析 <tool_call>、查注册表、真执行、结果回喂、轮数上限

      纯代码,零训练(12.3 的 dispatcher 全部属于这一层)

      决策层

      这句话该不该调工具、调哪个、参数填什么、什么时候收尾说人话

      模型能力:要么基座自带,要么 SFT 教出来

      执行层永远不需要训练,换任何权重都能直接复用,本章 12.3 的所有代码都在这一层。决策层按基座分三种情况:

      1. 强 instruct 基座(GPT-4 / DeepSeek / Qwen 官方版等):这些模型在后训练阶段就见过大量工具调用数据。把工具定义塞进 prompt,它就会照着定义输出调用请求,零训练可用。这正是 OpenAI Function Calling 的日常用法——开发者从不训练模型,只写解析与执行的代码。厂商通常还会叠加受限解码,用语法约束保证输出的 JSON 合法。但这是代码侧的增强:能管住格式,管不住模型该不该调、调哪个。

      2. 中等基座 + 手写示例(ReAct 风格的 Thought / Action / Action Input):这条路零训练也能跑通,但是十分脆弱。格式会漂移,该停的时候停不下来,参数容易填错。

      3. 纯预训练基座(本书的 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 的四个指标判断走哪条路:先跑一遍零训练基线。valid_tc 高而 correct_tool 低,说明模型学会了格式,却选不准工具,这时 SFT 的收益主要在选工具;valid_tc 本身就低,说明连协议都守不住,先考虑受限解码或 JSON 修复(习题 3),再考虑训练。

      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 用例因此全部用英文。

      我们沿"注册表与训练分布的关系"做过两组实测,都写进了本章的数字里:

      + . *把语料里没出现过的工具塞进注册表:模型照样学会 <tool_call> 协议(valid_tc 100%),却选不准工具(correct_tool 44%)——格式是分布内的,「该调哪个」不是。 . *语料本身带位置偏置:含调用的对话里,被调用的工具 85.7%(6814/7948)排在 <tools> 块第一位。355M 的模型学到的最强关联正是「调用块首工具」——把注册表顺序对调(tool_dispatch --tools get_movie_details,get_stock_price),影片查询立刻走通;而股价查询靠更强的语义匹配在两种顺序下都走通。组合泛化(全新的工具组合 + 全新的 query)是更大的模型才有的能力。

      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 让你亲手验证这一点。

      模型能「看到」哪些工具,完全由注册进 ToolRegistry 的定义表决定—— render_full_prompt 会把这份定义表渲染进 prompt 的 <tools> 块。 可执行函数与模型可见的定义在同一个对象里登记,因此不会出现 「模型调了一个注册表里没有的工具」。

      这个设计针对一个具体风险:定义表和函数表若分开维护,两边不同步时会出现「模型看得见、程序没法执行」的幽灵工具。这种缺陷在 prompt 里没有痕迹,只有运行到那一轮才暴露。配对登记就是冲着它去的——定义和函数写在同一处,想只登记一半都做不到:

      Diagram

      每个工具是「一个定义 + 一个函数」的配对(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 兜底

      两个关键机制:

      1. stop 串:默认 {"</tool_call>", "<tool_response>", "<|im_end|>"}——生成 文本尾部一旦命中就立即返回。</tool_call> 出现说明 JSON 已完整,继续解码只是 浪费时间,还可能在后面续出多余内容、干扰解析;<tool_response> 出现说明模型在替 工具「作答」(越界),同样截断;<|im_end|> 是 ChatML 的轮次边界——语料里部分 assistant 轮以它结尾,模型答完一轮后会续写「下一轮对话」的幻觉文本,命中边界立即停, 答案才干净(chat/dispatcher.h)。

      2. 回喂协议: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):

      指标 定义

      valid_tc

      期望 tool_call 时,输出含合法 <tool_call>{…​}</tool_call>

      correct_tool

      工具名与期望一致

      correct_args

      arguments 的 JSON keys 集合一致(值不查——参数值千变万化,键集合体现「会不会用这个工具」)

      natural_language

      期望自然语言时,输出不含 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)。

      自测清单

      能不回看就答出这些,本章就算过关:

      1. convert 产物为标准 Alpaca JSON,sft_train 零修改可训

      2. smoke 通过:未微调权重跑通全链路,输出乱码但解析不崩

      3. 微调后:语料原生的查股价 query 走满「tool_call → 执行 → 回答」循环;用 --tools 把 get_movie_details 排到块首后,影片 query 同样走满(12.2 TIP 的位置偏置)

      4. 纯聊天 query 不触发 tool_call(natural_language 维度高分)

      5. tool_eval 四指标 JSON 落盘,correct_tool 明显高于随机

      6. 自定义工具:在 make_builtin_registry() 加一个 ToolDef + ToolFn,模型就能调到它

      7. 能说出 stop 串两个字符串各自拦住什么,以及为什么 correct_args 只查键不查值

      8. 能说清执行层与决策层的分界:为什么本章换权重就必须重新训练,而 dispatcher 代码不用动

      习题

      1. 在 chat/tools.cpp 的 make_builtin_registry() 里加一个「查数据库」工具 (mock 数据即可),验证模型能否按你的 description 正确选工具—— 工具描述是 prompt 的一部分,写清楚与写含糊,选工具准确率差多少?

      2. 把 --max-turns 从默认的 4 改成 1,观察哪些 query 会失败——单轮 Agent 与 多轮 Agent 的能力边界在哪?

      3. 给 extract_tool_call(chat/chat_template.cpp)加 JSON 修复逻辑 (缺引号/尾逗号),对比 valid_tc 指标变化——量化「容错解析」的收益。

      4. dispatcher 默认就打印每轮 prompt;加 --json 后改为输出机器可读的 {"query","answer","turns","truncated"}。用两种模式各跑一次, 手动核对第二轮 prompt 与训练数据中「第二轮样本」的 instruction 渲染是否逐字节一致。

      5. (思考)为什么 correct_args 只查键不查值?如果要查值,评测脚本 该怎么设计才不会把「合理但不同」的参数值判成错的?

      6. (思考)把 get_movie_details 的影片白名单搬到 dispatcher 统一做,会有什么 问题?提示:考虑语料里没出现过的新影片该由谁放行。

      7. tool_dispatch 的 --system 参数能把任意文本插进 system 提示词。 把两三条手写示例(用户问句 → <tool_call> 原文)塞进去,对未微调 的 gpt2-medium 重跑 tool_eval,看 valid_tc 能涨多少:少样本提示 能把格式教会吗?能把「该不该调、调哪个」也教会吗?对照 12.1.1 的分界回答。

      8. tool_dispatch --tools get_movie_details,get_stock_price 能按给定顺序重排 注册表。分别用两种顺序跑同一批 query(12.2 TIP),记录「被调用的工具」: 块首位置与 query 语义,哪个才是模型选工具的依据?语料里被调用工具 85.7% 排在块首——你测出的比例是多少?

      拓展阅读