1. 从一道面试题说起:为什么“手写 Tool Use”成了高频考点
“手写一个 Tool Use”——这六个字放在三五年前,多数前端或后端岗位的面试里根本不会出现。但从大模型应用开发岗位大量释放开始,它几乎成了绕不开的一道坎。我最近帮几个朋友做模拟面试,十个人里有七个被问到类似的问题,形式各不相同:有的让你白板写伪代码,有的让你在共享编辑器里跑通一个最小闭环,还有的干脆扔给你一段残缺的调度逻辑让你补全。
先说清楚 Tool Use 到底是什么。你可以把它理解成大模型和外部世界之间的一根“数据线”。模型本身只会输出文本,它不知道今天天气、查不了数据库、发不了邮件。Tool Use 就是让模型在生成回复的过程中,主动“喊一声”——我需要调用某个函数,参数是这些——然后由我们的程序去执行这个函数,再把结果塞回给模型,让它继续生成最终答案。整个过程像是一个不会用工具的实习生,旁边坐着一个会跑腿的助理,实习生说“帮我查一下库存”,助理跑一趟回来告诉他结果,他再接着写报告。
面试官考这个,考的不是你会不会调某个现成的 SDK。现成的东西谁都会抄文档。他真正想看的是三件事:第一,你懂不懂模型输出和函数调用之间的协议长什么样;第二,你能不能处理多轮调用、参数校验、异常兜底这些脏活;第三,你在没有框架加持的情况下,能不能从零搭出一个能跑的调度循环。这三件事,恰好对应了三种不同层次的方案。
我前后用过三种写法来应对这类题目,从最裸的手动解析,到半自动的注册表调度,再到基于结构化输出的强约束方案。三种方案没有绝对的优劣,关键看面试官想考察什么、你手头的模型能力如何、以及你对“可控性”和“开发效率”的取舍。下面我把这三种方案逐一拆开,把每一步的意图、参数、坑点都讲透,你照着抄作业就能在面试里稳住。
2. 方案一:手动解析文本协议,最裸但最能体现理解深度
2.1 核心思路与适用场景
这个方案的核心就一句话:让模型按约定格式输出一段特殊文本,我们用字符串匹配把它抠出来,执行完再把结果拼回去。听起来很土,但它恰恰是最能证明你“懂原理”的写法。面试官如果问“如果不用任何框架,你怎么实现 Tool Use”,你把这套讲清楚,基本就稳了一半。
适用场景很明确:模型本身没有原生函数调用能力,或者你用的是自部署的开源模型,输出格式完全由提示词控制。这种方案的可控性极高,因为解析逻辑全在你手里,出问题也好排查。缺点是模型容易“跑偏”,格式稍微不对就得靠正则兜底,稳定性依赖提示词质量。
2.2 协议设计:怎么让模型“说人话”又“说机器话”
协议设计是这个方案的地基。我一般用类似下面这种结构,让模型在需要调用工具时输出一个固定标记包裹的 JSON:
<tool_call> {"name": "get_weather", "arguments": {"city": "杭州"}} </tool_call>为什么用 XML 风格的标签而不是直接输出 JSON?因为纯 JSON 容易和模型正常回复里的内容混淆,加个标签边界,正则匹配时误伤概率大幅降低。这个细节很多人不注意,实测下来标签包裹的解析成功率比裸 JSON 高出一大截。
提示词里要写清楚三件事:可用工具列表、每个工具的参数说明、以及“只有在确实需要外部信息时才调用”的约束。工具列表我习惯用 JSON Schema 的简化版描述,让模型知道参数类型和是否必填。这里有个经验:参数描述里一定要写例子,比如city: 城市名称,例如"杭州",模型对例子的敏感度远高于对类型声明的敏感度。
2.3 解析与执行:正则、异常与多轮循环
解析部分我通常写一个函数,用正则把<tool_call>...</tool_call>之间的内容抠出来,然后json.loads解析。这里必须包一层 try-except,因为模型偶尔会输出带注释的 JSON 或者单引号,直接解析会炸。解析失败时的兜底策略是:把原始输出当作普通回复返回给用户,同时记录一条日志,而不是直接抛异常中断对话。
执行环节要注意参数校验。模型给的参数不一定符合预期,比如该传整数的地方传了字符串,该传数组的地方传了单个值。我一般写一个轻量的校验层,按工具定义检查必填项和类型,不通过就构造一条错误信息塞回给模型,让它自己修正。这个“让模型自我修正”的机制特别重要,面试时主动提出来是加分项。
多轮循环是这套方案的灵魂。完整流程是:用户提问 → 模型输出(可能含 tool_call)→ 解析 → 执行 → 把执行结果作为一条新消息追加到对话历史 → 再次调用模型 → 直到模型输出不含 tool_call 的最终回复。循环要有最大轮数限制,我一般设 5 轮,防止模型陷入死循环反复调用同一个工具。
def run_agent(user_input, max_turns=5): messages = [{"role": "user", "content": user_input}] for _ in range(max_turns): reply = call_model(messages) tool_call = parse_tool_call(reply) if not tool_call: return reply result = execute_tool(tool_call) messages.append({"role": "assistant", "content": reply}) messages.append({"role": "tool", "content": str(result)}) return "达到最大轮数限制"注意:把工具执行结果追加为
tool角色消息时,不同模型的角色命名不一样,有的用function,有的用tool,有的干脆用user。这个必须对着你实际用的模型文档来,写错了模型会“看不懂”结果。
2.4 实操心得与常见坑
第一个坑是模型“假装”调用工具。它可能输出一段看起来像 tool_call 的文本,但其实是它在解释这个工具怎么用。解决办法是在提示词里强调“只有真正需要执行时才输出标签”,同时在解析后加一层判断:如果工具名不在注册列表里,就当作普通文本处理。
第二个坑是参数里的中文和特殊字符。正则匹配时如果用了贪婪模式,遇到嵌套内容会匹配过头。我一般用非贪婪的.*?配合re.DOTALL,确保跨行也能正确匹配。
第三个坑是对话历史膨胀。多轮调用后消息列表会越来越长,token 消耗飙升。我的做法是只保留最近 N 轮的工具调用记录,更早的用摘要替代。这个优化在面试里提一句,面试官会觉得你考虑过生产环境的问题。
3. 方案二:注册表加装饰器,把调度做成可扩展的框架
3.1 为什么要从“手动”升级到“注册表”
方案一能跑,但每加一个工具就要改解析逻辑和提示词,工具一多就乱。面试官如果追问“你有二十个工具怎么办”,方案一就露怯了。这时候方案二登场:用一个注册表把所有工具集中管理,用装饰器自动收集工具定义,调度器根据模型返回的工具名去注册表里找对应函数执行。
这个方案的核心价值在于“可扩展性”。新增工具只需要写一个函数加一个装饰器,提示词里的工具列表和执行时的查找逻辑都自动更新。这在真实项目里是刚需,也是面试官想看到的工程思维。
3.2 工具注册表的设计与实现
注册表本质上就是一个字典,键是工具名,值包含函数引用、参数 schema 和描述。我用装饰器在函数定义时自动注册:
TOOL_REGISTRY = {} def tool(name, description, parameters): def decorator(func): TOOL_REGISTRY[name] = { "function": func, "description": description, "parameters": parameters, } return func return decorator @tool( name="get_weather", description="查询指定城市的当前天气", parameters={ "type": "object", "properties": {"city": {"type": "string", "description": "城市名"}}, "required": ["city"], }, ) def get_weather(city): return f"{city}今天晴,气温 22 度"这样设计的好处是,工具定义和实现绑在一起,不会出现“改了函数忘了改 schema”的情况。生成提示词时,遍历注册表把每个工具的 name、description、parameters 拼成一段文本塞进系统提示。执行时,用工具名去TOOL_REGISTRY里查,查不到就返回错误信息。
3.3 调度循环与参数绑定
调度循环和方案一类似,区别在于执行环节。方案二是根据工具名动态查找函数,然后用**arguments把参数字典展开传进去。这里要注意参数名必须和函数签名一致,否则会报unexpected keyword argument。我一般在校验层加一步:用inspect.signature检查函数接受的参数名,和模型给的参数做比对,不匹配就提前拦截。
import inspect def execute_tool(tool_call): name = tool_call["name"] args = tool_call["arguments"] if name not in TOOL_REGISTRY: return {"error": f"未知工具: {name}"} func = TOOL_REGISTRY[name]["function"] valid_params = inspect.signature(func).parameters filtered = {k: v for k, v in args.items() if k in valid_params} try: return {"result": func(**filtered)} except Exception as e: return {"error": str(e)}这段代码里有个细节值得说:我用filtered过滤掉了模型多给的参数,而不是直接报错。因为模型有时候会“自作聪明”加一些额外字段,直接报错会让对话中断,过滤掉反而更稳。当然,必填参数缺失还是要报错的,这个交给前面的校验层。
3.4 工具描述的质量决定调用准确率
这个方案里,工具能不能被正确调用,八成取决于描述写得好不好。我踩过的坑是:两个工具功能相近,描述又都写得很笼统,模型就经常调错。解决办法是在描述里明确写出“什么时候用这个、什么时候不用”,甚至把反例写进去。
比如查询订单和查询物流这两个工具,描述里我会写:“查询订单状态用这个;如果用户问的是包裹到哪了,用查询物流那个。”这种“排他性描述”能显著降低误调用率。面试时如果你能主动提到这一点,说明你真的调过模型,不是纸上谈兵。
提示:工具数量超过十个以后,提示词会变得很长,模型对靠后工具的关注度会下降。我的经验是把最常用的工具放在列表最前面,同时在系统提示里用一句话概括“你有以下工具可用”,而不是把全部 schema 都塞进去。
4. 方案三:结构化输出加原生函数调用,最稳但依赖模型能力
4.1 结构化输出解决了什么根本问题
方案一和方案二都有一个共同的软肋:解析靠字符串匹配,模型输出格式一抖,解析就可能失败。方案三换了个思路——不让模型“自由发挥”输出文本,而是用模型原生的结构化输出能力,直接返回一个符合 schema 的对象。这样解析环节几乎不会出错,因为格式由模型侧保证。
现在主流的大模型 API 大多支持某种形式的函数调用或结构化输出。有的是在请求里传tools参数,模型返回一个专门的tool_calls字段;有的是用 JSON mode 强制输出合法 JSON。不管哪种,核心都是把“格式约束”从提示词层面下沉到模型层面。
4.2 原生函数调用的请求与响应结构
以常见的函数调用接口为例,请求时把工具定义放在tools字段里,每个工具用 JSON Schema 描述。模型如果决定调用工具,响应里会有一个tool_calls数组,每个元素包含id、function.name和function.arguments。注意arguments通常是一个 JSON 字符串,需要再解析一次。
response = client.chat.completions.create( model="your-model", messages=messages, tools=tool_schemas, tool_choice="auto", ) msg = response.choices[0].message if msg.tool_calls: for call in msg.tool_calls: name = call.function.name args = json.loads(call.function.arguments) result = execute_tool(name, args) messages.append({ "role": "tool", "tool_call_id": call.id, "content": str(result), })这里的关键是tool_call_id。多工具并行调用时,模型可能一次返回多个 tool_call,每个都有自己的 id。执行完把结果回传时,必须带上对应的 id,模型才能把结果和调用对应起来。这个 id 机制是方案三独有的,面试时能讲清楚说明你真的用过原生接口。
4.3 并行调用与结果回传的顺序问题
原生函数调用支持一次返回多个工具调用,这是方案一和方案二很难优雅实现的。比如用户问“北京和上海今天天气怎么样”,模型可以一次返回两个get_weather调用。执行时你可以串行也可以并行,但回传结果时必须每个结果单独一条消息,且都带上各自的tool_call_id。
我踩过的坑是:把多个结果合并成一条消息回传,模型会“懵”,因为它期待的是每个 id 对应一条结果。正确做法是每个结果一条tool角色消息,顺序无所谓,但 id 必须对得上。这个细节在面试里如果被问到“并行调用怎么处理”,答对了就是亮点。
4.4 三种方案的横向对比与选型建议
| 对比维度 | 方案一 手动解析 | 方案二 注册表调度 | 方案三 原生函数调用 |
|---|---|---|---|
| 实现难度 | 低 | 中 | 低(依赖 API) |
| 解析稳定性 | 低 | 中 | 高 |
| 可扩展性 | 差 | 好 | 好 |
| 对模型要求 | 无 | 无 | 需支持函数调用 |
| 并行调用支持 | 需自己实现 | 需自己实现 | 原生支持 |
| 调试难度 | 低(全在手里) | 中 | 高(黑盒) |
| 适合面试展示 | 原理理解 | 工程能力 | 实战经验 |
选型建议很直接:如果面试官想看你懂不懂底层协议,用方案一;如果想看你有没有工程化思维,用方案二;如果岗位偏应用落地、模型也支持,用方案三。最稳的回答是三种都讲,然后说明你在什么场景下会选哪种,这比只讲一种显得全面得多。
5. 面试现场怎么讲:把三种方案串成一个故事
5.1 从需求出发,而不是从代码出发
很多人一上来就开始写代码,面试官还没搞清楚你要解决什么问题,你就已经陷进细节了。我的做法是先花一分钟把需求讲清楚:模型需要调用外部工具,我们要解决的是“模型怎么表达调用意图”和“我们怎么执行并回传结果”这两个问题。然后说“我准备了三种方案,从简单到复杂,分别对应不同的约束条件”,这样面试官会觉得你有全局观。
讲的时候要有递进感。先说方案一,强调它最能体现对协议的理解;然后说“但方案一扩展性差,所以有了方案二”,自然过渡到注册表;最后说“如果模型原生支持函数调用,方案三最省事也最稳”。这个叙事线本身就是一次方案选型的演示,比干巴巴地罗列代码强太多。
5.2 主动暴露权衡,而不是只讲优点
面试官最怕听到“这个方案完美”。每种方案都有代价,主动说出来反而加分。比如方案一解析不稳定,方案二需要维护注册表,方案三依赖模型能力且调试困难。我会在讲每种方案时都带一句“它的代价是……”,然后说“所以我在实际项目里会根据模型能力和团队情况来选”。
这种“权衡思维”是资深工程师和初级工程师的分水岭。初级工程师只会说“我用某某框架”,资深工程师会说“我为什么不用某某框架”。面试官考手写 Tool Use,本质上考的就是后者。
5.3 现场编码时的节奏控制
如果面试要求现场写代码,千万别一上来就写完整实现。我的节奏是:先写核心数据结构(工具定义和注册表),再写解析函数,再写调度循环,最后补异常处理。每写一段停下来解释一句“这一步是为了解决什么问题”。这样即使时间不够没写完,面试官也能看出你的思路是完整的。
代码风格上,变量名要清晰,别用a、b、tmp这种。函数尽量短,一个函数只做一件事。注释不用多,但关键判断处要写一句为什么。这些细节在面试里都是隐形的加分项。
6. 生产环境里那些面试不会问但你必须知道的坑
6.1 工具执行超时与熔断
面试里写的调度循环通常假设工具秒回,但真实环境里工具可能超时、可能报错、可能返回超大结果。我一般给每个工具执行加超时控制,超过阈值就返回“工具执行超时”,让模型基于这个信息继续。同时要有熔断机制:同一个工具连续失败三次,就暂时把它从可用列表里摘掉,避免模型反复调用一个坏掉的工具。
6.2 结果截断与 token 控制
工具返回的结果可能非常长,比如查数据库返回几百行。直接塞回给模型会撑爆上下文。我的做法是给每个工具的结果设一个长度上限,超过就截断并加一句“结果已截断,如需更多请细化查询条件”。这个处理在面试里提一句,面试官会知道你考虑过生产环境的 token 成本。
6.3 安全边界:别让模型为所欲为
工具调用最大的风险是模型调用了不该调用的工具,或者传了危险的参数。比如一个删除数据的工具,模型可能因为理解偏差就调了。我的做法是给工具分级:只读工具可以直接调,写操作工具需要二次确认,危险工具直接不暴露给模型。这个安全分层的思路在任何涉及 Tool Use 的岗位面试里都是加分项。
注意:永远不要假设模型会“理智”地使用工具。提示词里的约束只是软约束,真正的安全边界必须写在代码里,而不是写在提示词里。
6.4 日志与可观测性
生产环境里出问题,第一件事就是看日志。我一般记录每次工具调用的:工具名、参数、执行耗时、返回状态、结果摘要。这些日志在排查“模型为什么调错了工具”时特别有用。面试时如果被问到“怎么调试”,把这套日志方案讲出来,比说“打 print”专业得多。
7. 我个人的一点体会
三种方案我都在真实项目里用过,也都在面试里讲过。如果只能选一种深入讲,我会选方案二,因为它最能体现“工程化”的能力,既不像方案一那么裸,也不像方案三那么依赖外部条件。但真正让我在面试里拿到 offer 的,不是某一种方案的代码写得多漂亮,而是我能把三种方案的取舍讲清楚,并且能说出每种方案在什么场景下会出什么问题、我怎么解决。
手写 Tool Use 这件事,代码本身不难,难的是背后的判断力。模型能力在变,框架在变,但“理解协议、管理调度、控制边界”这三件事不会变。把这三点吃透,不管面试官怎么问,你都能接得住。最后分享一个小技巧:面试前自己动手把三种方案各写一遍,跑通一个最小闭环,哪怕只是调用一个查天气的假函数。跑通过和只看过,讲出来的感觉完全不一样。