首页/Agent 核心/ReAct 模式与主循环
Agent 核心 · 第 08 章

ReAct 模式与主循环

第 7 章我们把工具接到了模型手上,但每一步都是你牵着模型走:何时调用、传什么参数,全由代码写死。Agent 的关键一跃是反过来——让模型自己决定何时用工具、何时停止。这一章分两步走:先回到原生 tool calling…

约 24 分钟9,723 字11 个代码示例

第 7 章我们把工具接到了模型手上,但每一步都是你牵着模型走:何时调用、传什么参数,全由代码写死。Agent 的关键一跃是反过来——让模型自己决定何时用工具、何时停止。这一章分两步走:先回到原生 tool calling 出现之前的 2022 年,那时人们就已经发明了 Agent,用的是纯文本协议和正则解析——我们故意不用第 7 章的工业化设施,手工搓一遍 ReAct,你会看清 Agent 的思想内核只是一个“思考 → 行动 → 观察”的循环,所有现代 Agent 框架都是它的变体。然后造引擎:Agent 的形式定义出人意料地简单——一个“调用模型 → 执行工具 → 回传结果”的 while 循环,但工程上真正的难点全在循环的边缘:什么时候停、怎么防失控、怎么知道它每一步在干什么。最终你会得到一个可观测、有保护、能复用的 Agent 类——第 9 章的搜索助手直接在它上面长出来。

1. 思想源头:2022 年,没有工具接口的年代

ReAct 出自 2022 年 10 月的论文《ReAct: Synergizing Reasoning and Acting in Language Models》(Yao 等,普林斯顿)。彼时的背景:

  • 模型只有“补全”接口(第 4 章的 base 形态),没有 chat,更没有 tools 参数
  • CoT(思维链)刚被发现:让模型先写推理过程再给答案,正确率大增
  • 但 CoT 有个死穴:模型只能“想”,不能“看”——推理再长,信息还是只有输入里的那些

论文的核心洞察一句话可以说完:

把“行动”插入“思考”之间,让模型边收集信息边推理。

名字即方案:Reasoning + Acting。对比三种范式:

范式 过程 缺陷
纯 CoT 思考 → 思考 → 答案 想象力丰富但无凭无据(幻觉)
纯行动 行动 → 行动 → 答案 机械执行,不会变通
ReAct 思考 → 行动 → 观察 → 思考 → … 两者互补

“观察”(Observation)是关键增量:每次行动后,真实世界的反馈进入上下文,成为下一步思考的依据——这就是第 4 章说“生成即上下文”的 Agent 版。

2. 文本协议长什么样

ReAct 原始形态是一个严格的文本格式,模型按格式续写,程序用正则解析:

Question: 特斯拉 2024 年交付了多少辆车?
Thought: 我需要搜索特斯拉 2024 年的交付数据
Action: search[特斯拉 2024 交付量]
Observation:                                        ← 程序停在这里!

协议三要素:

  • Thought:模型的推理——解释“为什么接下来要这么做”
  • Action:工具名[参数] 格式的动作声明
  • Observation:工具执行结果,由程序写入,不由模型生成

程序在 Action 后面停下(用 stop 序列——第 4 章埋的参数在此实战)、解析动作、执行工具、把结果作为 Observation 拼回去、让模型继续。循环往复,直到模型输出 Final Answer。

3. 手搓一个文本协议 ReAct

完整实现只要 40 行左右。先准备一个知识库工具:

from openai import OpenAI

client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")

# 模拟一个可检索的知识库(第 9 章换成真实搜索 API)
KB = {
    "特斯拉 2024 交付量": "特斯拉 2024 年全球交付约 179 万辆",
    "比亚迪 2024 交付量": "比亚迪 2024 年销量约 427 万辆",
    "qwen3 参数": "Qwen3 系列从 0.6B 到 235B 共 8 个规模",
}

def search(query: str) -> str:
    for key, val in KB.items():
        if key in query or query in key:
            return val
    return "未找到相关信息,建议换一个关键词"

然后是 ReAct 引擎:

import re

TOOLS_DOC = """可用工具:
- search[查询词]:在知识库中检索信息"""

PROMPT_TPL = """{tools_doc}

严格按以下格式逐步回答问题(一次只输出一个 Thought 或 Action,输出到 Action 后停下):

Question: {question}
"""

def react(question: str, max_steps: int = 6) -> str:
    transcript = ""      # 累积的 Thought / Action / Observation 记录

    for step in range(max_steps):
        prompt = PROMPT_TPL.format(tools_doc=TOOLS_DOC, question=question)
        prompt += transcript + "/no_think"      # qwen3 的软开关:关闭内置思考模式
        resp = client.chat.completions.create(
            model="qwen3:4b",
            messages=[{"role": "user", "content": prompt}],
            stop=["Observation:"],              # 第 4 章的 stop 序列:生成到此为止
            temperature=0,
        )
        output = resp.choices[0].message.content.strip()
        transcript += f"{output}\nObservation:"  # 拼回上下文,等待填入观察
        print(f"--- step {step + 1} ---\n{output}\n")

        # 终止条件 1:给出最终答案
        m = re.search(r"Final Answer:\s*(.+)", output, re.S)
        if m:
            return m.group(1).strip()

        # 终止条件 2:发起工具调用
        m = re.search(r"Action:\s*(\w+)\[(.+?)\]", output, re.S)
        if not m:
            # 模型没按格式来:把观察置为提示,请它重新遵守格式
            transcript += "(格式错误:请输出 Thought: 或 Action: 工具[参数])\n"
            continue

        tool_name, arg = m.group(1), m.group(2).strip()
        if tool_name == "search":
            observation = search(arg)
        else:
            observation = f"错误:不存在工具 {tool_name}"
        transcript += f" {observation}\n"

    return "(达到最大步数限制)"

print("答案:", react("比亚迪和特斯拉 2024 年谁卖得多?多多少?"))

一次典型的运行轨迹(含观察结果的整理版):

--- step 1 ---
Thought: 我需要先查比亚迪 2024 年的销量
Action: search[比亚迪 2024 交付量]
Observation: 比亚迪 2024 年销量约 427 万辆

--- step 2 ---
Thought: 拿到比亚迪的数据了,接着查特斯拉
Action: search[特斯拉 2024 交付量]
Observation: 特斯拉 2024 年全球交付约 179 万辆

--- step 3 ---
Thought: 两边数据都有了:427 - 179 = 248,比亚迪多
Final Answer: 比亚迪 2024 年销量约 427 万辆比特斯拉的约 179 万辆多,多了约 248 万辆。

注意 step 3 里模型心算 427-179——这是教学示例的偷懒,第 7 章说过算术应该强制走工具。也注意它做了件漂亮的事:自己规划了“先查 A 再查 B 再对比”的多步策略,没有任何代码硬编码流程。这就是 ReAct 的魔力:策略是模型现场生成的,你的代码只提供“执行一步”的机制。

4. 从文本协议到原生 Tool Calling

把本章前半的文本协议和第 7 章的工业化版本对照,你会发现一一映射:

ReAct 文本协议 现代 tool calling
Thought: ... content 里的推理(或独立字段 reasoning_content)
Action: search[...] tool_calls 结构化对象
Observation: ... role=tool 消息
stop 序列截断生成 服务端解析后直接返回
正则解析 Action SDK 反序列化 function.arguments
工具文档拼在 prompt 里 tools 参数注入

所以第 7 章说"tool calling 是结构化输出 + 后训练”并不完整,完整版是:原生 tool calling = ReAct 协议的结构化 + 工业化。模型在训练中就学会了这个协议,解析交给服务端,可靠性从“正则能不能匹配上”升级为“API 保证结构正确”。

反过来这也解释了为什么手搓版有教学价值:当你要调试一个 Agent 的行为时,最终都要回到“模型看到了什么、生成了什么”这个层面——文本协议把这一切摊开在你眼前。

5. ReAct 之外:三种循环变体

ReAct 是“边想边做”,但它不是唯一的循环哲学。了解三个重要变体(第 14 章 Graph Engineering 会正面处理它们):

变体 思路 适用
Plan-and-Execute 先生成完整计划,再逐步执行 步骤间依赖明确的任务(先拆解再干活)
Reflexion 执行后自我反思失败原因,带着教训重试 调试类、写作类需要自我改进的任务
LATS 树搜索:同时探索多条行动路径再择优 解空间大、可回溯的任务(如代码修复)

ReAct 的适用面最广(默认选择),但它有两个已知弱点,工程上要主动补偿:

  1. 短视:每步只看下一步,缺全局规划——复杂任务容易绕路。补偿:系统提示词要求先列计划
  2. 不可回溯:走了错路只能硬着头皮继续。补偿:轮次上限 + 任务级重试(本章第 7 节)

万事俱备。第 7 章给了协议和零件、本章前半给了思想。接下来把它们组装成真正的引擎——那个能自主循环、有终止条件、可观测的 Agent 主循环。

6. 心脏:十行的核心循环

先看最小可用的 Agent 主循环,用一句话定义就是:

反复调用模型,直到模型不再请求调用工具为止。

def agent_loop(user_input, registry, messages):
    messages.append({"role": "user", "content": user_input})
    while True:
        resp = client.chat.completions.create(
            model="qwen3:4b", messages=messages, tools=registry.schemas(),
        )
        msg = resp.choices[0].message
        messages.append(msg)

        if not msg.tool_calls:              # 终止:模型不再要工具 → 最终回答
            return msg.content

        for tc in msg.tool_calls:           # 执行所有调用并回传
            result = registry.dispatch(tc.function.name, tc.function.arguments)
            messages.append({"role": "tool", "tool_call_id": tc.id, "content": result})

这段代码没有任何新东西——第 7 章的协议与 Registry,加上本章前半的循环思想。但它有一个致命缺陷:while True 没有出口。模型一旦陷入“反复调用同一工具”的循环,你的程序就永久卡死(并持续烧 token)。

7. 终止条件:四道闸门

生产级 Agent 的循环必须有四道独立的终止闸门:

闸门 触发条件 防的是什么
自然终止 模型不再发起 tool_calls ——(正常出口)
轮次上限 循环次数 ≥ max_rounds 死循环、反复横跳
token 预算 累计消耗 ≥ budget 成本失控(第 4 章的经济学)
重复检测 连续 N 次调用完全相同 模型“卡带”:同参数反复调同一工具

死循环的两种典型形态,见一次就认识:

形态一(鬼打墙):
  search["qwen3 发布时间"] → 结果不满意 → search["qwen3 发布时间"] → ...

形态二(打乒乓):
  read_file[a.txt] → read_file[b.txt] → read_file[a.txt] → read_file[b.txt] → ...

成因通常是:工具结果让模型不满意,但模型缺乏“换策略”的元认知。防御代码:

def is_stuck(recent_calls: list[tuple], window: int = 3) -> bool:
    """最近 window 次调用完全相同时判定卡死"""
    if len(recent_calls) < window:
        return False
    return len(set(recent_calls[-window:])) == 1

8. 事件系统:给循环装上仪表盘

Agent 是黑盒中的黑盒——你看不见模型在想什么,只能看见它的行为。事件(events)是最轻量的观测手段,也是第 15 章可观测性的地基。原理是观察者模式:循环在关键节点广播事件,监听器各取所需:

# 事件类型常量
class Ev:
    ROUND_START = "round_start"     # 一轮开始
    TOOL_CALL    = "tool_call"       # 模型请求调用工具
    TOOL_RESULT = "tool_result"     # 工具执行完毕
    FINISH       = "finish"         # 自然终止
    ABORT        = "abort"          # 异常终止(超轮次/超预算)
    NUDGE        = "nudge"          # 检测到卡死,注入纠偏提示

监听器可以是打日志、打印、推 WebSocket、写入数据库——循环本身不关心。这个设计就是“业务逻辑与观测逻辑分离”。

9. 完整的 Agent 类

把四道闸门 + 事件系统 + 第 7 章的 Registry 组装起来(历史沿用第 6 章的 messages 管理思路)。这是整个教程的核心产出之一,值得逐行读懂:

from openai import OpenAI

client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")

class Agent:
    def __init__(self, system: str, registry, model: str = "qwen3:4b",
                 max_rounds: int = 8, token_budget: int = 20_000):
        self.registry = registry
        self.model = model
        self.max_rounds = max_rounds
        self.token_budget = token_budget
        self.messages = [{"role": "system", "content": system}]
        self.tokens_used = 0
        self.recent_calls: list[tuple] = []       # 重复检测用
        self.listeners = []                        # 事件监听器

    # ---------- 事件系统 ----------
    def on(self, listener):
        """注册监听器:fn(event: str, data: dict)"""
        self.listeners.append(listener)

    def emit(self, event: str, **data):
        for fn in self.listeners:
            fn(event, data)

    # ---------- 主入口 ----------
    def run(self, user_input: str) -> str:
        self.messages.append({"role": "user", "content": user_input})

        for round_no in range(1, self.max_rounds + 1):
            # 闸门 3:token 预算
            if self.tokens_used > self.token_budget:
                self.emit(Ev.ABORT, reason="token_budget_exceeded")
                return "(已达 token 预算上限,任务中止)"

            self.emit(Ev.ROUND_START, round=round_no, tokens=self.tokens_used)
            resp = client.chat.completions.create(
                model=self.model,
                messages=self.messages,
                tools=self.registry.schemas(),
            )
            msg = resp.choices[0].message
            self.messages.append(msg)
            self.tokens_used += resp.usage.total_tokens

            # 自然终止
            if not msg.tool_calls:
                self.emit(Ev.FINISH, round=round_no, tokens=self.tokens_used)
                return msg.content

            # 执行本轮所有调用
            for tc in msg.tool_calls:
                signature = (tc.function.name, tc.function.arguments)
                self.recent_calls.append(signature)
                self.emit(Ev.TOOL_CALL, **{"call": signature})

                result = self.registry.dispatch(*signature)
                self.emit(Ev.TOOL_RESULT, **{"call": signature, "result": result[:200]})
                self.messages.append({
                    "role": "tool",
                    "tool_call_id": tc.id,
                    "content": result,
                })

            # 闸门 4:重复检测(最近 3 次完全相同)
            if is_stuck(self.recent_calls):
                self.emit(Ev.NUDGE, calls=self.recent_calls[-3:])
                self.messages.append({                # 告知模型并给出最后一次机会
                    "role": "user",
                    "content": "检测到你连续多次发起完全相同的调用。请改变策略或给出最终答案。",
                })

        # 闸门 2:轮次上限
        self.emit(Ev.ABORT, reason="max_rounds_exceeded")
        return "(已达最大轮次上限,任务未完成)"

用第 7 章注册的工具跑起来,配一个打印监听器:

agent = Agent(
    system="你是一个文件助手,可以读写本地文件、做精确计算。",
    registry=registry,          # 第 7 章的 Registry 实例
)

def logger(event, data):
    if event == Ev.TOOL_CALL:
        print(f"  ⚙ 调用 {data['call'][0]}({data['call'][1][:60]})")
    elif event == Ev.TOOL_RESULT:
        print(f"  ↳ {data['result'][:80]}")
    elif event == Ev.FINISH:
        print(f"  ✔ 完成,共 {data['round']} 轮,消耗 {data['tokens']} tokens")
    elif event == Ev.ABORT:
        print(f"  ✖ 中止:{data['reason']}")

agent.on(logger)
print(agent.run("帮我算一下 (128+256)*3 的结果,写入 result.txt,然后读回来确认"))

一个可能的运行轨迹:

  ⚙ 调用 calculate({"expression": "(128+256)*3"})
  ↳ 1152
  ⚙ 调用 write_file({"path": "result.txt", "content": "1152"})
  ↳ 已写入 result.txt(4 字符)
  ⚙ 调用 read_file({"path": "result.txt"})
  ↳ 1152
  ✔ 完成,共 4 轮,消耗 2841 tokens

注意模型的行为策略完全是它自己规划的(算→写→验证),我们只给了工具和循环。这就是 Agent:能力来自模型,可靠性来自工程。

10. 循环里的边角案例

四个高频的“啊这”时刻,提前打疫苗:

① 模型不调工具,直接瞎答。该查文件的时候它凭记忆编。对策:系统提示词明确“涉及 X 必须先调用工具”(第 5 章的边界层),再不行用第 7 章的 tool_choice="required" 强制。

② 工具结果过大撑爆上下文。第 7 章的截断原则在 Agent 里加倍重要——工具结果会永久留在 messages 里,后续每轮都在为它付费。对大结果,截断后让模型“按需再取”(比如分页读取)。

③ 中途想换目标。用户第二轮说“算了,帮我改成 B”,历史里全是 A 的痕迹。对策:系统提示词声明“以最新指令为准”;重活可以开新 Agent(第 12 章子代理的清洁上下文)。

④ finish_reason 是 length。第 5 章埋的雷在 Agent 里更常见:轮次上下文越滚越大,最终回答被截断。对策:预算闸门提前拦住;检测到 length 时把已生成部分 + "请继续"回传续写。

11. 这个设计的边界

诚实说明这个 Agent 类没做什么(后面章节逐一补上):

  • 没有权限控制——write_file 可以写任何路径(第 15 章:安全与权限)
  • 没有记忆——会话结束即遗忘(第 15 章:Agent 记忆)
  • 历史无限增长——没有接第 6 章的裁剪策略(第 13 章:Context Engineering 会统一处理)
  • 单线程——工具串行执行(第 12 章:子代理并行)
  • 上下文装不下时没有升级手段——检索、压缩等策略分散在第 13、15 章

这不是缺陷清单,是学习路线图——每个缺口对应后面一个专章。

本章小结

  • ReAct = 推理与行动交替,Observation 让真实世界的反馈进入上下文
  • 文本协议三要素 Thought/Action/Observation;stop 序列 + 正则解析就能实现
  • 40 行代码的 Agent 验证了:策略由模型现场生成,代码只提供执行机制
  • 原生 tool calling 是 ReAct 的结构化工业化版本,两者一一映射
  • 变体:Plan-and-Execute(先规划)、Reflexion(自反思)、LATS(树搜索);ReAct 的短视与不可回溯需工程补偿
  • Agent 主循环 = while + 终止条件:模型不再调用即自然终止
  • 四道闸门:自然终止、轮次上限、token 预算、重复检测——while True 在生产里是事故
  • 事件系统(观察者模式)是最轻量的可观测手段:循环广播,监听器各取所需
  • Agent 类三件套:Session 式历史管理、Registry 工具分发、事件仪表盘
  • 能力来自模型(策略自规划),可靠性来自工程(闸门与兜底)

引擎造完了。下一章,把它开上真实世界的高速公路——一个能联网查证、给出带引用答案的搜索 Agent。

下一篇:09 · 实战:Deep Research Agent —— 综合运用第 5、6、7 章与本章的全部装备,造一个"不编造、带引用、会追问"的研究助手。