ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

面试手写Tool Use的三种方案:从JSON解析到MCP协议化设计

面试手写Tool Use的三种方案:从JSON解析到MCP协议化设计

面试官让我手写一个 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_weather
  • description:对工具功能的自然语言描述,这部分非常重要,因为模型不是靠函数签名理解工具的,它靠的是描述文本。描述写得越清晰,模型就越不容易乱调用
  • parameters:一个 JSON Schema 风格的参数模板,定义每个参数的类型、是否必填、取值范围。比如查询城市天气,需要 city(string 必填)和 unit(string 枚举: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": f"tool {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_ascii=False) for round_idx in range(max_rounds): # 拼接 system prompt,说明可选工具 response = llm.call( system="你是一个可以通过工具获取信息的助手。若需要调用工具,请输出JSON:" '{"thought": "思考", "action": "工具名", "action_input": {"参数": "值"}}。' "若已有足够信息,请输出JSON:" '{"thought": "思考", "final_answer": "回答内容"}', messages=messages, tool_descriptions=tool_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_ascii=False))} 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 工具等。这时候,一个能跨进程、跨语言、标准化描述和调用工具的“协议”,就比一堆本地函数注册表更有价值。

这就是 MCP(Model 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}, timeout=10) 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 考的不是记忆,而是对“模型与应用协作边界”的掌控力。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进