1. 面试现场为什么“手写 Tool Use”成了高频考题1.1 从“会调 API”到“会造轮子”的转变这两年做 AI 应用开发的人都有一个明显感受面试的考察重点变了。前两年你只要说“我调过某大模型的接口做过一个问答机器人”面试官基本就点点头放你过了。现在不行了面试官会往深了问——模型怎么知道该调用哪个工具参数是怎么从自然语言里抽出来的多轮对话里工具调用的上下文怎么维护如果模型返回了一个不存在的工具名你的代码会怎么处理这一连串问题背后其实都指向同一个核心能力Tool Use工具调用。它不是简单地写一个if-else去匹配关键词而是一整套让大模型能够“感知外部世界、执行具体动作、拿回结果继续推理”的机制。面试官让你手写一个 Tool Use本质上是在考察你对大模型应用架构的理解深度而不是看你背了多少 API 文档。我自己在最近几次面试里就反复遇到这个题。第一次被问到的时候我脑子里第一反应是“这不就是 function calling 吗”然后就开始讲怎么定义 JSON Schema、怎么解析返回结果。面试官听完没什么表情接着问“如果不用模型厂商提供的 function calling 能力你自己怎么实现一套”这一下就把我问住了。后来我回去认真复盘把市面上主流的几种实现思路都动手写了一遍才算真正把这块吃透。这篇文章就是把我踩过的坑、试过的三种方案以及每种方案背后的取舍逻辑完整地分享出来。如果你也在准备 AI 应用方向的面试或者正在做需要工具调用的项目这篇内容应该能帮你少走不少弯路。三种方案从简单到复杂分别适合不同的场景我会把每种方案的代码骨架、关键细节和实测表现都讲清楚。1.2 三种方案的总体定位与选择逻辑在动手写之前先说一下我对这三种方案的定位。它们不是互相替代的关系而是对应不同的工程阶段和需求层次。方案一基于提示词工程的纯文本解析。这是最原始也最通用的做法不依赖任何模型厂商的特殊能力只要模型能输出文本就能用。核心思路是在系统提示词里约定好工具调用的格式然后自己写解析器把模型输出里的工具名和参数提取出来。优点是兼容性极强缺点是稳定性依赖模型的指令遵循能力格式容易跑偏。方案二基于模型原生 function calling 能力。现在主流的大模型平台基本都支持了工具调用模型会直接返回结构化的调用请求你只需要按规范定义工具描述、解析返回的 JSON 就行。优点是稳定、省心缺点是绑定特定平台换模型可能要改代码。方案三基于状态机的多轮工具编排。这是前两种的进阶版核心解决的是“一次对话里需要连续调用多个工具、并且后一个工具的输入依赖前一个工具的输出”这种复杂场景。你需要维护一个执行状态机管理工具调用的生命周期处理失败重试、结果注入、循环终止等逻辑。下面我会逐个拆解这三种方案的实现细节。每种方案我都会给出可直接运行的代码骨架并且标注出我在实际测试中发现的坑点。2. 方案一纯文本解析不依赖任何平台能力2.1 核心设计思路与提示词约定这个方案的核心思想很简单既然模型能输出文本那我就在提示词里告诉它“当你需要调用工具时请按照固定格式输出”然后我用正则或者字符串解析把工具调用信息提取出来。听起来很土但它的价值在于通用性。你面对的是一个没有 function calling 能力的模型或者你需要在多个模型之间切换这个方案就是兜底选择。而且面试的时候面试官往往就是想看你能不能从零搭一套出来而不是调个 API 就完事。提示词的设计是这个方案的关键。我试过好几种格式约定最后总结下来最稳的是用类似 XML 标签的包裹方式。原因有两个一是标签边界清晰正则好写二是模型对标签格式的遵循度普遍比纯 JSON 高因为 JSON 的花括号容易被模型在生成过程中弄丢或者多写。我常用的提示词模板大概是这样你是一个可以使用工具的助手。当用户的问题需要调用工具时请严格按照以下格式输出 tool_call {name: 工具名称, arguments: {参数名: 参数值}} /tool_call 如果不需要调用工具直接回答用户问题即可。 可用工具列表 1. get_weather查询指定城市的天气。参数city字符串城市名 2. search_flight查询航班信息。参数from出发地、to目的地、date日期 3. calculate执行数学计算。参数expression数学表达式这个提示词里我特意做了几件事。第一用tool_call标签把 JSON 包起来解析的时候只需要找标签之间的内容。第二工具列表用自然语言描述而不是直接塞 JSON Schema因为纯文本模型对自然语言的理解往往更好。第三明确说了“不需要调用工具就直接回答”避免模型没事就调工具。2.2 解析器的实现与容错处理解析器的核心逻辑就是从模型输出里找到tool_call标签提取里面的 JSON然后解析成工具名和参数。但实际写起来容错处理才是重头戏。我第一版解析器写得很简单直接用正则匹配标签内容然后json.loads。结果测试的时候发现模型有时候会输出这样的内容tool_call {name: get_weather, arguments: {city: 北京}} /tool_call这没问题。但有时候它会变成tool_call {name: get_weather, arguments: {city: 北京}} /tool_call注意末尾多了个空格或者标签写成了tool_call 又或者 JSON 里用了单引号。这些情况都会让简单的解析器挂掉。所以我后来加了几层容错。第一层是标签匹配的宽松化用正则tool_call\s*(.*?)/tool_call\s*来兼容标签内的空格。第二层是 JSON 修复如果json.loads失败尝试把单引号替换成双引号、去掉尾随逗号、补全缺失的括号。第三层是兜底策略如果实在解析不出来就把原始输出返回给用户同时记录一条日志方便后续排查。下面是我实际在用的解析器代码骨架import re import json def parse_tool_call(text): # 第一层宽松匹配标签 pattern rtool_call\s*(.*?)/tool_call\s* match re.search(pattern, text, re.DOTALL) if not match: return None raw match.group(1).strip() # 第二层尝试直接解析 try: data json.loads(raw) return { name: data.get(name), arguments: data.get(arguments, {}) } except json.JSONDecodeError: pass # 第三层修复常见格式问题后重试 fixed raw.replace(, ) fixed re.sub(r,\s*}, }, fixed) fixed re.sub(r,\s*], ], fixed) try: data json.loads(fixed) return { name: data.get(name), arguments: data.get(arguments, {}) } except json.JSONDecodeError: return None这段代码看起来不复杂但每一层容错都是我实际踩坑之后加上的。特别是单引号替换那一步模型在生成 JSON 的时候偶尔会混用单双引号不加这层处理解析失败率大概在百分之五左右加上之后降到了百分之一以下。注意JSON 修复不要做得太激进。我试过用一些自动修复库结果把正常的嵌套结构也改坏了。手动处理几个最常见的格式问题就够了过度修复反而引入新问题。2.3 工具执行与结果回注的完整链路解析出工具调用之后下一步就是执行工具然后把结果回注给模型让模型基于结果生成最终回答。这个链路看起来简单但有几个细节容易出问题。首先是工具执行的安全边界。你不能让模型随便调用任何函数必须有一个白名单机制。我通常用一个字典来注册可用工具解析出来的工具名如果不在字典里直接返回错误信息给模型让它重新选择。TOOL_REGISTRY { get_weather: get_weather_func, search_flight: search_flight_func, calculate: calculate_func } def execute_tool(name, arguments): if name not in TOOL_REGISTRY: return {error: f工具 {name} 不存在请从可用工具列表中选择} try: result TOOL_REGISTRY[name](**arguments) return {result: result} except Exception as e: return {error: f工具执行失败{str(e)}}其次是结果回注的格式。执行完工具之后你需要把结果拼回对话历史里再让模型生成最终回答。我一般用这样的格式tool_result {result: 北京今天晴气温 25 度} /tool_result然后把这个结果作为一条新的消息追加到对话历史里再调用一次模型。模型看到工具结果之后就会基于这个结果来回答用户。这里有个坑我踩过如果工具返回的结果很长比如一个搜索结果列表直接塞回去可能会超出模型的上下文窗口。我的做法是在工具层面做截断只返回最相关的几条或者在提示词里告诉模型“工具结果可能被截断请基于已有信息回答”。还有一个细节是循环终止条件。如果模型拿到工具结果之后又发起了一次工具调用你需要判断是否继续执行。我一般设置一个最大调用轮数比如 5 轮超过就强制让模型基于已有信息回答避免无限循环。3. 方案二原生 function calling稳定但要注意平台差异3.1 工具描述的定义规范与常见误区如果你用的模型平台支持原生 function calling那方案二会省心很多。模型会直接返回结构化的工具调用请求你不需要自己写解析器。但这里的关键在于工具描述的定义定义得好不好直接影响模型能不能正确选择工具和抽取参数。工具描述本质上是一份 JSON Schema包含工具名、功能描述、参数列表和每个参数的类型、描述、是否必填。我见过很多人写工具描述的时候很随意功能描述就写一句“查询天气”参数描述就写“城市名”。这样写模型也能用但准确率会打折扣。我的经验是功能描述要写清楚“这个工具做什么、什么时候用、返回什么”。比如不要写“查询天气”而是写“查询指定城市当前和未来的天气情况包括温度、湿度、天气状况。当用户询问天气相关问题时使用此工具”。参数描述也要具体比如city参数不要只写“城市名”而是写“城市名称如北京、上海、广州不需要带‘市’字”。还有一个常见误区是参数类型定义得太宽泛。比如日期参数如果你定义成字符串模型可能会输出“明天”“下周三”这种相对日期你的工具函数处理起来就麻烦。更好的做法是在描述里明确格式要求比如“日期格式为 YYYY-MM-DD如 2024-01-15”。下面是一个我常用的工具定义示例{ name: search_flight, description: 查询两个城市之间指定日期的航班信息返回航班号、起飞时间、到达时间和价格。当用户需要查询航班时使用。, parameters: { type: object, properties: { from: { type: string, description: 出发城市名称如北京、上海 }, to: { type: string, description: 目的城市名称如广州、深圳 }, date: { type: string, description: 出发日期格式为 YYYY-MM-DD } }, required: [from, to, date] } }3.2 多工具场景下的选择准确率优化当工具数量多起来之后模型选错工具或者漏选工具的情况就会变多。我实测下来工具数量在 5 个以内的时候准确率基本没问题超过 10 个之后错误率明显上升。优化手段有几个。第一是给工具分组在描述里加上分类标签比如“【天气类】”“【出行类】”帮助模型快速定位。第二是控制单次请求里暴露的工具数量如果工具太多可以先让模型做一次意图分类然后只把相关类别的工具传给模型。第三是在系统提示词里加一些选择规则的说明比如“如果用户同时问了天气和航班先调用天气工具再调用航班工具”。我还试过一个技巧就是在工具描述里加“反例”。比如在search_flight的描述里加一句“此工具不用于查询火车票查询火车票请使用 search_train”。这样模型在边界情况下不容易选错。3.3 平台差异与迁移成本的真实体验原生 function calling 最大的问题就是平台绑定。不同平台的 API 格式、返回结构、甚至工具定义方式都有差异。我做过一个统计把一个工具调用模块从一个平台迁移到另一个平台平均需要改动百分之三十左右的代码。差异主要体现在几个地方。一是工具定义的字段名不同有的平台用parameters有的用input_schema。二是返回结构不同有的平台把工具调用放在tool_calls数组里有的放在function_call字段里。三是并行调用的支持程度不同有的平台支持一次返回多个工具调用有的只支持一个。所以如果你做的是需要长期维护的项目我建议在业务代码和平台 API 之间加一层适配层。适配层负责把统一的工具定义转换成各平台的格式把各平台的返回结果转换成统一的结构。这样换平台的时候只需要改适配层业务代码不用动。class ToolAdapter: def to_platform_format(self, tools): # 转换成目标平台的工具定义格式 pass def parse_response(self, response): # 把平台返回结果转换成统一结构 return { tool_calls: [...], content: ... }这层适配层看起来增加了工作量但在多平台切换或者平台 API 升级的时候能省下大量时间。我在一个项目里就是因为没做这层后来平台升级 API 版本改了整整两天才把所有调用点改完。4. 方案三状态机编排搞定多轮连续工具调用4.1 什么场景需要状态机前两种方案解决的是“一次工具调用”的问题。但实际业务里经常遇到需要连续调用多个工具的情况。比如用户说“帮我查一下明天北京到上海的航班然后看看上海那天的天气”这就需要先调航班查询工具拿到结果后再调天气工具。更复杂的情况是后一个工具的输入依赖前一个工具的输出。比如“帮我找一下价格最低的航班然后查一下那个航班到达城市的天气”这里天气查询的城市参数需要从航班查询的结果里提取。这种场景下简单的“调用-回注”循环就不够用了。你需要一个状态机来管理整个执行流程当前处于哪个阶段、下一步该调用什么工具、工具返回的结果如何影响后续决策、什么时候终止。4.2 状态定义与转移逻辑的设计我设计的状态机大概有这几个状态IDLE等待用户输入、PLANNING模型分析需要哪些工具、EXECUTING正在执行工具、WAITING等待工具结果回注、RESPONDING生成最终回答、ERROR异常处理。状态转移的逻辑是用户输入后进入PLANNING模型输出工具调用请求后进入EXECUTING工具执行完进入WAITING结果回注后如果模型还需要调用工具就回到EXECUTING否则进入RESPONDING。这里的关键是维护一个执行上下文记录已经调用了哪些工具、每个工具的结果是什么、当前还需要什么信息。我用一个字典来存这个上下文context { user_query: 帮我查明天北京到上海的航班然后看看上海天气, tool_history: [], current_step: 0, max_steps: 5, collected_data: {} }每次工具执行完把结果追加到tool_history里同时把关键信息提取到collected_data里。比如航班查询返回了到达城市是上海就把collected_data[arrival_city] 上海存起来。后续天气查询的时候如果模型没有明确指定城市就可以从collected_data里取。4.3 失败重试与循环终止的工程细节状态机方案里最容易出问题的就是异常处理。工具调用可能失败模型可能返回无法解析的内容依赖的数据可能缺失。这些情况都需要有明确的处理策略。我的做法是给每个工具调用设置重试次数默认 2 次。如果连续失败就把错误信息回注给模型让模型决定是换一个工具还是直接告诉用户失败了。同时设置一个全局的最大步数比如 10 步超过就强制终止避免死循环。还有一个细节是超时控制。工具执行可能很慢比如调用一个外部 API 要好几秒。如果不设超时整个对话就会卡住。我给每个工具调用设了 10 秒超时超时后返回一个超时错误让模型决定下一步。import signal def execute_with_timeout(func, args, timeout10): def handler(signum, frame): raise TimeoutError(工具执行超时) signal.signal(signal.SIGALRM, handler) signal.alarm(timeout) try: result func(**args) signal.alarm(0) return result except TimeoutError: return {error: 工具执行超时请稍后重试}提示signal.alarm只在主线程有效如果你在异步框架里用需要换成asyncio.wait_for。这个坑我在一个异步项目里踩过调试了半天才发现是信号机制的问题。5. 三种方案横向对比与选型建议5.1 关键维度对比表对比维度方案一纯文本解析方案二原生 function calling方案三状态机编排实现复杂度低中高平台兼容性极强任何模型可用弱绑定特定平台取决于底层方案调用准确率中依赖提示词质量高高多轮连续调用需自行实现部分平台支持原生支持调试难度低日志清晰中黑盒较多高状态多适合场景快速验证、多模型切换单平台稳定生产复杂业务流程5.2 面试场景下的选择策略如果是面试场景我的建议是先把方案一讲清楚因为它是基础能体现你对工具调用本质的理解。然后提一下方案二说明你知道原生能力的存在和优势。最后如果面试官继续追问复杂场景再展开讲方案三。面试官让你“手写一个 Tool Use”重点不是看你写得多快而是看你有没有完整的思考框架。你要能说清楚工具怎么定义、模型怎么选择工具、参数怎么抽取、结果怎么回注、异常怎么处理、多轮怎么编排。把这几个问题讲明白基本就稳了。我在面试里还遇到过一个追问“如果模型返回的工具名不在你的白名单里你会怎么做”我的回答是首先返回错误信息给模型让它重新选择如果连续多次选错就降级为直接回答用户问题同时记录日志用于后续优化提示词。这个回答面试官比较认可因为它体现了工程上的兜底思维。5.3 生产环境的混合使用建议实际项目里我通常是混合使用的。底层用方案二的原生能力保证稳定性和准确率同时在适配层里保留方案一的解析器作为降级方案。如果原生调用失败或者平台不支持自动切换到文本解析模式。对于复杂的多轮场景在方案二的基础上叠加方案三的状态机。状态机不关心底层是原生调用还是文本解析它只负责编排流程。这样分层之后每一层的职责清晰替换和升级都方便。6. 实操避坑与高频问题排查6.1 工具调用中最容易踩的五个坑第一个坑是参数类型不匹配。模型输出的参数值类型和工具函数期望的类型不一致比如模型输出字符串25而函数期望整数25。我的做法是在工具函数入口做一次类型转换和校验不依赖模型输出完全正确。第二个坑是工具描述歧义。两个工具的功能描述太相似模型分不清该用哪个。解决办法是在描述里明确区分边界必要时加反例说明。第三个坑是上下文丢失。多轮对话里模型忘记了之前工具调用的结果。解决办法是把工具结果以摘要形式保留在对话历史里而不是只保留原始输出。第四个坑是并发调用冲突。模型一次返回多个工具调用但这些调用之间有依赖关系不能并行执行。解决办法是在状态机里做依赖分析有依赖的串行执行无依赖的并行执行。第五个坑是结果过长导致截断。工具返回的结果太长回注后超出上下文窗口。解决办法是在工具层面做结果摘要只返回关键信息。6.2 调试工具调用的实用技巧调试工具调用最有效的方法是完整记录每一轮的输入输出。我会把系统提示词、用户输入、模型原始输出、解析结果、工具执行结果、回注内容全部打到日志里按轮次编号。出问题的时候一看日志就知道是哪一步出了偏差。另一个技巧是构造最小复现用例。当发现某个工具调用失败时不要在整个对话里调试而是把那一轮的输入单独拿出来写一个最小脚本复现。这样能快速定位是提示词问题、解析问题还是工具本身的问题。还有一个我常用的方法是给模型加“思考过程”输出。在提示词里让模型在调用工具之前先输出一段推理说明为什么选择这个工具、参数是怎么确定的。这样即使调用错了你也能从推理过程里看出模型的思路方便针对性优化。6.3 常见问题速查表问题现象可能原因排查方向解决建议模型不调用工具提示词未说明工具用途检查工具描述补充使用场景说明工具名解析错误格式约定不清晰检查输出格式用标签包裹并加容错参数缺失参数描述不具体检查参数定义补充格式和示例连续调用死循环无终止条件检查状态机设置最大步数限制结果回注后模型忽略结果格式不醒目检查回注格式用标签包裹并加提示多工具选择混乱工具描述重叠检查工具边界加分类标签和反例7. 我个人的一些实战体会三种方案写下来我最大的感受是工具调用这件事难点从来不在代码本身而在于对模型行为的理解和边界情况的处理。你写的解析器再漂亮模型输出格式跑偏了照样白搭你的状态机设计再精妙工具本身不稳定也是空中楼阁。所以我现在做工具调用相关的功能第一件事不是写代码而是先把工具描述打磨好把提示词写好然后用一批测试用例跑一遍看看模型的调用准确率。准确率达标了再开始写工程代码这样后面调试的成本会低很多。另外一点体会是不要过度追求“全自动”。有些场景下让用户确认一下工具调用的参数比模型猜错了再重试要高效得多。我在一个项目里加了一个“参数确认”步骤用户确认后再执行工具整体体验反而更好因为用户知道自己掌控着流程。最后说一个我觉得很有用的扩展方向把工具调用和缓存结合起来。很多工具调用的结果是可缓存的比如天气查询、航班查询短时间内重复查询可以直接返回缓存结果既省了 API 调用成本也加快了响应速度。这个优化在真实项目里效果很明显尤其是工具调用频繁的场景。
阅读完成 · 觉得有帮助?