
那场面试进行到一半面试官把电脑转过来屏幕上是四个字手写 Tool Use。我第一反应愣了一下。倒不是不会而是大部分人在项目里都是直接调现成接口真正从零写一个“让模型调用外部工具”的完整链路真没几个人细想过。面试官补了一句“不要跟我讲调 SDK从协议层讲清楚模型怎么知道该调哪个函数、参数怎么传、结果怎么拿、调用完了怎么继续。”我大概花了四十多分钟在他面前从最原始的正则解析方案一路讲到带循环控制的多轮 Agent 方案。面完出来我自己复盘了一遍觉得这三套递进方案其实值得好好整理一下。今天把它完整写出来既复盘这场面试也希望能帮到正在准备类似题目的人。这篇内容会讲清楚三件事第一Tool Use 的本质是什么面试官到底在考什么第二三种由浅入深的实现方案附能直接跑通的核心代码第三真实的坑和面试里值得主动讲的加分点。1. Tool Use 到底在解决什么问题1.1 给模型装外挂的本质逻辑大语言模型本质上是一个“只动嘴、不动手”的系统。它能理解自然语言、能生成看起来合理的回答但它不能真的去查天气、查数据库、算复杂表达式、调用业务系统。Tool Use 解决的就是这个矛盾在模型说话之前给它提供一次调用外部工具的机会让答案建立在真实执行结果之上而不是模型“猜”出来。你可以把它理解成一个秘书和一堆工具的关系。秘书不能亲自跑腿但秘书知道哪些工具大概能办什么事也知道工具的“说明书”长什么样。当老板用户抛来一个问题时秘书意识到需要查某个数据就填一张单子写上“用什么工具、传入什么参数”交给跑腿员代码去执行。跑腿员把结果拿回来秘书再基于这个结果组织语言给老板一个最终答复。这个协作过程拆成代码逻辑就是四步把工具的语义描述名称、用途、参数提前告诉模型。模型在回答时如果判断需要调用工具就输出一个“调用意图”。代码侧解析这个意图找到对应函数传入参数执行。把执行结果塞回上下文让模型基于结果组织最终答案。任何一个 Tool Use 实现无论封装得多花哨底层跑不出这四个步骤。1.2 面试官为什么要现场手写面试官让你手写 Tool Use考的不是你会不会用某个框架。说白了市面上工具调用能力强的模型平台都有现成方案会用的人一抓一大把。他真正想确认的是三件事第一你有没有理解格式约束这件事。模型只输出文本你要让它输出“结构化的意图”本质上是给它加一套格式纪律然后靠程序去解析。这是底层逻辑。第二你有没有工程处理意识。模型输出不一定是规范的解析可能失败、参数可能少传、工具可能报错、循环可能停不下来。这些边界你怎么兜。第三你有没有完整的系统视野。从单次工具调用到多轮 Agent 循环中间哪一步最关键每一步可能会出什么问题。一个只知道调接口的人是无法回答这些追问的。所以“手写”只是形式“讲清楚边界和取舍”才是真正的考点。2. 方案一提示词约束加正则解析最原始但最通用2.1 核心思路让模型在文本里夹带“私货”既然模型只会输出文本第一种最直觉的思路就是告诉模型当你需要调用工具时必须且只能输出一行特殊格式的文本。程序这边用正则或者 JSON 解析把这行文本抠出来识别出工具名和参数然后调用对应的 Python 函数。这个方案的优点是极其通用。它对模型没有任何特殊要求只要模型能遵循提示词里的格式指令就行。不管是强模型还是弱模型只要“听劝”都能跑。而且你完全掌控输出格式解析逻辑是自己写的出了问题知道去哪里排查。缺点是模型不一定守规矩。如果你用的模型理解能力一般它可能忘记输出格式、把 JSON 写错、把多个工具调用挤在同一句话里。所以提示词的措辞要写得很死甚至要把格式示例直接给出来。2.2 一个能跑通的最小实现我先定义两个工具函数。正常项目里这里是接真实业务接口我面试时为了演示就用两个模拟函数。import json import re def get_weather(city: str) - str: # 实际项目里这里调用天气服务演示时直接返回模拟数据 return f{city} 今天多云气温 18~24℃ def calculator(expr: str) - str: # 实际业务里不可能直接上 eval这里仅用于面试演示 try: result eval(expr, {__builtins__: {}}, {}) return f计算结果: {result} except Exception as e: return f计算失败: {e} TOOLS { get_weather: get_weather, calculator: calculator, }然后构造提示词核心是给出工具清单并严格约定输出格式。TOOL_DESC \n.join([ - get_weather(city: str): 查询指定城市的天气情况, - calculator(expr: str): 计算数学表达式例如 1 2 * 3, ]) def build_prompt(user_input: str) - str: return f你是智能助手可以调用外部工具回答问题。 可用工具 {TOOL_DESC} 当你的回复需要调用工具时必须且只能输出一行 JSON格式如下 TOOL_CALL {{name: 工具名, args: {{...}}}} 除此之外不要输出任何其他内容。 如果不需要调用工具直接回复普通文本。 用户问题{user_input}解析侧用正则从模型回复里捞TOOL_CALL开头的 JSONTOOL_CALL_RE re.compile(rTOOL_CALL (\{.*?\}), re.S) def parse_tool_call(text: str): match TOOL_CALL_RE.search(text) if not match: return None, None try: data json.loads(match.group(1)) return data.get(name), data.get(args, {}) except json.JSONDecodeError: return None, None主流程就是“先让模型回答发现要调工具就去调调完把结果喂回去再答一次”def run_once(question: str) - str: # call_llm 表示调用大模型接口在你自己的环境里替换为具体实现即可 messages [{role: user, content: build_prompt(question)}] reply call_llm(messages) name, args parse_tool_call(reply) if name is None: # 模型没有调用工具直接返回 return reply fn TOOLS.get(name) if fn is None: return f未知工具: {name} result fn(**args) messages.append({role: assistant, content: reply}) messages.append({role: user, content: f工具返回结果: {result}\n请根据结果回答用户问题。}) return call_llm(messages)2.3 这个方案的坑可以说得非常细我在面试现场专门挑了三个坑讲面试官果然来了兴趣。第一个坑是正则贪婪匹配。如果模型输出的 JSON 里有嵌套大括号比如args里塞了一个字典上面那个正则的\{.*?\}是典型的非贪婪写法遇到{city: 上海, extra: {pm25: 55}}这种嵌套结构时可能只匹配到{city: 上海就草草收场。真要在生产环境里做更稳的做法是先不依赖正则而是找到TOOL_CALL标记之后用“从TOOL_CALL后第一个{开始做括号配对扫描直到括号深度清零”的方式截取完整 JSON 片段。这个细节很少有人写但处理不好就是线上 bug。第二个坑是json.loads对模型输出的容忍度有限。模型喜欢在 JSON 里补注释、用单引号、输出多余的逗号这些都会导致解析失败。一个折中的办法是解析失败后把错误信息作为“观察结果”回传给模型明确告诉它“格式错误请重新按规范输出”。实测这一招能把不少模型的自我纠错能力调动起来。第三个坑是参数类型。模型输出的是字符串但工具函数可能需要int、float、list。所以参数透传之前最好做一层类型约束校验或者干脆在工具函数内部做一次防御性转换。提示用eval做计算器只适合面试演示生产环境里千万不要这么写要么用ast模块做安全解析要么直接用专门的表达式计算库。3. 方案二结构化工具声明协议生产环境的主流姿势3.1 为什么需要一份工具 Schema 而不是自然语言描述方案一虽然能跑但存在一个明显问题自然语言描述工具模型只能凭“语感”去猜。一旦工具多了名字相近、参数相似模型很容易混。而且解析结果完全依赖模型对格式纪律的遵守情况不确定性很大。生产环境的主流做法是给每个工具一份结构化的 Schema像说明书一样把名称、描述、参数类型、必填项目全部用 JSON 格式定义清楚。模型拿到这份 Schema 后不仅能理解工具语义还能被平台内部的约束机制强制按结构化格式输出调用意图。主流大模型的函数调用能力本质上就是这套逻辑的服务化封装请求时把工具 Schema 附加在消息里模型如果决定调用某个工具返回的内容里会有一个结构化的tool_calls字段里面是工具名和一个 JSON 字符串形式的参数。代码侧要做的事就是解析这个字段、执行函数、回传结果。这个方案相比方案一最大的提升是把格式约束从“提示词里约定、靠模型自觉”变成了“协议层面结构化”。只要模型支持函数调用协议输出格式就是稳定的不需要正则去猜。3.2 协议流转过程与核心代码工具 Schema 的定义大概是这个样子TOOL_SCHEMA [ { type: function, function: { name: get_weather, description: 查询指定城市的天气情况, parameters: { type: object, properties: { city: {type: string, description: 城市名} }, required: [city], }, }, }, { type: function, function: { name: calculator, description: 计算数学表达式, parameters: { type: object, properties: { expr: {type: string, description: 数学表达式} }, required: [expr], }, }, }, ]请求时把 Schema 传给模型侧的接入层。为了让你聚焦核心逻辑我这里用一个占位函数演示返回结构def call_llm_with_tools(messages, tools): # 实际环境请求大模型平台把 messages 与 tools 一起传过去 # 模型返回的 assistant 消息里会带有 tool_calls 字段。 # 这里直接给一个模拟返回结构。 return { role: assistant, tool_calls: [ { id: call_001, name: get_weather, arguments: {city: 上海}, } ], }分发执行的核心代码就是一个循环只要模型说要调工具就执行并回填结果直到模型不再发起工具调用。def execute_tool(name: str, args: dict): if name get_weather: return get_weather(args[city]) if name calculator: return calculator(args[expr]) raise ValueError(fUnsupported tool: {name}) def run_tool_loop(user_input: str) - str: messages [{role: user, content: user_input}] while True: resp call_llm_with_tools(messages, TOOL_SCHEMA) # 模型没有调用工具直接返回最终回答 if not resp.get(tool_calls): return resp[content] # 注意一次回复里可能带多个工具调用这里为了演示只取第一个 tool_call resp[tool_calls][0] messages.append(resp) name tool_call[name] args json.loads(tool_call[arguments]) messages.append({ role: tool, tool_call_id: tool_call[id], content: execute_tool(name, args), })3.3 分发执行阶段的工程细节执行阶段看着简单但实际做的时候有几个细节值得注意。一是arguments是一个字符串必须json.loads后才能用。这个字符串偶尔会出现 JSON 不合法的情况所以解析要包在try except里解析失败就回传一条报错消息给模型让模型重新生成参数。二是一次请求可能返回多个tool_calls比如模型同时想查天气和算表达式。有些平台支持并行执行多个工具有些必须串行。如果平台支持并行最好先全部解析成功再一起执行最后把多个结果按tool_call_id回填避免顺序错位。三是tool_call_id必须和工具结果一一对应。模型是靠这个 ID 把“某个调用结果”对应到“某个调用请求”的。我见过不少人图省事直接省略这个字段结果模型在多轮工具调用时完全失忆。四是工具数量要控制。有些团队把几十个工具一次性全塞给模型且不说上下文的 token 消耗模型在这么多工具里做选择本身就可能挑错。最好做一层工具路由先按用户问题做意图粗筛只把相关的三五个工具丢给模型。4. 方案三ReAct 循环从单次调用升级成完整 Agent4.1 ReAct 循环和前面两种方案的本质区别前面两种方案有一个隐含假设模型调用一次工具拿到结果然后就能给出最终答案。但很多真实问题需要连续调用多步才能解决。举个最简单的例子用户问“北京和上海哪个更冷”。正确的流程是先查北京天气再查上海天气拿到两个结果之后对比然后组织回答。如果你只给模型一次工具调用机会它只能查一个城市回答必然不完整。ReAct 的思路是让模型把“推理”和“行动”交替进行。每一步模型都可以输出一个行动调哪个工具、传什么参数程序执行完工具后把观察结果Observation放回对话上下文模型看到结果后决定下一步是继续调工具还是给出最终答案。这就像一个人解决问题的完整过程先想想缺什么信息查一步看到结果再思考再查直到信息足够。所以方案一和方案二解决的是“一次工具调用”方案三解决的是“一连串工具调用”。这也正是 Agent 和单次 Function Calling 的分水岭。4.2 一个能跑通的最小 ReAct 循环我把 ReAct 的提示词写得非常简单只支持四种输出前缀思考可省略、行动、观察、最终回答。核心是“行动 - 观察”的循环结构。REACT_PROMPT 你有以下三种输出格式 1. 需要调用工具时输出两行 ACTION: 工具名 ACTION_INPUT: {参数名: 参数值} 2. 当你看到工具结果后直接输出 OBSERVATION: 工具返回的内容 3. 当你已经获得足够信息回答用户时输出 FINAL: 给用户的最终答案 可用工具 - get_weather(city: str) - calculator(expr: str) def run_react(user_input: str) - str: context [{role: user, content: f{REACT_PROMPT}\n\n用户问题: {user_input}}] for step in range(6): # 最大执行 6 步防止死循环 reply call_llm(context) # 模型给出最终答案 if reply.startswith(FINAL:): return reply[len(FINAL:):].strip() # 模型想调用工具 if reply.startswith(ACTION:): lines reply.splitlines() tool_name lines[0].replace(ACTION:, ).strip() action_input {} for line in lines: if line.startswith(ACTION_INPUT:): action_input json.loads(line.replace(ACTION_INPUT:, ).strip()) result TOOLS[tool_name](**action_input) context.append({role: assistant, content: reply}) context.append({role: user, content: fOBSERVATION: {result}}) continue # 没识别出格式强制要求重新输出 context.append({role: user, content: 格式不识别请严格遵守 ACTION / FINAL 格式输出。}) return 超过最大执行轮次已自动终止4.3 循环终止与安全防护是不能省的ReAct 方案上手容易但生产环境的坑比前两种都要深。面试时我把循环控制和安全防护放在一起讲面试官明显更认可。第一个问题是死循环。模型可能因为上下文理解偏差反复调用同一个工具或者来回调用两个工具停不下来。所以必须设置最大循环步数。但我还要补一句如果只是简单地截断用户会得到一个“抱歉无法完成”的半成品。更好的做法是当循环次数接近上限时主动把“当前已获取的部分信息”发给模型让它基于已有信息尽量给出一个可用的回答而不是直接失败。第二个问题是上下文膨胀。每一步 ReAct 都会把模型回复和工具结果追加进消息列表。有些工具结果非常长连续十几轮后上下文会被撑爆。解决办法有两个层面一是对工具结果做截断只保留关键信息二是在消息历史里做压缩把较早的“行动-观察”轮次合并成一句摘要。第三个问题是并发和安全。Agent 一旦具备连续调用能力就意味着模型可以通过多次调用来组合出一些非预期的行为。工具权限管控、敏感操作二次确认、全链路日志这些在 Agent 落地时都不能省。风险控制永远要在能力之前做。5. 三种方案放一起对比怎么选我自己平时选型时会用一张表快速判断评估维度方案一正则解析方案二结构化协议方案三ReAct 循环实现成本最低中等最高对模型能力要求低但需要模型守格式要求模型支持函数调用协议要求模型推理和指令遵循能力强解析稳定性弱模型容易不守规矩强协议层保证结构化输出强但循环控制复杂度高支持单次调用可以可以可以支持多轮连续调用需要外部循环辅助需要外部循环辅助原生支持适用场景快速 Demo、模型平台不支持函数调用生产环境的标准工具接入需要工具组合、多步推理的 Agent 场景选型逻辑其实不难。如果模型平台不支持函数调用协议或者你是在做模型适配层必须兼容五花八门的弱模型那就老老实实走方案一把格式约束做到极致。如果接的是主流大模型平台工具调用是标准能力那直接用方案二把精力花在工具解析和分发上而不是自己发明协议。如果需要让模型自主决定“先查什么、再查什么”那就必须上方案三。不过这三个方案不是互斥关系。真实系统中它们经常叠加底层用结构化协议接入模型Agent 循环层用 ReAct 思路编排对个别不守规矩的模型再退化成提示词加强制解析。理解每一层的取舍组合起来才不容易出问题。6. 面试复盘我是怎么跟面试官讲这三套方案的6.1 先讲边界再讲实现面试的时候我没有上来就贴代码而是先把边界讲清楚方案一适用什么、方案二改进在哪里、方案三又解决了什么新的问题。讲完之后面试官自然能看出你对这个话题有过系统思考。我的表述逻辑大概是这样的Tool Use 的本质是“格式约定加工具分发”。方案一是最原始的格式约定用正则做分发方案二是把约定协议化用 Schema 做分发减少不确定性方案三是把分发改成循环加入了 Agent 编排。三个方案不是三个独立答案而是一条递进的主线面试官想看到的就是这条线。6.2 主动聊坑比背方案加分面试官抛出追问的时候我主动说“这个话题我踩过很多坑”然后快速列了几个解析失败怎么兜底、多工具调用怎么防错位、循环怎么防死锁。这些不是面试前背的而是真实经历过才会有的细节。面试官通常不会满足于“我这个方案能跑”。他会追问“如果模型输出格式不对怎么办”“如果一个工具里又要调另一个工具怎么办”“上下文太长怎么处理”。遇到这种追问能举出具体的兜底策略比如“解析失败就把错误回传给模型让它自我纠正”“长上下文就做历史压缩”效果远好于只说“我再让模型重新生成一次”。6.3 加分项主动说清楚未来演进方向最后一个问题是面试官问我“如果工具数量从五个涨到五百个这个方案会有什么问题”。这个追问其实特别有价值。五百个工具全部塞进 prompttoken 会爆模型选择准确率也会下降。我的回答是三层先做工具分组和意图路由只加载相关子集再做工具描述的向量化召回用相似度检索把候选工具压缩到十个以内最后做一次候选工具重排确保丢给模型的工具顺序合理。这种面对规模问题主动给出分层方案的表达往往比背标准答案更能体现系统设计能力。我个人在实际面试和后续复盘里的最大体会是Tool Use 不是一个“调接口”的题目而是一个“格式约定、协议设计、循环控制”三者叠加的系统题。你能写到第几层基本就代表了你在 AI 应用层的水位在第几层。最后再分享一个小技巧。面试前别光看理论亲手把上面三种方案各写一遍然后把“解析失败”“循环不退出”“参数类型不对”这几个 bug 故意造出来一次再亲手修掉。这个过程比背十篇技术文档都有用。因为这些 bug 一旦自己踩过一遍你再跟人讲的时候说的每一个字都是有底的。