最小实现ReAct Agent

  1. ReAct 是什么
    ReAct = Reason + Act,也就是"推理 + 行动"。

它不是让大模型一次性直接回答问题,而是让模型像一个会用工具的人一样:

先思考现在知道什么,还缺什么

决定要不要调用工具

拿到工具结果后继续思考

直到信息足够,再给出最终答案

所以 ReAct 的重点不是"回答",而是"边想边做"。

  1. 最小闭环是什么
    最小 ReAct 闭环就是 4 个东西:

LLM

Tool

State

Loop

可以记成一句话:

模型负责思考,工具负责取信息,状态负责记过程,循环负责推进到答案。

  1. 这 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,就结束

  1. THINK / ACT / OBS 分别是什么
    这是最小 ReAct 最核心的三个可观察步骤。

4.1 [THINK]

表示模型这一轮的思考。

它回答的是:

"我现在应该怎么判断这个问题?"

它不是工具结果,也不是最终答案,而是模型的当前判断。

4.2 [ACT]

表示模型决定执行的动作。

它回答的是:

"我接下来要做什么?"

这一步说明模型不再只是"说",而是真的开始调用工具。

4.3 [OBS]

表示工具执行后的观察结果。

它回答的是:

"我做完动作之后,看到了什么?"

这一步非常重要,因为 observation 会进入下一轮状态,变成模型继续思考的依据。

  1. 最小 ReAct 的完整流程

    你可以把它记成下面这 7 步:

    用户提出问题

    系统把 问题 + 工具规则 交给模型

    模型输出 Thought + Action 或 Thought + Final Answer

    如果是 Action,执行工具

    得到 Observation

    把 模型输出 + Observation 写回状态

    再次调用模型,直到输出 Final Answer

  2. ReAct是不是只有一个prompt

    本质上,ReAct 不是简单重复拼 prompt,而是:

    不断更新 state,然后把最新 state 发给模型。

    也就是说,模型每一轮看到的不是"同一个 prompt",而是:

    原始问题

    上一轮自己说过的话

    工具给回来的 observation

    当前还没解决的问题

    所以更准确的说法不是:

    "不断改 prompt"

    而是:

    "不断更新状态,再让模型基于状态继续推理。"

  3. 最小 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()
相关推荐
测试开发-学习笔记4 分钟前
代码详细解释
python
u01196082314 分钟前
ray-k8s部署
python
PAK向日葵3 小时前
我用 C++ 写了一个轻量级 Python 虚拟机,刚刚开源
c++·python·开源
财经资讯数据_灵砚智能4 小时前
基于全球经济类多源新闻的NLP情感分析与数据可视化(日间)2026年5月26日
大数据·人工智能·python·信息可视化·自然语言处理·ai编程·灵砚智能
我材不敲代码5 小时前
Python基础:列表详解、增删改查及常用高阶操作
开发语言·windows·python
AI玫瑰助手5 小时前
Python运算符:成员运算符(in/not in)的使用场景
开发语言·python·信息可视化
Warson_L5 小时前
python - class 入门
python
水木流年追梦5 小时前
大模型入门-大模型分布式训练2
开发语言·分布式·python·算法·正则表达式·prompt
ZHANG8023ZHEN6 小时前
Diffusion 数学推理
人工智能·python·机器学习
海天一色y6 小时前
SGLang 本地部署 Qwen3-8B 大模型实战指南
python·sglang