☰
手搓最小可用ReAct Agent:从循环到工具调用的完整实践
2026/10/14 8:10:35 网站建设 项目流程

看到rea这个项目代号,我第一反应就是 ReAct(Reasoning + Acting)的缩写。做 AI 应用这段时间,我越来越觉得判断一个智能体靠不靠谱,不在于它“能聊得多流畅”,而在于它能不能真正把任务落地。很多朋友拿着效果不错的模型,却发现让它去处理真实业务时总是翻车:要么答非所问,要么一本正经地编造一个根本不存在的执行结果。

这篇文章我会完整记录一个最小可用 ReAct Agent 的搭建过程,从循环结构、工具注册、动作解析,一直讲到死循环防护和稳定性优化。我尽量把每一步的“为什么这么做”也讲清楚,而不是只丢一段能跑的代码。如果你正准备把大模型从对话框里搬进真实的业务流程,这篇文章应该能帮你少走不少弯路。

1. 为什么 ReAct 能把“会聊天”变成“能干活”

1.1 从“直接回答”到“边想边做”

先聊聊底层逻辑。普通聊天场景下,模型拿到的只有一段 prompt,它必须基于自己记忆里的知识直接生成回答。这种模式应付“总结一段文字”“翻译一句话”没问题,但一旦遇到需要查资料、做计算、改文件这类任务,模型本质上是在“猜答案”,而不是“做事情”。

ReAct 的做法很朴素:把一次回答拆成“思考、行动、观察、再思考”的循环。模型每一步先想清楚自己缺什么信息,然后调用一个工具去拿真实结果,最后把观察结果纳入下一次推理。

我最早跑通这个循环时最大的感受是:模型终于不用“硬编答案”了。比如让它算表达式,它只需要写一句调用 calculator,真正算出结果的是工具,模型只负责决定“该不该算、拿结果做什么”。这就像一个人去餐厅,不需要自己进厨房颠勺,只要会点菜和判断菜好不好吃就行。

1.2 没有工具的时候,模型是怎么“假装完成”的

如果你没试过,可能意识不到这个问题有多严重。我见过不少 demo,让模型“查一下某个配置的真实生效值”,模型会像模像样地写出一段“根据配置,生效值为 xxx”,但那个值完全是从训练数据里拼出来的,根本不反映当前环境。

我用过一个很直接的类比来解释这件事:没有工具的模型,就像一个没有手的人,他可以口述“番茄炒蛋的做法”,但你要让他真的端一盘菜上来,他做不到,而他很可能不会承认自己做不到,反而给你描述出一道色香味俱全的虚拟番茄炒蛋。

ReAct 加入的“观察”环节,就是要逼模型承认“我还没拿到真实数据”。只要模型发现自己的知识覆盖不了当前任务,就必须选择调用工具,而不是继续硬着头皮编。这个机制比单纯在 prompt 里写“请只根据真实信息回答”可靠得多。

1.3 ReAct 的循环长什么样

用大白话描述,就是四步循环:

  1. 模型根据当前目标,说出自己打算做什么、为什么这么做。
  2. 如果还需要信息,就描述一个工具调用动作,并附上参数。
  3. 系统执行工具,把真实结果(或报错信息)当作观察结果返回。
  4. 模型根据观察结果更新判断,继续思考或直接给出最终回答。

这个循环的终止条件是:模型认为自己已经有足够信息,给出最终答案,或者循环超过设置的最大步数。后面会讲到如何用代码实现这套循环,以及哪些地方最容易出幺蛾子。

2. 手搓最小闭环:一个能算数、能查手册的 Agent

2.1 架构先想清楚,省得后面返工

我这个项目的整体结构很简单:模型服务、工具注册表、循环控制器,三块各管各的。

模型服务我选择对接“标准的 chat/completions 接口”,也就是 HTTP 调用一个/v1/chat/completions端点。现在绝大多数商用和开源推理服务都实现这个协议,所以代码里只需要一个call_llm函数,后面换模型等于换环境变量。工具注册表是一个“名称到函数”的字典,外加一份给模型看的工具描述清单。循环控制器负责把模型输出解析成动作、执行工具、塞回上下文。

先看最核心的模型调用函数。我这里尽量用 Python 标准库,避免引入第三方 SDK,方便你在任意环境里复现:

import json import os import re import urllib.request LLM_URL = os.environ.get("LLM_URL", "http://127.0.0.1:8000/v1/chat/completions") LLM_MODEL = os.environ.get("LLM_MODEL", "default-model") def call_llm(messages, temperature=0): payload = { "model": LLM_MODEL, "messages": messages, "temperature": temperature, "stream": False, } req = urllib.request.Request( LLM_URL, data=json.dumps(payload).encode("utf-8"), headers={"Content-Type": "application/json"}, ) with urllib.request.urlopen(req, timeout=120) as resp: body = json.loads(resp.read().decode("utf-8")) return body["choices"][0]["message"]["content"]

关于模型选择,我建议先用参数量中等偏小的模型做调试,因为小模型犯错方式更典型,反而能逼你把提示词和解析逻辑做扎实。等循环稳定了,再换更强的模型,效果会好很多。

2.2 工具怎么注册:给模型一份“会读的说明书”

工具注册表的核心不只是把函数放进去,还要给模型生成一份结构化的描述。你想象一下:模型是一个新员工,工具清单就是工位旁边的操作手册。手册写不清楚,新员工就会用错工具。

我这里的工具设计很简单,三个就能跑通闭环:

  • calculator:计算数学表达式。
  • read_manual:读取本地“操作手册”的某个小节。
  • save_note:把一段文本追加到本地文件。

注册逻辑大概是这样的:

TOOLS = {} TOOL_SCHEMAS = [] def register_tool(name, description, parameters, func): TOOLS[name] = func TOOL_SCHEMAS.append({ "type": "function", "function": { "name": name, "description": description, "parameters": parameters, }, })

然后逐个注册具体函数。我特别想提醒的一点是:工具描述里一定要写清楚“什么时候用、什么时候别用、常见坑是什么”。比如calculator的描述我写的是“只接受纯数学表达式,例如 (300+45)*2,不允许出现单位或文字”。

def calc(expr): expr = re.sub(r"[^0-9+\-*/(). ]", "", expr) if not expr: raise ValueError("表达式为空") return {"value": eval(expr, {"__builtins__": {}}, {})} register_tool( "calculator", "计算一个数学表达式,例如 (300+45)*2。输入必须是纯数学表达式,不允许包含单位或文字。", { "type": "object", "properties": {"expr": {"type": "string"}}, "required": ["expr"], }, calc, )

read_manual我设计成从一个固定字典里取内容,模拟真实业务里“读取知识库/产品手册”的场景:

MANUAL = { "concurrency": "推荐并发参数为 128,超过后错误率上升。", "timeout": "默认超时为 30 秒,长任务建议 60 秒。", } def read_manual(section): if section not in MANUAL: return {"text": "手册中没有这个条目"} return {"text": MANUAL[section]} register_tool( "read_manual", "读取本地操作手册的某个小节。section 参数可以是 concurrency 或 timeout。", { "type": "object", "properties": {"section": {"type": "string"}}, "required": ["section"], }, read_manual, )

2.3 主循环代码:从用户问题到最终回答

主循环是整套东西的心脏。我在这里做了一个明确的取舍:不让模型使用各家私有的“函数调用协议”,而是让模型在返回文本里输出一段 JSON 格式的动作描述。原因有两个:一是对接成本极低,换模型服务时不用改代码;二是每一步动作都会明文出现在对话历史里,调试日志一目了然。

系统提示词我这样写:

SYSTEM_PROMPT = """你是一个能调用工具解决问题的助手。请严格遵循以下格式输出: 1) 如果需要调用工具,输出 JSON: {"thought": "你的思考过程", "action": "工具名", "action_input": {"参数名": "参数值"}} 2) 如果已有足够信息,直接回答,输出 JSON: {"thought": "你的思考过程", "answer": "最终回答"} 只允许输出 JSON,不要输出任何解释或 markdown 标记。 可用工具及其参数:{tool_schemas}""".replace("{tool_schemas}", json.dumps(TOOL_SCHEMAS, ensure_ascii=False))

这里为什么用 JSON 而不是自然语言?因为 JSON 的action和action_input字段可以直接映射到函数调用,解析过程确定性强,几乎不需要猜。模型偶尔不守规矩,那就在解析层做兜底,后面专门讲。

主循环本身不复杂:

def extract_json(text): # 去掉可能的代码块围栏 text = re.sub(r"```(?:json)?", "", text) start = text.find("{") end = text.rfind("}") if start == -1 or end == -1: return None try: return json.loads(text[start : end + 1]) except json.JSONDecodeError: return None def run_agent(user_task, max_steps=8): messages = [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": user_task}, ] for step in range(max_steps): content = call_llm(messages) messages.append({"role": "assistant", "content": content}) print(f"Step {step}: {content}") data = extract_json(content) if data is None: messages.append({ "role": "user", "content": "格式错误:请只输出 JSON,不要包含任何解释。", }) continue if "answer" in data: return data["answer"] tool_name = data.get("action") tool_args = data.get("action_input", {}) if tool_name not in TOOLS: observation = f"错误:没有名为 {tool_name} 的工具。可用工具: {', '.join(TOOLS)}" else: try: observation = json.dumps(TOOLS[tool_name](**tool_args), ensure_ascii=False) except Exception as exc: observation = f"错误:工具执行失败: {exc}" messages.append({"role": "user", "content": f"Observation: {observation}"}) return "达到最大步数,任务未完成"

需要注意,工具执行结果被包装成一条新的 user 消息回到上下文里,这一步其实是“观察”的落地。模型看不到自己编造的结果,只能看到真实执行结果,这就堵住了“假装完成”的路。

2.4 一个完整的运行记录

我拿一个组合任务来演示:“查一下操作手册里推荐的并发参数,把它乘以 2.5 后保存到 notes.txt。”

实际跑起来的对话记录大致如下:

Step 0: {"thought": "需要先查看手册中的并发参数", "action": "read_manual", "action_input": {"section": "concurrency"}} Observation: {"text": "推荐并发参数为 128,超过后错误率上升。"} Step 1: {"thought": "手册推荐 128,现在计算 128 乘以 2.5", "action": "calculator", "action_input": {"expr": "128*2.5"}} Observation: {"value": 320.0} Step 2: {"thought": "结果已经算出来,保存到文件", "action": "save_note", "action_input": {"content": "并发参数: 320"}} Observation: {"status": "saved", "filename": "notes.txt"} Step 3: {"thought": "文件已保存,可以给出最终回答", "answer": "已按手册推荐值 128 计算,乘以 2.5 后得到 320,并写入 notes.txt。"}

这个例子看起来很简单,但它体现了 ReAct 最核心的价值:模型不会在第一步就“编一个答案”,而是真的去读了手册、做了计算、写了文件,每一步都有真实观察结果支撑。

3. 最容易翻车的地方:解析、兜底和循环控制

3.1 模型不按 JSON 格式输出怎么办

我在调试初期吃的最多苦头,就是模型不好好输出纯 JSON。最常见的三种情况:用代码块包住 JSON、在 JSON 前后写解释文字、JSON 本身语法错误。

extract_json里我已经处理了代码块围栏和“截取第一个{到最后一个}”的情况。实测下来,配合“只允许输出 JSON”的系统提示词,大部分模型都能在 2 到 3 轮内学会。

但总会有模型死性不改。我的处理是:解析失败后,不直接终止,而是塞一条“格式错误”的反馈,让它重新输出。这个反馈本身也是一种观察结果,模型看到后一般会老实很多。要注意的是,这种重试会消耗 token,所以我会在循环次数上做限制,避免模型一直绕圈。

有一个技巧特别管用:在系统提示词里放一个非常短的正确示例,比如:

{"thought": "需要调用工具", "action": "calculator", "action_input": {"expr": "1+1"}}

这比写十行“你必须遵守规则”都有效。模型是跟着模式走的,给它一个清晰的正例,它自然知道你要什么。

3.2 工具执行报错之后,如何进入“反思”分支

工具报错是家常便饭。比如用户让模型计算“abc+1”,calc函数会把非数字字符过滤掉,最后变成空表达式,抛出“表达式为空”。这个异常被我捕获后,会变成Observation: 错误:工具执行失败: 表达式为空。

关键在于,这个报错信息不会被当作“任务失败”结束,而是作为新的上下文继续喂给模型。模型下一轮看到报错后,通常会改变策略,比如向用户确认输入,或者换一种表达方式。这就是一种很天然的反思机制。

不过我也见过模型在同一个错误上反复栽跟头:第一次用calculate,报错“没有这个工具”;第二次还是calculate。原因是模型对工具名的记忆不稳定。我的解决办法是把“可用工具列表”直接写进报错观察结果里,让模型看一眼就知道该用哪个名字。这一步修正之后,工具名错误的出现频率大幅下降。

3.3 死循环保护:最大步数与预算

ReAct Agent 最让人头疼的问题不是“不会做”,而是“停不下来”。模型可能因为观察结果不符合预期,反复调用同一个工具;也可能在两个工具之间来回切换,始终不给出最终答案。

我的应对方案分三层。第一层就是代码里的max_steps,这个最直接,默认 8 步,超过就返回“未完成”。第二层是在系统提示词里写明:“如果观察结果显示错误,不要使用相同参数重试,请换一种方案或向用户说明。”这句话能有效减少无意义重试。第三层是外部预算控制,在调用模型前先估算当前对话的大致 token 数,一旦超过阈值就强制结束。

我把常见的失败模式和对应处理整理成了表格,方便后面排查:

现象根因处理方法
输出被代码块包裹模型默认生成 markdown提取第一个{到最后一个}
工具名多一个字母模型对工具名记忆不准报错观察里回传完整工具列表
同一工具反复调用观察结果没有促使模型换策略提示语增加“不要同参数重试”
跳过工具直接给答案任务太简单,模型觉得不需要工具提示语要求“没有观察结果不得给出最终回答”
JSON 语法错误直接续写导致字符串截断解析失败后插入错误反馈并重试

4. 从 Demo 走向可用:记忆、反思与稳定性

4.1 短期记忆与长期记忆的分工

跑通最小闭环之后,你马上会撞上“上下文窗口不够用”的墙。每次工具调用都会往消息列表里塞一轮内容,复杂任务几步下来,上下文就很长了。

短期记忆就是当前消息列表,它的作用是支撑本轮任务的推理连续性。这里要做的是“截断与摘要”。当消息条数超过某个阈值,比如 20 条,就调用一次模型,把前面若干轮对话浓缩成一段摘要,替换掉旧消息。这一步能极大降低长任务的 token 消耗。

长期记忆则是跨任务的。我实现的版本很简单:每完成一个任务,把“任务目标、执行过程摘要、最终结果”写成一行 JSON,追加到一个本地文件里。下次用户发起新任务时,先把文件里的历史任务摘要拉出来,让模型参考。这个方案不需要任何外部依赖,单机就能跑。

如果你想更进一步,可以把这些历史摘要做向量化,塞进任意一个向量数据库,按语义相似度检索相关历史。原理就是“给 Agent 一个翻旧账的能力”,很多重复性的业务问题,其实都能从历史执行记录里找到答案。

4.2 让 Agent 学会“自我纠错”

我观察到一个很有意思的现象:当工具返回结果明显不合理时,模型未必会主动质疑。比如calculator返回负数的价格,模型可能照单全收。

于是我加了一个“反思步骤”:在每次观察结果之后,额外追问模型一句“请判断这个结果是否合理,如果不合理请说明原因并设计新的方案”。不要小看这句话,它相当于在模型的工作流里硬塞了一个复盘节点,让模型在进入下一步推理前先对当前状态做一次校验。

代价是多消耗一些 token,但对可靠性要求高的场景,非常值得。我现在的做法是:只有当观察结果中出现“错误”“异常”“0 条”“为空”这类关键词时,才插入反思步骤,大多数正常路径不额外消耗。

4.3 稳定性指标:用一组测试任务反复检验

没有评估就没有优化。我搭好 demo 后的第一件事,不是继续加功能,而是准备了一组固定测试任务:

  • 纯计算任务:“计算 (245+89)*6”
  • 查手册任务:“手册推荐的超时时间是多少”
  • 组合任务:“查询默认超时时间,乘以 3 写入文件”
  • 非法输入任务:“计算 abc+1 并解释结果”
  • 未知工具任务:“用不知道名字的工具完成 xxx”

每个任务跑两遍,记录“成功率、平均步数、平均 token 消耗”三个指标。任何提示词或代码改动,都拿这组任务重新跑一遍,用数据说话。

这个习惯帮我避开了非常多的“移花接木”式优化:有时候你觉得提示词改得更好了,但一测成功率反而掉了 10 个百分点。没有固定的测试集,你根本发现不了这种回退。

5. 我的几个实操心得与教训

5.1 提示词的写法比工具本身更影响成功率

同样的工具注册表、同样的模型,提示词写得粗糙和写得细致,成功率能差出几倍。我的经验是:系统提示词要包含四件事——角色与目标、输出格式、工具使用原则、不重试原则。少任何一件,都会在某些任务上暴雷。

尤其“工具使用原则”要写清楚“什么时候必须用工具”:凡是涉及动态数据、外部查询、数学计算、文件操作的,一律先调用工具再回答,绝不允许基于猜测完成。这句话多写一行,能挡掉一大半“假装完成”的案例。

5.2 先跑小任务,再逐步扩张

我一开始的冲动是把所有能想到的工具都注册进去,让 Agent 显得很全能。结果模型频繁在多个工具之间选错。后来我把工具砍到只剩两个,把每个工具的 description 写得更细致,效果立刻变好。

工具数量对模型的判断负担是有影响的。如果你确实需要十个工具,我建议按“类别”组织,并在 description 里加上筛选关键词。比如“read_manual适用于查手册、查参数、查配置”这样,等于提前帮模型做了信息检索。

5.3 日志是 Agent 调试最好的朋友

最后一个建议可能听起来很土,却是我最想分享的:一定要把每一轮对话的完整内容、工具执行结果、token 用量都打出来,落盘保存。

很多时候 bug 复现不了,不是因为代码不稳定,而是因为你没有记录“当时模型到底说了什么”。我把运行日志按任务 ID 命名,每轮追加,出问题时直接翻日志,几分钟就能定位是模型解析问题还是工具逻辑问题。比起反复猜测、盲改提示词,这个习惯省下的时间太多了。

如果你也想在自己的项目里复刻这套东西,我的建议是:别急着堆工具,先跑通两个工具、一个组合任务,把循环、解析、兜底、日志这四个环节全部打磨扎实,再去扩展更多能力。ReAct 本身不复杂,复杂的是你怎么把边界和意外处理清楚。等这套最小闭环稳定之后,你再往上加记忆、加反思、加多 Agent 协作,就是水到渠成的事了。

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

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

立即咨询