在不少面试现场手写代码题往往不是考察背诵而是考察你在限定时间内能不能把一个核心机制徒手搭出来。那次面试官给我出的题就是“手写一个 Tool Use”我当时心里先是一紧随后快速把思路收拢成三条路线先用最轻的 Prompt 解析方案保底再上主流平台原生的 Tool Calling 能力最后再加一层 Agent 循环把工具反馈喂回去。整场写下来三种方案其实对应了三种层次的理解也把 Tool Use 的演进脉络展示得比较完整。这篇文章就还原一下当时的思考路径、核心代码和踩过的坑希望能给同样要面对这类题目的人一点参考。1. 拆解需求面试官到底想看到什么1.1 Tool Use 的本质是什么Tool Use翻译成大白话就是让大模型在生成回答的过程中按需调用外部工具来获取信息或执行操作。比如用户问“北京现在天气怎么样”模型本身不联网它只能靠训练时的知识回答一个过期的信息。但如果给它挂一个get_weather函数让它先查询实时天气再基于查询结果回答这个问题就能被精准解决。所以 Tool Use 的核心链路其实只有四步模型判断当前问题需要哪个工具模型按工具的接口约束生成调用参数调用方执行真实工具拿到结果结果被回传给模型模型结合结果生成最终回答。面试官让我手写 Tool Use本质上就是想看我能不能清晰地把这四个环节串起来。他关心的不仅仅是“你会不会调某个 SDK”而是“你懂不懂这里面的消息结构和状态流转”。1.2 三种方案的整体思路对比我当场给面试官画了一条路线图从纯文本约定的“弱约束”方案到平台原生的“强结构化”方案再到带循环反馈的“Agent 化”方案。三种方案不是互相替代的关系而是同一个需求在不同约束条件下的解法。方案核心思路实现成本可靠性适合场景方案一Prompt 驱动输出解析用文本提示词规定输出格式再用代码解析最低最弱演示 Demo、原型验证、低复杂度工具方案二原生 Tool Calling利用平台接口的结构化 Tool Call 能力中等较强生产环境单轮或有限轮工具调用方案三Agent 循环反馈在方案二基础上增加循环、观测和终止机制较高最强多步骤推理、动态决策、复杂流程选择哪种方案取决于你对“工具调用”的掌控粒度。面试时我特意强调了一句方案一能让你理解 Tool Use 的原理方案二能让你写出可上线的代码方案三能让你设计出健壮的系统。三者我都写得出来但现场时间有限我会按这个优先级去写。2. 方案一纯 Prompt 驱动的输出解析2.1 最小实现让模型喊出函数名方案一的思路最简单粗暴我在系统 Prompt 里告诉模型“如果你需要工具就输出一段固定格式的 JSON”然后在代码里解析这段 JSON。当时我写了一个极简的天气工具作为演示import json import re # 模拟一个查询天气的工具 def get_weather(city: str): data { 北京: 晴25°C, 上海: 多云28°C, 广州: 小雨30°C, } return data.get(city, f暂无{city}的天气数据) # 1. 定义工具的描述拼进 system prompt tools_desc 可用工具 - 名称: get_weather 功能: 查询某个城市的实时天气 参数: {city: 城市名字符串} 当用户询问天气时你必须返回以下 JSON 格式不要返回其他内容 {tool: get_weather, args: {city: 城市名}} def parse_tool_call(text: str): # 用正则尝试抽取 JSON 片段 match re.search(r\{.*\}, text, re.S) if not match: return None try: data json.loads(match.group()) if tool in data and args in data: return data[tool], data[args] except json.JSONDecodeError: return None return None # 模拟模型输出面试现场就是直接 mock 这一段 model_output {tool: get_weather, args: {city: 北京}} # 2. 解析 parsed parse_tool_call(model_output) if parsed: tool_name, args parsed if tool_name get_weather: result get_weather(**args) print(工具结果:, result)这段代码跑通后我立刻给面试官指出它的核心整个方案里没有模型只有“约定的文本协议 解析器”。模型只要愿意遵守格式整个链路就能跑通。2.2 这套方案的死穴方案一看起来很轻量但如果直接用在生产上会在一周内被用户五花八门的输入折磨疯。我现场列了三个典型问题第一个是模型输出格式漂移。模型可能在 JSON 外面包一层解释性文本比如“好的让我查一下{...}”我的正则虽然能勉强抓到第一个花括号但如果 JSON 里嵌套了字典\{.*\}这种贪婪匹配就会出错。更麻烦的是模型偶尔会把单引号当成合法 JSON 输出json.loads直接崩溃。第二个是工具名幻觉。模型可能编造一个不存在的工具名比如get_temperature我的代码里只有get_weather一旦匹配不上就直接返回空。实际上用户问“北京多少度”模型根据训练记忆可能输出一个任意函数名这种错误在 Prompt 方案里很难完全防住。第三个是参数类型错误。get_weather需要city是字符串但模型可能输出{city: [北京, 上海]}这时候get_weather(**args)会直接抛TypeError。所以方案一适合演示和验证链路但真要上生产必须配合一堆防御代码更健壮的 JSON 解析库、工具名白名单校验、参数 Schema 校验。大家能理解面试官让我手写 Tool Use其实是希望我不仅知道这条链路还能指出它的边界在哪。2.3 什么场景下够用虽然方案一带病但它并不是一无是处。在一些模型接口不支持原生 Tool Calling、或者企业内部模型是自研老模型的时候这个方案几乎是唯一选择。我见过不少团队把工具描述写进 system prompt用严格的 JSON Schema 加上兜底重试逻辑也能把调用成功率稳定在 90% 以上。另外方案一的代码量少逻辑直白非常适合用来跟非技术同事解释“工具调用到底是怎么工作”的。当时我还补了一句细节如果要做更稳的版本可以让模型输出一个 markdown 代码块然后专门提取代码块内容这样能显著减少解析失败的概率。3. 方案二原生 Tool Calling 能力3.1 为什么这是标准答案说完方案一我马上切换到方案二使用平台原生的 Tool Calling / Function Calling 能力。这个方案之所以是标准答案因为它把“模型如何决定调用工具”这件事从脆弱的口头约定变成了协议级的规范。原生方案的核心区别在于模型返回的消息里会携带一个结构化的tool_calls字段里面明确给出了工具名和参数对象而不是一串需要正则去猜的文本。这相当于模型厂商把“工具调用意图”单独建模了。它的好处有三点参数是结构化对象不是字符串不需要硬解析模型经过针对性训练工具名幻觉的概率大幅降低平台支持多工具并行调用一次响应能返回多个tool_calls。3.2 最短可运行代码当时我用一份简化版的接口来演示代码结构跟主流的厂商接口一致import json # 模拟平台返回的完整消息 response { message: { role: assistant, content: None, # 普通文本为空 tool_calls: [ { id: call_001, function: { name: get_weather, arguments: json.dumps({city: 北京}), } } ] } } # 工具注册表 tools { get_weather: lambda city: f{city} 天气晴25°C, } def execute_tool_call(message): tool_calls message.get(tool_calls, []) results [] for call in tool_calls: func_name call[function][name] func_args json.loads(call[function][arguments]) # 执行真实工具 result tools[func_name](**func_args) results.append({ tool_call_id: call[id], result: result, }) return results # 执行结果 for r in execute_tool_call(response[message]): print(r)这段代码展示了一个关键点arguments字段是字符串需要json.loads解析成字典。这是最容易踩的接口细节之一。3.3 处理多工具与并行调用面试官一般会紧接着问如果一个回答里同时需要查天气和查股票怎么办原生方案里模型返回的tool_calls是一个列表里面可以包含多个调用而且这些调用在业务上通常是互相独立的。我的处理方式是循环执行每个tool_call把每个执行结果映射回对应的tool_call_id。这里有个容易犯错的点执行完工具后需要把每个结果包装成一条角色为tool的消息并附上对应的tool_call_id这样模型才能把“哪个结果对应哪个调用”对应起来。def build_tool_messages(results): messages [] for item in results: messages.append({ role: tool, tool_call_id: item[tool_call_id], content: json.dumps(item[result], ensure_asciiFalse), }) return messages注意content我习惯统一用 JSON 字符串。这样不管是数值、数组还是嵌套字典都不会在传输中丢失结构。3.4 这次踩到的关键点方案二看着简单但有几个细节很容易翻车。第一个是工具描述写得不够详细。模型判断“该不该调用这个工具”时主要依据的是description字段。如果工具描述写得含糊比如只说“查询天气”那模型可能在该调用的时候没调用或者在不该调用的时候乱调用。我在现场特别强调描述里应该写清楚工具适用的场景、参数含义、边界条件甚至举一个预期示例。第二个是参数 Schema 要与模型平台兼容。有些字段比如minimum、maximum、regex部分平台不支持强行传上去会导致接口报错。所以工具参数的 Schema 应该保持克制能用type和enum表达清楚的就不要用太复杂的校验规则。第三个是工具执行阶段不要做“模型不该做的事”。比如鉴权信息、密钥、内部请求地址这些都不应该出现在发给模型的工具描述里。工具描述会被模型看到一旦泄露就有风险。正确的做法是在工具执行层注入这些上下文而不是写在工具的静态描述里。方案二已经能支撑绝大多数单轮工具调用场景了。但面试官通常不会满足于此他可能还会追问如果工具第一次返回的结果不理想需要再调一次怎么办这就自然过渡到了第三个方案。4. 方案三带观测反馈的 Agent 循环4.1 循环的必要性方案二有个隐含的假设模型调用一次工具拿到结果然后直接把结果组织成回答就完事了。但现实情况更复杂。比如用户问“北京今天需要带伞吗”模型先查天气发现“小雨”它可能还需要再查一个“降水概率”才能给出稳妥的结论。这种情况下只靠一轮调用是不行的。Agent 循环把单次 Tool Call 升级成一个闭环模型决定调用某个工具执行后把结果作为新的上下文喂回去模型根据这个结果决定是继续调用工具还是直接生成最终回答。4.2 循环怎么设计才安全我当时设计了一个带最大轮数的循环避免模型因为某种原因持续调用工具导致死循环。这个保护机制是必须的否则一个错误的参数可能让循环在线上空转到超时。def agent_run(user_query, max_steps5): messages [{role: user, content: user_query}] step 0 while step max_steps: # 调用模型传入工具定义 response call_llm(messages, toolstools_schema) # 情况一模型没有发起工具调用直接输出文本结束 if not response.message.get(tool_calls): return response.message.content # 情况二模型发起了工具调用执行后把结果追加到消息列表 tool_messages [] for call in response.message[tool_calls]: func_name call[function][name] func_args json.loads(call[function][arguments]) result execute_tool(func_name, func_args) tool_messages.append({ role: tool, tool_call_id: call[id], content: json.dumps(result, ensure_asciiFalse), }) messages.append(response.message) messages.extend(tool_messages) step 1 return 已达到最大执行步数已停止工具调用。 # 伪代码call_llm 与 execute_tool 在下文解释这段代码里最关键的是messages.extend(tool_messages)。很多初学者会把工具结果直接拼到原始messages的末尾但忘记先把模型的这条包含tool_calls的消息也追加进去。如果不追加模型下次生成时上下文就丢失了“它曾经决定调用过这个工具”的事实逻辑会断裂。4.3 循环里最容易出问题的两件事第一个是“原地打转”。模型反复调用同一个工具但参数每次都略有不同目的却是想“撞出一个正确结果”。这其实更像模型在瞎试。解决方法是把工具执行失败的错误信息也作为tool消息回传给模型让它知道这次调用触发了什么异常从而调整策略。我当时加了一个示例try: result execute_tool(func_name, func_args) except Exception as e: result {error: str(e), tool: func_name, args: func_args}把异常信息结构化地回传给模型比让模型蒙在鼓里重新猜要高效得多。第二个是上下文膨胀。每多一轮调用messages就会增加至少两条消息模型调用消息 工具结果消息。如果工具返回的是超长内容七八轮下来上下文可能就把模型窗口挤爆了。这时候需要做轻量压缩只保留工具结果的摘要或者只保留最近几轮的调用历史。这个优化属于工程问题但在面试中提出来会很加分。5. 面试现场常见的追问与排查技巧5.1 高频追问面试官听完三种方案后通常还会追几个实操问题我当时也一一应对了。一个是“工具执行超时怎么办”。答案是给工具执行包一层超时控制超时后返回一个约定好的错误结果而不是让整个循环卡死。具体可以用线程池或异步任务实现但面试时只要讲清楚思路即可。另一个是“并发调用多个工具时怎么保证结果匹配”。我的做法是每个tool_call都保留唯一的tool_call_id执行完的结果按 id 回填而不是按顺序猜。这个机制在方案二和方案三里是通用的。还有一个是“工具返回的结果格式应该怎么设计”。我建议统一用 JSON 序列化后的字符串这样不会丢失类型信息。不要直接拼一段自然语言文本因为模型对文本的理解虽然强但结构化数据在处理时更不容易产生幻觉。5.2 现场最容易翻车的 4 个问题我把面试和项目实现中最常见的四个问题整理成了速查表问题原因处理方法模型返回了不存在的工具名工具描述不清晰或模型训练边界内不存在该工具增加工具名白名单校验不在白名单内则拒绝调用并把错误信息反馈给模型参数解析失败json.loads报错模型返回的arguments不是合法 JSON包一层异常捕获同时用更宽松的解析策略必要时要求模型重新生成参数模型反复调用同一个工具缺少终止条件或者工具结果没有被正确反馈增加最大步数限制确保工具结果已追加到上下文把工具错误结果也回传工具结果太长把上下文撑爆工具返回了完整数据未做截断对工具结果做摘要、截断或用“展示前 N 条 汇总统计”的方式返回这张表看起来简单但每一条背后都有真实的线上教训。特别是“工具结果太长”这件事很多人都要等线上报错后才意识到比如搜索引擎工具返回了 100 条链接模型根本不需要全部看完。5.3 半小时落地清单如果面试官真的给你半小时让你从零写一个 Tool Use建议按照下面这个清单推进先定工具选两个足够简单的工具比如查天气和算加法不要贪多用方案一跑通链路锻炼解析和执行的连接感升级到方案二换成原生 Tool Calling 结构让模型输出结构化调用再套循环加上最大步数和错误反馈让链路具备 Agent 雏形补充防御白名单校验、参数解析异常处理、上下文截断。这个清单的核心在于每一步都在前一步的基础上多一层保障。你会发现最终手写出来的 Tool Use 并不难难的是每一步背后你对“为什么这么做”的解释是否到位。写到这我回想起当时面试的最后几分钟面试官问了我一句“如果用户的问题根本不涉及工具你的链路会怎么走”我回答在方案三的循环里模型不返回tool_calls时就会直接返回content文本这就是普通的聊天流程只不过被包在了同一个循环框架里。他点了点头。那次面试之后我也养成了一个习惯手写 Tool Use 时永远先考虑“不调用工具”这条默认路径是不是通畅的。很多线上问题恰恰就出在这条被忽略的默认路径上。