☰
ReAct Agent实战:从零手写一个能抑制大模型幻觉的最小智能体
2026/10/20 3:03:52 网站建设 项目流程

最近在技术社区里,总能看到"大模型又翻车了"的讨论。翻车原因十有八九不是模型变笨,而是幻觉冒出来了:明明不知道的事,模型却能一本正经地讲出一段听起来很像回事的假答案。我自己做问答机器人时也撞上过——用户问某产品的具体参数,模型把两个不同型号的数据揉到了一起,还煞有介事地给出一个根本不存在的配置。后来我把方案切到 ReAct Agent 架构上,类似的翻车才明显少了一截。

ReAct 这个词这几年的热度一直没降,核心其实就一句话:让模型在回答前,先"思考、行动、观察",像人一样边查边答。这篇文章我会从零讲清楚它的思路,再用最朴素的 Python 代码手写一个最小 Agent,最后聊一聊我实测踩过的坑。不管你是刚接触 Agent 开发的小白,还是已经在用框架的开发者,这篇内容对理解整套循环的底层逻辑都有参考价值。

1. 先看清幻觉的源头,才知道 ReAct 在解决什么

1.1 幻觉的本质:模型是在"编答案",不是"查答案"

大语言模型的底层机制,用最简单的话说,就是根据前面所有 token 去预测下一个 token 的概率。它从海量文本里学到的,是语言和概念之间的统计关联,而不是一份可以随时翻阅的可靠数据库。所以当问到一个训练语料覆盖不充分的问题时,模型并不会停下来告诉你"我不会",而是按照概率把最像答案的文本续写出来。这就是幻觉的直接源头:不是故意骗人,而是在缺少事实锚点的情况下,它只能靠概率补全。

我在自己的问答机器人项目里就撞到过一次典型翻车。用户问某产品的远程控制功能,模型顺着常见产品功能的高频词,一本正经地列了一堆"支持项",其中有一条是该产品根本没有的能力。如果不去查实际规格,单看那段回答,流畅得几乎挑不出毛病。这个经历让我意识到,想在应用层解决幻觉,靠的是改变生成过程本身,而不是事后打补丁。

1.2 传统"防瞎说"三板斧,为什么都不够用

市面上常见的对抗幻觉手段,大致能分成三类。第一类是拉长上下文,把资料塞进提示词,也就是常说的给模型"喂文档";第二类是压低采样温度,让输出更保守;第三类是微调模型,让它在特定领域更"懂行"。这些方法都有用,但各有明显的天花板。

  • 拉长上下文:上下文窗口再大,也装不下所有知识。而且资料越多,模型反而可能在冗长内容里"选错重点",照样产生幻觉。成本还随着 token 数直线上升。
  • 压低温度:让输出变得机械、官腔,幻觉并没有消失,只是语气收敛了。模型照样可能用非常严肃的腔调编出一个不存在的结论。
  • 微调:成本高、迭代周期长,而且微调只是改变参数分布,并没有给模型新增"查证"的能力,它依然可能编造细节。

这几种方案的共同点,是都在模型内部寻找答案。而 ReAct 换了一个思路:不在参数里死磕,而是把外部工具当成生成答案时的依据来源。

1.3 ReAct 想换一种思路

ReAct 这个名字来自 Reasoning + Acting,也就是"推理 + 行动"。这个架构的思路最早在 2022 年的一篇论文里被系统提出来,之后逐渐成了各种 Agent 应用的主流底座。它做的事情很朴素:让模型每回答一步,都先输出自己的想法,然后选择一个工具去执行,把执行结果拿回来作为下一步推理的依据,直到最后得到答案。

这跟传统的"把问题丢给模型,让它一口气回答"最大的区别,在于生成了"中间的事实校验点"。模型不再直接猜测答案,而是先尝试用工具拿到客观数据,再基于这些数据组织回答。幻觉的空间被工具结果压缩了,这也就是为什么很多人说 ReAct 是缓解幻觉的"新范式"。当然,说它是新范式,不等于它没有前提条件。先把问题背景讲清楚,接下来就进入架构本身,看看这个循环到底是怎么转起来的。

2. ReAct 核心架构拆解:思考、行动、观察三轮驱动

2.1 一句话概括 ReAct

ReAct 的核心,是让模型在回答前先走一个"思考—行动—观察"的循环。思考是模型用自然语言描述自己打算怎么解决问题;行动是在一个预先定义好的工具列表里选择一个工具,并填入参数;观察是拿到工具执行后的客观结果,再把这个结果当作下一轮思考的输入。

如果问题简单,这个循环可能只走一轮。如果问题复杂,模型就需要多轮循环,像剥洋葱一样逐步逼近答案。最后它看到观察结果足够支撑回答了,才会输出最终答案。整个过程里,思考负责方向,行动负责动手,观察负责验证,三者形成一个闭环。

2.2 用点外卖类比:你也不会闭着眼睛下单

这个架构好不好理解?我们拿点外卖来类比。一个真人点外卖,不会一上来就按概率猜今天吃什么,而是会想:我今晚想吃什么口味?然后打开外卖平台,看看附近哪些店在营业;看到菜单之后,再根据配送时间和价格做决定。如果第一轮筛选的结果和自己的预期不符,就重新调整条件再筛一轮,最后才下单。

ReAct 就是给了模型这个"打开平台看一眼"的能力。没有这个能力的时候,问它某个产品规格,它只会凭印象编;有了工具,它能先查一下再回答。工具本身不一定是什么高深的东西,可能就是一个查天气的接口、一段知识库检索代码、一个计算器,或者一句数据库 SQL。关键在于模型的每一步回答,都被工具返回的真实结果约束住了。

2.3 Thought / Action / Observation 三个字段的格式要求

在最早的 ReAct 实现里,模型被要求按固定文本格式输出这几种字段:

Thought: 我需要先确认目标城市在哪里。 Action: search[目标城市信息] Observation: 目标城市是深圳,多雨。 Thought: 既然深圳多雨,我需要查询降雨预警。 Action: search[深圳降雨预警] Observation: 暂无预警。 Final Answer: 深圳当前有降雨过程,暂未发布预警。

这里的格式要求其实非常严格。模型说Thought是为了让你看清它的推理过程;Action必须落在预设的动作空间里,不能自创;Observation必须原样记录工具返回结果,不能由模型转述加工;直到推理链足够完整,才能输出Final Answer。

现代的大模型接口大多已经把"工具调用"做成了原生能力,也就是大家常说的 function calling。它在底层仍然延续了 ReAct 的套路:模型决定要调用哪个函数、传什么参数,平台返回结构化结果后再原样喂回模型。所不同的是,字段的解析由平台帮你做了,省去一堆正则匹配的麻烦。所以下文实操部分,我也直接采用 function calling 的方式来实现 ReAct 循环,而不是手写文本解析。

3. 白手起家手写一个最小 ReAct Agent

3.1 环境准备:不是非得上框架

我见过太多人一上来就套开源 Agent 框架,结果遇到问题完全不知道底层发生了什么。建议先用最朴素的方式把循环写通,再考虑框架。

环境上只需要三样东西:一个能运行 Python 的环境、一个支持 function calling 的大模型接口、以及 requests 这个 HTTP 库。整个过程不需要本地 GPU,也不需要向量数据库。把模型接入方式封装成两三个函数就够了。

下面我从零写一个最小的 Agent,工具只做一件事:查天气。但你会看到,加更多工具只是扩充工具列表的问题,循环逻辑完全不用变。

3.2 定义工具箱:让模型手里有锤子

先定义一个查询天气的工具。真实项目里可以接天气服务的公共接口,我这里先用一个模拟数据,把逻辑说清楚:

import json import requests def get_city_weather(city: str) -> str: # 真实项目中这里可以替换为天气服务 API mock_weather = { "北京": "晴,气温 25℃,东南风 2 级", "深圳": "多云,气温 27℃,湿度 80%", } if city in mock_weather: return mock_weather[city] return f"未找到城市 {city} 的天气数据" tools = { "get_city_weather": { "description": "查询指定城市的实时天气,输入参数为城市名,返回天气描述。", "func": get_city_weather, } }

注意这里我给每个工具都写了 description。这段描述非常重要,它不是给人看的,而是给模型看的使用说明。模型要根据这段描述判断何时该用这个工具、该传什么参数。描述写得含糊,模型就会在错误场景里调用。后面我会单独展开讲工具设计的细节。

为了让模型能通过 function calling 调用这个工具,还需要提供一份工具描述 schema。不同平台的字段名略有差异,但思路一致:

TOOL_SCHEMA = [ { "type": "function", "function": { "name": "get_city_weather", "description": "查询指定城市的实时天气。", "parameters": { "type": "object", "properties": { "city": { "type": "string", "description": "城市名称,例如:北京" } }, "required": ["city"] } } } ]

3.3 Agent 循环:把推理、调用、观察串成一条线

核心循环大概是这样:

API_BASE = "https://your-api-endpoint/v1" MODEL_NAME = "your-model-name" API_KEY = "your-api-key" def chat(messages, tools=None): payload = {"model": MODEL_NAME, "messages": messages} if tools: payload["tools"] = tools resp = requests.post( f"{API_BASE}/chat/completions", headers={"Authorization": f"Bearer {API_KEY}"}, json=payload, timeout=60, ) resp.raise_for_status() return resp.json()["choices"][0]["message"] def run_agent(user_question, max_steps=5): messages = [ { "role": "system", "content": ( "你是一个严格依赖工具回答问题的助手。" "如果需要查证信息,必须调用工具。" "只有在拿到工具返回结果后,才能给出最终回答。" ), }, {"role": "user", "content": user_question}, ] for step in range(max_steps): message = chat(messages, tools=TOOL_SCHEMA) # 如果模型没有产生工具调用,说明准备给出最终答案 if not message.get("tool_calls"): return message["content"] # 把模型的调用意图追加到上下文 messages.append(message) # 逐个执行工具调用,并把观察结果回填 for call in message["tool_calls"]: fn_name = call["function"]["name"] fn_args = json.loads(call["function"]["arguments"]) print(f"[step {step}] 调用工具: {fn_name}({fn_args})") if fn_name not in tools: result = f"错误:未知工具 {fn_name}" else: try: result = tools[fn_name]["func"](**fn_args) except Exception as e: result = f"工具调用异常: {e}" messages.append( { "role": "tool", "tool_call_id": call["id"], "content": result, } ) return "达到最大步数,未能完成回答。"

这个循环的关键点在于:模型每一次推理都是基于完整的历史记录,包括上一个工具的调用和结果。观察结果被作为新消息回填进 messages,模型才能在下一轮里知道"查到的是什么",进而决定继续查还是收尾。

3.4 完整运行效果

跑一下看看效果:

用户提问: 深圳今天适合穿短袖吗? [step 0] 调用工具: get_city_weather({'city': '深圳'}) 最终回答: 深圳今天多云,气温 27℃,湿度 80%。气温不低,穿短袖完全可以,不过湿度较大,建议带伞。

和没有工具直接问的对比其实非常明显。没有工具时,模型会靠训练数据里对深圳天气的大概印象来回答,容易出现"深圳四季温暖"之类的笼统表述;有了工具之后,回答里的温度、湿度都有了明确来源,至少不会在关键事实上凭空捏造。

我还提醒一句:在run_agent里必须处理"工具名不存在"和"工具抛异常"这两类情况。很多人在早期踩坑,就是模型收到了一个错误的观察结果,却照样顺着错误继续编。把异常结果明确写成"工具调用异常"喂回去,模型才知道要停下来,而不是继续脑补。

4. 为什么 ReAct 能压制幻觉:四个关键机制

4.1 事实锚定:外部工具给答案兜底

ReAct 减少幻觉的第一层机制,叫事实锚定。模型最终回答里出现的关键事实,优先来自工具的真实返回,而不是来自模型内部对训练语料的概率猜测。说得直白一点:模型把问题"外包"给了外部世界,外部世界的答案自然比参数里的印象更接近事实。

在实现层面,观察结果会被直接注入后续上下文中。模型看到的是"深圳 27℃ 湿度 80%",而不是某个模糊的"南方沿海城市气候温暖"的统计印象。回答再离谱,也离不开工具给出的这几个数字。这就是外部信号对生成过程的硬约束。

4.2 推理暴露:幻觉会自己现形

第二层机制是推理暴露。强制模型输出 Thought,会迫使它把推理过程摆到明面上。一个在幻觉边缘挣扎的模型,它的 Thought 往往会暴露矛盾:猜测、跳跃、自相矛盾,甚至前言不搭后语。这些问题如果在普通问答里可能被流畅的最终回答掩盖,但在 ReAct 模式下,你可以在推理链上发现它,进而截断、提醒或换路。

这一点对调试尤其有价值。我见过很多次模型在 Thought 里说"我需要确认 X",但 Action 里却调用了跟 X 完全无关的工具。这种裂缝在普通输出里根本看不出来,但在 ReAct 轨迹里非常显眼。对生产系统来说,推理链本身就是一份可审计的日志,出了错也更容易追溯是哪一步推理导致的。

4.3 自我纠错:观察结果会把模型拉回正轨

第三层机制是自我纠错。当模型的初始判断和工具观察结果不一致时,它会在下一步推理里调整方向。举个例子:模型先 Thought"北京此刻可能正在下雨",然后调用天气工具,观察结果却是"晴",模型就会修正自己的结论,而不是坚持原来的猜测。这种纠错能力不是模型天生自带的,而是"观察"这个环节强制加入的。

更常见的情况是多跳问题。模型第一轮查到 A,发现 A 还依赖 B,于是第二轮继续查 B;如果 B 的结果推翻了 A,模型就会回头重新决策。这种"边查边改"的能力,让 Agent 在复杂问题上比纯参数驱动稳定得多。

4.4 知识边界:让模型敢于说"不知道"

第四层机制是知识边界。传统模型很难承认自己不知道,因为它没有区分"知道"与"不知道"的机制。但在 ReAct 下,如果工具返回"未找到数据",模型就有了合理的理由终止回答"我无法确认"。这种"明确拒绝"反而是防幻觉里最宝贵的能力:与其编一个错误答案,不如直接告诉用户查不到。

不过必须说实话:ReAct 不能保证幻觉 100% 消失。它把幻觉从"自由发挥"变成了"受外部信号约束",但如果工具本身返回了错误数据,或工具根本没覆盖到所需信息,模型仍然可能基于错误观察生成错误结论。它减少的是无根据编造的幻觉,消除不掉"假工具 + 真模型"组合出来的新错误。这一点我在项目里反复验证过,所以别对它做不切实际的期待。

5. 进阶实操:工具设计和 Prompt 打磨才是灵魂

5.1 工具描述是给模型看的"说明书"

工具描述直接影响模型能否在正确场景调用正确工具。我见过一个极端的反面案例:某工具名为get_info,描述是"获取信息"。模型几乎永远不敢调用它,因为"获取信息"太宽泛了,模型不知道它能不能解决当前问题。把它改成"查询某个城市的天气,输入城市名"后,调用率立刻上去了。

写描述时有几个原则:第一,说清楚这个工具能解决什么问题;第二,写清楚边界,什么时候不该用它;第三,把参数含义讲明白。模型决定调不调用工具,本质上是在做一次语义匹配,描述越贴近用户问题的自然表达,匹配越准。

5.2 返回结果的结构化设计

工具返回结果也要设计。如果返回一段没有结构的纯文本,模型很难准确取用;如果返回结构化的 JSON,模型就能准确提取字段。我的习惯是统一成下面这种格式:

{ "status": "ok", "data": { "city": "深圳", "temperature": 27, "humidity": 80 } }

出错时则返回:

{ "status": "error", "message": "城市不存在或查询超时" }

这种格式能显著改善 Agent 的稳定性。模型看到status: error,会选择换工具或停止回答;如果不管错误,直接返回空字符串,模型就会进入"不知道查到了什么也要硬答"的状态,幻觉就又回来了。

5.3 多工具路由与冲突处理

当工具数量变多,路由就重要了。模型需要从多个工具中选出最合适的一个。这时有两个技巧很实用。第一个是在描述里加上"触发条件"。比如某个工具专门处理用户投诉,描述写"当用户表达不满、投诉或要求退款时使用此工具",模型的调用准确率就会高很多。第二个是给有冲突的工具排序。比如同时有"查最新库存"和"查历史库存"两个工具,如果不写清楚,模型经常选错。我的做法是在描述里加一句"优先使用此工具查询实时数据;若要查看历史记录,再使用另一工具"。

另外还要小心工具的副作用。比如"发送邮件"这类工具,应该限制模型只在用户明确授权时调用。否则模型可能因为某个看似合理的理由,擅自触发敏感操作。给工具加权限注释,也是 Agent 工程化的重要一环。

6. 实战踩坑实录:循环、乱答与成本失控

6.1 模型陷入死循环怎么办

Agent 最常见的故障,是模型反复调用同一个工具,停不下来。原因是观察结果没有包含足够信息,模型觉得还没拿到答案。处理思路有三层:第一层是设置max_steps,超了就强制终止,这是底线。第二层是检测重复调用,如果在历史里发现同样的工具和参数已经调用过,就给模型反馈一句"该工具已调用过,请基于已有信息作答或换一个思路"。第三层是给工具返回结果里加一个标志,比如"这是最终结果,请直接据此回答",把模型的"还想再查一次"的冲动摁住。

实际中,我经常把max_steps设成 4~5 步,而不是更大的值。步数越大,token 消耗越多,而步数一旦大于 5,效果提升就很不明显了。对大多数问答场景,3 步内已经能收敛。

6.2 模型不调用工具,总想直接答

另一种常见问题是模型太过自信,跳过工具直接给答案。原因多半是系统提示没写死,或者模型认为这个问题不需要查证。想让它"先查再答",除了把系统提示改强硬,还可以考虑强制指定工具。现在很多平台的调用接口支持tool_choice参数,可以直接指定第一轮必须调用某个工具,或者必须从工具列表里选一个。对某些关键查询,这是最直接的兜底。

调试时记得把每一轮的 messages 都打印出来。看模型是直接回答,还是只编了个工具调用但参数残缺。这类问题往往一眼就能在日志里定位。

6.3 幻觉并没有完全消失

这是最容易被误解的地方。ReAct 是好方案,但不能让幻觉归零。我实际遇到过的残留幻觉场景有三类。第一类,工具返回了失败结果,但模型觉得失败也得给用户一个说法,于是编了一个合理但并不存在的答案。处理办法是让错误结果足够刺眼,甚至直接在系统提示里写"工具返回 error 时必须停止回答"。第二类,工具返回超长内容,模型在压缩转述时丢失细节,产生走样。处理办法是工具层做摘要,不让模型在看不到全貌时就转述。第三类,多轮之后上下文过长,模型遗忘早期 observation。处理办法是给重要观察做个汇总节点,或者缩短历史。

还有一个我反复强调的原则:观察结果永远不要由模型转述。让模型用自然语言总结工具结果,看起来顺眼,但每次转述都是一次信息损耗。直接拼接原始结构,模型提炼效率最高。

6.4 token 成本飞涨

ReAct 每多一次工具调用,就会把系统提示、全部历史、观察结果重新塞给模型一次。一个简单的问答跑出几千 token 是很常见的事。控制成本有几个成熟手段:给观察结果截断,例如限制只取前 500 个字符;对简单问题走快速通道,先用低成本的直接回答模式,只有低置信度时才启用完整 Agent 循环;还有就是对工具结果做缓存,同一个参数同一个问题,第二次直接命中缓存,不用再调外部接口。

我在某个知识问答 Demo 里就做过一个混合方案:问题先进分类器,命中高频简单问题,直接检索回答;只有需要跨多步推断或实时数据的问题才进入 ReAct 循环。成本省了一半,延迟也明显降低,幻觉率并没有上升。

7. 最后聊一点个人体会

做了一段时间 ReAct Agent 之后,我最大的体会是:这个架构解决的不是"模型聪不聪明"的问题,而是"模型敢不敢承认自己不知道"的问题。它给模型的才华装了一道护栏,让它在信息不足时,至少学会走出去查一查,而不是坐在原地编故事。

如果你刚接触这个方向,我的建议是先从单工具循环写起。不要迷信框架,先把Thought、Action、Observation这个闭环在代码里跑通,再慢慢加工具、加场景、加路由。你会很清楚地看到,模型每次从"瞎猜"变成"查证"的那个转折点,正是观察结果回填的那一刻。

最后再分享一个实操小技巧:把每轮 Agent 的完整记录存成日志,包括用户问题、Thought、工具调用参数、观察结果、最终答案。调模型、调工具、调提示词的时候,这份日志比任何文档都管用。很多时候幻觉没有消失,不是 ReAct 失效,而是你的某个工具返回结果太粗糙。日志看多了,问题出在哪一环,一眼就知道。

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

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

立即咨询