☰
手写 Tool Use 三种方案:从正则到 ReAct 循环,面试与实战全拆解
2026/10/16 13:16:12 网站建设 项目流程

面试复盘,总得写点能救命的干货。前段时间面某大模型应用岗,聊到 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}?(?P<city>[\u4e00-\u9fa5]{2,4})的?(?:天气|气温)" ], "calculator": [ r"计算\s*(?P<expr>\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 found"

2.2 这个方案的命门

面试官肯定会追问:正则漏掉了怎么办?用户说“杭州明天冷不冷”,你的正则匹配吗?答案是大概率不匹配。所以方案一只是“能跑”,不具备泛化能力。

但这里有一个容易被低估的优势:正则方案的可解释性极强。工具为什么被调用、参数是怎么填的,每一环都有迹可循。在生产环境里,如果用户投诉“我明明说的是查天气,怎么没调工具”,你直接看匹配日志就能定位是哪个正则没跑到。这对排查线上问题非常友好。

我当时和面试官强调了一句:正则方案不是用来凑数的,它适合作为冷启动版本,或者作为高置信度快路径(fast path)存在。主模型判断不了的时候,还能有一个规则兜底。很多成熟的 Agent 框架里其实都保留着这类规则层,只是不会只靠它。

2.3 手写时给这个方案的定位

面到这一步,我明确告诉面试官:方案一适合“工具数量不超过五个、用户表达高度模板化”的场景。比如我在一个模拟项目里做的“语音告警机器人”,用户只会说“查一下某台机器”“重启某个服务”,这种封闭环境里正则方案跑了三个月没出过大问题。面试官显然听过这种场景,点了点头。

但如果环境放开,比如用户会自由表达,工具从五个涨到二十个,正则方案会迅速失控,模式之间互相覆盖,新增一条规则可能要花半天调优先级。这时候就得走到方案二。

3. 方案二:约束模型输出结构化 JSON,把解析交给 format

3.1 从硬编码到半自动化

既然正则方案的问题在于规则写死,那下一步自然把“意图识别”和“参数抽取”外包给模型。具体思路是:构造提示词,明确告诉模型“如果你认为需要调用工具,就严格输出一段 JSON,里面包含工具名和参数;否则输出普通文本回答”。程序侧只需要解析这一段 JSON,然后分发调用。

这一步的核心变化是:不再靠人的规则猜测用户意图,而是让模型理解用户表达后自己决定。工具的注册信息怎么告诉模型?最简单的方法是写进 System Prompt,或者拼在用户消息前面,让模型在生成时看到当前有哪些工具可选、每个工具的参数约束是什么。

我当时的核心实现长这样:

import json TOOLS_DESCRIPTION = """ 可用工具: - get_weather: 查询天气, 参数 {"city": "城市名", "date": "日期, 格式YYYY-MM-DD"} - calculator: 计算器, 参数 {"expr": "数学表达式, 如 12+34"} 当且仅当用户请求需要调用工具时, 只返回一个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 resp

3.2 关键细节:缺失的“模型提示”环节

用过这套方案的人都知道一个坑:模型输出 JSON 的时候偶尔会把参数名写错、漏掉必填项,甚至把中文回答也包进 JSON 里。我当时面试时主动提到了这个坑,面试官眼睛明显亮了一下。

最简单的容错办法是:工具调用失败时,把失败信息拼成一段新的用户上下文,再交给模型重试一次。比如它调 calculator 时把表达式写成了“十二加三十四”,程序报错后,你就把错误信息“参数 expr 无法解析:十二加三十四”和原问题一起再发给模型,让它修正 JSON 重试。这已经带上了“反馈回路”的雏形。

另一个关键细节是校验层。模型输出的 JSON 不能直接信,哪怕格式正确也可能参数非法。所以要加一个轻量校验函数:检查必填字段、检查参数类型、检查工具名是否在注册表里。我一般这样写:

def validate_call(parsed): tool = parsed.get("tool") if tool not in TOOL_REGISTRY: raise ValueError(f"unknown tool: {tool}") required = TOOL_REGISTRY[tool]["required_args"] args = parsed.get("args") or {} for k in required: if k not in args: raise ValueError(f"missing arg: {k}") return tool, args

3.3 方案二的核心局限:模型可能靠“编”来调用

面试官追问:模型没有真实执行工具,它不知道工具返回值长什么样,如果返回值里有个关键信息你让它自己根据上下文继续答,它可能会编造。我说对,这就是方案二缺的最后一环:观测结果反馈。工具调用完了,模型看到结果吗?方案二里我直接把工具返回结果当成最终答案输出给用户,并没有把结果塞回对话上下文里,让模型基于真实结果再做一轮推理。

但方案二的价值也不能低估。在很多真实环境里,工具调用本身就是“一次性”的,比如拉起一个任务、发一条通知,执行完就算结束,用户不需要模型对结果做二次加工。这种场景下,方案二就是性价比最高的选择。

而且面试里写方案二,可以展示你懂提示工程、懂得约束生成格式、懂得解析容错,这些细节恰恰是面试官在方案一中看不到的。写到这里我通常还会补一句:如果接口本身支持 Function Calling,可以直接把 TOOLS_DESCRIPTION 换成官方的 tools 参数声明,后面的解析逻辑不变,方案二就进化成了“标准 Function Calling 调用范式”。

4. 方案三:ReAct 循环,带观测反馈的完整 Tool Use

4.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_code=BEIJING”,模型不去纠正参数格式,反而调用了第二次一模一样的接口。加了重复调用检测之后,这类问题才算彻底兜住。这段在面试现场说出来,比单纯展示代码骨架更有说服力。

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,我建议你至少把方案三的循环画明白、把对比表的利弊背熟,再把容错和终止条件当成自己的亮点讲出来,绝对比你闷头调参更有记忆点。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询