1. 为什么面试官总爱让你“手写 Tool Use”最近后台收到不少读者留言都在聊同一个面试题让候选人现场手写一个 Tool Use工具调用。有位读者A同学前两天去面一个 Agent 方向的岗位面试官上来没问八股直接丢给他一句话“我们不限定框架你手写一个 Tool Use 的完整闭环给我看看。”A同学一开始觉得这题不难毕竟平时调 API 调惯了心想不就是把函数描述传给模型模型返回参数我再执行函数把结果回传嘛。结果真动笔的时候发现要写得清楚、写得完整、写得能跑远比想象中复杂。他在电话里跟我复盘了两个小时最后感慨了一句这题看起来考的是手速其实考的是你对“工具调用”这件事的理解深度。其实我特别理解面试官的意图。Tool Use 是大模型从“聊天玩具”走向“生产力工具”的关键转折点。没有工具调用模型只能基于训练数据里的静态知识作答有了工具调用模型才能去查天气、算数学、操作数据库、调用业务 API真正变成一个能“动手干活”的智能体。所以面试官问这道题本质上是在考察你懂不懂 Agent 的底层工作流。我后来跟A同学说这题如果你想答得出彩至少得准备三种不同层级的实现方案每种方案的取舍、适用场景、潜在坑点都要心里有数。今天我就把这三套方案完整地拆开揉碎写出来既有可以直接抄的代码骨架也有我在实际项目里踩过的坑。无论你是准备面试、刚接触 Agent 开发还是想在团队里落地工具调用能力这篇都能给你一个比较完整的坐标系。先把话撂在前面Tool Use 这件事从实现难度上可以分成三个梯度——纯提示词方案、原生 Function Calling 协议方案、通用 Agent 执行循环方案。三套方案没有绝对的好坏只有合不合适的区别。2. 方案一不依赖平台能力用提示词硬控 JSON 输出2.1 适用场景与核心思路先讲最原始、最“手写”的方案。假设你面对的模型服务没有任何原生的工具调用接口只有最普通的 chat/completions 能力你还能不能实现 Tool Use答案是能靠的就是把工具描述写进系统提示词再严格约束模型的输出格式。这套方案的核心思路是把“工具的定义”塞给模型告诉它“当用户的问题需要用到外部工具时你别直接回答先输出一个特定格式的 JSONJSON 里写明你要调用哪个工具、传什么参数”。然后你在代码里对这个 JSON 做解析拿到参数后调用对应的本地函数再把函数的结果拼接成一段文字作为一条新的系统消息塞回给模型让模型基于工具结果生成最终回答。听起来简单但这里面有一个关键认知大模型本身不执行任何代码它只负责“决定”调什么工具、填什么参数真正执行工具的是你写的宿主程序。你是在用提示词“驯”出一个工具调用的决策协议。2.2 关键实现代码骨架我直接给出一版可以直接跑的 JavaScript 伪代码const tools { get_weather: { description: 查询指定城市的实时天气, parameters: { type: object, properties: { city: { type: string, description: 城市名称 } }, required: [city] }, execute: async ({ city }) { // 这里换成真实天气 API 调用 return ${city} 今天晴气温 18~25℃; } }, calc: { description: 计算两个数的四则运算结果, parameters: { type: object, properties: { expr: { type: string, description: 数学表达式如 12*3 } }, required: [expr] }, execute: async ({ expr }) { return eval(expr); // 仅示意生产环境别用 eval } } }; const toolDescriptions Object.entries(tools).map(([name, def]) ( ### 工具 ${name}\n${def.description}\n参数 JSON Schema\n${JSON.stringify(def.parameters)} )).join(\n\n); const systemPrompt 你是一个智能助手。当用户问题需要外部信息或计算时你必须调用工具。 工具列表如下 ${toolDescriptions} 调用规则 1. 判断用户问题是否需要调用工具。若不需要直接回答。 2. 若需要严格输出如下 JSON不要包含任何其他文字 {name: 工具名, arguments: {参数名: 参数值}} 3. 当收到工具结果后基于结果组织自然语言回答。 ;接着是主循环async function chat(messages) { const resp await callModel({ messages: [{ role: system, content: systemPrompt }, ...messages] }); const content resp.choices[0].message.content; // 尝试解析 JSON const toolCall tryParseJson(content); if (!toolCall || !tools[toolCall.name]) { return content; // 没有工具调用直接返回 } // 执行工具 const result await tools[toolCall.name].execute(toolCall.arguments); // 把工具结果回传给模型 messages.push( { role: user, content }, { role: user, content: 工具执行结果${JSON.stringify(result)} } ); return chat(messages); }2.3 这套方案的坑我一个个帮你踩过先说最痛的坑模型总是不老实输出的 JSON 里混着解释性文字。比如它会输出“好的我来查询天气以下是结果{...}”。你用JSON.parse直接解析必崩。我后来学乖了每次都写一个tryParseJson函数先用正则把字符串里第一个{到最后一个}之间的内容抠出来再解析容错率会高很多。第二个坑是循环调用失控。如果模型连续五轮都认为需要调用工具你的循环就会无限跑下去。我一般在循环里加一个maxSteps限制比如最多允许 3 轮工具调用超过就强制返回最后一次结果再加一句“我尝试了多次但未能获取有效结果”。第三个坑是工具描述写得太含糊。模型对工具的理解完全来自你写的 description很多初学者只写一句“获取天气”模型根本不知道该传什么参数、参数单位是什么、默认值是什么。我习惯把参数说明写得像 API 文档一样细甚至附上一个示例值。方案一的最大优点是轻量不依赖任何平台特性换个模型服务只需要调整提示词风格就行。最大缺点是可靠性偏低模型的输出格式偶尔会飘而且随着工具数量变多、逻辑变复杂提示词会越来越臃肿维护成本呈指数上升。3. 方案二拥抱原生 Function Calling 协议3.1 协议化带来的质变如果平台本身就支持 Function Calling现在主流的大模型服务基本都支持只是叫法不同——有的叫 tool call有的叫 function call那你千万别自己用提示词硬憋。协议化的东西一定要走协议这是我在生产环境里吃过亏之后才彻底想通的。方案二跟方案一的本质区别在于“模型是否要调用工具”这件事从“让模型自由发挥”变成了“结构化决策”。你通过 API 参数把工具列表传给模型服务模型会在生成的响应里附带一个结构化的tool_calls字段里面包含工具名和参数而且参数是模型侧解析好的 JSON 对象不用你再去字符串里扒。我印象最深的是第一次切换方案二时的感受之前方案一里那些“JSON 里混文字”“参数漏传”“格式多一个逗号”的问题一下子全部消失了。因为模型服务端为了保证协议可靠会强制约束输出格式。这就是“平台帮你兜底”和“全靠自己扛”的区别。3.2 请求与响应的完整数据流转来看一版标准的示例。首先是请求侧你要在 messages 之外额外传一个tools数组{ model: your-model, messages: [ { role: user, content: 北京今天适合穿短袖吗 } ], tools: [ { type: function, function: { name: get_weather, description: 查询指定城市的实时天气, parameters: { type: object, properties: { city: { type: string, description: 城市名称 } }, required: [city] } } } ] }响应侧如果模型决定调用工具返回的消息里会多出tool_calls{ choices: [ { message: { role: assistant, content: null, tool_calls: [ { id: call_abc123, type: function, function: { name: get_weather, arguments: {\city\: \北京\} } } ] } } ] }注意arguments是一个字符串不是对象你需要自己JSON.parse。拿到之后执行本地函数再把结果作为一条role: tool的消息追加进上下文集发起第二轮请求{ model: your-model, messages: [ { role: user, content: 北京今天适合穿短袖吗 }, { role: assistant, content: null, tool_calls: [ { id: call_abc123, type: function, function: { name: get_weather, arguments: {\city\: \北京\} } } ] }, { role: tool, tool_call_id: call_abc123, content: 北京今天晴气温 12~20℃ } ] }第二轮的响应里模型会结合工具结果给出最终答案“北京今天 12~20℃ 偏凉建议穿长袖或薄外套短袖可能扛不住。”3.3 协议细节里的关键索引逻辑方案二虽然省心但有一些协议细节你必须搞懂否则会调不通。第一tool_call_id绝对不能错。你回传工具结果的时候必须把那条 assistant 消息里tool_calls[0].id原样带回。这个 id 是模型服务端用来关联“哪次调用”的唯一索引。我曾经图省事自己生成一个新 id 填进去结果服务端直接报错。老老实实从响应里取。第二assistant 消息必须原样回传。很多新手在这里栽跟头第一轮返回了带tool_calls的 assistant 消息第二轮组装 messages 时他们只回传了用户消息和工具结果没回传那条 assistant 消息。这会导致服务端无法理解上下文逻辑直接混乱。正确的做法是把带 tool_calls 的 assistant 消息完整塞回去。第三当轮可以返回多个 tool_calls。如果用户问“北京和上海哪个更热”模型可能同时发起两次 get_weather 调用。你需要遍历tool_calls数组逐个执行然后把所有结果都追加为 tool 消息再发起新一轮请求。很多初学的代码只处理tool_calls[0]导致多工具并行调用的场景直接漏掉。第四系统消息在参数里写 type: function还是直接写对象取决于不同平台的协议版本。这是我在实际切换平台时被坑得最惨的一次——同一个语法在 A 平台能用在 B 平台直接 400。正确做法是先查目标平台的 API 文档再看它的 SDK 封装了哪些类型别凭着记忆照搬。方案二目前是生产环境的主流选择因为可靠、结构化、有生态支持。但它的局限也很明显工具定义和调度逻辑还是写死在代码里的没有统一的注册机制也没有处理“工具报错后模型如何恢复”的通用策略。所以当你面对的工具数量一多、场景一变复杂就需要进阶到第三套方案。4. 方案三自研工具注册表与通用执行循环4.1 为什么要搞一个“注册表”而不是写一堆 if-else面试官如果是真在招 Agent 方向的工程师前两套方案只能体现你的“及格线”真正能拉开差距的是第三套。这套方案解决的核心问题是当你的工具数量从 3 个涨到 30 个代码结构怎么组织才不会烂掉。如果你写一堆 if-else 去匹配工具名每加一个工具就要改一次主逻辑那这套系统很快就会变成没人敢动的“屎山”。正确的做法是引入工具注册表Tool Registry每个工具就是一个自带元信息的对象主动“注册”到全局表里调度循环只认注册表完全不关心具体工具的实现。这就像点菜系统每道菜工具把菜名、做法执行函数、需要什么食材参数 Schema登记在菜单上。顾客模型只需要报菜名后厨执行循环按菜单找厨师做菜。新增一道菜只需要加菜单条目不需要改后厨的运转流程。4.2 注册表设计与调度循环实现工具注册表的核心数据结构如下class Tool: def __init__(self, name, description, parameters, handler): self.name name self.description description self.parameters parameters self.handler handler class ToolRegistry: def __init__(self): self._tools {} def register(self, tool: Tool): self._tools[tool.name] tool def get(self, name: str): return self._tools.get(name) def list_specs(self): # 生成给模型看的工具列表 return [ { type: function, function: { name: t.name, description: t.description, parameters: t.parameters } } for t in self._tools.values() ] # 使用示例 registry ToolRegistry() registry.register(Tool( nameget_weather, description查询指定城市的实时天气, parameters{...}, handlerlambda args: f{args[city]} 晴12~20℃ ))有了注册表之后通用执行循环可以统一收敛成一个run_agent函数def run_agent(user_input, registry: ToolRegistry, max_steps5): messages [{role: user, content: user_input}] step 0 while step max_steps: resp call_model( messagesmessages, toolsregistry.list_specs() ) msg resp.choices[0].message # 如果没有工具调用直接返回 if not msg.tool_calls: return msg.content # 先把 assistant 消息压入上下文 messages.append(msg) # 遍历执行所有工具调用 for tool_call in msg.tool_calls: fn_name tool_call.function.name fn_args json.loads(tool_call.function.arguments) tool registry.get(fn_name) if not tool: result f错误工具 {fn_name} 不存在 else: try: result tool.handler(fn_args) except Exception as e: result f工具执行失败{str(e)} messages.append({ role: tool, tool_call_id: tool_call.id, content: json.dumps(result, ensure_asciiFalse) }) step 1 return 已达到最大调用轮数请简化问题后重试这套循环里错误处理我特意做了向上抛文字——当工具内部抛异常时把错误信息作为工具结果返回给模型模型会基于错误信息调整策略比如换个参数再试一次。这比直接在代码层中断整个流程要优雅得多也是 Agent 系统里一个容易被低估的设计细节。4.3 生产化必做的几个加固点第三套方案如果真要做到生产可用以下细节必须处理工具 Schema 的严格校验。模型生成的参数有时候会漏字段、多字段、类型不对。我习惯在tool.handler执行前用 Schema 校验库做一次validate(fn_args)不合法就直接返回“参数校验失败xxx”让模型自己改。工具描述的动态裁剪。当一个 Agent 挂了几十个工具所有工具描述全部塞给模型会占用大量上下文窗口而且模型反而更容易选错工具。正确做法是根据用户问题的关键词做工具召回只把最相关的几个工具描述放进请求。这一步做得好不好直接影响用户体验。工具执行结果的上下文压缩。如果某个工具返回了一个几百行的大 JSON全部塞进 messages 会给模型增加很大负担。我通常在工具返回后做一步后处理只截取前 N 个字符或者用另一个小模型把结果先“压缩”成摘要再把摘要传给主模型。幂等与超时控制。工具调用可能触发有副作用的操作写库、发消息。我习惯在工具定义里加一个idempotent标记位非幂等工具在注册表里会额外做确认防护。同时每个工具的执行都要包一层超时控制避免某个外部 API 卡死整个 Agent。到这里你会发现第三套方案已经不止是“手写 Tool Use”了而是一个微型 Agent 框架的核心骨架。如果面试官看到你能把注册表、错误恢复、上下文压缩这些点都讲清楚大概率会愿意跟你多聊半小时。5. 三套方案怎么选以及面试时怎么答才加分5.1 决策路径从场景反推方案三套方案全掌握之后真正难的是在合适的场景里选合适的方案。我整理了一个简单的决策思路也是我在团队里推动工具化建设时一直沿用的标准先问自己三个问题。第一平台支持原生的 Function Calling 吗如果不支持认命用方案一但你得把 JSON 解析和容错做到位。第二工具的数量和变更频率高吗如果只有两三个固定工具方案二最省事如果工具集在快速增长、需要多人协作维护直接上方案三的注册表架构。第三Agent 的链路复杂吗只需要“查一次工具就回答”的简单场景“方案二 简单循环”就够了需要多轮工具调用、跨工具协作、失败自恢复的必须考虑方案三的完整循环。还有一个我个人的强烈建议哪怕你只是写个 Demo也建议从方案三的注册表结构起步。因为从方案二迁移到方案三的成本远高于一开始就按注册表组织代码的成本。我见过太多项目前期贪方便写了硬编码分发的工具调用等工具数量过十个之后重构的痛感让人只想重写。5.2 面试官到底想听什么回到面试的场景。面试官让你现场写 Tool Use其实他内心有一张隐藏的评分表第一层看你能不能跑通完整闭环——模型决策、函数执行、结果回传一个都不能少。第二层看你有没有协议意识——知不知道原生 Function Calling 和提示词硬控的本质区别知不知道tool_call_id索引之类重要的协议细节。第三层看你有没有工程化嗅觉——工具注册表怎么设计、错误怎么处理、上下文怎么管理、工具多了怎么办。大部分候选人卡在第二层和第三层之间。所以我的建议是面试时不要一上来就闷头写最优方案。你可以先说一句“我有三种方案取决于平台能力和业务复杂度。我先给你写一个不依赖平台的原生实现再讲讲为什么生产环境我建议用原生协议和注册表。”这是一个展示技术判断力的过程远比默默写代码更有记忆点。我在实际面试别人的时候最怕遇到一种候选人一上来就用某个现成框架的 API把工具一注册run 一下完了。这只能说明他会用框架说明不了他理解 Tool Use 本身。真正区分水平的是脱离框架之后你还剩多少底层理解。6. 我踩过的几个细节坑写给你避雷最后单独写一节都是我在真实项目里花过时间代价才摸清楚的细节。这些内容一般文档里不会写面试的时候如果你能主动提出来会是一个非常亮眼的加分项。坑一模型返回空字符串 content 不代表异常。在 Function Calling 流程里当模型决定调用工具时content字段经常是null或空字符串而信息都放在tool_calls里。新手代码如果对content做if (!content) return就会在模型正要调用工具的关键时刻中断流程。正确的判断条件应该是如果tool_calls非空无论如何都要进入工具执行分支。坑二工具描述里的示例值要“正在使用”。我在写工具描述时会专门在description里带上一个典型输入示例比如“查询城市的实时天气示例查询北京天气时应传 city北京”。别看就这么一句话模型面对模糊查询时的参数命中率明显提升。原理很简单模型对模式的模仿能力远强于对抽象规则的理解给示例本质上是给它一个“参考模板”。坑三工具并发执行的返回顺序要保持稳定。当模型一次发起多个工具调用你并行执行它们时回传给模型的 tool 消息必须保持与tool_calls数组相同的顺序。如果顺序错乱模型会把 A 工具的结果当成 B 工具的结论会完全不可信。.map到Promise.all的经典问题在 Agent 场景里同样存在。坑四别在提示词里写“如果工具不可用就编一个答案”。这条我特别想强调。模型非常容易受到提示词里的负面指令暗示一旦你写了类似“如果工具调用失败你可以基于常识回答”它就会更倾向不调用工具、直接猜答案整个 Tool Use 体系直接形同虚设。我的做法是绝不使用“如果失败”类表述只在系统提示词里写清楚“工具结果未返回前不得回答该问题”。坑五日志留痕非常重要。Tool Use 链路涉及模型请求、工具执行、结果回传任何一环出错都可能交互异常。我在生产环境里一定会在关键节点打结构化日志请求时间、模型的 tool_calls 原始内容、参数校验结果、工具执行耗时、回传前消息长度、循环轮数。出了问题时这些日志是唯一能定位到原因的抓手。很多线上 Agent 事故排查半天无果一看日志发现是某个工具的返回格式把模型带偏了这类问题没有日志几乎无从下手。这些坑单拎出来每个都很小但如果你写 Tool Use 时没有防范意识它们会在生产环境里轮番轰炸你。最后分享一点个人体会我从纯提示词方案起步到切换原生 Function Calling再到自研注册表体系中间经历过不少自我怀疑的时刻——总觉得是不是直接套一个现成框架更省事。但回过头看恰恰是这一段“手写”的经历让我对工具调用这件事有了底层层面的理解。框架会迭代协议会变化但调用决策、参数流转、执行回填这条链路的基本逻辑无论平台怎么演进都没变过。如果你也在准备面试或者正在做 Agent 相关开发我的建议是挑一个周末别开 IDE 的自动补全从零写一遍三套方案。写的过程中你会被迫思考很多平时被框架隐藏掉的细节这些思考才是面试官真正想看到的东西。
阅读完成 · 觉得有帮助?