大模型工具调用实战:手写Tool Use的三种方案与面试考点
1. 从一道面试题说起为什么“手写 Tool Use”成了高频考点“手写一个 Tool Use”——第一次在面试里听到这个要求的时候我脑子里闪过的第一个念头是这东西平时不都是调库吗怎么还手写但转念一想面试官想看的从来不是你会不会调 API而是你脑子里有没有一套完整的“模型如何调用外部工具”的机制模型。换句话说他想知道的是当一个大模型需要查天气、算数学、读数据库的时候它到底是怎么把“我想调用某个函数”这件事表达出来又是怎么把结果拿回来继续推理的。这个问题的本质其实是Function Calling函数调用或者更广义的Tool Use工具使用机制。它解决的核心问题是大模型本身只会生成文本它没有实时数据、没有计算器、没有数据库连接但通过一套约定好的协议模型可以输出一个结构化的“调用意图”外部程序解析这个意图、执行真正的函数、再把结果塞回给模型模型基于结果继续生成最终回答。这套流程听起来简单但里面涉及的细节非常多怎么定义工具描述、怎么让模型稳定输出结构化内容、怎么处理多轮调用、怎么防止模型幻觉出不存在的工具、怎么在流式输出场景下做增量解析。适合谁来读这篇内容如果你正在准备面试尤其是涉及大模型应用开发的岗位这篇可以直接当复习提纲如果你已经在做 Agent 相关的开发但一直是调框架、没自己从零搭过那这篇能帮你把底层机制补上哪怕你只是对“模型怎么用工具”这件事好奇我也会尽量用生活化的类比把原理讲清楚。接下来我会把当时面试里用到的三种方案完整拆开每一种都给出可运行的代码思路、参数选择的理由以及我在实际踩坑中总结出来的经验。2. 方案一基于提示词工程的纯文本协议解析2.1 核心思路与适用场景第一种方案最“土”但也最能体现你对机制的理解不依赖任何模型厂商提供的原生函数调用能力完全靠提示词工程让模型按照约定格式输出一段文本然后你自己写解析器把工具名和参数提取出来。这种方案的优势是通用性极强——任何能生成文本的模型都能用不管是本地部署的小模型还是 API 调用的大模型劣势也很明显稳定性完全取决于模型的指令遵循能力格式跑偏是家常便饭。我当时选这个方案打头阵是因为面试官说“你先用最简单的方式实现”那我就用最朴素的办法。核心设计是这样的在系统提示词里定义好可用工具的列表每个工具包含名称、功能描述、参数说明然后要求模型在需要调用工具时输出一个特定标记包裹的 JSON 块比如用tool_call和/tool_call作为边界。模型正常回答时就直接输出文本需要调工具时就输出这个块外部程序检测到标记就解析执行。2.2 工具描述的设计要点工具描述写得好不好直接决定了模型能不能正确选择工具、能不能填对参数。我总结下来有几个关键点。第一工具名称要有语义别用func1、tool_a这种要用get_weather、calculate_expression这种一看就懂的。第二功能描述要写清楚“什么时候用”和“什么时候不用”比如“当用户询问实时天气时使用此工具不要用于查询历史天气数据”。第三参数说明要包含类型、是否必填、取值范围最好给一个示例值。下面是我当时写的一个工具描述模板你可以直接参考你可以使用以下工具来辅助回答 工具名称get_weather 功能描述查询指定城市的实时天气信息。当用户询问当前天气、温度、是否下雨等问题时使用。 参数 - city (string, 必填): 城市名称例如北京、上海 - unit (string, 可选): 温度单位可选值为celsius或fahrenheit默认为celsius 工具名称calculate 功能描述计算数学表达式。当用户需要进行数值计算时使用。 参数 - expression (string, 必填): 合法的数学表达式例如2 3 * 4 当你需要调用工具时请严格按照以下格式输出 tool_call {name: 工具名称, arguments: {参数名: 参数值}} /tool_call这个模板看起来简单但每一条都有讲究。比如unit参数给了默认值是为了减少模型必须填的字段数量降低出错概率再比如明确说了“当用户询问当前天气时使用”是为了防止模型在闲聊时也乱调工具。2.3 解析器的实现与容错处理解析器这块我用 Python 写了一个基于正则的提取逻辑。核心就是找tool_call和/tool_call之间的内容然后json.loads解析。但实际跑起来你会发现模型输出的 JSON 经常有小毛病单引号代替双引号、末尾多逗号、转义字符处理不对。所以我在解析前加了一层清洗用正则把常见的格式问题修一修再尝试解析解析失败就返回一个错误信息让模型重试。import re import json def parse_tool_call(text): pattern rtool_call\s*(.*?)\s*/tool_call match re.search(pattern, text, re.DOTALL) if not match: return None raw match.group(1) # 常见修复单引号转双引号去除末尾逗号 raw raw.replace(, ) raw re.sub(r,\s*}, }, raw) raw re.sub(r,\s*], ], raw) try: data json.loads(raw) return data except json.JSONDecodeError as e: return {error: f解析失败: {e}, raw: raw}这段代码里re.DOTALL是为了让.能匹配换行符因为模型输出的 JSON 经常是格式化的多行。清洗逻辑里单引号转双引号是最常见的修复但要注意如果参数值里本身包含单引号这个粗暴替换会出问题所以更稳妥的做法是用ast.literal_eval先试一次不行再走正则清洗。这个细节我在面试时专门提了一嘴面试官明显对这个容错思路比较认可。2.4 多轮调用的循环控制工具调用不是一次性的模型可能调完一个工具拿到结果后还需要再调另一个。所以外部程序需要一个循环模型输出 - 检测是否有工具调用 - 有就执行 - 把结果拼回对话历史 - 再让模型生成 - 直到模型不再输出工具调用为止。这个循环必须设最大轮次限制不然模型可能陷入死循环。我当时设的是 5 轮超过就强制让模型基于已有信息回答。注意把工具执行结果拼回对话时一定要明确标注这是“工具返回结果”并且把原始的工具调用也保留在历史里这样模型才能建立“我调了什么、拿到了什么”的对应关系。我见过有人只把结果塞回去模型完全不知道这个结果对应哪个问题导致答非所问。3. 方案二利用模型原生 Function Calling 能力3.1 原生能力的优势与调用约定第二种方案是直接用模型厂商提供的原生函数调用接口。以 OpenAI 风格的接口为例你可以在请求里传一个tools参数里面用 JSON Schema 描述每个工具模型如果决定调用会在返回的tool_calls字段里给出结构化的函数名和参数。这个方案的最大优势是稳定性高——因为模型在训练时就专门针对这种格式做过对齐输出格式几乎不会跑偏参数类型也基本正确。但这里有个面试常问的点原生 Function Calling 到底是怎么实现的其实底层原理和方案一没有本质区别模型仍然是在生成文本只不过训练阶段用了大量“工具调用”格式的数据做微调让模型学会了在特定上下文下输出特定结构的 token 序列。厂商在服务端做了一层解析把这段结构化输出提取出来以tool_calls字段返回给你。理解这一点很重要因为它意味着如果厂商的解析逻辑有 bug或者模型版本更新导致行为变化你是无法控制的。3.2 JSON Schema 的编写细节原生方案里工具描述用的是 JSON Schema 格式比方案一的纯文本模板严格得多。我当时的写法是这样的tools [ { type: function, function: { name: get_weather, description: 查询指定城市的实时天气信息, parameters: { type: object, properties: { city: { type: string, description: 城市名称例如北京、上海 }, unit: { type: string, enum: [celsius, fahrenheit], description: 温度单位 } }, required: [city] } } } ]这里有几个容易踩坑的地方。第一required字段一定要写对不写的话模型可能漏填必填参数。第二enum能加就加把参数取值范围限制住模型就不会瞎编。第三description要写得具体别写“城市名称”就完了加上示例值能显著提升模型填参的准确率。我实测下来加了示例值之后参数填错的概率大概能降一半。3.3 处理并行调用与结果回传原生方案支持模型一次返回多个tool_calls也就是并行调用。比如用户问“北京和上海哪个更热”模型可能一次性返回两个get_weather调用。这时候你需要遍历tool_calls列表逐个执行然后把每个结果用对应的tool_call_id关联回去。回传的格式是往消息列表里追加一条role为tool的消息带上tool_call_id和content。for tool_call in response.tool_calls: func_name tool_call.function.name args json.loads(tool_call.function.arguments) result execute_tool(func_name, args) messages.append({ role: tool, tool_call_id: tool_call.id, content: json.dumps(result, ensure_asciiFalse) })这个tool_call_id是关键它保证了结果和调用的对应关系。如果模型一次调了三个工具你回传结果时顺序乱了或者 id 对不上模型就会懵。我在实际项目里遇到过因为 id 没对齐导致模型反复重复调用同一个工具的情况排查了半天才发现是回传时 id 写错了。3.4 原生方案的局限与应对原生方案虽然稳但有两个明显局限。一是绑定厂商换一家模型可能接口格式完全不同迁移成本高。二是黑盒你无法干预模型选择工具的逻辑有时候模型会选一个不太合适的工具你只能通过优化 description 来间接引导。应对办法是做一个抽象层把不同厂商的接口统一成内部格式这样切换模型时只需要改适配器。这个思路在面试时也值得提能体现你的架构意识。4. 方案三基于语法约束的强制结构化输出4.1 语法约束的原理第三种方案是我个人最喜欢的一种也是最能体现技术深度的一种用语法约束Grammar Constrained Decoding强制模型输出符合特定语法的内容。简单说就是在模型生成每一个 token 的时候动态地屏蔽掉那些会导致输出不符合目标语法的 token只让合法的 token 有机会被选中。这样模型输出的内容在语法层面保证是合法的 JSON 或特定的调用格式从根本上杜绝了格式跑偏的问题。这个方案的原理可以用一个类比来解释普通生成就像让一个人在纸上自由写作他可能写出任何东西语法约束就像给他一个填字游戏模板每个空只能填特定类型的词填出来的结果必然符合模板要求。实现上需要在解码阶段维护一个状态机根据当前已生成的 token 序列计算出下一个位置允许的 token 集合然后把这些 token 之外的 logits 全部设为负无穷。4.2 工具调用语法的定义我当时定义的语法大概是这样的一个工具调用由tool_name和arguments两部分组成tool_name必须是预定义工具名之一arguments是一个 JSON 对象其键必须是该工具定义的参数名值的类型也要符合定义。用 EBNF 表示大概是这样tool_call :: { \name\ : string , \arguments\ : object } string :: \ [a-zA-Z0-9_\u4e00-\u9fa5] \ object :: { (pair (, pair)*)? } pair :: string : value value :: string | number | boolean | object | array实际实现时我不会手写这么复杂的语法而是用现成的语法约束库比如基于 GBNF 或者 JSON Schema 转语法的工具。核心工作是把工具定义转换成对应的语法规则然后在推理时加载这个语法。4.3 实现中的关键参数与性能考量语法约束对推理性能有一定影响因为每个 token 生成时都要做一次状态转移和 token 屏蔽计算。我实测下来在 7B 级别的模型上开启语法约束后生成速度大概下降 15% 到 30%具体取决于语法的复杂度和词表大小。优化手段有几个一是预编译语法状态机别每次推理都重新构建二是缓存 token 屏蔽掩码相同状态下的掩码可以复用三是限制语法复杂度别把整个 JSON Schema 都塞进去只约束关键字段。还有一个坑是中文参数值的处理。很多语法约束库默认只支持 ASCII 字符遇到中文城市名就会卡住。解决办法是在语法定义里显式加入 Unicode 范围或者把中文参数值先做转义处理。我当时是在语法里加了\u4e00-\u9fa5这个范围让状态机允许中文字符通过。4.4 三种方案的横向对比跑完三种方案之后我做了一个对比表格面试时也直接拿出来了对比维度方案一提示词工程方案二原生 Function Calling方案三语法约束格式稳定性低依赖模型指令遵循高训练时专门对齐极高语法层面保证通用性极强任何模型可用弱绑定厂商接口中需要模型支持 logits 访问实现复杂度低低高性能开销无额外开销无额外开销生成速度下降 15%-30%可控性高可自定义格式低黑盒极高可精确控制适用场景快速原型、本地小模型生产环境、API 调用对格式要求极严的场景这个表格一摆出来面试官基本就能看出你对三种方案的取舍有清晰认知。我的建议是原型阶段用方案一快速验证生产环境用方案二省心省力对格式有极致要求且能控制推理过程时用方案三。5. 实操中的常见问题与排查技巧5.1 模型不调用工具怎么办这是最常见的问题模型明明应该调工具却直接编了一个答案。排查思路分三步。第一检查工具描述是否清晰是不是功能描述写得太模糊模型没意识到该用。第二检查提示词里有没有明确说“当遇到 X 情况时必须调用工具”有时候需要强制引导。第三检查模型本身的能力小模型可能根本没学会工具调用这时候要么换模型要么在提示词里给 few-shot 示例。我遇到过一个典型案例用户问“现在几点了”模型直接编了一个时间。后来发现是工具描述里写的是“获取当前时间”但没写“当用户询问时间时必须使用此工具”加上这句话之后就正常了。所以工具描述里的触发条件一定要写明确。5.2 参数填错或漏填怎么处理参数问题一般有三类漏填必填参数、填了不存在的参数、参数类型不对。漏填的话在解析后做一次校验发现缺失就构造一个错误消息让模型重新填。填了不存在的参数解析时直接忽略多余字段但最好也返回一个提示让模型知道。类型不对的话能做自动转换就转换比如字符串25转数字25转不了就报错重试。提示重试次数一定要设上限我一般设 2 次。超过 2 次还填不对说明模型能力不够或者工具描述有问题继续重试只是浪费 token。5.3 多轮调用陷入死循环模型调了工具拿到结果又调同一个工具反复循环。这种情况通常是因为工具返回的结果没有满足模型的预期模型以为没调到就再调一次。解决办法是在工具结果里加上明确的“这是最终结果”标记或者在提示词里说“如果已经获取到足够信息请直接回答不要重复调用工具”。另外最大轮次限制是最后的兜底一定要设。5.4 流式输出下的工具调用解析流式场景下模型的输出是一个 token 一个 token 来的工具调用的 JSON 可能被拆成好几段。这时候不能等全部输出完再解析需要做一个增量解析器维护一个缓冲区每次收到新 token 就尝试解析解析成功就触发工具执行。这个实现起来比较绕我的做法是检测到tool_call开始标记后就进入“收集模式”一直收集到结束标记再整体解析。原生 Function Calling 的流式返回里tool_calls也是分片的需要按 index 拼接。6. 面试复盘与个人经验总结回过头看这道面试题面试官真正想考察的其实不是“你会不会写代码”而是你对工具调用这件事的机制理解深度和方案取舍能力。三种方案从土到精覆盖了不同的技术层次能把这三种都讲清楚基本就证明你既懂原理又懂工程。我个人在实际操作中的体会是别一上来就追求最完美的方案。很多人直接上语法约束结果发现模型不支持 logits 访问或者性能下降太厉害反而耽误了进度。正确的做法是先用手工解析跑通流程验证工具定义和调用逻辑没问题再根据实际需求决定要不要升级到原生方案或语法约束。另外工具描述的质量比方案选择更重要我见过太多人纠结用哪种方案结果工具描述写得一塌糊涂换什么方案都救不了。最后分享一个小技巧在定义工具的时候给每个工具写一个“反例”也就是明确说什么情况下不要用这个工具。比如天气工具的反例是“不要用于查询历史天气数据”这个反例能显著减少误调用。这个技巧我在多个项目里验证过效果很稳。