ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

手写最小 ReAct 循环:看透 Agent 工具调用底层机制

手写最小 ReAct 循环:看透 Agent 工具调用底层机制 上周有个做后端的同事跑来问我Agent 里的工具调用到底是怎么转起来的。他的项目已经用上了某个封装得很厚的 Agent 框架能跑通 demo但一旦模型不按套路出牌、或者工具返回了脏数据他就完全不知道该从哪下手。我给他的建议是先把框架放一边拿三十行代码手写一遍 ReAct 循环写完再回头看那些库基本一眼就通。ReAct 这个词拆开就是 Reasoning 加 Acting说白了就是让模型先想一步、再动手一步、看完结果接着想。它听着玄剥开之后核心就是一个 while 循环加上几段格式约定和几个正则。真正难的不是循环本身而是边界情况模型输出格式跑偏了怎么办工具名编造了怎么兜参数类型不对怎么救循环停不下来怎么掐。这些东西恰恰是框架帮你藏起来、也是你在实际排障和面试里最容易被追问的部分。这篇东西适合三类人看刚接触 Agent 工具调用、想搞懂底层机制的新手用着框架但遇到问题不知道怎么排查的同学以及要自己搭一套轻量编排逻辑、不想背一整个框架的人。下面我不讲虚的从头把一个能跑通的 ReAct 循环拼出来顺带把每一步为什么这么写讲清楚。1. 为什么建议先手写一遍再去看 Agent 框架1.1 框架帮你封装了什么又藏起了什么先说框架的好话。成熟的 Agent 框架确实解决了一堆脏活提示词模板管理、多轮上下文拼接、工具 schema 自动生成、并发调用、重试与降级、流式输出、可观测性埋点。你接上一个模型 key注册几个函数它就能把一个能用的 Agent 跑起来。对于业务方来说这些封装省下来的时间是真金白银。但问题也在这儿。封装层次一多出了问题你看到的现象和真正的病因之间就隔着好几层。模型没调用工具可能是提示词被框架改写过了可能是工具的 description 写得太模糊可能是多轮历史把关键指令挤到了上下文末尾也可能是模型本身对这个工具名不敏感。你要是只会看框架的日志很容易在错误的方向上反复试。我自己的经验是手写一遍之后你对“模型看到的到底是什么”会形成肌肉记忆。你会知道上下文里每一段来自哪里会条件反射地去打印真正发给模型的完整 prompt而不是盯着抽象层给你的那点摘要。这个习惯能省下的 debug 时间远超你手写那几十行代码的成本。1.2 ReAct 的最小闭环长什么样把 ReAct 压缩到最简它其实就四件事在循环把用户问题、可用工具说明、已经发生的历史拼成一段文本发给模型模型输出一段带有 Thought 和 Action 的文本你从这段文本里把 Action 和 Action Input 抠出来去执行对应的函数把函数的返回值作为 Observation 追加回历史进入下一轮。用伪代码写出来大概是这个骨架scratchpad while step max_steps: prompt 系统说明 工具列表 用户问题 scratchpad output 调用模型(prompt) action, action_input 解析(output) if action finish: return action_input observation 执行工具(action, action_input) scratchpad output \nObservation: observation \n就这么多。没有魔法没有隐藏状态机。你把这个跑通再去看任何 Agent 框架的源码都能对上号它无非是在这四步里各自加了一层“让工程更稳”的壳。1.3 手写一遍能省下哪些 debug 时间我踩过的典型场景是这样的某次线上 Agent 忽然开始反复调用同一个查询工具三轮下来都没给答案。当时用的是框架日志里只看到“tool call repeated”看不出原因。后来我自己写了个最小复现把完整 prompt 打出来才发现工具返回的内容里有换行和引号被直接塞进历史后把后面 Observation 的格式给污染了模型误以为上一轮还没结束于是又调了一次。这种问题你不手写一遍是碰不到的因为框架会自动帮你做清洗反而是清洗规则本身出了偏差。手写的时候你会亲手决定“Observation 要不要截断”“要不要转义”“要不要保留原始换行”这些决定点的存在感极强也最容易积累成你自己的经验。提示如果你已经在用框架最省事的排查手段仍然是打印完整 messages 数组。绝大多数“模型不听话”最后都能在这段文本里找到原因。2. ReAct 循环的四个核心部件拆解2.1 提示词模板格式契约才是命根子很多人以为 ReAct 的难点在工具实现其实提示词模板里的格式契约才是最容易翻车的地方。模型是概率生成的你给它一个宽松的格式它就会给你五花八门的输出有时候 Action 写成“行动”有时候 Action Input 忘了加花括号有时候把 Thought 和 Action 挤在同一行。我一般会固定三件事。第一明确告诉模型每一步只能输出一个 Thought 和一个 Action不允许一次给多个。第二明确 Action 必须从给定工具名列表里选把列表名直接写进提示词里。第三给一个 finish 的出口让模型知道什么时候可以停而不是被工具列表牵着一直调下去。另外stop 序列值得单独说。如果你用的是文本解析方案把\nObservation:加进 stop 参数非常有用它能让模型在写完 Action Input 后自动刹车不会自作主张地幻想出一个 Observation 来续写。这一条我在实际项目里几乎是必配的能直接砍掉一大类“模型自己编结果”的诡异现象。2.2 工具注册表从函数签名到给模型看的说明工具注册表承上启下。对上它要能生成一段人类和模型都能读懂的说明对下它要能真的把参数传进去执行。最省事的做法是用装饰器注册把函数本身、描述、参数签名一起存下来。这里有个细节值得强调模型的“工具选择能力”几乎完全取决于你写的 description。描述写得太抽象模型就会漏调或误调。我的写描述原则是“动词加对象加边界”比如“查询指定城市当前的天气情况参数为城市中文名”而不是“天气工具”。前者模型一看就知道什么时候该用后者就只能靠猜。参数类型也要尽量收敛。能用一个字符串参数解决的别设计成复杂的嵌套对象。模型生成嵌套 JSON 的出错率明显高出一截尤其是层级超过两层之后少一个大括号整个解析就崩了。2.3 解析器把自由文本变成可执行调用解析器是整条链路上最“脏”的一环因为它要处理的是模型的自由输出。我的原则是能宽容就宽容但宽容要有上限。具体做法上正则不要写得太死板。Action 后面的冒号中英文都要兼容Action Input 后面可能跟一段 JSON也可能跟一个裸字符串。解析失败时不要立刻抛异常终止而是把一条“格式错误请重试”的提示塞回历史让模型自己纠正。实测下来模型在收到明确纠正提示后第二次输出合规格式的概率相当高。还有一种情况是参数类型幻觉比如工具要的是整数模型给了3这种字符串。这种情况下与其在解析层做复杂的类型推断不如在工具执行层做一次温和的强制转换转换失败再返回一条结构化的错误说明。让错误信息本身成为模型下一轮的输入这是 ReAct 循环自带的纠错能力别浪费。2.4 循环控制器终止条件与死循环防护循环控制器管三件事什么时候停、最多跑几轮、异常怎么收场。终止条件要分两种。正常终止是模型主动调用 finish返回答案。异常终止包括达到最大步数、连续两轮调同一个工具且参数相同、解析连续失败超过阈值。后面这两种我强烈建议加上因为实际跑起来模型偶尔会陷入“调工具、看不懂结果、再调一次同样的工具”的死循环你不掐它它就一直在烧 token。最大步数我一般设 8 到 12。设太小稍微复杂一点的多跳问题就跑不完设太大真出问题时等你发现已经烧了不少钱。这个值没有标准答案得看你任务的复杂度和工具粒度我的建议是先设 8观察实际任务的平均步数再往上留 50% 的冗余。3. 从零实现一个能跑通的 ReAct Agent3.1 环境准备与依赖选择这部分我尽量克制只依赖requests和标准库方便你把注意力放在循环本身。模型侧你随便接一个兼容 OpenAI 接口的网关就行本地的、云端的都可以这里用通用的 endpoint 和 key 占位。选requests而不是某个 SDK理由很直接你要清楚地看到自己发出去的 JSON 长什么样。SDK 往往会帮你塞一些默认参数比如默认的 system 消息、默认的 tools 字段这些在排查问题时反而是干扰项。import json import re import inspect import requests LLM_ENDPOINT https://your-llm-gateway/v1/chat/completions LLM_KEY sk-xxxx MODEL_NAME your-model def call_llm(messages, temperature0.0): resp requests.post( LLM_ENDPOINT, headers{ Authorization: fBearer {LLM_KEY}, Content-Type: application/json, }, json{ model: MODEL_NAME, messages: messages, temperature: temperature, stop: [\nObservation:], }, timeout60, ) resp.raise_for_status() return resp.json()[choices][0][message][content]温度设 0 是为了让工具调用这类结构化任务更稳定。有人喜欢留一点随机性让表达更自然但那是用在最终回答生成阶段的事在“选哪个工具”这一步上确定性比创意重要得多。3.2 工具定义与 JSON Schema 生成工具用装饰器注册顺便把函数签名存下来后面生成说明的时候直接读。TOOL_REGISTRY {} def tool(name, description): def decorator(fn): TOOL_REGISTRY[name] { name: name, description: description, fn: fn, signature: inspect.signature(fn), } return fn return decorator tool(get_weather, 查询指定城市当前的天气情况参数是城市中文名) def get_weather(city: str) - str: fake {杭州: 26摄氏度多云, 北京: 31摄氏度晴} return fake.get(city, f{city}暂无数据) tool(calc, 计算一个不含变量的四则运算表达式例如 (128)*3) def calc(expression: str) - str: if not re.fullmatch(r[0-9\-*/().\s], expression): return 表达式包含非法字符只允许数字和 - * / ( ) return str(eval(expression)) # 演示用生产请换成安全的表达式解析库calc这里用eval只是为了让示例短一点。真实项目里永远不要直接 eval 模型给的字符串换成专门的表达式解析库或者干脆只暴露几个固定业务函数。工具的手动校验是最后一道闸门别指望模型永远守规矩。生成给模型看的工具说明时把参数名和类型也带上模型对参数名的敏感度其实挺高。def render_tools(): lines [] for meta in TOOL_REGISTRY.values(): parts [] for pname, param in meta[signature].parameters.items(): anno param.annotation type_name anno.__name__ if anno is not inspect._empty else str parts.append(f{pname}: {type_name}) sig , .join(parts) lines.append(f- {meta[name]}({sig}): {meta[description]}) return \n.join(lines)3.3 提示词构造把工具描述塞进上下文提示词模板我一般写成三块角色与格式约定、工具列表、历史记录。放到 system 里还是 user 里效果差异不大但放 system 更符合语义也方便某些网关做缓存。SYSTEM_TEMPLATE 你是一个可以调用工具的助手请严格按格式思考并行动。 可用工具 {tool_desc} 每一步只能输出一个 Thought 和一个 Action格式如下 Thought: 你的推理过程 Action: 工具名必须是 [{tool_names}] 之一 Action Input: 调用工具的参数JSON 对象 当你能回答用户问题时使用 Thought: 我已经可以作答 Action: finish Action Input: {{answer: 最终答案}} 历史记录 {scratchpad} def build_messages(question, scratchpad): system SYSTEM_TEMPLATE.format( tool_descrender_tools(), tool_names, .join(TOOL_REGISTRY), scratchpadscratchpad if scratchpad else 暂无, ) return [ {role: system, content: system}, {role: user, content: f用户问题{question}}, ]注意Action Input: {{answer: ...}}里的双花括号这是str.format的转义写法不加会直接报错。这个坑我第一次写的时候也踩过。3.4 主循环与解析器实现解析器要宽容主循环要能兜底。两者配合起来循环的鲁棒性就上来了。ACTION_RE re.compile(rAction\s*[:]\s*(.)) INPUT_RE re.compile(rAction\s*Input\s*[:]\s*(.), re.S) def parse_step(text): action_match ACTION_RE.search(text) if not action_match: return None, None action action_match.group(1).strip().splitlines()[0].strip() input_match INPUT_RE.search(text) if not input_match: return action, None raw input_match.group(1).strip().splitlines()[0].strip() try: return action, json.loads(raw) except json.JSONDecodeError: try: return action, json.loads(raw.replace(, )) except json.JSONDecodeError: return action, None主循环里每个失败分支都要往 scratchpad 里写一句可读的提示这是循环自我修复的关键。MAX_STEPS 8 def run_agent(question, max_stepsMAX_STEPS): scratchpad for _ in range(max_steps): messages build_messages(question, scratchpad) output call_llm(messages).strip() scratchpad output \n action, action_input parse_step(output) if action is None: scratchpad Observation: 格式错误请严格输出 Action 与 Action Input。\n continue if action finish: if isinstance(action_input, dict): return action_input.get(answer, ) return str(action_input) meta TOOL_REGISTRY.get(action) if meta is None: scratchpad fObservation: 工具 {action} 不存在可用工具为 {list(TOOL_REGISTRY)}。\n continue try: if isinstance(action_input, dict): observation str(meta[fn](**action_input)) else: observation Action Input 必须是 JSON 对象 except TypeError as e: observation f参数不匹配{e} except Exception as e: observation f工具执行异常{type(e).__name__}: {e} scratchpad fObservation: {observation[:800]}\n return 已达到最大步数限制未能得到最终答案。observation[:800]这个截断是有意的。工具返回的内容经常很长比如一篇文章或者一大段 JSON全塞回去会迅速吃掉上下文预算也会让模型抓不住重点。800 个字符对大多数查询类工具足够用了如果你确实需要传大块数据更好的做法是切成文件让模型按需检索。3.5 跑一次完整轨迹的拆解拿“杭州今天多少度顺便算一下 26 减 8 乘 2”这个问题跑一遍看它在历史里长什么样。Thought: 用户问杭州天气我调用 get_weather Action: get_weather Action Input: {city: 杭州} Observation: 26摄氏度多云 Thought: 温度是26度还需要计算 26-8*2调用 calc Action: calc Action Input: {expression: 26-8*2} Observation: 10 Thought: 两个信息都齐了可以作答 Action: finish Action Input: {answer: 杭州今天26摄氏度多云26-8*2 的结果是 10。}整个过程三轮结束看着很顺。但这只是顺境。现实里模型可能第一轮就漏掉 Action Input也可能把calc的参数写成{expr: ...}这些才是你真正要花时间处理的场景。所以我一直强调ReAct 的价值不在这段漂亮轨迹而在循环对错误轨迹的恢复能力。4. 真实排障记录那些框架不会告诉你的坑4.1 模型死活不按格式输出怎么办这是新手最先遇到的问题。模型输出得很“聪明”但就是不带 Action 这一行。原因通常有三个提示词里格式说明不够靠前、工具描述和用户问题对不上、或者温度开太高。我的排查顺序是先看完整 prompt确认工具列表真的拼进去了再把格式约定挪到 system 的最前面用最强的语气写清楚“必须”最后把温度降到 0。这三步下来百分之九十的格式问题都能解决。剩下那些多半是模型本身能力不够硬扛不如换一个对指令跟随更好的模型。还有一种隐蔽情况是 stop 序列设错了。如果你把 stop 设成了Observation而没有前面的换行模型可能在正常输出里包含这个词就被截断了导致 Action Input 只写了一半。这种问题特别难查因为看起来像是模型自己写崩了。4.2 工具调用参数幻觉与类型错误模型编参数这件事太常见了。你要city它给你city_name你要expression它给你expr。根本原因还是参数名的语义不够直白或者工具描述里没把参数名写清楚。我的做法是在工具描述里把参数名原样带上比如“查询指定城市当前天气参数 city 为城市中文名”。这样模型在看到city这个词的时候会更容易直接复用。另外一个兜底技巧是在TypeError分支里把出错信息写得更具体比如“缺少参数 city”而不是抛出原始异常。模型看到明确的缺失字段名下一轮补上的概率会高很多。类型错误也是同理。需要一个整数它给字符串这时候在工具内部做一次int(...)尝试比在解析层写复杂逻辑更划算。转换失败就返回“参数 expression 需要整数”这样的提示让模型再试一次。4.3 无限循环与 token 爆炸死循环一般有两种形态。一种是重复调用同一个工具、同一个参数另一种是解析一直失败模型一直在重写格式。针对第一种我会维护一个最近两轮的(action, action_input)记录如果完全一致就直接在 scratchpad 里加一句“你已经用相同参数调用过该工具请注意总结已获得的信息”。这比硬性终止更友好很多时候模型看到这句就开窍了。针对第二种连续三次解析失败就直接退出返回一个诚实的失败提示别一直空转烧钱。token 爆炸还有另一个来源历史无限增长。多轮之后光 scratchpad 就能把上下文撑满。解决办法不止是截断 Observation还可以定期把早期轮次压缩成一句摘要比如“前三轮已查到天气和计算结果”。这段压缩逻辑放在循环里比事后补救管用得多。4.4 常见问题速查表下面这张表是我自己排障时用得最多的按现象查原因基本能覆盖日常八成的坑。现象可能原因处理方式不输出 Action格式说明靠后、温度过高格式前置、温度设 0Action Input 解析失败模型输出单引号或多余文本做引号兼容、加 stop 序列反复调同一工具工具结果没被理解追加提醒语、限制重复工具名不存在提示词未列出工具名Action 强制从列表选参数类型不匹配模型自由生成工具内轻转换、返回明确错误上下文超长历史无限增长截断 Observation、压缩历史模型自己编 Observation未设 stop把\nObservation:加入 stop注意上面每一条都不是孤立存在的实际排查时优先怀疑“模型看到的提示词和你以为的不一样”这一步能解决绝大多数玄学问题。5. 让循环从“能跑”到“能上生产”的几处改造5.1 用原生函数调用替代文本解析前面那套文本解析方案好处是通用、透明、任何模型都能用坏处是脆弱。现在很多模型网关都支持原生的工具调用协议模型会返回一个结构化的tool_calls字段而不是让你去抠文本。这条路的好处是解析几乎不会出错参数已经是合法的 JSON你只需要按function.name去查表调用就行。代价是绑定模型能力换个不支持该协议的模型就用不了。我的折中方案是两套都留着优先走原生工具调用网关不支持时自动降级到文本解析。这样既拿了稳定性又保了兼容性。5.2 记忆、并发与错误重试如果工具本身是幂等的而且彼此独立其实可以做并行调用。比如用户同时问三个城市的天气你可以一次让模型返回三个工具调用并发执行后再把三份 Observation 一起塞回去。这里要留意的是并发的 Observation 顺序要和模型请求的顺序对应不然模型会对错号。错误重试也要分层次。工具层的网络抖动可以在工具内部重试两三次模型层解析失败靠循环自身纠正如果连续多轮都没进展就果断终止把已有的中间结果整理成一段“我查到了这些但没能完成任务”的回复这比一个空洞的报错体验好得多。记忆这块短期记忆就是 scratchpad长期记忆则需要外部存储。我的经验是别在没有明确需求的时候硬加长期记忆它带来的检索噪声和上下文污染问题往往比它解决的问题还多。先把单轮任务做扎实再考虑跨会话记忆。5.3 可观测性与评测怎么做Agent 上线之后最怕的是“感觉不太好用”这种模糊反馈。解决办法是把每轮的完整 prompt、模型输出、解析结果、工具入参、工具返回值全部落库形成一条完整的调用轨迹。有了这些数据你才能回答“是模型选错了工具还是工具返回不准”这类问题。评测方面我会准备一批带标准答案的小任务集比如十道需要一到两次工具调用的题目每次改动提示词或换模型就跑一遍看通过率和平均步数。通过率掉了一定是改坏了步数涨了说明提示词变得啰嗦了。这套东西不难搭但它能让你在改动时有据可依而不是靠感觉拍脑袋。我自己在项目里还养成了一个小习惯就是给每个工具都写一个“什么时候不该用”的负面说明塞进 description 里。比如“查询实时天气不要用于历史气候问题”。加了这句之后工具误调率肉眼可见地下降尤其是在工具数量超过五个之后效果更明显。
返回列表