手写 Tool Use 三种方案:从正则到 ReAct 循环,面试与实战全拆解

发布时间:2026/10/16 13:16:18
手写 Tool Use 三种方案:从正则到 ReAct 循环,面试与实战全拆解
面试复盘,总得写点能救命的干货。前段时间面某大模型应用岗聊到 Agent 相关的项目经历面试官突然来了句“那你现在手写一个 Tool Use不用跑通给我看看思路。” 当时白板上一片空白我脑子里过了三个版本从最土的正则匹配到结构化 JSON 分发再到带观测反馈的完整 ReAct 循环最后把整个链路讲清楚了。Tool Use 这个词在大模型应用里几乎每天都在碰说白了就是让模型按需去调用外部工具而不是只靠它背下来的参数硬答。这篇文章我不讲花架子直接把当时在白板上面试官面前推演过的三种方案完整拆开从思路、代码骨架到各自适用场景和局限一次说透。适合正在准备大模型岗位面试的人也适合刚上手写 Agent 应用、被工具调用搞得头大的工程师。1. 面试官在问什么Tool Use 的四层真实考点1.1 先搞清楚 Tool Use 到底解决什么问题大模型再厉害本质还是“给定上下文生成下一个 token”。你要查实时天气、要调一个内部接口、要操纵数据库它自己做不到因为它没有“手”。Tool Use 就是给模型装上一双手让它在恰当的时候输出一个“我要调用某工具、传某参数”的信号然后由程序去执行、把结果拿回来再让模型基于结果继续回答。这听起来很直白但真正到了面试场景面试官不是想听你背定义。他更关心你有没有真的写过 Agent 类系统你让模型什么情况下去调工具调错了怎么办工具返回了脏数据你如何解释给用户这些都不在“定义”里全在实现细节里。1.2 四个考点拆开看我在白板上给自己列了个清单面试官问手写 Tool Use本质上在考四层东西第一层是意图识别。模型得知道当前这轮用户请求需不需要调用工具。这一层最容易被忽略很多人上来就写“解析模型输出然后执行”但忘了最前置的判断用户说“你好”你调什么工具所以 Tool Use 一定包含一个“闸门”逻辑。第二层是参数抽取。决定了要调用工具那工具名字是什么、参数从哪来。用户说“帮我查一下杭州明天的天气”你得能把“杭州”“明天”从自然语言里抽出来填到 get_weather(city, date) 里面。第三层是动作执行与结果回传。工具执行完返回结果这个结果要重新组织成模型能读的文本塞回对话上下文让模型知道“这次调用发生了什么”。第四层是错误与边界处理。工具抛异常了怎么办参数解析失败怎么办模型连续多次调用同一工具停不下来怎么办一个有工程经验的面试者至少要把这一层提出来哪怕不做完整实现。我当时告诉面试官手写一个 Tool Use 的难度不取决于代码量而取决于你把这四层做到哪一层。只做前两层叫“API 封装”做齐四层才叫“Tool Use 基础设施”。2. 方案一正则加简单规则最快打通调用链路2.1 思路与实现面试官让我先给一个“即使模型不支持 Function Calling 也能跑”的方案。我第一个想到的就是正则。这是最笨、但最不容易出错的方法直接在用户输入文本上用正则去匹配预案好的模式命中哪个模式就调用对应的工具。这本质上是把“意图识别”和“参数抽取”全部退化成了规则匹配。不用模型参与不怕模型乱来速度也快得惊人。适合的场景是工具数量少、输入格式固定、用户意图基本能被几条正则覆盖。比如一个内部运维助手只有查日志、看服务状态、重启实例三个工具用户习惯说“看下A服务状态”“重启B服务”这种场景下正则方案反而最稳。我当时在白板上写了这样一段伪代码import re TOOL_PATTERNS { get_weather: [ r(?:查|看|告诉我).{0,4}?(?Pcity[\u4e00-\u9fa5]{2,4})的?(?:天气|气温) ], calculator: [ r计算\s*(?Pexpr\d\s*[\-*/]\s*\d) ] } def match_tool(user_input: str): for tool, patterns in TOOL_PATTERNS.items(): for pattern in patterns: m re.search(pattern, user_input) if m: args m.groupdict() return tool, args return None, None核心就两步遍历工具对应的正则模式命中了就返回工具名和参数字典。调用层更简单def execute_tool(tool: str, args: dict): if tool get_weather: return fake_weather_api(args[city]) if tool calculator: return eval(args[expr]) return tool not found2.2 这个方案的命门面试官肯定会追问正则漏掉了怎么办用户说“杭州明天冷不冷”你的正则匹配吗答案是大概率不匹配。所以方案一只是“能跑”不具备泛化能力。但这里有一个容易被低估的优势正则方案的可解释性极强。工具为什么被调用、参数是怎么填的每一环都有迹可循。在生产环境里如果用户投诉“我明明说的是查天气怎么没调工具”你直接看匹配日志就能定位是哪个正则没跑到。这对排查线上问题非常友好。我当时和面试官强调了一句正则方案不是用来凑数的它适合作为冷启动版本或者作为高置信度快路径fast path存在。主模型判断不了的时候还能有一个规则兜底。很多成熟的 Agent 框架里其实都保留着这类规则层只是不会只靠它。2.3 手写时给这个方案的定位面到这一步我明确告诉面试官方案一适合“工具数量不超过五个、用户表达高度模板化”的场景。比如我在一个模拟项目里做的“语音告警机器人”用户只会说“查一下某台机器”“重启某个服务”这种封闭环境里正则方案跑了三个月没出过大问题。面试官显然听过这种场景点了点头。但如果环境放开比如用户会自由表达工具从五个涨到二十个正则方案会迅速失控模式之间互相覆盖新增一条规则可能要花半天调优先级。这时候就得走到方案二。3. 方案二约束模型输出结构化 JSON把解析交给 format3.1 从硬编码到半自动化既然正则方案的问题在于规则写死那下一步自然把“意图识别”和“参数抽取”外包给模型。具体思路是构造提示词明确告诉模型“如果你认为需要调用工具就严格输出一段 JSON里面包含工具名和参数否则输出普通文本回答”。程序侧只需要解析这一段 JSON然后分发调用。这一步的核心变化是不再靠人的规则猜测用户意图而是让模型理解用户表达后自己决定。工具的注册信息怎么告诉模型最简单的方法是写进 System Prompt或者拼在用户消息前面让模型在生成时看到当前有哪些工具可选、每个工具的参数约束是什么。我当时的核心实现长这样import json TOOLS_DESCRIPTION 可用工具: - get_weather: 查询天气, 参数 {city: 城市名, date: 日期, 格式YYYY-MM-DD} - calculator: 计算器, 参数 {expr: 数学表达式, 如 1234} 当且仅当用户请求需要调用工具时, 只返回一个JSON: {tool: 工具名, args: {...}} 否则正常用中文回答。 def build_prompt(user_input): return f{TOOLS_DESCRIPTION}\n\n用户: {user_input}\n def run_with_tool(user_input): resp llm_chat(build_prompt(user_input)) try: parsed json.loads(resp) tool parsed[tool] args parsed[args] result dispatch(tool, args) return f工具返回: {result} except (json.JSONDecodeError, KeyError, TypeError): # 模型没有按约定输出JSON视为普通回答 return resp3.2 关键细节缺失的“模型提示”环节用过这套方案的人都知道一个坑模型输出 JSON 的时候偶尔会把参数名写错、漏掉必填项甚至把中文回答也包进 JSON 里。我当时面试时主动提到了这个坑面试官眼睛明显亮了一下。最简单的容错办法是工具调用失败时把失败信息拼成一段新的用户上下文再交给模型重试一次。比如它调 calculator 时把表达式写成了“十二加三十四”程序报错后你就把错误信息“参数 expr 无法解析十二加三十四”和原问题一起再发给模型让它修正 JSON 重试。这已经带上了“反馈回路”的雏形。另一个关键细节是校验层。模型输出的 JSON 不能直接信哪怕格式正确也可能参数非法。所以要加一个轻量校验函数检查必填字段、检查参数类型、检查工具名是否在注册表里。我一般这样写def validate_call(parsed): tool parsed.get(tool) if tool not in TOOL_REGISTRY: raise ValueError(funknown tool: {tool}) required TOOL_REGISTRY[tool][required_args] args parsed.get(args) or {} for k in required: if k not in args: raise ValueError(fmissing arg: {k}) return tool, args3.3 方案二的核心局限模型可能靠“编”来调用面试官追问模型没有真实执行工具它不知道工具返回值长什么样如果返回值里有个关键信息你让它自己根据上下文继续答它可能会编造。我说对这就是方案二缺的最后一环观测结果反馈。工具调用完了模型看到结果吗方案二里我直接把工具返回结果当成最终答案输出给用户并没有把结果塞回对话上下文里让模型基于真实结果再做一轮推理。但方案二的价值也不能低估。在很多真实环境里工具调用本身就是“一次性”的比如拉起一个任务、发一条通知执行完就算结束用户不需要模型对结果做二次加工。这种场景下方案二就是性价比最高的选择。而且面试里写方案二可以展示你懂提示工程、懂得约束生成格式、懂得解析容错这些细节恰恰是面试官在方案一中看不到的。写到这里我通常还会补一句如果接口本身支持 Function Calling可以直接把 TOOLS_DESCRIPTION 换成官方的 tools 参数声明后面的解析逻辑不变方案二就进化成了“标准 Function Calling 调用范式”。4. 方案三ReAct 循环带观测反馈的完整 Tool Use4.1 前两个方案都缺了什么方案二做完面试官问了句“如果工具返回后模型需要结合结果继续推理怎么办”这就是在引导我写 ReAct 了。我理解的 Tool Use 完整版不是一个单发的调用动作而是一个循环模型思考 → 决定调用工具 → 执行工具 → 把结果作为观测Observation喂给模型 → 模型再判断是继续调工具还是直接给最终答案。面试官想看的就是能不能写出这个循环。因为真实场景里绝大多数用户问题不是一个工具调用就能收官的。比如“帮我查一下明天杭州到北京的航班然后对比一下高铁哪个更便宜”你需要先调航班查询再调高铁查询还可能调一个比价函数最后模型综合两个工具返回结果生成答案。每一步都是独立的模型不能一步到位。4.2 核心流程拆解我当时把 ReAct 循环拆成四步写在了白板上第一步是生成决策。把当前对话历史、用户问题、之前所有工具调用及返回结果拼成一个新的上下文让模型决定下一步动作。动作类型有三种调某个工具、直接回答最终结果、给出“无法完成”的说明。第二步是解析动作。从模型输出中按格式提取动作类型和参数。这一步可以复用方案二的 JSON 解析逻辑只是多加一个字段比如 {action: call_tool, tool: ..., args: {...}} 或者 {action: finish, answer: ...}。第三步是执行并回填。工具执行成功就把返回结果包装成一段 Observation 文本拼到上下文中失败就把错误信息也拼进去。这一步很关键错误信息也是反馈模型看到报错后有时会自己换一种方式重试。第四步是循环控制。设置最大迭代次数比如 5 轮达到上限还没完成就强制终止并返回“当前已获取的部分信息”。这是为了防止模型陷入死循环一直调同一个工具。4.3 代码骨架我当时的实现骨架是这样的def react_loop(user_query: str, max_steps: int 5): messages [{role: user, content: user_query}] for step in range(max_steps): resp llm_chat(messages) action parse_json_action(resp) if action[type] finish: return action.get(answer, 我无法完成这个请求。) if action[type] call_tool: tool action[tool] args action[args] observation safe_execute(tool, args) # 把工具的执行结果作为系统观测塞回上下文 messages.append({ role: system, content: f[工具 {tool} 返回]: {observation} }) continue return 模型输出格式异常已终止。 return 超过最大尝试步数停止调用工具。4.4 面试官期待的三个细节写到循环骨架子面试官会继续挖细节。我主动补了三个第一个是“观测不可信”的假设。工具可能返回空值、超时、或者一段异常堆栈。safe_execute 不能直接抛异常给模型看应该做一层包装把异常转成“工具执行失败某某原因”让模型能理解而不是收到一堆 stack trace。第二个是上下文打包策略。ReAct 循环会持续拼接消息最多 5 轮可能塞进几千 token 的工具返回结果容易把模型上下文撑爆。实际开发中要给 Observation 做长度裁剪比如只保留前 500 字符并在裁剪时保留关键字段。第三个是终止条件。除了最大步数还要加一个“重复调用检测”如果模型连续三轮调用了同一个工具、参数也一样直接判死返回用户“工具调用异常”。这类行为在真实模型里一点都不罕见尤其是模型在错误反馈上过度自信的时候。我还和面试官说了个我在某模拟项目里踩过的坑模型在查天气时调用了一个不存在的城市代码工具返回“无效参数city_codeBEIJING”模型不去纠正参数格式反而调用了第二次一模一样的接口。加了重复调用检测之后这类问题才算彻底兜住。这段在面试现场说出来比单纯展示代码骨架更有说服力。5. 三种方案怎么选面试现场临场定大小的脑子5.1 横向对比一张表写到这里白板上的内容其实已经能形成一张完整的对比表。当时我直接在面试官面前把三个方案拉了个对照账维度方案一 正则规则方案二 结构化JSON输出方案三 ReAct循环意图识别方式人工规则匹配模型自行判断模型多轮推理判断参数抽取正则捕获组JSON字段解析JSON字段解析是否支持多轮工具调用不支持不支持或需要额外封装天然支持循环机制对模型能力要求极低几乎不依赖中等要求模型不乱改格式较高要求模型能推理决策运行成本几乎没有每轮一次模型调用每步一次模型调用可能多轮可控性与可解释性最高中高中等最适场景封闭场景、固化命令一次性工具调用、Function Calling复杂任务、Agent型应用手写难度低半小时内搞定中高重点是循环与反馈设计这张表最大的作用是帮面试官判断你的思维层级你不仅会写代码还清楚不同方案的边界。手写 Tool Use 题大部分候选人能写出一版方案但能列出边界的人不算多。5.2 我最终给出的选择逻辑面试官最后问我“真实项目里你选哪个”我说看两个变量工具数量和维护成本。如果这个 Tool Use 功能是一次性交付工具也就两三个用户表达模板基本固定我会选方案一不是因为技术含量低而是因为它最不容易坏而且出了问题我看得懂。如果要接入大模型且工具数量在五到二十个之间用户表达不可控我会默认选方案二同时把 Function Calling API 作为等价实现来看待。原因很简单单轮工具调用场景占比最高直接给出执行结果就能满足 80% 的需求没必要为了 20% 的复杂场景把整个系统复杂度拉高。只有当我明确判断应用形态是 Agent、需要多步推理和上下文反馈我才会写方案三的循环架构。并且我会在方案三里内嵌方案二的 JSON 解析逻辑再内嵌方案一的规则兜底作为 fast path。三个方案不是互斥的它们在生产里实际上是三层自下而上的组合。5.3 手写题里的三个加分动作分享三个面试中特别加分的小动作如果你也想在类似题目中表现得更扎实可以试试第一个动作是主动定义边界。写任何一个方案前先画一条分界线哪些情况模型自己答、哪些情况走工具。不要一上来就写解析代码。面试官要的不是“你写过”而是“你想清楚过”。第二个动作是展示容错闭环。方案哪怕再简单也要写出 try-except 分支或者异常回传逻辑。很多 Candidates 写的 Tool Use 不具备任何容错能力工具一报错就崩。你能多写一行“错误信息回塞给模型”这道题就已经胜出了半条街。第三个动作是结合真实数据说话。讲完实现后补一句“我当时上线之后发现正则兜底大概拦住了 X% 的边缘表达”或者“加了最大步数限制后模型死循环的比例降到接近 0”。具体数字记不清没关系补一句真实教训也比空泛总结好。面试官在乎你有没有线上手感。那次面试之后我把这类“手写题”的心得整理成了一条思路不要只背标准答案要把每个方案背后的决策逻辑想清楚。工具调用不是越复杂越好是用最合理的方式帮模型获得它不知道的信息。后来我在自己做的小 Agent 项目里就按这个思路搭了个三层结构规则层接住高置信度命令Function Calling 层处理常规工具请求ReAct 循环层兜住多步推理任务。跑了一个多月整体稳定至少没有再被“模型反复调同一个工只能干瞪眼”这种问题折磨。面试时我用的也是这套话术面试官听完没有追问技术细节反而聊了二十分钟 Agent 产品化的事情。如果你也准备在面试里手写 Tool Use我建议你至少把方案三的循环画明白、把对比表的利弊背熟再把容错和终止条件当成自己的亮点讲出来绝对比你闷头调参更有记忆点。