ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

ReAct Agent实战:边想边查,根治大模型幻觉问题

ReAct Agent实战:边想边查,根治大模型幻觉问题

干AI应用开发这几年,我一直觉得,大模型"一本正经地胡说八道"是最劝退用户的现象。你问它一个事实问题,它给你编一个有模有样的答案,语气自信得像真查过资料一样。这个问题有专门的名字——幻觉(hallucination)。为了治这个毛病,我试过换更大参数量的模型、试过各种提示词技巧,但真正让我觉得"路走通了"的,是 ReAct Agent 架构。这套架构让模型不再只靠内部记忆硬答,而是像人一样先想、再查、后答。这篇文章我会从幻觉的根源讲起,然后手把手带你实现一个完整的 ReAct Agent,最后聊聊实测效果和我在落地时踩过的坑。全文不设门槛,代码可以直接复制运行,小白照着做也能跑起来。

1. 幻觉问题:模型为什么总是"自信地胡编"

1.1 一个让我冷汗直流的Demo事故

前两年我做一个内部知识库问答Demo,测试阶段问了一条关于产品退换货政策的问题。当时模型给出的答案是"支持7天无理由退换,且运费由平台承担,用户只需在订单页提交申请"。这段话结构完整、语气笃定,甚至贴心地补充了操作路径,我第一反应是"答得真好"。后来核对原始文档才发现,那条产品线根本不在无理由退换货范围内——答案里除了"7天"这个数字碰巧对上了,其余全是模型自己拼接出来的。

这件事给我刺激很大。不是气模型答错,而是它答错时的"自信程度"让人完全放下防备。做应用的人都知道,幻觉对用户体验的伤害是颠覆性的:你推荐再准、交互再顺,只要用户在一件关键事情上发现你说谎了,整个系统的信任就塌了。幻觉不是锦上添花时的瑕疵,而是决定产品竖不立得住的底线问题。

1.2 拆开看:幻觉的三个根源

要解决幻觉,得先搞清楚它从哪儿来。我自己的理解是三个原因叠加:

第一,大模型本质上是个"下一个词预测器"。它学的是文本里的统计规律,不是客观事实。训练时它看到"苹果""红色""水果"经常一起出现,就学会在这些词之间建立强关联。但遇到冷门事实时,它没有足够统计信号,就只能按"最通顺"的方向生成。换句话说,模型的目标不是"说得对",而是"说得像"。这是幻觉的底层原因。

第二,训练数据有截止时间,也有覆盖盲区。昨天的新闻、某个产品的内部售后政策、某个小店的营业时间——这些东西要么不在训练数据里,要么只占极小比例。模型不知道就是不知道,但它在生成机制上没有任何"主动承认不知道"的倾向,于是就会编。

第三,也是容易被忽视的一点:模型生成答案后没有"校验回路"。人写完答案会复查一遍,模型没有这个机制。你问它一个问题,它生成完一整段回答,这段回答自始至终没有经过任何外部事实的交叉验证。这就好比一个人闭卷考试,交卷前不允许翻书、不允许对答案,但他必须把每道题都填满。

这三个根源决定了:单纯换更大参数量的模型,治不了幻觉;单纯优化提示词,也治不了。因为问题不在"生成后怎么改",而在"生成过程中模型的信息来源太单一"。要解决,就必须让模型在回答时能主动获取外部信息——这就是 ReAct 的出发点。

2. ReAct的思想拆解:从"背书机器"到"解决问题的人"

2.1 一句话理解ReAct:边想边查,查完再想

ReAct 这个词来自学术圈一个经典思想,是 Reasoning(推理)和 Acting(行动)的组合。它的核心主张非常朴素:大模型推理时不应该只靠内部知识一路写到尾,而应该在需要的时候"动手"——调用搜索、查询数据库、运行计算器——然后基于工具返回的真实结果继续推理。

我做个类比你就明白了。以前的模型答题像闭卷考试:一个学生对题目没把握,也得硬着头皮写,写错了也只能将错就错。ReAct 之后的模型答题像开卷考试:学生可以先写下"我这题缺个数据,需要查一下",然后翻书、按计算器,看到真实结果后重新整理答案。开卷和闭卷的差距,在信息密度高、时效性强的问题上,是天壤之别。

这个思想的价值在于:它把模型的能力边界从"内部参数里固化的知识"扩展到了"外部世界的一切可获取信息"。

2.2 和Chain-of-Thought的区别:多了一步"动手"

很多人会把 ReAct 和 Chain-of-Thought(思维链)混淆。两者都让模型输出中间推理过程,但有个本质区别:CoT 的中间过程只是"脑内思考"的显式化,模型从头到尾没有接触任何新信息;ReAct 的中间过程则真正允许模型中断生成,去执行一次工具调用,拿到工具反馈后再继续。

举一个我实际遇到的例子。用户问"某公司2025年营收同比增长了多少"。用纯 CoT 的方式,模型会想:"我需要知道2024年和2025年的营收,然后用公式计算。"——然后呢?它没有2025年的数据,因为训练数据可能只到2024年,最后只能编一个数字。用 ReAct 的方式,模型会在思考到"缺数据"这一步时停下,调用查询工具获取两年真实营收,再调用计算器算出增长率。同样是"多步思考",前者靠记忆,后者靠证据,可靠度完全不同。

所以我对 ReAct 的评价是:它不是对 CoT 的否定,而是在 CoT 之上加了一个"外部世界接口"。思想再深,也得有手有脚才算完整。

2.3 核心循环:Thought、Action、Observation

ReAct 的运行机制可以简化成一个三步循环,反复执行直到模型认为信息足够了:

  • Thought(思考):模型用自然语言描述它当前的判断,比如"我缺少2025年的营收数据,需要先查询"。
  • Action(行动):模型选择一个工具并给出参数,比如"查询财务信息(某公司2025年营收)"。
  • Observation(观察):系统执行工具,把真实结果返回给模型,比如"某公司2025年营收为120亿元"。
  • 然后模型基于 Observation 进入下一轮 Thought,循环往复;当它觉得信息齐了,就输出 Final Answer。

这个循环最让我喜欢的一点是全程可追溯。模型哪一步想偏了、哪一次工具调用选错了,都能看到。排查问题时比面对一个"黑盒答案"舒服太多了。

我用一个具体例子展示完整循环。假设问题依然是"某公司2025年营收同比增长了多少":

  1. Thought:需要知道2024年和2025年的营收数据。
  2. Action:查询财务信息(某公司2024年营收)
  3. Observation:某公司2024年营收为100亿元
  4. Thought:还需2025年数据。
  5. Action:查询财务信息(某公司2025年营收)
  6. Observation:某公司2025年营收为120亿元
  7. Thought:现在数据齐了,计算增长率,(120-100)/100×100=20%。
  8. Action:计算器((120-100)/100*100)
  9. Observation:20.0
  10. Thought:同比增长率为20%。
  11. Final Answer:某公司2025年营收同比增长20%。

从第1步到第11步,每一步都有据可查。这就是 ReAct 的基本面貌——理论层讲清楚了,下面进入实现。

3. 从零实现一个ReAct Agent:完整代码与运行效果

3.1 环境准备:你只需要三样东西

动手之前先列清单。实现一个最小可用的 ReAct Agent,你只需要:

  • Python 3.9 或更高版本,装好requests(如果需要调外部API)。
  • 一个大模型聊天接口的访问权限。你可以用它原生的 SDK,也可以封装一个llm.chat()函数。这篇文的代码里我会用抽象接口,你自己替换成实际客户端即可。
  • 若干个工具函数。工具是 ReAct 的"手",下面我会定义两个:一个模拟的时效信息查询工具,一个计算器。

提示:工具函数不一定要接真实API。先把流程跑通,再逐步替换成搜索服务、数据库查询、企业内网接口。建议第一次落地时都用 mock 工具调试,成本低、速度快。

3.2 定义工具:给模型"装上手"

工具分两层:一层是给模型看的"工具说明",模型靠它决定何时调、怎么调;一层是给代码执行的"工具实现",真实返回结果。先看我定义的 mock 工具:

def query_finance(topic: str) -> str: """模拟财务信息查询工具""" knowledge = { "某公司2024年营收": "某公司2024年营收为100亿元", "某公司2025年营收": "某公司2025年营收为120亿元", "某公司2024年净利润": "某公司2024年净利润为15亿元", } return knowledge.get(topic, "未检索到相关信息") def calculator(expression: str) -> str: """模拟计算器工具,返回计算结果""" try: # 生产环境不要直接eval,建议用AST解析或专用计算库 result = eval(expression) return f"计算结果:{result}" except Exception as e: return f"计算错误:{e}"

这里我故意用eval简化代码,但必须提醒你:生产环境千万不能直接eval用户或模型传入的字符串,存在严重安全风险。建议替换成ast.literal_eval或只允许白名单运算符的表达式解析器。

工具的返回值尽量做成"一句完整的话",比如"某公司2025年营收为120亿元",而不是输出一堆 JSON 字段。原因后面第5章会细讲,先记住:模型对自然语言文本的处理能力,远比对树状结构的处理能力稳定。

3.3 提示词模板:告诉模型"怎么想,怎么动手"

ReAct 的提示词是灵魂。它要做三件事:定义可用工具、规定每一步的格式、说明何时可以停止。我用的模板长这样:

你是一个能够使用工具来回答问题的人工智能助手。 在回答过程中,你必须使用以下循环格式: Thought: 你当前对问题的思考 Action: 工具名称(从可用工具中选择) Action Input: 要传给工具的参数 当工具返回结果后,继续输出 Thought,直到你认为信息足够。 最终必须以如下格式结尾: Final Answer: 对用户的最终回答 可用工具: - query_finance: 查询指定公司或项目的历史财务数据。参数为查询主题字符串。 - calculator: 计算数学表达式。参数为合法的数学表达式,如 (120-100)/100*100。

注意几个细节:每个工具说明要写清楚用途和参数格式;格式约束要严格,"Action"和"Action Input"成对出现;还要明确告知模型"循环可以多轮,直到信息足够"。我没在模板里写死"最多几轮",这个由代码控制,不给模型讨价还价的余地。

3.4 主循环实现:让模型真正跑起来

主循环的职责是:把模型输出解析成结构化动作,执行工具,把 Observation 追加回对话历史,再让模型继续生成。

import re def parse_response(text: str) -> dict: """把模型输出解析为动作或最终答案""" final_match = re.search(r"Final Answer[::]\s*(.+)", text, re.S) if final_match: return {"type": "final", "answer": final_match.group(1).strip()} action_match = re.search(r"Action[::]\s*(.+)", text) input_match = re.search(r"Action Input[::]\s*(.+)", text, re.S) if action_match and input_match: return { "type": "action", "tool": action_match.group(1).strip(), "input": input_match.group(1).strip(), } return {"type": "unknown", "text": text} def execute_tool(tools: dict, tool_name: str, tool_input: str) -> str: """执行工具,返回观察结果""" if tool_name not in tools: return f"错误:未知工具 {tool_name},可用工具为 {list(tools.keys())}" try: return tools[tool_name](tool_input) except Exception as e: return f"工具执行异常:{e}"

然后是run_agent主循环:

def run_agent(question: str, tools: dict, max_steps: int = 6): system_prompt = build_react_prompt(tools) history = [{"role": "user", "content": f"问题:{question}"}] for step in range(max_steps): response = llm.chat(messages=system_prompt + history) history.append({"role": "assistant", "content": response}) parsed = parse_response(response) if parsed["type"] == "final": return parsed["answer"] if parsed["type"] == "action": observation = execute_tool(tools, parsed["tool"], parsed["input"]) history.append({"role": "user", "content": f"Observation: {observation}"}) continue # 未知格式:把提示反馈给模型,让它自行修正 history.append({ "role": "user", "content": "你的输出格式不正确,请重新输出,必须包含 Action 和 Action Input,或 Final Answer。" }) return "达到最大步数,仍未得到可靠答案。"

这里有个容易踩的坑:Observation 是由系统产生的,但我把它放在user角色里,而不是assistant角色。原因是现在很多大模型接口对角色敏感,你如果冒充"assistant"去插入一段它没说过的话,模型可能直接混乱。实测用user角色携带 Observation 最稳。

我还设置了最大步数max_steps=6。这一步很重要——如果模型始终找不到答案,循环会无限执行下去,消耗大量 token。限制步数等于给流程装了个刹车。

搭建完成,看一次真实运行的日志。假设用户问的是"某公司2025年营收同比增长了多少",模型输出:

Thought: 我需要知道该公司2024年和2025年的营收,先查2024年的。 Action: query_finance Action Input: 某公司2024年营收

系统执行工具后,把Observation: 某公司2024年营收为100亿元喂回去。模型继续:

Thought: 再查2025年的营收。 Action: query_finance Action Input: 某公司2025年营收

拿到"120亿元"后:

Thought: 数据齐了,现在计算增长率。 Action: calculator Action Input: (120-100)/100*100

Observation 返回"计算结果:20.0",模型输出:

Thought: 2025年营收相比2024年增长了20%。 Final Answer: 某公司2025年营收同比增长20%。

整个过程一气呵成。你第一次跑通时可能会很兴奋——"它居然真的知道去查数据了"。但这只是开始,真正的门道在下文的实测和踩坑里。

4. 实测效果:哪些幻觉被治好了,哪些仍然顽固

4.1 病根诊断:先让模型"裸答",再上ReAct对比

只看一个成功案例不足以服人。我习惯的做法是:同一个问题,先用纯模型(不接工具)回答,再用 ReAct Agent 回答,最后对照差异。

先说一个事实性问题的实测。问题是"某公司2025年全年营收是多少"。纯模型回答:"某公司2025年全年营收约为90亿元,相比去年有稳步增长。"——这个答案最大的问题不是数字错(实际上也是错的,真实是120亿),而是它用'约为''稳步增长'这种模糊措辞掩盖了不确定性。模型不是不知道答案,而是知道自己不知道,但被语言习惯带偏了,只能给出一个"安全但不准确"的表述。

同一个问题交给 ReAct Agent,流程是:模型先 Thought"我需要查询该公司2025年财务数据",然后调query_finance,Observation 直接返回"某公司2025年营收为120亿元",最终答案精确到单位。差距不是"好一点",而是"有据可查"和"凭空捏造"的差距。

4.2 运算类问题:模型不擅长算数,但很擅长"使用工具"

大模型的数学能力这几年进步不少,但复杂一点的计算仍然容易翻车,尤其是多位小数、百分比变化、汇率换算这类。我测过一个更刁钻的:"一件商品价格从80元涨到96元,涨幅是多少个百分点?"纯模型可能绕半天给你个"20.0%",它算对了倒还好;但一旦问题变成"连续两次涨跌后的最终价格",模型犯错的概率直线上升。

ReAct 在这个场景的价值是:它不需要自己算,它能调用计算器。数学能力不行没关系,借力打力。我测试时,模型会在 Thought 阶段明确写"我需要进行精确计算",然后Action: calculator,Observation 返回精确结果,最终答案零误差。这种"知道自己该用工具"的判断力,比"会做算式"更稀缺。

4.3 诚实的边界分析:哪些幻觉ReAct治不了

把话撂在这:ReAct 不是万能药,它在三类场景下依然会幻觉。

第一,工具本身返回错误信息。如果你的搜索接口返回的内容本身过期或有误,模型基于错误观察继续推理,得到的答案同样是错的。ReAct 只能保证"答案有据可查",没法保证"据是准确的"。

第二,模型在多轮推理中出现"漂移"。随着循环步数增加,对话历史越来越长,模型可能忘记最初的问题,或者在多轮观察之间迷失方向。我遇到过模型查完 A 公司的数据,后续 Thought 却套到 B 公司头上的情况。这是上下文注意力衰减导致的,和幻觉机制同源。

第三,模型没有选对工具或参数。如果你提供的工具描述不清楚,或者可用工具不匹配问题域,模型会硬从工具列表里挑一个接近的,然后基于错误输出硬编答案。工具设计不好,ReAct 的幻觉率可能比纯模型还高。

看清楚边界之后,你应该明白:ReAct 是"降低幻觉概率"的方案,不是"消灭幻觉"的方案。它把幻觉从"无据可查的编造"变成了"链路中某一环的错误"。但即便如此,它的价值依然巨大——因为链路中的错误是可发现、可修复的,而编造是没法修的直接推翻。

5. 进阶优化:让Agent更可靠的关键经验

5.1 工具设计三原则:别让模型猜你的工具

工具设计直接决定 Agent 的成败,我的经验浓缩成三条:

第一,工具说明里写清楚"什么时候用、什么时候不用"。比如query_finance的说明如果是"查询财务数据",模型面对"这家公司口碑如何"这种问题时也可能去调它。更稳妥的写法是"仅用于查询营收、净利润等可公开获得的财务指标;不用于评价公司声誉"。限制越明确,误用越少。

第二,参数数量能少则少。一个工具被期望接收三个参数时,模型出错概率大幅上升。尽量把多参数封装成单字符串参数,例如"某公司2025年营收"而不是company=某公司, year=2025, metric=revenue。字符串天然适合自然语言模型。

第三,返回值必须是"完成的一句话"。我最开始让工具返回 JSON 字典,模型解析时经常出错;改成"某公司2025年营收为120亿元"这种自然语言后,准确率立刻上来了。工具返回的是给模型"读"的信息,不是给程序解析的数据,一定要按模型的阅读习惯来。

5.2 错误处理:让模型自己"擦屁股"

工具可能失败,比如查询无结果、API超时、计算出错。处理原则是:不要把异常直接吞掉,也不要让程序崩掉,而是把错误信息作为 Observation 还给模型。

举个例子:模型调了calculator("120-"),表达式不合法,函数返回"计算错误:invalid syntax"。模型看到这条 Observation 后,通常会意识到自己参数传错了,重新构造合法表达式再试一次。这种"让模型从错误中自愈"的方式,比你在代码里硬编码重试逻辑灵活得多。

当然,自愈次数要限制。我的做法是:同一个工具连续失败超过两次,就把错误信息直接写入 Final Answer,让用户知道"系统查到这一步出了问题",而不是让模型强行编一个答案。

5.3 成本与延迟控制:Agent不是越多步越好

ReAct 的循环步数乘上模型调用次数,token 开销确实比单次问答高不少。我自己的实际优化手段有四个:

  • 限制最大步数。我生产环境一般设 4~6 步,超过直接终止。大多数问题 3 步内就能解决,少数复杂问题 5 步也够了。
  • 先用小模型做"路由"。收到问题时,先让一个小模型判断"这个问题是否需要工具?需要哪个工具?"如果需要再走 Agent 流程,不需要就直接用轻量模型回答。这样可以把简单问题的成本压下来。
  • 工具结果做截断。搜索或查询返回的内容可能很长,我只保留前 200~500 字喂给模型,防止上下文爆炸。
  • 缓存工具结果。相同 query 参数的工具结果直接复用,这在知识库问答场景收益极大,因为不同用户问的往往是同一批问题。

5.4 我踩过的三个坑,希望你绕开

踩坑一:提示词里没强制要求"每步先写 Thought"。结果模型跳过思考直接给 Action,流程虽然能跑,但一旦出问题,完全无法定位是在哪一步想偏的。后来我在系统提示里加了"每一步必须先输出 Thought"后,问题立刻改善。

踩坑二:正则解析太严格,模型一换格式就解析失败。有的模型会输出"Action: query_finance\nAction Input: 某公司2024年营收",有的会输出"Action:query_finance(查询财务数据)"。我在解析正则里加了冒号中英文兼容,并对工具名做了模糊匹配,解析失败率从5%降到0.2%以下。真实项目中建议再加一层"解析失败时把反馈喂回给模型让它自己修正"的兜底。

踩坑三:工具返回内容太啰嗦。我有一个查询工具返回3000字的文档片段,模型经常被无关细节带偏,甚至丢掉最初问题。后来我强制工具只返回"直接与查询目标相关的段落",幻觉率下降明显。工具返回的内容不是资料库,而是"刚好够模型下判断的那一口"。

我在实际落地中的体会是:ReAct 不是银弹,但它是目前性价比最高的一种幻觉治理手段。它没法让模型无所不知,但能让模型在"不知道"的时候主动去查,在"查不到"的时候老实承认。这个转变,对做应用的人来说,已经是从"没法用"到"能用"的关键一步。如果你正在被幻觉问题折磨,别急着换模型,先在模型外面套一层 ReAct 试试——大概率能让你少掉一半头发。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进