手写Tool Use三种方案:从规则匹配到ReAct循环全解析 面试官让我手写一个 Tool Use我用了 3 种方案上周刚面完一轮技术终面面试官前期聊了半天项目经验、系统设计最后冲刺阶段突然丢给我一个开放题“现在给你一个没有内置工具调用能力的大模型要求让它具备 Tool Use 的能力你打算怎么做不限语言手写思路。”题目乍一看不难但真正落到写代码、把链路跑通里面还是有不少门道的。我当时大概花了一个半小时在白板上从最朴素的方案写起一路升级到可以支撑真实业务的版本最后面试官现场追加了“工具异常时模型怎么办”“多轮参数回填怎么做”几个追问我也一一给出了答案。这篇文章就把当时三种方案的完整思路、核心代码实现、踩过的坑和面试复盘整理出来。无论你是准备面试还是想为自己的大模型应用加上工具调用能力都可以直接参考。1. 项目背景为什么“手写 Tool Use”成了高频面试题先把这个题目为什么被反复拿出来考讲清楚。很多人一听到 Tool Use第一反应是“这不是 Function Calling 吗”确实是一类东西但面试官在这里玩了一个偷换概念他不限定你用平台自带的 Function Calling而是让你手工实现一套类似机制。也就是说他不会让你直接调用某个平台上封装好的函数调度器而是要求你理解背后的原理再自己写出来。考察点非常集中你理不理解大模型本身是“没有工具能力”的工具调用是一个系统工程而不只是加一个参数。你知不知道怎么让模型输出结构化的、可被程序解析的内容。你有没有构建“模型-工具-结果回填”完整链路的经验而不只是做接口封装。你考虑不考虑异常、重试、超时、多轮对话状态等工程问题。所以这道题的本质是考察一个人从“会写提示词”到“能构建 Agent 基础设施”的跨越度。我后来复盘面试官真正想看的不是你背了多少框架而是你能不能从第一性原理出发把工具调用这个能力徒手搭出来。这个题干里还有一句潜台词“不限语言”。我当时选择用 Python 来写因为它在字符串解析、JSON 处理、函数注册方面的生态最顺代码读起来也最清楚。后面我给出的三种方案复杂度从低到高分别是规则匹配与关键字抽取、结构化输出约束JSON Schema 模型自身能力、带观测回填的 ReAct 循环。它们也可以理解为 Tool Use 的三个成熟度阶段第一阶段是“伪工具调用”第二阶段是“让模型学会填写工具指令”第三阶段是“让模型自主决定调用链条”。2. 方案一规则匹配与关键字抽取最朴素的“伪 Tool Use”先明确一个问题如果不借助任何模型能力一个普通的对话系统怎么实现“帮我查下某城市天气”这种需求很多初学者第一反应是用正则表达式去匹配地名、动作词这没错本质上这就是最早的命令式交互系统。2.1 核心思路方案一的思路是通过正则表达式识别用户输入中的意图和实体再根据意图映射到对应的本地函数。这套方案的优点是完全不依赖额外模型能力速度极快可解释性极强缺点是极其脆弱只要用户换一种说法规则就马上失效。我用一个最简单的 Python 用例来演示。import re from datetime import datetime # 定义两个简单的“工具函数” def get_time(city: str ) - str: 模拟获取本地时间实际应用可替换为API调用 if not city: city 本地 now datetime.now() return f{city} 当前时间为 {now.strftime(%Y-%m-%d %H:%M:%S)} def calculate(expression: str) - float: return eval(expression) # 规则表 TOOL_DISPATCH [ {name: get_time, pattern: r时间, extract: lambda text: re.search(r(.?)[的时], text).group(1) if re.search(r(.?)[的时], text) else }, {name: calculate, pattern: r计算, extract: lambda text: text.replace(计算, ).strip()}, ] def rule_based_tool_call(query: str): for rule in TOOL_DISPATCH: if re.search(rule[pattern], query): # 1. 抽取参数 param rule[extract](query) # 2. 映射函数 func {get_time: get_time, calculate: calculate}[rule[name]] # 3. 执行并返回结果 result func(param) return {tool: rule[name], param: param, result: result} return {tool: None, result: 未识别到可用工具}这套代码跑起来非常直接 rule_based_tool_call(北京时间是多少) {tool: get_time, param: 北京, result: 北京 当前时间为 2024-06-01 14:22:11}2.2 我在面试时补充的工程细节第一不要在有安全边界的系统里用 eval / exec。我上面的 example 里用了 eval纯粹是演示。真实业务中计算器这类需求应当使用专门的表达式解析库或者直接在沙箱环境执行否则注入风险极大。面试官很可能追问 “你的工具函数会不会有安全问题”这句话就是加分的铺垫。第二规则表要设计成可配置的。把 pattern 和 extract 方法放在一个 list 里系统扩容时只需要追加一行生产级的路径上一般会改成 YAML 或数据库配置方便非开发同学维护。我见过很多二次开发框架都采用这种设计可见它确实实用。第三参数抽取的正则要覆盖“别名”。中文里“北京”“首都”“帝都”是同一个实体单纯靠正则只能做字面匹配。在这个阶段可以引入一个简单的同义词典进行归一化。这个方案的致命伤很明显用户语料稍微复杂一点规则就会崩溃。比如“帮我看看北京明天会不会下雨”这里的意图是天气查询但“下雨”这个词未必会出现在规则表里“明天”这个相对时间没有转化为具体日期这些都需要额外处理。方案一能作为面试的开场但绝不能作为收尾。3. 方案二结构化输出约束让模型学会“填写工具指令”第二种方案是真正意义上把“大模型”接入 Tool Use 链路。思路是不再靠人写规则去猜意图而是让模型自己判断需要调用什么工具、生成什么参数我们通过提示词和结构约束把模型的输出限制在可解析的范围里。3.1 核心思路从“自由文本”到“JSON 指令”这里最关键的一步是让模型输出结构化 JSON而不是自然语言。做法很简单在 system prompt 里告诉模型它身处的环境、可用的工具列表、工具的参数 schema以及输出格式必须是 JSON。我用一个代码伪码来展示这种实现方式假设使用 OpenAI 兼容接口import openai import json client openai.OpenAI() # 仅示意实际请使用自己的密钥与服务地址 TOOLS [ { type: function, function: { name: get_weather, description: 获取任意城市的实时天气信息, parameters: { type: object, properties: { city: {type: string, description: 城市名如 北京、上海}, }, required: [city], }, }, }, { type: function, function: { name: calculate, description: 计算数学表达式如 (12)*3, parameters: { type: object, properties: { expression: {type: string, description: 要计算的数学表达式}, }, required: [expression], }, }, }, ] def ask_model(user_query: str) - dict: resp client.chat.completions.create( modelgpt-4o-mini, messages[ {role: system, content: 你是一个智能助手需要根据用户需求调用工具。只输出JSON不要输出任何解释。}, {role: user, content: user_query}, ], toolsTOOLS, tool_choiceauto, response_format{type: json_object}, ) return json.loads(resp.choices[0].message.content)原理上这里的核心是模型在训练阶段已经看过大量“函数声明 JSON 输出”的数据所以只要你给它明确的 Schema它就能把“请帮我查下北京的天气”翻译成{tool: get_weather, params: {city: 北京}}这样的指令文本。3.2 方案二能做到什么程度方案二已经可以支撑基本的单轮工具调用。有一次我把 20 句不同口语习惯的问题丢给同一个系统测覆盖“北京今天有雨吗”“帮我算下 3.14 乘以 2”“五点整的闹钟”等等模型在绝大多数情况下都能正确输出工具名和参数。这个方法最大的价值是可维护性。工具的数量从两个增长到二十个时只要把 TOOLS 数组扩充不需要改任何一行调度逻辑模型就能自行匹配新的工具。3.3 但方案二存在三个硬伤第一个问题是模型输出不稳定。即便给了 JSON Schema模型偶尔仍会输出多行解释、单引号字符串、缺字段的 JSON。一旦混入自然语言解析层就会直接报错。这个问题我在其他框架里见过无数次处理方式通常是用 JSON 修复库兜底或者在提示词里加入“不要输出任何额外文字包括标点符号”这种强约束。第二个问题是没有反馈回路。工具执行完拿到结果系统就停了模型并不知道这个结果对不对。比如计算器工具返回了一个异常错误模型下一次对话完全不知道刚才发生了什么无法对自己的操作进行修正。第三个问题是多轮上下文维护。你给模型的历史消息里如果包含了工具调用和工具结果模型的注意力会被带偏它可能会误以为工具结果本身就是用户的需求。如果没有把工具结果封装回“tool 消息”并正确归属会话一长模型就开始发疯。这三个问题就是面试官的追问利器也是引出第三种方案的最好铺垫。4. 方案三带观测回填的 ReAct 循环Agent 的最简形态方案三才是面试过程中的重头戏。它把 Tool Use 从“单次映射函数”升级为“多轮循环推理”也就是 ReAct 模式Reasoning推理 Acting行动。核心变化是模型可以在一轮回答里连续完成“思考 - 调用 - 观察 - 再思考 - 再调用 - 最终回答”的过程。4.1 ReAct 循环的标准数据流我把这个循环抽象成四个阶段规划期模型读入用户问题结合可调用的工具列表决定调用哪个工具并生成参数。执行期调用真实函数得到原始结果可能是 JSON、字符串或异常信息。观测期把工具结果作为新的消息返回给模型让模型看到函数返回了什么。决策期模型判断工具结果是否已满足用户需求。若满足则输出最终答案不满足则继续发起新一轮工具调用直至满足或达到最大迭代次数。这种方式即使在完全不使用平台 API 的 Function Calling 功能时也可以通过提示词实现因此它才是“手写 Tool Use”的最佳答案。4.2 基于提示词的纯手工 ReAct 实现不使用平台自带 function call只靠 prompt 和 chat completion 接口同样可以实现一个最简 ReAct Agent核心代码不到 100 行。import openai import json from typing import Dict, List, Callable import datetime import requests # 一个最简单的工具注册表 TOOL_DEFINITIONS { get_weather: { description: 获取城市天气, parameters: [city], func: lambda city: 晴24°C if 北京 in city else 晴28°C, }, get_time: { description: 获取当前时间, parameters: [], func: lambda: datetime.datetime.now().strftime(%Y-%m-%d %H:%M:%S), }, } SYSTEM_PROMPT 你是一个智能助手。你可以在思考过程中调用工具工具的输入输出如下 可用工具 - get_weather(city: str): 获取指定城市的天气 - get_time(): 获取当前时间 你必须按以下格式输出 Thought: 你思考用户需求并决定下一步动作 Action: 工具名称 Action Input: JSON格式的参数 当你知道最终答案后输出 Thought: 我现在可以回答用户了 Final Answer: 最终答案 不要输出除了上述格式以外的任何内容。 def parse_react_output(raw: str) - Dict[str, str]: 解析模型的ReAct格式输出 lines raw.strip().split(\n) result {} for line in lines: if line.startswith(Thought:): result[thought] line[len(Thought:):].strip() elif line.startswith(Action:): result[action] line[len(Action:):].strip() elif line.startswith(Action Input:): result[action_input] line[len(Action Input:):].strip() elif line.startswith(Final Answer:): result[final_answer] line[len(Final Answer:):].strip() return result def run_react_agent(user_query: str, max_steps: int 5) - str: client openai.OpenAI() messages [ {role: system, content: SYSTEM_PROMPT}, {role: user, content: user_query}, ] for step in range(max_steps): resp client.chat.completions.create( modelgpt-4o-mini, messagesmessages, temperature0, ) raw_output resp.choices[0].message.content parsed parse_react_output(raw_output) # 如果模型决定结束直接返回答案 if parsed.get(final_answer): return parsed[final_answer] # 否则执行工具 action parsed.get(action) if action not in TOOL_DEFINITIONS: # 模型输错了工具名把错误信息回传给模型 messages.append({role: user, content: f你使用了未定义的工具 {action}请重新选择工具。}) continue tool TOOL_DEFINITIONS[action] raw_params parsed.get(action_input, {}) try: params json.loads(raw_params) except json.JSONDecodeError: messages.append({role: user, content: f参数不是合法JSON请根据工具参数类型重新输出正确格式如: {{}}}) continue # 执行真实工具捕获异常作为观测 try: observation tool[func](**params) except Exception as e: observation f工具执行异常: {e} # 把观测结果回填给模型 messages.append({role: user, content: f工具执行结果{observation}}) return 达到最大迭代次数未能完成需求。这个循环在处理“今天几点了”和“北京天气如何”这类问题时执行链路非常清晰模型先产生Action: get_time得到观测值后生成最终答案。整个过程中模型参与了两轮推理第一轮决定调用工具第二轮基于观测结果给出答案。4.3 我补充的两个关键改进在实际写代码过程中我只实现了基础版但面试时我主动提了两个优化点也是后来被认可的地方。一、给工具加执行阈值。真实环境下工具调用可能非常耗时网络请求、数据库查询不能让模型无限循环。基础版的 max_steps 是 5但对于复杂任务这个数字可能不够。可以在每次执行工具时注入当前步数信息让模型感知复杂度并提前规划。二、给工具执行结果加“可读性包装”。很多工具返回的是原始 JSON 或数组模型难以直接从中提取关键信息。我们在把结果回填给模型之前应尽量将其转换为自然语言描述。我第一次调试时把 SQL 查询返回的直接回填给模型结果模型把字段名和值混为一谈回答了错误信息。后来在回填前加一层格式化准确率立刻提升。这个方案最大的优点是可以完全独立于平台的 function calling 能力适用于任何支持文本对话的模型。而代价是提示词工程变得复杂模型的输出格式必须严格约束否则一乱就全盘崩。5. 三种方案对比面试官视角下的优劣分析三种方案的实现路径完全不同面试官之所以会问“你用了三种方案”多半是想看你能不能跳出平台接口层面的舒适区真正理解下面这张表里的权衡。维度方案一规则匹配方案二结构化输出约束方案三ReAct 循环模型依赖无中需求模型有 JSON 生成能力高需求模型有推理和多轮对话能力可解释性很强规则明确中模型有时会理解错中链路长需要日志辅助排查扩展成本每新增一个工具需要写规则只需要加 JSON Schema只需扩展工具注册表和提示词多轮能力极差较差强天然支持连续调用异常处理弱无法自愈一般需要外部兜底强模型可基于观测值自查工程复杂度极低较低偏高大模型成本无 token 消耗每次调用都消耗 token多轮循环消耗较多 token我记得很多人刚学 Tool Use 时容易一步到位直接上 ReAct但我实际建议的路径是先跑通方案二再根据需求升级到方案三。方案一虽然看起来“土”但它让你理解规则引擎与大模型驱动之间的根本区别对面试和日常任务拆解都很有价值。从面试官视角看如果你能流利地说出“方案一换成方案二本质是把意图识别从人写规则交给了模型生成”这就是理解工具调用的第一步。再进一步说出“方案二到方案三的关键是把工具调用从单步执行变成循环迭代并加入观测回填”这就说明你掌握了 Agent 的基础模型。6. 实操复盘我踩过的坑和面试追问应对这节写一些实际写作过程中遇到的坑。毕竟面试时时间有限不可能每个细节都推敲。我把这些坑归类方便你直接拿去做 checklist。6.1 输出格式解析不稳容易整段卡死方案三里我用的是“Action / Action Input / Final Answer”三行格式。最初模型经常输出“Action: get_time()”而不是“Action: get_time”参数也常写成“北京xxx”而不是 JSON。后来把参数强制改为 JSON 并用json.loads解析同时在系统提示词里加了两条示例few-shot稳定率大幅提升。提示无论方案二还是方案三response_format{type: json_object}这类参数只能约束顶层输出是 JSON并不能保证字段内容符合设想所以解析后的校验逻辑不能省。6.2 工具结果回填时的角色归属最开始我调试时把工具结果直接以 user 身份塞进 messages模型会误认为这是用户新消息进而尝试直接回答而不会触发下一轮工具调用。正确归属是使用专用角色如tool角色或特殊前缀或者在消息内容里明确标注“这是工具观测结果不是用户新输入”。很多人在自研 Agent 时都会在这里绕一圈我建议先把这条链路用日志打出来再调比盲目改 prompt 高效得多。6.3 工具执行异常必须“可见”有一次天气接口返回 503我的工具函数抛了个连接超时。ReAct 循环捕获到了异常但异常信息过于底层发给模型后模型不知道发生了什么事只能猜测“网络繁忙”并推荐用户重试。这就是观测值包装的意义。我后来把异常统一格式化为{error: timeout, tool: get_weather, message: 连接超时请检查服务状态}模型才能正确理解故障点并主动调整参数或等待重试。6.4 安全性和权限控制如果工具里包含数据库读写、文件上传必须在调度层加权限校验。模型的输出只是文本任何工具执行都必须经过独立的授权层。我在面试中主动提了“应当在工具注册表里声明 execute 权限字段由调度器统一校验”面试官在这个问题上沉默了几秒我能感觉到这就是重点加分项。6.5 面试追问应对模板如果你也被追问下面这类问题可以参考我的回答思路追问一“最大迭代次数设多少合适” 我答“根据任务复杂度决定。单工具调 3 次足够推理链条长的设 8 到 10 次。上线前应做日志统计根据平均调用次数加一个安全余量。”追问二“如果模型连续调用同一个工具三次怎么办” 我答“记录工具调用历史设定同一工具最大重试次数。超过上限则输出异常观测不再重试。”追问三“多用户并发时如何隔离工具状态” 我答“每次请求都创建独立的 conversation_id工具执行环境通过上下文对象隔离不允许工具读取全局状态。这是将无状态服务升级为有状态 Agent 的基本要求。”7. 从面试代码到可落地的生产级 Tool Use最后聊一下怎么把面试里的代码升级成能真正跑在生产环境的东西。手写在面试里是“证明思想”落地则要“补全工程”。第一把所有工具定义抽取出独立模块。我刚演示的 TOOL_DEFINITIONS 是一个字典生产上至少应该做到按目录加载、可热插拔、带版本号。这样不止是为了可维护性也是让 Agent 的“技能清单”能被模型精确感知。第二增加可观测性。在循环的每一步记录输入输出、耗时、token 消耗并输出 trace。有一次线上 Agent 回答越来越慢就是因为某个工具接口延迟从 200ms 涨到了 3s而模型的每轮循环都等在网络调用上。没有 trace这个问题排查要花几倍时间。第三做好成本控制。方案三的循环调用会带来 token 消耗的多倍膨胀。可以把工具执行结果限制在 500 字符以内截断无用信息同时给每轮循环的最大 token 设置预算。对高频场景甚至可以先用方案二筛掉简单需求只在复杂任务时才启动完整 ReAct 循环做一个分级调度。第四精细化 prompt。把工具的 schema 从英文描述改成中文描述加 few-shot都会直接影响模型的调用成功率。我实际测试中同一工具prompt 里加一条示例调用准确率能提升 15% 到 20%。这个收益性价比极高值得优先做。第五不要轻视工具结果的结构化校验。大多数 Tool Use 失败不是发生在“模型生成阶段”而是在“工具结果回填阶段”——工具返回了预期外的字段模型误读或本应是数组却返回了对象。给每个工具注册一个 output_schema在回填前做校验和标准化能规避掉大部分线上异常。写这篇文章时我又把三种方案从头到尾跑了一遍。说句实话方案一已经是上个时代的玩法但它在面试中的存在意义不是“能用”而是“对照”“你看我清楚整个人工规则引擎的边界在哪也知道为什么需要引入模型推理。” 只有把这条演进路径完整讲清楚才算是真正掌握了 Tool Use 这道题。如果你正在准备大模型方向的技术面试建议不要只在网上刷题找个周末把这三个方案手写一遍跑通两三个用例你会发现面试官追问的任何细节你都能接住。毕竟这类题面试官想看的从来不是标准答案而是你亲手踩过坑之后沉淀下来的工程直觉。