上周有个做后端的同事跑来问我,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 Agent
3.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, temperature=0.0): resp = requests.post( LLM_ENDPOINT, headers={ "Authorization": f"Bearer {LLM_KEY}", "Content-Type": "application/json", }, json={ "model": MODEL_NAME, "messages": messages, "temperature": temperature, "stop": ["\nObservation:"], }, timeout=60, ) 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", "计算一个不含变量的四则运算表达式,例如 (12+8)*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_desc=render_tools(), tool_names=", ".join(TOOL_REGISTRY), scratchpad=scratchpad if scratchpad else "(暂无)", ) return [ {"role": "system", "content": system}, {"role": "user", "content": f"用户问题:{question}"}, ]注意Action Input: {{"answer": ...}}里的双花括号,这是str.format的转义写法,不加会直接报错。这个坑我第一次写的时候也踩过。
3.4 主循环与解析器实现
解析器要宽容,主循环要能兜底。两者配合起来,循环的鲁棒性就上来了。
ACTION_RE = re.compile(r"Action\s*[::]\s*(.+)") INPUT_RE = re.compile(r"Action\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_steps=MAX_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 += f"Observation: 工具 {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 += f"Observation: {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 | 格式说明靠后、温度过高 | 格式前置、温度设 0 |
| Action 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 里。比如“查询实时天气,不要用于历史气候问题”。加了这句之后,工具误调率肉眼可见地下降,尤其是在工具数量超过五个之后,效果更明显。