面试这事儿有时候真挺奇妙。上周去某家做 AI 应用的公司面试前面聊 Agent 架构聊得挺顺畅结果面试官突然说了一句咱们不聊概念了你现场给我手写一个 Tool Use 出来看看。我当时先是一愣紧接着心里其实有点暗喜——这年头人人都能聊 Tool Use但真正自己动手写过完整闭环的人不多。Tool Use 说白了就是让大模型在对话之外具备调用外部函数、工具和 API 的能力它是 Agent 从会聊天走向能干活的分水岭。我调整了一下思路用了三种方案依次递进从最朴素的正则匹配讲到 Function Calling 再讲到 ReAct 循环。面试官看完之后明显话多了起来后面聊了将近四十分钟。这篇我就把这三种方案完整拆开连同当时的思考过程、踩过的坑一起分享出来希望对准备面试或正在做 Agent 落地的你有点帮助。1. 面试的开场他真正想看的是什么1.1 一个典型到不能再典型的手写题当时面试官给我的要求其实很简单不限制语言不要求接真实大模型只要实现一个模型说想调工具系统就能把工具结果拿回来继续对话的最小闭环。看起来是道开放题但真正考察的东西藏在细节里。他随后补充了几个约束要有工具注册机制新增工具不需要改主流程模型返回的结果要经过解析和校验工具执行完的结果要能喂回给模型继续生成。听完这几个约束我就明白了他考察的并不是某种框架或者某个平台的 API而是三个底层能力协议设计、执行路径、以及循环控制。这基本上就是 Agent 运行时最核心的部分了。我当场先给了一个简单方案然后逐步升级到三种。整个过程他用白板和我一起推演我每写一段代码他就会追问一句这样设计的理由是什么。说实话这种面试方式比背概念舒服太多了因为不需要表演代码会替你说实话。1.2 Tool Use 拆到底就三件事很多人把 Tool Use 想得很玄乎其实拆开看就三件事意图识别、参数提取、结果回填。意图识别是判断用户这句话要不要调用工具参数提取是确定调用哪个工具、传什么参数结果回填则是把工具返回的数据拼进对话上下文让模型基于真实结果继续推理。理解了这三件事再看市面上各种方案就清楚了正则匹配解决的是意图识别和参数提取的一个窄版本Function Calling 解决的是让模型自己完成意图识别和参数提取ReAct 循环解决的是多轮决策、工具编排和结果回填的完整闭环。三种方案并不是竞品关系而是递进关系应对的复杂度和落地场景完全不同。面试官考手写本质上就是看你能不能在上面的抽象层次之间自如切换而不是只会调用封装好的库。我后来自己也带过新人发现能把这三件事讲清楚的人写出来的 Agent 代码通常边界感都很强不太会出现那种工具调用链烂成一锅粥的情况。1.3 先说结论我最后用的是什么组合如果你赶时间想直接知道面试该怎么答我的结论是先用方案一证明你理解最小链路再用方案二展示工程化能力最后用方案三拔高。三个方案之间要有清楚的演进理由不要一上来就写最复杂的 ReAct那样反而容易暴露你在边界处理上的薄弱。我在现场的实际顺序是十分钟完成方案一十五分钟升级到方案二最后二十分钟把方案三跑通并讨论安全边界。时间分配看起来平均但重点其实放在方案二的协议设计和方案三的循环控制上因为这两个才是工业级 Tool Use 的命门。下面我把三个方案一个一个完整讲透代码是我整理后的版本现场手写会比这个简化一些。2. 方案一正则驱动的意图匹配——最简单但思路完整2.1 核心思路把工具调用变成模式匹配第一种方案不需要大模型参与直接把用户的输入文本拿来做正则规则匹配命中某个规则就调用注册好的工具。它的价值不在于工程可用性而在于帮你把工具调用链路的最小骨架搭出来用户输入进来、解析器判断意图、执行器调用函数、结果返回给用户。这个方案的设计逻辑很简单——我把工具当成一张注册表每个工具包含名称、描述、处理函数解析器里维护一组规则规则负责从文本中抽出工具名和参数。它的优点是链路清晰没有任何黑盒逆操作面试时讲起来特别顺畅。缺点是极其脆弱用户换一种说法就可能匹配不上而且参数稍微复杂一点就处理不了。但你得先有了这个骨架后面往上加东西才不慌。2.2 最小可运行 Demo我用 Python 写了三个文件结构上合并成一段。第一步定义一个工具注册表第二步定义规则表第三步写主循环。注意第一步我用了一个装饰器风格的注册方式第二步规则用元组维护。import re # 工具注册表 TOOLS {} def register_tool(name, description): def decorator(func): TOOLS[name] { handler: func, description: description, } return func return decorator register_tool(get_weather, 查询城市天气) def get_weather(city: str): return f{city} 今天晴气温 25℃ register_tool(calc, 计算算术表达式) def calc(expression: str): # 演示用生产环境绝不要直接 eval return str(eval(expression)) # 规则表一组正则匹配到哪个就调哪个 RULES [ (re.compile(r(?:查询|查一下|看看)?(.{2,6}?)的?天气), get_weather, city), (re.compile(r[计算算下]?\s*([0-9\-*/().\s])), calc, expression), ] def parse_intent(text): for pattern, tool_name, arg_name in RULES: m pattern.search(text) if m: return tool_name, {arg_name: m.group(1)} return None, None def main(): while True: user_input input(请输入: ) if user_input.strip() in (exit, quit): break tool_name, args parse_intent(user_input) if tool_name is None: print(没有匹配到工具请换个说法) continue result TOOLS[tool_name][handler](**args) print(工具返回:, result) if __name__ __main__: main()运行一下就明白了输入查询北京天气会命中第一条规则输出北京 今天晴气温 25℃。输入计算 3 * 7 1会命中第二条规则调用 calc 函数。这套代码最完整的部分是注册表的抽象新增工具只需要写一个函数加装饰器主流程一行不动。面试官看这个代码时会觉得你有工程意识而不是临时堆逻辑。2.3 翻车现场用户一换说法就崩方案一看起来能跑但经不起细问。我当时在面试现场简单测试了一下翻车场景很多。比如用户说北京今天冷吗这个请求明显需要查天气还要结合温度判断但正则没法做语义理解直接没命中。又比如3乘7加1等于多少里面是中文数字正则就抓不住了。更尴尬的是参数耦合问题。规则表里的正则模式必须和后端 handler 的参数名严格对齐一旦工具参数改名规则也要跟着改。而且两个正则之间的优先级很难控制如果用户说帮我算一下北京今天多少度两条规则可能同时匹配最后调用哪个完全取决于规则顺序。这个方案暴露的问题恰好引出了方案二要解决的核心矛盾自然语言的意图种类是无限的纯靠人去枚举规则写正则永远跟不上。必须把意图识别和参数提取这部分决策交给模型来做而不是交给死板的字符串匹配。3. 方案二函数注册表加 JSON 协议——标准 Function Calling 的迷你复刻版3.1 设计思路让模型自己说我要调用哪个工具方案二的跃迁点在职责分配我不再试图写规则去理解用户的话而是把工具列表和参数约束告诉模型让模型在生成回复时输出一个结构化的工具调用请求。代码这边只负责解析、校验、执行和回填。这就带来一个协议设计问题也是面试官最关注的部分。我选择的协议是模型在需要调用工具时只输出一行严格的 JSON包含 tool 和 args 两个字段。例如{tool: get_weather, args: {city: 北京}}。为了让模型稳定输出这个格式系统提示词里要给出工具清单每个工具都要有 name、description、parameters 三个部分parameters 要符合基本的 JSON Schema 语法。整个闭环是用户消息进来拼上系统提示词发给模型模型返回 JSON 字符串代码解析 JSON查注册表校验参数执行 handler把执行结果作为一条新的 assistant 消息拼回对话历史再一次调用模型这次模型就能基于工具的真实返回结果生成最终回复。3.2 工具 Schema 应该怎么设计很多人在这一步偷懒直接让模型输出一段自由文本然后用正则抠结果就是线上各种解析失败。我的习惯是每个工具都维护一套 parameters 描述严格声明属性名、类型、是否必填和含义。现场手写不用搞完整 JSON Schema但至少要写出结构和校验逻辑。TOOLS { get_weather: { name: get_weather, description: 查询指定城市的实时天气适合回答与天气、温度、出行相关的问题, parameters: { type: object, properties: { city: { type: string, description: 城市名称例如 北京、上海 } }, required: [city] } }, calc: { name: calc, description: 计算数学表达式适合算术运算。不要把中文数字传进来, parameters: { type: object, properties: { expression: { type: string, description: 数学表达式例如 3 * 7 1 } }, required: [expression] } } }为什么 description 要写得那么具体因为模型是靠字符串理解工具的描述越模糊模型就越可能传错参数。比如 calc 工具如果不强调不要把中文数字传进来模型很可能真的把三乘七当作字符串传进来后面直接报错。3.3 核心闭环代码为了演示方便我这里用一个 mock 的模型接口真实场景替换成模型 API 调用即可。import json import jsonschema def call_llm(messages): # 真实环境替换为模型 provider 的调用 # 演示模型经过思考决定调用 get_weather return json.dumps({tool: get_weather, args: {city: 上海}}) def execute_tool(tool_call): tool_name tool_call[tool] args tool_call.get(args, {}) tool TOOLS.get(tool_name) if tool is None: raise ValueError(f未知工具: {tool_name}) # 参数校验是关键一步 jsonschema.validate(args, tool[parameters]) result tool[handler](**args) return result def run_single_tool_cycle(user_input): messages [{role: user, content: user_input}] system_prompt ( 你是智能助手。当你认为需要查询工具时只输出如下JSON {tool: 工具名, args: {参数名: 值}}不要输出其他文本。 ) full_messages [{role: system, content: system_prompt}] messages response call_llm(full_messages) # 容错尝试解析 JSON try: tool_call json.loads(response) except json.JSONDecodeError: return 模型没有返回合法JSON需要重试或上报失败 result execute_tool(tool_call) # 把结果回填到对话 messages.append({role: assistant, content: response}) messages.append({role: tool, content: str(result)}) final_response call_llm(full_messages) return final_response print(run_single_tool_cycle(上海天气怎么样))这段代码的核心动作是把模型的 JSON 响应当成一种远程过程调用解析、校验、执行、回填。和方案一相比它多了一层协议解析和参数校验但换来的是模型能理解任意说法的用户请求不再依赖人写正则。3.4 参数校验和失败重试这个环节必须暴露给模型看我特意在代码里加了jsonschema.validate这是方案二最重要的安全边界。模型再智能也可能会输出错误参数名、错误类型或者不完整参数如果直接调 handler轻则拿到异常重则可能触发不安全的操作。参数校验失败时的策略也值得讲一下。不要直接吞掉错误或者把异常抛给用户而是把校验失败的完整信息当作一次工具结果拼接回对话里让模型自己修正重来。比如校验器抛出一句city 是必填字段但你只传了 temperature模型看到这句话之后下一轮往往会自动补上城市名。这个把错误反馈给模型的模式是 Agent 工程里常见的手段面试时主动说出来会比你闷头写代码要加分得多。它体现的不是你会写一行校验而是你理解模型和代码之间如何协作纠错。3.5 实测体会它已经能干活但还不是 Agent面试现场我把方案二跑通之后问了一句北京天气多少模型输出了 JSON代码调用了天气工具再把结果喂回给模型模型顺利给出了自然语言回答。到这里Tool Use的最小闭环已经成立。但面试官马上追问如果用户的问题是上海和北京哪个更冷我要不要穿羽绒服这个闭环会怎样单次循环只能支持一次工具调用虽然可以让模型输出多个 JSON但这属于低级编排遇到需要多次查询、条件判断、中间结果比较的任务就做不了了。这个追问顺理成章地把我推向方案三。4. 方案三ReAct 循环加动态执行器——进化成迷你 Agent4.1 ReAct 的核心让模型在思考与行动之间交替ReAct 这个思路现在已经是 Agent 领域的事实标准了核心就是把思考和行动显式交替。每轮让模型输出一段文本里面包含 Thought当前推理和 Action要调用的工具及参数。代码解析到 Action 就执行工具把结果拼成 Observation观察结果再喂回给模型直到模型认为信息足够了输出 Final Answer。和方案二最大的区别是方案二只做一次工具调用就结束方案三则是一个循环循环体内反复执行模型出 Action、代码执行工具、回填 Observation的流程。多步编排能力就来自这里模型可以在第一轮查到城市 A 的天气第二轮刷新城市 B 的天气第三轮再比较两者。4.2 一个能跑的多轮循环下面这段是我现场手写的精简版去掉了很多边界细节但保留了控制核心。注意解析 Action 时我用了一个简单的 JSON 提取函数专门应对模型偶尔在 Action 前后夹杂其他文本的情况。import json import re # 复用前面的 TOOLS 注册表 SYSTEM_PROMPT 你是一个具备工具调用能力的智能助手。 请严格遵循以下格式输出 Thought: 你的推理过程 Action: {tool: 工具名, args: {参数名: 值}} 当收集到足够信息后输出 Thought: 我可以回答用户问题了 Final Answer: 你的最终回答 .strip() def extract_action(response): match re.search(rAction:\s*(\{.*?\}), response, re.DOTALL) if match: return json.loads(match.group(1)) return None def run_react_agent(user_input, max_steps5): history [ {role: system, content: SYSTEM_PROMPT}, {role: user, content: user_input}, ] for step in range(max_steps): response call_llm(history) print(模型输出:, response) action extract_action(response) if action is None: # 没有 Action检查是否有 Final Answer if Final Answer in response: return response history.append({role: assistant, content: response}) continue result execute_tool(action) observation fObservation: {result} history.append({role: assistant, content: response}) history.append({role: user, content: observation}) return 达到最大迭代次数强制终止 print(run_react_agent(北京和上海哪个更冷))这段代码的精髓在循环控制条件。我设了一个最大迭代次数避免模型陷入无休止的工具调用。每轮把模型输出和 Observation 都拼进历史模型就能在下一轮看到之前的思考链和工具返回结果这是它继续推理的依据。4.3 终止条件、上下文长度和死循环三个绕不开的大坑多轮循环看起来酷但实际跑起来才会暴露问题。第一个坑是终止条件。如果模型一直不输出 Final Answer 怎么办除了 max_steps 之外我还会检查连续两轮 Action 是否完全一样、参数是否相同如果一样就判定模型在原地打转直接打断并给出提示。第二个坑是上下文长度。ReAct 每轮都要把思考和 Observation 追加进历史十轮下来 token 消耗非常快。实际上我见过最夸张的情况一个复杂的任务跑二十轮历史里堆积了大量工具返回的冗长 JSON最后模型反而被噪声干扰。解法也很实在Observation 在拼进历史前做截断只保留前 200 到 500 个字符或者做一次摘要压缩。第三个坑是工具本身报错引发的错误循环。比如天气接口超时Observation 里放了一个异常对象模型看到异常之后可能会尝试重新调用同一个工具越试越僵。我后来在 execute_tool 外面套了一层 try把异常类型和简短原因格式化成Observation同时要求模型换一种方案或直接告诉用户暂时不可用而不是无限重试。4.4 安全边界白名单、超时和权限控制讲到这里面试官一定会问安全问题。越开放的工具调用能力意味着越大的攻击面。我在现场提了三个控制手段你可以直接拿去用。工具白名单是默认强制项Agent 只能调用注册表里显露过的工具拒绝任何动态加载的未知函数。超时控制方面每个工具执行都要包一层超时机制避免某个工具卡死拖垮整个循环。权限控制要区分只读工具和写工具比如查询类工具允许模型自主调用但发送消息、删除数据这类敏感操作必须二次确认或者限制在特定上下文中。还有一个很少人提但很重要的点日志和可审计性。每轮调用都记录模型输出、Action、Observation、耗时出了事故可以回溯。线下开会时大家经常吐槽 Agent 不可靠其实很多问题靠日志都能快速定位就是初期没人愿意多写几行记录。5. 面试复盘三种方案怎么选、怎么答、怎么说5.1 横向对比复杂度不一分工不同维度方案一正则匹配方案二函数注册表加JSON协议方案三ReAct循环加动态执行器意图识别人工规则模型判断模型推理加多步决策参数提取正则子串提取脆弱模型输出 JSON校验后执行模型逐步输出可以修正多步编排完全不支持支持一次性多个调用但编排弱支持完整的多步规划与修正工程复杂度很低中等偏高落地场景客服话术匹配、演示Demo单轮工具调用、垂直场景复杂任务、数据分析、多工具编排安全风险风险较小但规则死板需要参数校验和权限控制需要白名单、超时、日志审计这张表建议背熟。面试时被问三个方案选哪个不要直接说第三个最好而是说取决于任务复杂度、可接受的工程成本和控制力要求。大多数生产环境 Team 会把方案二的严谨协议和方案三的循环编排混合使用。5.2 面试官高频追问 Top 5我复盘了一下当天被追问的问题整理成五个最值得提前准备的。第一个如果模型输出非法 JSON 怎么办。答要拆两层第一层增强提示词第二层把错误信息回传给模型让它修正但必须设置最大修正次数防止死循环。第二个工具调用结果很长怎么处理。答截断、摘要、或者结构化提取关键字段再拼进上下文。重点是不能直接放任不管上下文长度是硬约束。第三个多个工具调用结果有先后依赖怎么办。答可以在 Action 里约定一个 task_idObservation 里带对应 id也可以让模型拆分子任务逐步执行。ReAct 天然适合这种模式。第四个如何保证 Agent 不执行危险操作。答白名单、权限分级、敏感操作人工确认、审计日志。这四件套一口气说出来基本就过了。第五个你如何验证你的 Tool Use 是可靠的。答用例集和回归测试比如准备五十条多轮任务输入每次跑完对比关键输出同时用日志追踪每一轮 Action 是否符合预期。这比口头说我测过没问题要可信得多。5.3 现场手写的时间分配建议如果你也遇到类似的面试题我建议按三步走。先讲一遍整体设计输入、协议、执行器、循环、回填这五部分描述完大约需要两分钟。然后先写注册表和协议定义这部分能展示你的工程化底座再写执行循环。最后一定留五分钟跑测试用例哪怕跑一个最简单的查询天气再比较用例也能证明代码是可运行的。不要一上来就埋头写代码。面试官想知道的是每个方案背后的 trade-off先给结论再给细节比你默默敲半天代码再解释要有说服力得多。说实在的手写代码只是载体整个面试过程真正在考察的是你的系统设计直觉和边界意识。工具调用链路并不复杂复杂的是在有限资源、有限可靠性下把链路管理好。所以记住一个原则代码要干净协议要显式边界要说清。6. 我踩过的坑和给你的几点实战建议6.1 第一个坑把工具描述写得太随意我早期做 Agent 时工具描述随手写一句查询天气结果模型经常传错参数或者把省份和城市混到一起。后来我把描述改成查询中国任意地级市及直辖市的实时天气优先使用常用城市名简称如北京、上海准确率立刻上去了。模型的工具选择依赖文本描述描述就是工具的说明书写详细一点完全值得。6.2 第二个坑所有东西都让模型做不做防御有一段时间我认为模型很强参数不需要校验结果生产环境经常出现类型错误、缺字段、甚至工具名拼错。后来我强制引入 JSON Schema 校验还必须把校验错误拼回对话。整体失败率降了一截。这也解释了为什么方案二里校验是核心它不是可有可无的加分项而是保命项。6.3 第三个坑ReAct 循环不加约束我最早写 ReAct 循环只设了一个最大轮数结果某个工具连续执行了差不多十轮还没结束浪费了时间也把上下文撑爆了。后来我加了三个约束最大轮数、相同 Action 连续重复检测、每轮 Observation 截断。这个组合拳基本解决了多轮失控问题。整体来看Tool Use 手写题考的不只是你会不会调大模型而是你有没有能力在一个又小又紧张的环节里做完整的工程思考。就算你平时用成熟的框架也建议自己动手把这三个方案依次实现一遍。当你亲手写过正则、JSON协议、ReAct循环之后再看任何 Agent 框架都会觉得透明很多因为它们本质上就是这三个方案的工程化、产品化和体量放大版。最后再分享一个小技巧面试手写代码前先在纸上或编辑器里画出那个闭环流程图不用画给面试官看画给自己看就够了。很多人在现场卡住不是因为不会写某个函数而是没想清楚消息在模型、解析器、执行器之间怎么流转。只要把输入—解析—执行—回填—再生成这条主线理顺了后面的代码基本是水到渠成。那天我的面试结尾面试官点评了一句话让我印象很深他说 Tool Use 要写好最需要的不是聪明而是克制——知道让模型做什么、让代码做什么、让规则守什么边界。这句话也是今天我最想告诉你的总结。
阅读完成 · 觉得有帮助?