Featured image of post 第五话|ReAct:让模型把「想」和「做」接起来

第五话|ReAct:让模型把「想」和「做」接起来

从「光想不做会幻觉、光做不想会盲目」出发,讲清 ReAct 为什么让推理和行动交替,并亲手跑出一个 Observation 改变下一步 Action 的最小例子。

系列 GYA(Get Your Agent) 第 5 / 12 篇
主题 AgentLLMReAct教程

第四话结束,我们手里有了一个能安全执行单次工具调用的 Runtime:模型说"调 get_weather",代码校验、执行、把结果回喂,模型再总结成一句话。

这是"会做"了。但它藏着一个更深的问题,上一话没来得及展开:模型的"想"和"做",还是断开的。

模型提一次请求,代码执行一次,结束。中间没有"想"——没有让模型停下来,根据刚拿到的结果,再决定下一步。

本篇就把这两件事接起来。先记住一句话,它是全文的锚:

光想不做,会空转;光做不想,会盲目。ReAct 做的,就是让"想"和"做"交替起来。

一、光想不做、光做不想,各有什么毛病

先把两种"缺一半"的形态说清楚。ReAct 就是为了治这两种病而生的。

光想不做(Chain-of-Thought 的局限)

让模型一路推理,它每一步都靠自己的记忆往下推。问题是:它推理时不接触外部世界,没有办法去查、去验证。推到一半,遇到它不知道的事实,它不会停下来说"我不知道,去查一下",而是接着编。错了还往下推,越推越偏。

这就是"幻觉"和"错误传播":模型在脑子里空转,没有真实反馈把它拉回现实。

光做不想(纯工具调用)

反过来,模型只会调工具,但不动脑。它拿到一个结果,不知道这个结果意味着什么、下一步该干嘛、什么时候该收手。盲目地执行,却不解释为什么。

一句话立住:想,缺真实反馈会空转;做,缺推理会盲目。 两种形态各缺一半,ReAct 要做的,是把两半合起来。

二、ReAct 的答案:把"想"和"做"交替起来

2022 年的一篇论文把这个想法抽象成了模式,叫 ReAct(Reasoning + Acting),arXiv 2210.03629。

想法很朴素:让模型交替地"想一步、做一步、看一步"。

要素是什么谁负责
Thought(想)模型推理:我知道什么、还缺什么、下一步干嘛模型
Action(做)调用某个工具,或给出最终答案模型提出,代码执行
Observation(看)工具执行后返回的真实结果代码

关键在那个"看"。Observation 是外部世界的真实反馈,它把模型的"想"从自己的幻觉里拉回现实。 这是 CoT 缺的那一环——CoT 一路想下去,没有地方停下来"看一眼真实世界"。

于是循环成立:想一步 → 做一步 → 看一眼真实结果 → 再想一步。想的给做的指方向,做的给想的补事实。这就是"推理和行动交替"。

有两点要划清,避免误读:

第一,本文不把任何供应商的 reasoning_content 字段当成论文里的 Thought。论文的 Thought 是文字推理轨迹,现代 API 的 reasoning 字段是另一回事。这篇只验证一个更小、更可观察的问题:模型的下一步动作,是不是真的随观察到的真实结果改变。

第二,ReAct 并没有在所有任务上都赢过 CoT。论文配套结果里,HotpotQA 6-shot 下 ReAct 是 27.4,CoT 是 29.4,两者结合是 35.1。别把"ReAct 更强"当成普适结论——它带来的是外部反馈回路,不是万能药。

关键洞察:ReAct 的突破,不是"模型更聪明了",而是给模型的推理接上了外部反馈——每一步都踩在真实工具结果上,而不是模型自己的幻觉上。

三、工程上怎么承载:一个循环外壳

“想和做交替"这件事,落到代码上,就是一个循环:

for step in range(1, max_steps + 1):
    turn = model.decide(state.messages, tools)      # 模型"想":下一步干嘛
    if not turn.tool_calls:                          # 不想动手了 → 结束
        return state
    observation = execute(turn.tool_calls)           # 代码"做":执行,拿到真实结果
    state.messages.append(observation)               # 把"看"到的写回历史

循环是外壳,交替是灵魂。不是套个 while 就完事——有三件事,是让这个外壳真正立起来的前提:

1. 模型没有记忆,每次都得喂全。 模型每次调用都是独立的,不记得上一轮干了什么。所以循环要自己维护消息历史,每一轮把"问题 + 之前做过的 + 看到的结果"完整交给模型。它不是"记得”,是被每次提醒。

2. 循环必须有出口。 两个出口:模型不再调工具(直接给最终回答)→ 正常结束;到达 max_steps 上限 → 兜底强制停止。缺了后者,模型偶尔会陷入"反复调同一个工具"的怪圈,把额度烧穿。

3. Observation 必须真实,绝不让模型自己编。 这是 ReAct 的生命线。如果模型能自己写"北京天气 25℃",它就会偷懒编造。Observation 必须来自工具的真实执行结果,代码负责,模型无权编。在原生 tool_calls 里,这一点由 API 层保证:结果以 role=tool 消息回传,模型只能基于它推理。

这三件事,是"循环外壳怎么搭"的注意点,不是 ReAct 的主角。主角永远是那句:让"想"和"做"交替,让每一步想都踩在真实的"看"上。

四、看一次真实的"交替"

光说不够,跑一个真的。任务是"公司总部今天的天气"——它天然需要两步:先解析"公司总部"是哪个城市,再用这个城市查天气。

配套代码在 code/agent-tutorial/c05-react-agent/,Python 和 Java 双份,工具是三个本地只读夹具(不依赖外部 API,避免网络和额度污染控制流证据):

工具作用行为
resolve_company_address解析总部地址上海总部→上海,北京总部→北京,其余→待澄清
get_weather查城市天气上海→小雨 22℃,北京→晴 18℃
request_clarification信息不足时请求补充无副作用

真实模型跑出来的对照 Trace,是这篇最想让你看到的:

上海:resolve_company_address({"company_name": "上海总部"})
  → 看到 RESOLVED(city="上海")
  → get_weather({"city": "上海"})

北京:resolve_company_address({"company_name": "北京总部"})
  → 看到 RESOLVED(city="北京")
  → get_weather({"city": "北京"})

注意看第二步:模型"想"出的下一步,参数随它"看"到的结果改变——看到"上海"就查上海,看到"北京"就查北京。

这比"最后回答换了城市"强得多。它证明的不是"循环多跑了几步",而是模型真的在根据 Observation 调整下一步的"想"——这正是 ReAct 和"固定脚本多跑几遍"的分水岭。

跑起来:

# 离线回归,不需要 Key
cd code/agent-tutorial/c05-react-agent/python
python3 -m unittest test_react_agent.py

# 真实模型路径,Key 只从环境变量读
export LLM_API_URL='...' LLM_API_KEY='...' LLM_MODEL='...'
python3 react_agent.py

五、这一篇停在哪,下一篇往哪走

到这里,ReAct 的"交替"讲清了:模型想一步、做一步、看一步,每一步想都踩在真实结果上。

但有一个口子,正好是下一篇的入口。注意第三节那个循环,每一轮都把完整历史喂给模型。历史一长,问题就来了:

  • 每次都喂全,Token 越来越贵,也塞不下;
  • “看"到的结果、Run 当前走到哪一步,都只在内存里——进程一重启,全没了。

模型"想"和"做"的交替立起来了,但**“记得住、断得了、恢复得回来"这件事还没解决**。这是第六话要讲的:状态怎么显式存下来、怎么 checkpoint、怎么在崩溃后接着跑。


演示代码code/agent-tutorial/c05-react-agent/(Python:react_agent.py + test_react_agent.py;Java 21 等价实现见 java/)。真实模型路径只从 LLM_API_URL / LLM_API_KEY / LLM_MODEL 环境变量读配置,密钥不进代码、不进 Trace。 参考:ReAct 论文 arXiv 2210.03629;Google Research 官方介绍 React: Synergizing Reasoning and Acting in Language Models;Trace 为 2026-09 本机实测(Python 与 Java 双跑)。

留言

欢迎分享你的想法。评论提交后会在审核通过后显示。