
面试官让我手写一个 Tool Use我用了 3 种方案先说结论这场面试我没有拿到那个 offer但事后复盘这轮技术面是我收获最大的一次。题目本身不复杂就是让候选人现场手写一个大模型工具调用Tool Use的核心逻辑不限制语言、不限制框架给半小时写完讲思路。表面上看是考工程能力实际上考的是你对“大模型应用落地”这件事的理解层次。我在这半小时里先后写了三种方案从最朴素的 JSON 解析到带循环决策的完整代理再到面向协议化的 MCP 思想层层递进。如果你也在准备大模型应用方向的面试或者正在做一个需要让模型调用外部工具的小项目这篇内容值得你花十分钟看完。Tool Use 这个词放在两年前还是研究圈里的概念现在已经成了 Agent 应用的底座能力。无论你是做 RAG、做自动化办公、做客服机器人只要系统里存在“模型要查数据、要发请求、要操作某个服务”的需求就绕不开工具调用。面试官把这道题拿出来不是为了听你背 API而是想看看你在没有任何脚手架的情况下能不能从零把这条链路打通并且能讲清楚每一步的设计取舍。1. 先说清楚面试官到底在考什么1.1 一次真实的面试现场回顾当天面试官的表述很简短“我们不做那种基础八股今天手写一个 Tool Use。假设你有一个大模型它能支持函数调用你告诉它今天天气怎么样模型会返回一个结构化指令你的代码要负责解析这个指令、执行对应函数、把结果回传给模型最后让模型基于结果给出回答。你就实现从模型输出到工具执行的这一段。”听起来不难对吧但真正坐下去写的时候你会发现这里头藏着大量细节模型返回的格式可能不规范、工具执行可能出错、结果回传之后模型还要再生成一轮、参数到底怎么校验、超时怎么处理。更关键的是面试官会不断追问“如果多个工具都能匹配怎么办”“如果模型没有调用工具怎么办”“如果工具执行返回了异常怎么办”。每一问都是在逼你做工程上的取舍。我第一版方案只用了十五分钟就写完了很简单把工具定义注册到一个字典里然后用正则从模型输出里提取函数名和参数。面试官看了一眼说可以跑但如果模型换一种输出格式就废了。这句话点醒了我——Tool Use 的核心不是解析而是“约定和适配”。后面我重构了两次才逐渐把问题的全貌摸清楚。1.2 揭开 Tool Use 的三层理解要想在面试现场做到不慌你得对 Tool Use 有一个分层认知而不是停留在“调用函数”这个表面。第一层是通信协议层。模型本身是一个文本进文本出的系统它要调用外部函数必须通过一种双方约定的格式来交互。最常见的做法是给模型一份工具清单包含函数名、参数描述、必填项、用途模型在需要调用时输出一个特殊标记的 JSON 块由工程侧解析执行。这一层的核心是“怎么描述工具”和“怎么解析指令”。第二层是执行调度层。拿到模型返回的指令之后要不要校验参数、要不要做权限控制、怎么执行函数、怎么把执行结果回传、要不要处理多轮调用这些都属于这一层。站在面试官的角度他们特别看重你对“执行体”的设计——很多候选人的代码能把指令解析出来但一遇到工具抛异常就崩了说明缺少生产思维。第三层是决策循环层。真正落地时模型一次调用往往解决不了问题。比如要算“北京和上海谁的温差大”模型可能得先查北京的天气、再查上海的天气然后才能比较。这就需要你的系统支持循环模型提出调用请求 - 执行工具 - 把结果交给模型 - 模型再判断是否继续调用。这个循环就是 Agent代理的原型。面试官把这三层全部问完之后我才意识到手写 Tool Use 这道题本质上是在考察你有没有构建完整 Agent 执行链路的能力而三种方案的差异恰好就是这三层递进关系的物化体现。2. 方案一基于 JSON Schema 的静态注册与解析2.1 核心思路把工具定义交给 JSON方案一是我在现场写的第一版也是绝大多数人第一时间能想到的做法定义工具、注册到字典、解析模型输出、执行函数。它的核心思路是把“工具长什么样”这件事标准化成 JSON 结构模型只需要参照结构输出调用意图工程侧按图索骥执行即可。具体来说每个工具包含三个字段name工具的唯一标识比如 get_weatherdescription对工具功能的自然语言描述这部分非常重要因为模型不是靠函数签名理解工具的它靠的是描述文本。描述写得越清晰模型就越不容易乱调用parameters一个 JSON Schema 风格的参数模板定义每个参数的类型、是否必填、取值范围。比如查询城市天气需要 citystring 必填和 unitstring 枚举celsius/fahrenheit然后写一个 register 函数把这些定义注册到一个全局字典里。注册这个动作看起来没啥技术含量但它是整个系统的心脏——模型能看到哪些工具、能调用哪些工具完全由注册表决定。tool_registry {} def register_tool(tool_def, func): tool_registry[tool_def[name]] { definition: tool_def, function: func, } def get_weather(city: str, unit: str celsius): # 这里假装去天气服务拿数据 return {city: city, temp: 26, width: unit}2.2 具体实现函数注册、Schema 描述、结果回填注册完了之后关键在处理流程上。完整链条分四步第一步系统把注册表里所有工具的 definition 组装成一个列表拼进发给模型的 prompt。Prompt 的格式大致是“你可以通过以下工具完成任务当需要工具时请严格按照 JSON 格式输出不要输出任何无关内容。”然后在下方列出工具的 JSON 定义。这里有个小陷阱如果工具数量很多比如超过二十个直接把全部定义塞进 prompt 会占用大量上下文窗口而且会让模型的注意力发散效果反而变差。第二步模型输出后系统用 JSON 解析器提取调用意图。我建议直接使用语言自带或第三方 JSON 解析库不要手写正则。原因很简单模型输出 JSON 时很容易出现多余字符、换行、或者嵌套结构正则在这种场景下就是给自己挖坑。第三步按 name 找到注册表里对应的函数把参数传进去执行。执行前必须做参数校验哪怕 JSON 解析已经通过也要对照 parameters 定义检查必填项和类型。这一步能拦住模型输出脏数据的问题。第四步把执行结果转成文本拼接到原有的对话历史中带着结果再让模型生成一轮回答。这个“拼接”的动作就是 Tool Use 区别于普通函数调用的地方——模型必须能读取到工具执行的结果才能形成最终回复。def execute_tool_call(tool_call: dict, history: list): name tool_call[name] args tool_call[arguments] if name not in tool_registry: return {error: ftool {name} not registered} try: result tool_registry[name][function](**args) except Exception as e: result {error: str(e)} history.append({ role: tool, tool_name: name, result: result, }) return history这套方案最大的优点就是直白、可控、便于 debug。每个工具的执行逻辑都是显式注册的不会出现模型自由发挥导致不可控的情况。对于内部系统、固定业务线的工具集合这完全够用。2.3 这套方案的边界在哪里现场面试官在我讲完第一版后问了一个很现实的问题如果模型返回的内容里函数名是对的但参数名出现了拼写错误而且不满足 JSON Schema 校验你怎么办我当时想到的解决办法是给模型一个“修正机会”解析失败或者校验失败时把错误信息重新喂给模型让它自己纠错后重新输出。这其实就是一次简单的模型内省循环代码也不复杂但这种“失败重试”的思路在后来的 Agent 设计中是最重要的一环没有之一。不过静态注册方案的边界也确实明显。它缺少自主决策能力——系统无法决定“该不该调工具”“先调哪个工具”“如果连续两次调用是否是对同一步操作的重要执行”。所有工具的执行顺序判定都必须由外层逻辑写死。所以当你需要做一个能独立规划步骤的 Agent 时方案一会显得非常吃力。这也是我在面试现场迅速转向第二种方案的原因。3. 方案二基于 ReAct 循环的动态工具决策3.1 核心思路让模型自己决定调用谁如果说方案一是“收到指令后翻译执行”方案二就是“让模型像人一样思考-行动-观察”。这是 ReAct Reasoning Acting模式的核心也是当前 Agent 应用最常见的执行框架。它把一次任务拆分成若干轮循环每一轮模型有两个选择如果信息已足够直接输出最终回答final_answer如果信息不足输出一个工具调用thought action在这个框架里工具调用不再是收到指令就执行的被动行为而是模型在“推理链”上主动发起的行动。整个系统有一个外层循环负责调度模型输出 action - 系统执行工具 - 把观察结果observation拼接回上下文 - 再次交给模型 - 循环往复走到模型输出 final_answer 为止。我当年第一次看到这种设计时觉得挺绕的但后来用生活类比才彻底理解它就像你问一个实习生“帮我查一下明天的机票价格”实习生不会一次性给你全部答案而是先查日期、再查航线、再对比价格每查一步他都把结果记在便签上然后继续下一步查证。直到信息足够他才给出最终建议。ReAct 就是这个实习生的“思考-行动-观察”循环。3.2 手写一个最小的 ReAct 循环现场写第二版时核心循环用不到 20 行代码但每一行都有讲究。def agent_loop(user_input: str, tools: list, max_rounds: int 5): messages [{role: user, content: user_input}] tool_descriptions json.dumps(tools, ensure_asciiFalse) for round_idx in range(max_rounds): # 拼接 system prompt说明可选工具 response llm.call( system你是一个可以通过工具获取信息的助手。若需要调用工具请输出JSON {thought: 思考, action: 工具名, action_input: {参数: 值}}。 若已有足够信息请输出JSON {thought: 思考, final_answer: 回答内容}, messagesmessages, tool_descriptionstool_descriptions, ) parsed json.loads(response) if final_answer in parsed: return parsed[final_answer] # 执行工具调用 tool_name parsed[action] tool_input parsed[action_input] observation execute_tool_call({name: tool_name, arguments: tool_input}) # 将“模型思考 行动 观察结果”一起追加到上下文 messages.append({role: assistant, content: response}) messages.append({role: tool, name: tool_name, content: json.dumps(observation, ensure_asciiFalse))} return 已达到最大轮次限制任务终止。注意几个关键点一是把 tool_descriptions 放进了 system prompt这意味着模型在每一轮对话开始前都会重新看到完整的工具清单不会因为多轮往返导致上下文丢失。二是把模型的 action 输出也追加到 messages 里。这一点非常容易被忽略但它很重要让模型看到自己上一轮说了什么是 Agent 保持推理连续性的前提。如果只追加工具结果模型的思维链会断掉。三是在循环开头展示系统提示要求模型优先输出 final_answer避免它反复调用同一工具空转。3.3 需要注意的循环控制问题ReAct 方案不是写出来能跑就行实际面试和工程落地中容易出现几个坑这里单独展开。第一个坑是无限循环。模型可能在某种 prompt 诱导下反复调用同一个工具而不输出 final_answer。比如它连续十次调用 get_weather每次都传同一个城市就是没有得出“天气适合出行”的结论。解决办法就是我上面代码里的 max_rounds 参数一旦超过轮次就强制终止。面试时一定要主动提到这个设计面试官会因此认为你有生产级意识。第二个坑是异常路径的上下文污染。工具执行报错时如果把 error 信息直接拼进 messages模型可能会陷入“反复重试同一错误操作”的死循环。我常用的做法是在工具结果里加一个“success”: false 字段然后在 prompt 里提醒模型“如果工具执行失败请换一种方式解决问题或直接告知用户”这样模型的容错表现会好很多。第三个坑是参数注入风险。模型输出的 action_input 直接作为函数的**kwargs 传进去如果工具内部执行的是 SQL 或 shell 命令这本质上就是一次注入攻击面。我在面试现场特别说明真实系统里必须对 action_input 做白名单校验绝对不允许透传。这是 Tool Use 工程化与玩具代码的分水岭。对比方案一ReAct 方案的自主性明显强了一个量级但它也有代价模型每轮都需要重新读一遍工具清单和上文token 消耗变大且它依赖模型本身的推理能力如果模型太弱很容易在第三、四轮开始胡言乱语。所以在面试里我把这个方案的适用场景定义为任务边界不清晰、需要模型自行规划多步骤的交互式智能体。4. 方案三面向未来的 MCP 协议化设计4.1 从一次性实现到协议标准写完前两个方案之后面试官问了一个角度很刁钻的问题“如果后端的工具不是你写的而是别的部门暴露出来的一堆 HTTP 接口你的 Tool Use 怎么接”这个问题直接把我拽出了“写本地函数”的舒适区。前面两种方案本质上都要求工具以函数形式注册到系统内部这在一个小团队、一个单体应用里没有问题。但现实的大模型应用往往需要接入搜索服务、企业内部的业务 API、第三方 SaaS 工具等。这时候一个能跨进程、跨语言、标准化描述和调用工具的“协议”就比一堆本地函数注册表更有价值。这就是 MCPModel Context Protocol可以理解为 AI 世界的 USB-C 接口它定义了一套统一的交互约定模型应用客户端通过这套约定去发现工具、调用工具、获取结果而工具提供方只需要实现一套遵循约定的服务端。对本项目的直接启示是就算不立刻上全套 MCP也要把工具调用视为一种“远程服务调用”而不是“本函数调用”。这个认知转变是我在现场写出第三种方案的起点。4.2 简化版 MCP 调度器怎么落我不是说要在半小时面试里把完整 MCP 规范实现出来那不现实。但可以用 MCP 的核心思想写一个瘦身后的协议化调度器工具定义不再保存在内存字典里而是经由一个轻量接口动态发现调用不再直接调函数而是发一个结构化请求到某个执行端点工具结果统一封装成标准包带上执行状态、耗时、返回结构。下面是我后来复盘时整理的简化版结构# 工具发现接口返回所有可用的工具定义列表 def discover_tools(): # 示例这里可以是静态列表也可以来自一个远程注册中心 return [ { name: query_stock, description: 查询股票实时价格, inputSchema: { type: object, properties: { symbol: {type: string, description: 股票代码}, market: {type: string, enum: [SH, SZ, HK, US]}, }, required: [symbol], }, endpoint: http://internal-service/tools/stock/execute, }, ] # 统一执行通过 endpoint 发送工具调用请求 def execute_remote_tool(call: dict): tool_name call[name] args call[arguments] endpoint TOOL_ENDPOINTS[tool_name] resp requests.post(endpoint, json{tool: tool_name, arguments: args}, timeout10) return normalize_response(resp.json()) # 统一结果封装 def normalize_response(raw: dict): return { success: raw.get(success, True), data: raw.get(data), error: raw.get(error, ), duration_ms: raw.get(duration_ms, 0), }这套“定义即服务、调用即请求、结果即结构”的设计三个好处立竿见影第一跨系统协作变得干净。团队 A 只需要保证接口遵循约定团队 B 完全不需要知道团队 A 的内部实现细节。第二安全边界清晰。工具执行点被隔离在独立服务里主进程不直接持有工具实现能做更精细的权限管控。第三扩展性极强。新工具接入时只需增加一个工具定义和一条 endpoint 映射。不需要改动主流程也不影响已上线的其他工具。4.3 什么项目才值得上 MCP面试官听完我这个“协议化设计”点头之后又追了一句那什么场景不需要 MCP、直接用函数注册就行我的回答是看团队规模和工具接入面。如果只有三五个工具、全在一个进程里跑硬套 MCP 反而增加复杂度和延迟方案一就够了。但如果工具数量大于十几个或者工具由多个团队、多个服务提供或者存在同一工具要在不同 Agent 间复用的需求那协议化设计就是必选项。MCP 不是银弹它是组织复杂度上升到一定阈值后的自然产物。在这个环节我还补充了一个个人观点协议化设计不需要一步到位可以从“私有 JSON 协议”演进到“标准 MCP”。就像团队内部先统一接口格式跑通了再考虑开源标准。面试官明显对这个“分步演进”的思路更有好感因为它说明候选人不是拿到了工具就亂用而是知道什么时候该复杂、什么时候该简单。5. 三种方案横向对比与选型建议5.1 一张表看清三种方案的差异把三种方案放在同一张表里对比优缺点就一目了然。| 对比维度 | 方案一静态注册 | 方案二ReAct 循环 | 方案三MCP 协议化 | | --- | --- | --- | --- | | 系统复杂度 | 低 | 中 | 高 | | 模型自由度 | 较低按指令执行 | 高自主规划调用 | 中高取决于调度策略 | | 上下文消耗 | 低 | 高多轮往返 | 中 | | 可扩展性 | 中本地注册麻烦 | 中 | 高服务化发现 | | 安全可控性 | 高显式函数映射 | 中需干预循环风险 | 高服务隔离更清晰 | | 典型适用场景 | 固定流程、内部小工具 | 复杂任务、智能助手 | 中大型团队、跨系统工具平台 |我个人的经验是方案一适合“任务流固定、模型只需要触发”的半自动场景比如客服系统里让模型判断“是否需要转人工”再调一个转接函数方案二适合“任务目标明确但路径未知”的全自主场景比如让模型做一个小型研究助手它自己决定先去搜索还是先查数据库方案三适合“工具太多、团队太多”的规模化阶段它更像是组织架构在技术上的投影。5.2 面试表现如何反推选型思路如果你在面试中也被问到这道题我的建议不是“直接背出三种方案”而是先和面试官确认场景再给出相应的设计。不同的前提条件会直接左右你选的方案如果面试官说“工具就三五个可以本地定义”方案一 简要说明校验和重试机制就足够如果面试官说“模型需要自己决定做多步操作”方案二必须写出来而且要主动讲出 max_rounds 和异常处理如果面试官说“这些工具是服务化的、第三方提供的”方案三就是最佳答案这种“先问场景再设计方案”的临场反应本身就是面试考察的核心软技能。技术方案永远是为了解决具体问题服务的一个合格的工程师不会在没摸清问题边界时就开始写代码。你在面试时表现出来这种意识比多写几十行代码要加分得多。我反而要提醒你不要一上来就把三种方案全部倒出来。先给出一个最贴合面试题描述的主方案讲透然后主动补充“如果换了场景我会怎么调整”让面试官看到你的横向对比能力。这比一次性把三种方案怼完更有收割感。我面试时是因为被追问才自然递进出三版如果你主动列出三版却每版都不深反而显得不踏实。6. 真实面试中踩过的坑和避坑心得6.1 最容易翻车的三个瞬间整个面试过程中我自己也踩了几个坑在这里记录一下希望后来人避开。第一坑是“只写了解析不写执行结果回填”。很多候选人在实现 Tool Use 时把精力全部放在“如何从模型输出中解析出函数调用”但忽略了“解析成功之后结果怎么回到模型上下文里”。导致系统虽然调用了工具但模型完全不知道结果是什么回答依旧基于自己的幻觉。我第一版代码就是在这里疏忽的被面试官一句话点醒。第二坑是“把所有工具一股脑塞进 Prompt”。如果工具定义包含超长参数说明全部塞进 Prompt 会导致上下文爆炸甚至远端的模型会因为输入过长直接拒绝响应。正确做法是根据用户意图做工具筛选只在 Prompt 中列出有可能相关的 3-5 个工具。这一思想在决策链路较长的 Agent 里尤为关键。第三坑是“没有处理并发和工具调用的时序”。在自然语言对话中用户可能同时提出两个请求比如“帮我查一下北京的天气然后预定一张去上海的高铁票”。如果你的 Tool Use 系统是同步顺序执行的不仅耗时翻倍而且第二工具依赖第一工具结果时就无法正确工作。更合理的设计是支持“并行提交多个工具请求 按步骤等待结果”这也是从方案二升级到成熟 Agent 框架时的必修课。6.2 关于工具调用执行链路的经验清单复盘之后我把一个生产级 Tool Use 执行链路压缩成一份清单无论用哪种方案最终都应该具备这些要点统一的工具描述格式能描述工具名称、用途、参数、返回结构参数校验与白名单机制绝不能直接信任模型的 action_input超时控制与优雅降级工具慢或失败时不让整个 Agent 卡死重试机制要区分可重试错误比如网络波动与不可重试错误比如参数非法上下文累积控制多轮循环中及时清理不再需要的冗长中间结果可观测性每次工具调用都留下结构化日志方便回溯这条清单本质上是一个边界意识的产物。手写一个 Tool Use 并不难三五十行代码就能跑通最小链路难的是把“工具调用”从一段演示代码升级为一个可靠的产品能力。面试官想要的其实不是堆出来的代码行数而是你有没有把这些边界问题纳入考虑范围。我后来把这次面试的方案整理成了一个小项目用方案二作为主链路给缝合了方案三的接口设计思想效果意外地好。这也让我养成了一个习惯面试题不是应付完就扔的东西它更像是一次低成本的外部能力体检——你在哪个设计维度上存在盲区面试官一追问就原形毕露与其被动挨打不如主动把每个追问变成一次自我迭代的机会。最后再分享一个小技巧如果你准备去面这类偏 Agent 应用的岗位强烈建议提前在本地搭一个小项目模拟“用户提问 - 模型调用工具 - 执行服务 - 结果回填 - 生成回答”的完整闭环。用真实业务场景去刷这个闭环比背三五种框架的 API 管用得多。你越熟练于这些底层模式的取舍面试官就越难把你问倒。说到底手写 Tool Use 考的不是记忆而是对“模型与应用协作边界”的掌控力。