- ReAct 是什么
ReAct = Reason + Act,也就是"推理 + 行动"。
它不是让大模型一次性直接回答问题,而是让模型像一个会用工具的人一样:
先思考现在知道什么,还缺什么
决定要不要调用工具
拿到工具结果后继续思考
直到信息足够,再给出最终答案
所以 ReAct 的重点不是"回答",而是"边想边做"。
- 最小闭环是什么
最小 ReAct 闭环就是 4 个东西:
LLM
Tool
State
Loop
可以记成一句话:
模型负责思考,工具负责取信息,状态负责记过程,循环负责推进到答案。
- 这 4 个部分分别做什么
3.1 LLM
LLM 是"大脑"。
它每一轮都会根据当前状态决定下一步要做什么,输出两种格式之一:
bash
Thought: ...
Action: search[...]
或
Thought: ...
Final Answer: ...
含义是:
Thought:当前怎么想
Action:下一步要做什么
Final Answer:已经可以结束了
3.2 Tool
Tool 是"工具"。
最小版本里一般只保留一个工具,避免把重点分散掉。
工具的作用不是替模型思考,而是帮模型拿外部信息。
3.3 State
State 是"记忆本"。
它记录整个过程里已经发生过什么,不然模型每一轮都会像失忆一样重新开始。
最关键的状态通常包括:
用户问题
system prompt
模型前几轮的输出
工具返回的 observation
当前步数
是否结束
最终答案
你现在最应该记住的一点是:
State 不只是用户问题,而是整个对话轨迹 history。
3.4 Loop
Loop 是"驱动器"。
它负责一轮一轮执行:
调用 LLM
解析输出
如果是 Action,就执行工具
把工具结果写回状态
再进入下一轮
如果是 Final Answer,就结束
- THINK / ACT / OBS 分别是什么
这是最小 ReAct 最核心的三个可观察步骤。
4.1 [THINK]
表示模型这一轮的思考。
它回答的是:
"我现在应该怎么判断这个问题?"
它不是工具结果,也不是最终答案,而是模型的当前判断。
4.2 [ACT]
表示模型决定执行的动作。
它回答的是:
"我接下来要做什么?"
这一步说明模型不再只是"说",而是真的开始调用工具。
4.3 [OBS]
表示工具执行后的观察结果。
它回答的是:
"我做完动作之后,看到了什么?"
这一步非常重要,因为 observation 会进入下一轮状态,变成模型继续思考的依据。
-
最小 ReAct 的完整流程
你可以把它记成下面这 7 步:
用户提出问题
系统把 问题 + 工具规则 交给模型
模型输出 Thought + Action 或 Thought + Final Answer
如果是 Action,执行工具
得到 Observation
把 模型输出 + Observation 写回状态
再次调用模型,直到输出 Final Answer
-
ReAct是不是只有一个prompt
本质上,ReAct 不是简单重复拼 prompt,而是:
不断更新 state,然后把最新 state 发给模型。
也就是说,模型每一轮看到的不是"同一个 prompt",而是:
原始问题
上一轮自己说过的话
工具给回来的 observation
当前还没解决的问题
所以更准确的说法不是:
"不断改 prompt"
而是:
"不断更新状态,再让模型基于状态继续推理。"
-
最小 ReAct 最容易出错的地方
7.1 Tool 返回质量太差
比如搜索结果只有视频、标题党、没有日期、没有数值。
这时模型就算会推理,也是在垃圾信息上推理。
所以:
Tool 不只是要能调用,还要返回高质量 observation。
7.2 模型太早结束
比如看到一个"看起来像最新"的网页就直接输出 Final Answer。
这就是典型的"证据不够就收尾"。
所以需要加入约束:
没看到日期不能结束
没看到数值不能结束
没确认是今天的数据不能结束
7.3 时间理解错误
像"今日、今天、最新"这种词,如果不先锚定成绝对日期,模型就容易乱猜。
比如你这个 case 里最关键的一点就是:
"今日"必须先转成 2026-05-26 这样的绝对日期。
7.4 把 observation 当成展示,而不是输入
很多人会觉得 [OBS] 只是打印给人看。
其实不是。
真正重要的是:
Observation 会回写到 state,成为下一轮推理的输入。
8.用一句话解释最小 ReAct
最小 ReAct 就是:让大模型先思考要不要调用工具,如果要,就执行工具并把结果写回状态,再继续思考,直到证据足够时输出最终答案。
demo
bash
"""
最小可运行的 ReAct Agent --- 不依赖 LangChain/LangGraph,只有核心闭环。
LLM (Qwen) + Tool (Tavily 搜索) + State (消息历史) + Loop (Think-Act-Observe)
用法:
cd step_1
uv run python tests/demo1.py
uv run python tests/demo1.py --question "2026年诺贝尔物理学奖得主是谁?"
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
from pathlib import Path
import httpx
# ============================================================
# 环境配置(复用 step_1/.env)
# ============================================================
def load_env(env_path: str | Path) -> None:
"""极简 .env 加载器,避免依赖 python-dotenv。"""
path = Path(env_path)
if not path.exists():
return
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, val = line.partition("=")
os.environ.setdefault(key.strip(), val.strip())
def require_env(name: str) -> str:
val = os.getenv(name, "").strip()
if not val:
raise SystemExit(f"[ERROR] 缺少环境变量: {name}")
return val
# ============================================================
# Tool --- Tavily 搜索
# ============================================================
def search_tavily(query: str) -> str:
"""
唯一允许的工具:搜索。
直接使用 httpx 调用 Tavily API,不依赖 tavily SDK。
"""
api_key = require_env("TAVILY_API_KEY")
url = "https://api.tavily.com/search"
payload = {
"api_key": api_key,
"query": query,
"search_depth": "advanced",
"max_results": 3,
}
try:
resp = httpx.post(url, json=payload, timeout=30.0)
resp.raise_for_status()
data = resp.json()
except Exception as e:
return f"[搜索出错] {e}"
results = data.get("results", [])
if not results:
return "没有找到相关结果。"
lines = []
for item in results[:3]:
title = (item.get("title") or "").strip()
content = " ".join((item.get("content") or "").split())[:200]
url = (item.get("url") or "").strip()
lines.append(f"- {title}: {content}... ({url})")
return "\n".join(lines)
# ============================================================
# LLM --- Qwen (DashScope)
# ============================================================
TOOL_DESC = """
你是一个严谨的 ReAct Agent。
每次回复必须严格遵循以下两种格式之一:
──────────────────────────────────
格式 1 ------ 需要搜索时:
Thought: 你当前的思考(简短)
Action: search[<搜索关键词>]
格式 2 ------ 已有足够信息时:
Thought: 你当前的思考(简短)
Final Answer: 你对用户的最终回答
──────────────────────────────────
规则:
- 你只有一个工具:search,用于搜索互联网信息
- 当需要外部信息时使用 Action,当信息足够时使用 Final Answer
- 不要编造其他工具名
- Thought 保持简洁
在最终回答前,你必须自检:
1. 我的回答是否体现了时间约束?
2. 我的回答是否体现了地点/场景约束?
3. 我的回答是否明确了涉及的人物角色?
4. 我的回答是否围绕具体事件展开?
5. 我的建议是否可执行?
6. 我是否编造了用户没有提供的信息?
7. 如果信息缺失,我是否明确说明了假设?
""".strip()
def call_llm(messages: list[dict]) -> str:
"""调用 Qwen API,返回模型输出的文本。"""
base_url = require_env("QWEN_API_BASE")
api_key = require_env("QWEN_API_KEY")
model = require_env("QWEN_MODEL")
payload = {
"model": model,
"messages": messages,
"temperature": 0.2,
}
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
try:
resp = httpx.post(base_url, headers=headers, json=payload, timeout=60.0)
resp.raise_for_status()
data = resp.json()
return data["choices"][0]["message"]["content"].strip()
except (KeyError, IndexError, TypeError) as e:
raise RuntimeError(f"LLM 返回异常: {e} | {resp.text}") from e
# ============================================================
# State --- Agent 状态
# ============================================================
class AgentState:
"""Agent 的完整状态:问题、消息历史、步数、完成标志。"""
def __init__(self, question: str):
self.question = question
self.messages: list[dict] = [
{"role": "system", "content": TOOL_DESC},
{"role": "user", "content": f"Question: {question}"},
]
self.step_count = 0
self.done = False
self.final_answer: str | None = None
# ============================================================
# 解析模型输出
# ============================================================
def parse_output(text: str) -> dict:
"""
解析模型输出为结构化结果。
返回: {"thought": str, "action": str|None, "input": str|None, "final": str|None}
"""
text = text.strip()
# 提取 Thought
thought = ""
m = re.search(r"Thought:\s*(.+?)(?=\n(?:Action|Final Answer):|\Z)", text, re.DOTALL)
if m:
thought = m.group(1).strip()
# 尝试匹配 Final Answer
m = re.search(r"Final Answer:\s*(.+)", text, re.DOTALL)
if m:
final = m.group(1).strip()
return {"thought": thought, "action": None, "input": None, "final": final}
# 尝试匹配 Action
m = re.search(r"Action:\s*(\w+)\[(.+)\]", text, re.DOTALL)
if m:
return {
"thought": thought,
"action": m.group(1).strip(),
"input": m.group(2).strip(),
"final": None,
}
raise ValueError(
f"无法解析模型输出。期望包含 Action: search[...] 或 Final Answer: ...\n"
f"原始输出:\n{text}"
)
# ============================================================
# Agent --- 核心 ReAct 循环
# ============================================================
def run_react_agent(question: str, max_steps: int = 10) -> str:
"""ReAct 主循环:State → Think → Act → Observe → State → ..."""
state = AgentState(question)
print(f"\n{'='*60}")
print(f"问题: {question}")
print(f"{'='*60}\n")
while not state.done and state.step_count < max_steps:
state.step_count += 1
print(f"\n--- Step {state.step_count} ---")
# ── [LLM] 基于当前状态生成下一步 ──
raw = call_llm(state.messages)
# ── [THINK] ──
parsed = parse_output(raw)
print(f"[THINK] {parsed['thought']}")
if parsed["final"]:
state.done = True
state.final_answer = parsed["final"]
print(f"[ANSWER] {parsed['final']}")
break
# ── [ACT] 执行工具 ──
if parsed["action"] != "search":
raise ValueError(f"未知工具: {parsed['action']},只允许 search")
print(f"[ACT] search: {parsed['input']}")
observation = search_tavily(parsed["input"])
# ── [OBS] 观察结果 ──
print(f"[OBS] {observation[:300]}{'...' if len(observation) > 300 else ''}")
# ── 把本轮输出 + 观察结果追加到状态,形成下一轮的输入 ──
state.messages.append({"role": "assistant", "content": raw})
state.messages.append(
{
"role": "user",
"content": f"Observation: {observation}\n\n继续 ReAct 循环。如果已有足够信息,输出 Final Answer;否则输出 Action: search[...]。",
}
)
if not state.done:
return "[未能在限定步数内完成]"
return state.final_answer or "[模型未返回有效答案]"
# ============================================================
# CLI 入口
# ============================================================
def main():
parser = argparse.ArgumentParser(
description="最小 ReAct Agent --- 仅 LLM + Tool + State + Loop"
)
parser.add_argument("--question", "-q", help="直接传入问题(不传则交互式输入)")
args = parser.parse_args()
# 从 step_1/.env 加载环境变量
env_path = Path(__file__).resolve().parent.parent / ".env"
load_env(env_path)
# 验证必要环境变量
for name in ("TAVILY_API_KEY", "QWEN_API_BASE", "QWEN_API_KEY", "QWEN_MODEL"):
require_env(name)
question = args.question or input("Question: ").strip()
if not question:
raise SystemExit("问题不能为空。")
result = run_react_agent(question)
print(f"\n{'='*60}")
print(f"最终答案: {result}")
print(f"{'='*60}")
if __name__ == "__main__":
main()