资讯动态

手写 Tool Use 全解析:从正则指令到 ReAct Agent 的实现路径

发布时间:2026/10/13 13:05:17 来源:尧图企业网站定制
上个月面了一家做智能客服的创业公司技术面进行到一半面试官把电脑转向我说“你简历里写了‘熟悉大模型应用开发’那应该用过 Tool Use。我不需要你背 SDK你给我在白板上手写一个 Tool Use思路完整就行。”这道题让我愣了一下。平时用模型厂商提供的 function calling 接口很熟练几个参数一填就能把工具挂上去但真要脱离框架、用裸代码实现一套工具调用机制脑袋里还是得把整条链路重新捋一遍。我最后在现场写了三个由浅入深的方案从最简单的正则指令解析到 JSON 结构化调用再到带循环的 ReAct Agent。这篇文章把当时那二十分钟的思考完整展开顺便把面试复盘里被追问最多的几个点也补上。如果你也在准备大模型应用岗或者想搞明白 function calling 背后到底发生了什么这篇应该对你有用。1. 面试官实际上想看到什么1.1 表面是“写代码”实际是考机制Tool Use 的本质是让大模型在对话过程中主动请求外部工具程序负责解析请求、执行工具、把结果送回模型最终生成面向用户的回答。它不是一个函数而是一条完整的数据流从用户输入出发经过模型决策、程序解析、工具执行再回到模型生成回复。很多人会直接用模型平台封装好的 function calling 能力但不知道底层发生了什么。面试官让你手写其实是想看你能不能回答这几个问题模型怎么知道当前有哪些工具可用模型怎么表达“我现在要调用某个工具”程序怎么安全地执行这次调用执行结果怎么回到模型手里如果调用出错循环怎么继续只要能把上面五个问题变成代码就是一个可运行的 Tool Use 实现。至于用哪个模型、哪个框架反而不重要。1.2 核心链路五步我当场把 Tool Use 拆成了五步这也是后来我设计所有工具层都能复用的骨架定义工具元信息工具名、参数结构、功能描述。将元信息注入上下文把工具的 JSON Schema 或说明写到系统提示词里让模型知道存在这个工具。模型输出结构化调用请求可以是工具名(参数)这种文本也可以是严格的 JSON。程序解析并校验把模型输出转换为可执行的函数名和参数做类型检查、必填项检查。执行工具并回传结果把结果作为新的上下文交给模型生成最终回复或继续调用下一个工具。这五步的输入输出我在白板上画成了一张表步骤输入输出关键问题定义工具手工维护的工具列表工具描述、参数Schema谁来维护怎么保证不过时注入上下文工具元信息系统提示词片段提示词太长会不会挤占对话窗口模型输出请求用户输入 提示词结构化调用文本格式不稳定怎么办解析校验模型输出可执行的函数调用参数缺失、工具不存在怎么办执行并回传参数工具结果Observation结果太长如何截断报错如何恢复如果你只用过封装好的 function calling可能觉得这五步被框架藏起来了。手写一遍每一步的边界问题都会暴露出来。1.3 为什么不能只会调 SDK我面试时直接跟面试官说“我平时用 function calling 很熟但我不打算只写一行tool_calls回传因为我理解里面每一层黑盒。”他明显对这句话更感兴趣。很多候选人的真实状态是只会把工具列表传给模型接口然后拿到一个tool_call_id回传结果。至于模型为什么选了某个工具、参数是从哪句话里抽出来的、返回值太长会怎样完全没有概念。面试官只要追问两次就能判断你是真的做过还是只跑过 Demo。手写一遍这些问题全部绕不开。2. 方案一正则指令解析五分钟先跑通2.1 最直觉的做法方案一的思路极其朴素告诉模型“如果需要查询天气请输出get_weather(城市名)”然后程序用正则从模型回复中抓取工具调用并执行。这个方案非常适合用来跟面试官建立初版认知因为它代码量最小、逻辑最直白。我在白板上写完大概花了五分钟。它也能解释清楚 Tool Use 最基本的概念让模型生成一段特殊文本程序识别后执行。但它的短板也一样明显模型输出的格式稍微一飘正则就抓不到整个链路直接断掉。2.2 最小可运行版本我当时写的代码大致是这样用 Python 演示实际项目里也是这个结构import re from datetime import datetime TOOL_DEFS [ get_weather(city: string): 查询指定城市的天气, get_current_time(): 获取当前时间, ] def get_weather(city: str) - str: # 实际项目中替换为真实天气服务这里用模拟数据 return f{city} 晴天气温 26 度 def get_current_time() - str: return datetime.now().strftime(%Y-%m-%d %H:%M:%S) TOOL_MAP { get_weather: get_weather, get_current_time: get_current_time, } def build_system_prompt(): return ( 你是一个助手。当你需要查询工具时必须严格输出以下格式\n 工具名(参数1, 参数2)\n 可用工具\n \n.join(TOOL_DEFS) ) def parse_call(text: str): pattern r([a-zA-Z_])\s*\(([^)]*)\) match re.search(pattern, text) if not match: return None, None tool_name, raw_args match.groups() args [a.strip() for a in raw_args.split(,) if a.strip()] return tool_name, args然后在主循环里调用user_input 城市A今天热吗 response chat(build_system_prompt(), user_input) tool_name, args parse_call(response) if tool_name: result TOOL_MAP[tool_name](*args) final_answer chat( build_system_prompt(), f工具返回{result}\n请根据结果回答用户 ) print(final_answer)基本过程是第一次让模型决定要不要调用工具程序解析出工具调用后执行再把结果拼进第二次模型请求让模型组织语言回答用户。这个“决定调用”和“组织回答”分成两次请求的模式其实就是后面所有 Tool Use 方案的雏形。2.3 这个方案容错有多差我当场给面试官举了个反例。用户问“城市A今天热吗”模型正常应该输出get_weather(城市A)但模型偶尔会输出“好的我需要先查询城市A的天气请稍等。”这句话看起来没问题正则也能匹配到get_weather(城市A)。可如果模型换一种表达“让我调用天气工具获取城市A的温度”正则就匹配不到工具名解析函数返回None整个调用链静默失败。更麻烦的是参数顺序和类型。get_weather(城市A, 明天)这种多参数请求正则方案很难保证参数和函数签名一一对应。函数定义时要求第二个参数是天气时间模型却可能把城市和时间调换位置程序一执行就是 TypeError。所以方案一只能用来快速演示原理不能进生产。面试时我主动承认了它的脆弱性然后马上切到方案二——真正能扛住格式波动的方案。3. 方案二JSON 结构化输出才是真正的 Tool Use3.1 核心思想把“格式要求”变成“格式强约束”方案二的核心是不让模型自由写文本而是要求它输出一段合法的 JSON里面明确写出要调用的工具名和参数。可以这么理解方案一是让模型“说人话”方案二是让模型“填表单”。表单字段固定、类型固定解析逻辑就会非常稳定正则失效的概率大大降低。我在面试中给的提示词模板长这样你是一个工具调度器。请根据用户请求决定是否调用工具。 如果调用工具只输出一个 JSON 对象格式如下 {tool: 工具名, params: {参数名: 参数值}} 可用工具 - get_weather: 查询城市天气 参数city (string, 必填) - get_current_time: 查询当前时间 参数无 注意不要输出任何解释性文字。配合代码定义一个工具注册表。这个注册表就是整个 Tool Use 的门面from dataclasses import dataclass from typing import Callable, Any dataclass class ToolSpec: name: str description: str parameters: dict func: Callable[..., Any] TOOLS [ ToolSpec( nameget_weather, description查询城市天气, parameters{ city: {type: string, required: True} }, funclambda city: f{city} 温度 26 度晴, ), ToolSpec( nameget_current_time, description查询当前时间, parameters{}, funclambda: datetime.now().strftime(%Y-%m-%d %H:%M:%S), ), ]解析执行函数import json def extract_json(text: str): # 先摘掉代码块标记再尝试截取最外层 {...} if in text: text text.split()[1].strip() start text.find({) end text.rfind(}) if start -1 or end -1: raise ValueError(no json found in model output) return text[start:end 1] def execute_tool(tool_name, params): tool next((t for t in TOOLS if t.name tool_name), None) if tool is None: raise ValueError(funknown tool: {tool_name}) # 实际项目中这里要做 JSON Schema 校验 return tool.func(**params) def run_with_tool_call(user_input): resp chat(build_prompt(), user_input) raw extract_json(resp) call json.loads(raw) tool_name call[tool] params call.get(params, {}) result execute_tool(tool_name, params) final chat(build_prompt(), f工具返回{result}\n请回答用户{user_input}) return final3.2 解析与校验的细节才是分水岭这部分是面试中能拉开差距的地方。JSON 方案虽然格式稳定但依然要处理几个边角。能把这几个边角讲清楚面试官就会觉得你不只是会复制粘贴。第一个是模型输出的 JSON 可能夹带废话。有些模型会输出“好的我来查询json {...}”。我写了一个extract_json先把代码块剥离再用find定位最外层{和}。如果还失败就把错误信息拼给模型让它重新生成一次。第二个是参数类型校验。city必须是字符串如果模型传了一个数字直接执行函数会出问题。我建议在ToolSpec里维护一份参数 Schema执行前逐字段检查是否必填、类型对不对、参数名是否合法。校验不通过就不要执行函数而是让模型修正参数。第三个是不存在的工具名。模型可能幻觉出一个query_weather如果代码里直接报KeyError用户看到的是调用栈体验非常差。正确做法是捕获异常返回给模型一条 Observation“工具 query_weather 不存在可用工具是 get_weather、get_current_time。”让模型自己重来。这一步相当于给模型装了一个“纠错回路”。3.3 执行完是不是就结束了很多人手写 Tool Use 时只做到“执行工具拿到结果”就停了。实际上工具返回值往往是结构化数据比如 JSON、日志、数据库查询结果不是给用户看的自然语言。我们要做第二次模型调用把工具结果作为上下文让模型生成面向用户的回答。示例final chat([ {role: system, content: 你是客服助手。}, {role: user, content: 城市A今天热吗}, {role: tool, content: json.dumps( {city: 城市A, temperature: 32, condition: 晴}, ensure_asciiFalse )}, {role: user, content: 请根据工具返回数据回答用户}, ])这个模式在模型平台里叫 tool result 回传本质就是把工具输出变成新的输入片段。面试时如果能把这一步讲清楚比只写一个execute_tool强很多。4. 方案三ReAct 循环把 Tool Use 升级成 Agent4.1 为什么需要循环方案二解决了格式问题但仍是“一问一调”的直线结构。真实场景里用户不会只问一个能直接映射到工具的问题而是一个需要多步推理的任务。比如“城市A和城市B哪个更冷”模型需要先查城市A温度再查城市B温度最后比较。如果用方案二就得人为编排两次调用非常死板。ReAct 模式解决的就是这个问题。它把模型的生成过程组织成“思考-行动-观察”的循环Thought我下一步要做什么。Action需要调用哪个工具。Action Input传给工具的参数。Observation工具执行后返回的结果。模型反复输出这些段落直到它认为信息足够了输出Final Answer。程序要做的是解析这些段落、执行工具、把观察结果追加到对话历史里。这就是一个极简 Agent。4.2 手写一个最小 ReAct Agent我在面试中写的伪代码结构如下class ReActAgent: def __init__(self, tools, model_fn, max_steps5): self.tools {t.name: t for t in tools} self.model_fn model_fn self.max_steps max_steps def run(self, user_message): messages [ {role: system, content: build_react_prompt()}, {role: user, content: user_message}, ] for _ in range(self.max_steps): resp self.model_fn(messages) messages.append({role: assistant, content: resp}) action_input parse_action(resp) if action_input is None: if Final Answer in resp: return resp.split(Final Answer:)[-1].strip() continue tool_name, params action_input try: observation self.tools[tool_name].func(**params) except KeyError: observation fError: unknown tool {tool_name} except Exception as e: observation fError: {e} messages.append({role: system, content: fObservation: {observation}}) return Exceeded max steps.解析函数按 ReAct 的格式写正则def parse_action(resp): action re.search(rAction:([a-zA-Z_]), resp) if not action: return None action_input re.search(rAction Input:(\{.*\}), resp, re.S) params {} if action_input: params json.loads(action_input.group(1)) return action.group(1), params这里的关键点是 messages 里持续追加assistant的原始输出和system的 Observation。模型看到自己的历史思考才能继续下一步。如果少了 Observation 那一条模型会在原地打转反复输出同一个 Action。4.3 状态管理、步数与安全ReAct 方案最容易被追问的就是“怎么防止模型死循环”。我给了三个对策后来在实际项目里也一直在用。最大步数上面代码里的max_steps5超过就主动终止。生产环境一般设置在 8 到 10 步再多意义不大还会浪费 token。错误恢复工具执行失败时不要直接对用户输出“服务异常”而要把错误信息变成 Observation 交回给模型让模型换一种工具或换参数再试。比如天气接口返回{error: city not found}模型可以尝试纠正城市名。上下文窗口每一步都会追加一段 assistant 输出和 Observation多轮之后序列很长。工具如果返回大段 JSON建议先做截断或摘要只保留关键字段。我在项目里会对工具返回做一层summarize_tool_output比如只保留前 50 个 token 的关键数据。这三点全做到手写的 Tool Use 已经具备生产雏形。面试官当时还追问了一句“多步推理里如果连续调用同一个工具怎么办”我的回答是可以在Observation里编号区分比如Observation(1): ...这样模型就不会把两轮结果搞混。5. 三种方案的取舍与面试追问拆解5.1 横评什么时候用哪个面试时我画了一张表给面试官看维度方案一正则解析方案二JSON 结构化方案三ReAct 循环实现成本低半小时内中需要提示词和校验高需要循环框架格式稳定性差模型一飘就断好JSON 约束强中需要配合解析多步推理不支持需要外部编排原生支持错误恢复弱可以通过 Observation 纠正强模型自行纠错适用场景演示、原型、固定短语生产环境单步工具调用需要多步推理的 Agent 任务结论很直接如果只是给客服机器人挂一个查订单、查天气的接口方案二最合适。如果目标是做一个能自主规划任务的 Agent比如“帮我查三个城市天气并比较”那就直接上方案三。方案一更多是用来理解原理的敲门砖。5.2 面试官常追问的几个问题我在那场面试里被追问了好几个问题有些当场答了有些是回来后补全的这里一起列出来。问模型输出了一个不存在的工具怎么办答工具注册表里找不到就抛错然后把“可用工具有哪些”作为 Observation 反馈给模型让它重新生成新请求。不要把异常直接抛给用户。问工具参数用户没给全怎么办答先在校验层判断缺哪些必填参数再通过一条 Observation 让模型向用户追问而不是强行用空参数调用函数。问工具执行有副作用怎么办答生产环境里要做权限分级、限流、审计日志。手写代码时应强调所有工具调用必须走一个统一的 execute 入口在入口处加白名单和超时控制。问如果工具返回结果不稳定比如两次返回不一样怎么办答可以在模型决策前对工具结果做缓存或者把工具返回的原始数据直接透传给模型尽量不提前加工避免二次失真。这些问题没有标准答案但能反映你是否真正接触过线上系统的工具调用。5.3 面试复盘我最终为什么这样回答手写这三种方案不是为了炫技而是想展示同一个问题在不同约束下的解法。面试官问的是“写一个 Tool Use”但真正在评估的是“你能不能从第一性原理出发拆解链路并且知道每种方案的边界”。我在白板上写完后跟面试官说了一句话“如果只要求现场演示方案一足够如果要上线我会选方案二如果要做 Agent方案三是起点。”这句话基本把我对 Tool Use 的完整认知讲完了。6. 手写 Tool Use 时最容易踩的五个坑以及我的规避习惯6.1 工具描述写得太模糊模型会乱选工具第一个坑是系统提示词里的工具说明太随意。比如只写“get_weather: 获取天气”模型很难理解这个工具到底返回什么、适合什么场景。结果可能用户问“城市A适不适合穿短袖”模型选择查询气温工具但工具返回的字段里根本没有体感温度最终回答就很空。我现在的做法是给每个工具写一段“场景说明”什么时候用、什么时候不用、参数含义、返回结构、潜在错误。比如get_weather: 当用户询问天气、温度、降水、风速时使用。 参数 city: 中文城市名必填。 返回: {temperature: float, condition: string, wind: string} 注意本工具不能预测未来一周天气如用户要求预报请结合其他工具。这段描述同样会注入系统提示词模型选择工具的准确率会明显提升。6.2 只校验工具是否存在不校验参数类型第二个坑是执行器里只查了工具名if tool_name not in tool_map但参数的个数和类型没有校验。模型输出{city: 12345}时函数直接收到一个数字很容易在内部触发类型错误而且错误信息不直观模型自己也不知道怎么修。我的做法是在execute_tool入口统一做一次轻量校验按照ToolSpec.parameters里的 schema逐字段检查 required、type、enum。校验失败就返回一条标准错误 Observation比如“参数 city 必须是字符串当前收到的值是整数”。这样模型能根据明确反馈修正请求而不是面对一个调用栈发呆。6.3 把工具原始返回直接丢给用户第三个坑是把工具返回的 JSON 原封不动当最终回答。工具返回往往是{temperature: 32, condition: 晴, wind_level: 3}用户看到的是乱码一样的结构阅读体验极差。正确做法是先让模型做一步“结果转写”把工具返回作为上下文让模型用口语化语言回答用户。这一步相当于第二次模型调用成本看起来翻倍了但用户体验好得多。而且如果工具返回出错模型还能主动转成“天气服务暂不可用”的抱歉话术。6.4 忽略了工具返回的 Token 消耗第四个坑是工具返回大数据时直接把整个 JSON 塞回上下文。比如查数据库返回了 200 行记录不加截断地塞给模型轻则超出上下文窗口重则每次调用费用飙升。我现在会在工具执行和回传之间加一道摘要层。规则很简单超过大约 500 个字符的返回先截取前半段关键字段再附一句“其余 N 条记录已省略”。模型需要更多细节时可以再次调用同一个工具而不是一次把全部数据都灌进去。这个习惯在成本敏感的生产环境尤其重要。6.5 没有给循环设置终止条件第五个坑也是 ReAct 方案里最严重的是循环没有步数上限。模型会反复输出“Action: get_weather”每次拿到同样的 Observation又回到同样的 Thought形成死循环。没有终止条件的 Agent在线上就是一笔无限增长的 token 账单。我的经验是同时加两道保险一道是max_steps比如最多 8 轮另一道是重复检测如果连续两轮 Action 和 Action Input 完全相同说明模型在打转直接终止并返回当前结果或者默认兜底话术。两道保险同时生效才能避免线上事故。结尾后来我收到了这家公司的二面通知虽然最后因为岗位方向的问题没有继续聊下去但“手写 Tool Use”这道题成了我简历里反复咀嚼的一个案例。现在做 AI 应用时我更习惯先画一条调用链路图再决定用方案几单步工具调用就用 JSON 方案多步推理就上 ReAct复杂度再高就考虑引入记忆和规划器。最后再分享一个小技巧。以后准备这类面试别只看 function calling 的 SDK 文档。新建一个空文件不引任何框架用裸requests调模型的接口从系统提示词写起一步一步把工具调用的五个环节跑通。这个过程比读十篇教程都管用。我面完之后回去把代码补成了一个小工具库后来在公司的智能问答项目里从方案二迁移到方案三只改了调度层工具注册表原封不动。这就是手写链路带来的回报。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑