手写Tool Use的三种实现:从ReAct到Function Calling再到约束解码

发布时间:2026/10/22 19:04:27
手写Tool Use的三种实现:从ReAct到Function Calling再到约束解码 面试官把题目推到屏幕上手写一个 Tool Use。我第一反应是这不就是调一下 function calling 接口吗结果对方跟了一句不能用现成框架也别只贴请求封装我要看到完整闭环。这句补充把题目从API 拼装直接拉升到协议设计。我停了大概两秒在脑子里把工具调用的链路过了一遍然后当场给出了三种实现先写一个不依赖任何厂商约定的朴素闭环ReAct 标签解析再升级到原生 Function Calling最后用约束解码把参数生成变成文法问题。面完出来我自己复盘发现这个过程特别适合拿出来聊——它不只是一道面试题也基本覆盖了 Tool Use 从原型到生产的全部形态。这篇文章我把现场写的三个方案都展开成可运行的代码连同当时的思考路径一起讲清楚。按你自己的基础取用如果你刚接触 Agent 开发重点看方案一的闭环逻辑如果你日常调大模型 API方案二会帮你补上消息顺序的细节如果你在折腾本地小模型方案三值得反复看。1. 面试题拆解这道手写 Tool Use真正要考的三层能力1.1 一句话说清 Tool Use 的本质Tool Use工具调用不是让模型会写函数而是让模型输出一个可执行的意图再由外部代码去真正执行。完整链路是这样的用户提出需求比如北京今天天气怎么样。模型判断自己不知道天气数据但发现有一个get_weather工具于是输出我要调用 get_weather参数 city北京。我们自己的代码拿到这个结构去请求天气服务。执行结果北京多云18~26℃作为一条新消息回传给模型。模型基于真实数据组织语言回答用户。这个链路里模型只负责决定 描述真正干活的是第 3 步的外部代码。可以打个比方模型像是一个只写菜单的厨师后厨系统负责按菜单出菜出菜结果再反馈给厨师调整后续做法。没有第 4 步菜单写得再漂亮也没有意义。1.2 面试官不说的潜台词从会用 API到能造轮子这道题表面上是考 Tool Use实际上考的是三层能力请求层会不会构造 messages、tools 参数。这是大多数人的舒适区。协议层知不知道 assistant 的tool_calls消息要原样回传tool 角色的消息要带tool_call_id顺序还不能乱。这一层刷掉一批只会套框架的人。机制层懂不懂为什么参数必须是合法 JSON这件事本质上是由解码过程保证的而不是靠提示词哀求。面试官加的那句我要看到完整闭环就是想把考生钉在协议层和机制层。我现场给自己定的标准是每个方案都要能跑通用户提问 → 模型给出工具调用 → 执行 → 结果回填 → 模型总结这条回路缺一步都不算完成。2. 方案一ReAct 标签解析先把闭环跑通再说2.1 为什么第一版选择 ReAct 思路ReAct 是 Reasoning Acting 的缩写核心做法是用文本约定让模型输出固定的思考/行动格式。它不依赖任何厂商协议只要你用的聊天模型能听话就能跑。面试现场写这种方案有一个很大的好处它只需要提示词 字符串解析两样东西任何模型都能兜底。当时我的策略是先证明我理解闭环再把方案升级到更稳定可靠的形态。提示词里约定模型在需要调用工具时输出一段带标签的内容例如action{name: get_weather, args: {city: 北京}}/action外部代码负责把这段标签里的 JSON 提取出来执行对应工具然后把结果拼成一条 Observation 回填给模型循环往复。2.2 最小可用的解析器与主循环下面这是我在现场写的思路补全成了完整代码。为了演示先定义两个模拟工具import json import re TOOLS { get_weather: 查询指定城市实时天气参数 city: 城市名, calc: 执行四则运算参数 expr: 表达式字符串, } def run_tool(name: str, args: dict) - str: if name get_weather: return f【模拟】{args[city]} 今日多云18~26℃ if name calc: # 仅用于演示生产环境绝对不要用 eval return str(eval(args[expr])) return f未知工具: {name}然后是核心的提取函数。模型输出的内容可能有多余前后缀所以用正则先把action标签里的文本抠出来再做 JSON 解析def extract_action(text: str) - dict | None: m re.search(raction(.*?)/action, text, re.S) if not m: return None raw m.group(1).strip() try: return json.loads(raw) except json.JSONDecodeError: # 常见翻车模型把 JSON 包在 json 代码块里 cleaned raw.replace(json, ).replace(, ).strip() return json.loads(cleaned)最后是主循环。注意每次模型输出后我们都要把它的原始回复原样追加进 messages再把工具执行结果以 Observation 形式追加进去这样模型才能看到自己刚才的调用和结果def build_loop_prompt(user_input: str) - str: tool_docs \n.join(f- {name}: {desc} for name, desc in TOOLS.items()) return ( 你是一个智能助手。需要调用工具时必须严格输出\n action{name: 工具名, args: {参数名: 参数值}}/action\n 可用的工具\n tool_docs \n用户问题 user_input ) def react_loop(user_input: str, max_steps: int 5): messages [{role: user, content: build_loop_prompt(user_input)}] for _ in range(max_steps): reply chat_completion(messages) # 任意兼容 chat API 的方法 messages.append({role: assistant, content: reply}) action extract_action(reply) if action is None: return reply # 没有工具调用说明模型直接给了答案 result run_tool(action[name], action[args]) messages.append({role: user, content: fObservation: {result}}) return 达到最大迭代次数结束。这里有个值得注意的点工具执行结果我用的是role: user回传而不是专门的 tool 角色。因为 ReAct 本质上是纯文本协议模型并不认识tool这种消息角色把 Observation 放进 user 消息里反而最保险。这也是方案一和方案二最大的差异之一。2.3 这套方案的隐痛ReAct 能跑通但我在实际项目里反复用过之后对它有两个明确的抱怨第一格式稳定性差。模型偶尔会在 JSON 外套代码块或者在action前面加一句好的我来帮你查询。解析器虽然做了容错但容错本身就是在跟模型的不确定性搏斗。第二Token 开销大。每次循环都要把工具描述重新放进提示词模型还要输出 Thought/Action 一串内容在长对话里成本很扎眼。面试现场写到这里我的判断是方案一证明了基础闭环但稳定性撑不起生产。所以下一步我主动说我换个更工业化的做法顺势切到了方案二。3. 方案二原生 Function Calling让模型自己输出合规工具调用3.1 原生 Function Calling 的调用协议长什么样面试官听到我要写 Function Calling 时其实有点期待我直接背 API。但我没急着写请求而是先讲清楚一件事原生方案和 ReAct 的根本区别在于参数合法性不再靠解析器兜底而是由服务端在解码阶段就约束出来。模型返回的tool_calls里function.arguments原则上应当是一段可直接解析的 JSON 字符串。请求端的关键是tools参数。每个工具都要带上名字、描述和 JSON Schema 格式的参数定义from openai import OpenAI client OpenAI() tools [{ type: function, function: { name: get_weather, description: 查询指定城市的实时天气, parameters: { type: object, properties: { city: {type: string, description: 城市名} }, required: [city] } } }] def call_once(messages): resp client.chat.completions.create( modelgpt-4o-mini, messagesmessages, toolstools, ) return resp.choices[0].message这段代码里最容易忽略的是description字段。schema 只约束参数类型真正引导模型什么时候该调这个工具的是描述文本。描述写得含糊模型就会在不需要的时候乱调描述写得过于僵硬模型又会该调不调。这是我在生产里调过很多轮的教训。3.2 手写多轮闭环assistant 消息和 tool 消息的顺序纪律原生方案的主循环比 ReAct 多了一条铁律assistant 返回的tool_calls消息必须原样追加回 messages然后按顺序追加对应的 tool 角色结果消息。顺序一旦错乱服务端会直接报错。完整闭环如下import json def tool_loop(user_input: str, max_steps: int 5): messages [{role: user, content: user_input}] for _ in range(max_steps): msg call_once(messages) if not msg.tool_calls: return msg.content # 第一步assistant 的完整消息原样回传 messages.append(msg) # 第二步按 tool_call_id 逐个回传执行结果 for tc in msg.tool_calls: args json.loads(tc.function.arguments) result run_tool(tc.function.name, args) messages.append({ role: tool, tool_call_id: tc.id, content: result, }) return 达到最大迭代次数结束。这里的messages.append(msg)很多人会写错成只追加{role: assistant, content: msg.content}。如果这样写tool_calls信息就丢了服务端无法把 tool 结果和对应的调用 ID 对上。我见过不止一个同事在这里踩坑应用的 Agent 一直调用了工具但模型看不见结果。另外注意tool_call_id必须严格对应。如果模型一次请求里同时发起两个工具调用比如查天气又算温度你回传结果时把 ID 搞混了模型会基于错配的数据继续推理整个对话就直接歪了。3.3 原生方案没说的三个限制方案二稳定但它不是免费的午餐。我实际用下来有三点体会被厂商协议绑住换一个不严格支持tools参数的模型服务这套代码就得重写。许多本地推理服务虽然宣传兼容 function calling实际返回的arguments偶发不合法依然要靠兜底。Schema 设计影响巨大required字段加得太多模型会因为参数凑不齐而频繁拒答加得太少模型又会漏传关键参数。这是调优阶段最磨人的事。调试黑盒化模型为什么选了这个工具而不是另一个天然不可解释。排查问题时只能靠调 description 和看 log没有太多抓手。面试问答到这个环节我已经把协议层的知识展示得差不多了。但我不想停在会用 API的层面于是主动加了第三种方案把话题拉到机制层。4. 方案三约束解码从 Token 层面保证 Tool Use 永不语法失控4.1 换个角度看问题Tool Use 本质上是带约束的结构化生成面试官听到我说还有第三种方案时表情明显认真了起来。第三种方案的主线是无论 ReAct 还是 Function Calling模型输出参数本质上都是在做一次自由文本生成只是我们在事后用解析器把意外写坏的文本扳回正轨。那能不能在生成阶段就不让它写坏这就是约束解码constrained decoding的核心思路。它在大模型每一步预测下一个 token 时用一个有限状态机去屏蔽掉非法 token。比如当前正在生成一个 JSON 对象的 key此时模型想吐出、{、字母之外的字符解码器直接把这些候选概率置为 0只允许合法 token 参与采样。一句话类比普通生成是随便写写完再纠错约束解码是每次落笔都只能写在表格的框里非法路径从一开始就不存在。4.2 一个能体现思路的约束解码示例这类能力在很多推理引擎里有现成实现比如通过 JSON Schema 编译成文法再接在采样器后面。以下是用开源方式调用本地小模型做约束解码的概念示例import outlines model outlines.models.transformers(Qwen/Qwen2.5-3B-Instruct) tool_schema { type: object, properties: { name: {type: string, enum: [get_weather, calc]}, arguments: {type: object} }, required: [name, arguments] } generator outlines.generate.json(model, tool_schema) result generator(北京今天天气怎么样) print(result) # 输出一定是形如 {name: get_weather, arguments: {...}} 的合法 JSON这种方式的优势是肉眼可见的输出的每一个 token 都在 schema 许可范围内arguments 的 JSON 语法错误被结构性消灭。在本地小模型上做 Agent这是目前我认为最稳的路线之一。你甚至可以让name字段的enum直接等于你系统里注册的工具表模型想编造一个不存在的工具名都做不到。不过要说明白现场写方案三时我不会真的把整个解码器代码默写出来——面试官要的是思路和边界感。我当时的讲法是如果模型服务开放 logits 或支持 grammar 参数我会用约束解码保证输出合法如果不开放我会退而求其次用原生 Function Calling 加一个严格的二次校验层。4.3 约束解码的代价不是万灵药约束解码听起来完美但实际落地有三笔账单性能损耗每一步解码都要跟状态机做匹配schema 越复杂这个匹配开销越大。模型输出会明显变慢。实现复杂度自己基于 logits 改造采样器是个大工程中间要处理 tokenizer 的字节级编码问题。生产上通常只能依赖框架能力不敢从头造。兼容性受限托管的大模型 API 基本不开放 logits约束解码只对你能完全控制的本地推理服务适用。所以在生产系统里我的推荐是原生 Function Calling 非法参数二次修正而不是强制上约束解码。约束解码更像一把精密切削刀适合对成本和合规要求极高、且模型完全自部署的场景。5. 三种方案横向对比面试考卷上的最优解其实是分场景的5.1 从稳定性、成本、可控性三个维度看清差异三种方案都写完之后面试官问了一个经典追问如果生产系统只能选一个你选哪个我没有直接回答而是先把三者的差异摆了一张表维度方案一ReAct 标签解析方案二原生 Function Calling方案三约束解码模型兼容性任何聊天模型依赖服务端支持 tools 参数依赖解码器开放采样层参数合法性靠解析器容错服务端尽力保证仍需兜底解码阶段结构性保证Token 成本较高每次循环重述工具低工具描述只在请求里出现低但解码耗时上升实现复杂度最低中等较高调试难度高格式问题隐蔽中等schema 调优磨人低输出稳定但问题难定位适用场景原型验证、多模型兼容托管大模型 API 的生产首选本地小模型、强合规场景看完这张表我给面试官的结论是最优解不存在只有约束条件下最合适的解。如果模型跑在别人家的开放 API 上方案二是唯一的理性选择因为稳定性有服务端背书如果模型是本地部署的小模型方案三能省掉大量参数坏了重试的对话轮次如果只是快速做 Demo 或者需要同时对接多个异构模型方案一反而是最划算的因为它把不确定性统一收编到了一个正则表达式里。5.2 面试追问复盘什么回答最加分这轮对话我复盘了几次最加分的其实不是第三个方案本身而是我主动给出了选择的边界条件。面试官真正想看到的不是你会背几个 API而是你在面对框架甚至协议都不可靠的情况下有没有一套自己的判断标准。当时我还补了一个细节即使选了方案二我也会留一道最后防线。也就是在json.loads(tc.function.arguments)外面套一层异常处理解析失败时把错误信息作为新的 tool 消息回传给模型让它修正参数重新输出。这个小设计让面试官确认我是真正在线上被这个问题毒打过的人而不是只跑过官方 demo。6. 手写 Tool Use 最容易翻车的四个地方与补救手段6.1 方案一翻车点模型不按标签输出或者干脆演完整场戏ReAct 方案最常见的翻车是模型不吐action标签而是直接自信地编一个答案。补救手段有两种一种是在 Observation 回传后追加一句请基于以上结果回答另一种是在提示词里给一个极短的 few-shot 示例让输出标签这个行为变成显式示范而不是抽象要求。更隐蔽的翻车是模型假装调用了工具但没等结果就继续推理。有些模型会在同一个回复里既输出action又输出根据查询结果今天是晴天可观测数据根本没回来。解决方式也很朴实只要检测到action标签就强制丢弃标签之后的所有文本不让模型自问自答。6.2 原生 Function Calling 翻车点arguments 也会不合法方案二最大的认知误区是用了原生 API 就没解析负担了。实际上部分兼容服务和个别模型返回的arguments是截断的、带注释的甚至根本不是 JSON。我处理过最离谱的一例是模型把参数写成了 Python 字典格式True而不是true。我的兜底策略分三层先尝试json.loads。失败后把原始arguments拼进一条提示消息要求模型只输出修正后的 JSON不给任何解释。修正两次仍失败就放弃这次工具调用直接向用户说明工具参数生成失败绝不静默重试到超时。6.3 约束解码翻车点文法与 Tokenizer 的摩擦约束解码也不是零事故。某些模型的分词器会把常见符号切得更碎比如把和后面的字母切到不同 token 里导致状态机匹配时出现预期之外的路径。另外schema 写得过大过深解码速度会肉眼可见地下降用户等得不耐烦。我在小模型上实践后的建议是把工具参数 schema 控制在两层以内复杂对象尽量拆成多个工具而不是一个大 schema同时在解码器外层保留一层 JSON Schema 校验做到双重保险。6.4 通用保命手段迭代上限、错误回传与上下文裁剪不管哪种方案有三件事必须写进代码最大迭代次数。我通常设 4~5 轮超过就终止并把当前对话上下文丢给用户确认。没有上限的 Tool Use 循环遇到模型反复调用同一个工具时会把上下文撑爆账单也吓人。执行异常也回传。工具抛异常不应该让程序崩溃而应该把异常信息格式化成一条Observation或tool消息返回给模型。模型看到错误后往往能自我修正这是 Agent 最有价值的行为之一也是面试时一个很好的加分细节。上下文裁剪。工具返回结果可能非常大直接全量塞进对话会挤压后续消息空间。我会在回传前对结果做截断保留前 1000~2000 字符并加一句结果已截断。这样既保住关键信息又防止长文档把模型淹没。最后再分享一个个人体会手写 Tool Use 这类题目最忌讳一上来就追求最正确的方案。先把 ReAct 这类最简单闭环跑通让整条数据流在眼前动起来再逐层替换脆弱环节——方案一告诉你闭环是什么方案二告诉你产品级稳定性靠什么方案三告诉你底层机制还能怎么加固。这个递进顺序本身比任何一个方案的代码都更能回答面试官真正的问题。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询