零基础复现Claude Code(三):灵魂篇——ReAct循环的骨架

0 阅读14分钟

开篇:人类解决问题的通用模式

想象你在玩一个密室逃脱游戏。

你走进房间,看到一张桌子、三把钥匙、一个上锁的箱子。你会怎么做?

[第1轮]
👀 观察:房间里有桌子、三把钥匙(金色、银色、铜色)、一个上锁的箱子
🧠 思考:箱子可能藏着线索,我应该试试哪把钥匙能打开它
🔧 行动:拿起金色钥匙,尝试开锁
👀 观察:锁没开,钥匙拧不动

[第2轮]
🧠 思考:金色的不行,试试银色的
🔧 行动:换银色钥匙,尝试开锁
👀 观察:咔哒一声,箱子打开了!里面有一张纸条

[第3轮]
🧠 思考:纸条上写着密码,我应该找找房间里的密码锁
🔧 行动:环顾四周,发现门上有个密码锁
👀 观察:输入密码后,门开了!

这就是人类解决问题的通用模式:观察(Observe)→ 思考(Think)→ 行动(Act)→ 再观察 → 循环

Agent也是这样工作的。这个循环有个专业名字:ReAct(Reasoning + Acting)

💡 回到第一篇的"实习生"比喻:ReAct循环就是实习生的工作习惯。

你给实习生一个任务:"帮我修Bug"。他不会一次性干完,而是:

  • 先看看代码(Observe)
  • 想想问题在哪(Think)
  • 改一下试试(Act)
  • 看看改对了没(Observe)
  • 如果没对,继续想办法(Think)...

这种"小步快跑、边做边调整"的方式,比"一次性规划所有步骤"更灵活、更可靠。

这一篇,我们就要实现这个循环的骨架——Agent的"灵魂"。

本节目标

读完这篇文章,你将:

  • 理解ReAct循环的本质:为什么Agent需要循环,而不是一次性回答
  • 掌握循环的完整流程:从模型输出到解析、执行、反馈的闭环
  • 实现第一个可运行的循环:虽然还没有真实工具,但能看到循环在转动

原理深潜:ReAct循环的完整解剖

📍 回到第一篇的公式

还记得第一篇文章中我们建立的公式吗?

初始状态 S_0 = (用户指令, 空对话历史)

循环 t = 0, 1, 2, ...:
    Thought_t, Action_t = LLM(S_t)  ← 第2篇解决了这部分
    Observation_t = Execute(Action_t)  ← 本篇要解决这部分
    S_{t+1} = S_t + (Thought_t, Action_t, Observation_t)

第2篇我们解决了LLM(S_t):让模型输出结构化的Thought和Action。

这一篇我们要解决Execute(Action_t)和整个循环

  1. 如何解析模型输出的Action
  2. 如何执行Action(先用模拟,下一篇才接入真实工具)
  3. 如何把Observation反馈给模型
  4. 如何判断循环何时结束

公式化定义:状态更新函数

用更数学化的方式表达ReAct循环:

Agent状态更新:S_{t+1} = Observe(Act(Think(S_t)))

展开:
1. Think(S_t) → (Thought_t, Action_t)  # 模型思考
2. Act(Action_t) → Observation_t       # 执行工具
3. Observe(Observation_t) → S_{t+1}   # 更新状态

用人话翻译

  • Think:大脑根据当前状态,决定下一步做什么
  • Act:手脚执行大脑的决定
  • Observe:眼睛看到执行结果,更新大脑的认知

这三个步骤缺一不可,形成闭环反馈

对比教学:普通对话 vs ReAct Agent

让我们看看两种模式的本质区别:

模式A:普通对话模型(一问一答)

用户:帮我修Bug
模型:好的,你需要先找到Bug在哪个文件,然后修改代码,最后运行测试。
[结束]

问题:模型只会"说",不会"做"。它给了建议,但文件没有被读取,代码没有被修改。

模式B:ReAct Agent(循环执行)

用户:帮我修Bug

[第1轮]
模型:Thought: 我需要先看看代码
      Action: read_file('main.py')
系统:Observation: [文件内容...]

[第2轮]
模型:Thought: 我看到第42行有问题
      Action: write_file('main.py', [修改后的内容])
系统:Observation: 文件已保存

[第3轮]
模型:Thought: 我应该跑测试验证
      Action: run_cmd('pytest')
系统:Observation: 测试通过

[完成]
模型:Bug已修复,测试通过

看到区别了吗?

  • 模式A:模型是"顾问",只出主意
  • 模式B:模型是"执行者",能看到结果并调整

这就是为什么Agent需要循环——每一步的结果都会影响下一步的决策

循环的数据流图解

让我们用ASCII图展示一次完整的循环:

PC端完整版:

┌─────────────────────────────────────────────────────────┐
│  第 t 轮循环开始                                         │
│  当前状态 S_t = [用户指令, 历史对话]                     │
└─────────────────────────────────────────────────────────┘
                        ↓
┌─────────────────────────────────────────────────────────┐
│  步骤1:Think - 模型思考                                 │
│  输入:S_t                                               │
│  输出:Thought_t = "我需要读取main.py"                   │
│        Action_t = "read_file('main.py')"                │
└─────────────────────────────────────────────────────────┘
                        ↓
┌─────────────────────────────────────────────────────────┐
│  步骤2:Act - 执行工具                                   │
│  输入:Action_t = "read_file('main.py')"                │
│  执行:调用read_file函数                                 │
│  输出:Observation_t = "文件内容:def main()..."         │
└─────────────────────────────────────────────────────────┘
                        ↓
┌─────────────────────────────────────────────────────────┐
│  步骤3:Observe - 更新状态                               │
│  S_{t+1} = S_t + (Thought_t, Action_t, Observation_t)   │
│  新状态包含了这一轮的所有信息                             │
└─────────────────────────────────────────────────────────┘
                        ↓
                  判断是否继续?
                  /          \
            有Action         无Action
                ↓               ↓
          进入第t+1轮        循环结束

手机端简化版:

S_t (当前状态)
    ↓
Think (思考)
→ Thought + Action
    ↓
Act (执行)
→ Observation
    ↓
Observe (更新)
→ S_{t+1} (新状态)
    ↓
有Action? → 继续循环
无Action? → 结束

对话历史累积示意图

理解S_t如何增长是理解ReAct循环的关键。让我们看一个具体例子:

初始状态(t=0):

messages = [
    {"role": "system", "content": "你是一个工程师Agent..."},
    {"role": "user", "content": "帮我修Bug"}
]

第1轮后(t=1):

messages = [
    {"role": "system", "content": "你是一个工程师Agent..."},
    {"role": "user", "content": "帮我修Bug"},
    {"role": "assistant", "content": "Thought: 我需要读文件\nAction: read_file('main.py')"},
    {"role": "user", "content": "Observation: 文件内容..."}  ← 注意这里
]

第2轮后(t=2):

messages = [
    {"role": "system", "content": "你是一个工程师Agent..."},
    {"role": "user", "content": "帮我修Bug"},
    {"role": "assistant", "content": "Thought: 我需要读文件\nAction: read_file('main.py')"},
    {"role": "user", "content": "Observation: 文件内容..."},
    {"role": "assistant", "content": "Thought: 我看到Bug了\nAction: write_file(...)"},
    {"role": "user", "content": "Observation: 文件已保存"}
]

关键洞察

  • 每一轮都会增加2条消息:assistant(模型输出)+ user(Observation)
  • Observation用role="user"是因为:从模型的视角看,Observation是"外部世界告诉我的信息",就像用户的输入一样
  • messages列表越来越长,模型能"看到"之前所有的思考和行动

这就是S_{t+1} = S_t + (Thought, Action, Observation)的具体体现。

循环终止条件:什么时候停下来?

循环不能无限转下去,需要终止条件。常见的有三种:

  1. 模型主动停止:模型输出的内容中不再包含Action

    Thought: 任务已完成,测试通过
    [没有Action]
    
  2. 达到最大轮数:防止死循环,通常设置为10-20轮

    MAX_ITERATIONS = 15
    if iteration >= MAX_ITERATIONS:
        break
    
  3. 遇到错误:工具执行失败,且模型无法恢复

    Observation: Error: 文件不存在
    [模型尝试3次后仍失败]
    

我们的简化版会用前两种。

动手实操:实现第一个ReAct循环

现在我们开始写代码。这一篇的目标是实现循环的骨架,先不接入真实工具,而是用模拟数据。

第一步:设计模型输出的格式

我们需要让模型输出这样的格式:

Thought: [思考过程]
Action: [工具名称]([参数])

或者,如果任务完成:

Thought: [思考过程]
Answer: [最终回答]

创建一个文件react_agent.py

import re
from llm_client import LLMClient

class ReActAgent:
    """实现ReAct循环的Agent"""
    
    def __init__(self, max_iterations=10):
        self.client = LLMClient(model="gpt-4")
        self.max_iterations = max_iterations
        
        # 🔑 Agent的System Prompt
        self.system_prompt = """你是一个Python工程师Agent。

你的工作方式:
1. 先思考(Thought):分析当前情况,决定下一步做什么
2. 再行动(Action):调用工具执行操作
3. 观察结果(Observation):系统会告诉你执行结果
4. 根据结果继续思考,直到任务完成

可用工具(注意:本篇中工具是模拟的,下一篇会接入真实工具):
- read_file(path): 读取文件内容
- write_file(path, content): 写入文件
- run_cmd(command): 执行Shell命令

输出格式(严格遵守):
Thought: [你的思考过程]
Action: [工具名称]([参数])

如果任务完成,输出:
Thought: [总结]
Answer: [最终回答]

示例:
Thought: 用户想看main.py的内容,我应该读取这个文件
Action: read_file('main.py')
"""
    
    def parse_response(self, response):
        """
        解析模型输出,提取Thought、Action或Answer
        
        返回:
            {
                'thought': str,
                'action': str or None,
                'answer': str or None
            }
        """
        result = {'thought': None, 'action': None, 'answer': None}
        
        # 🔑 提取Thought
        # 正则解释:
        # - Thought:\s* : 匹配"Thought:"后面的空白字符
        # - (.+?) : 非贪婪匹配任意字符(捕获组)
        # - (?=\n(?:Action|Answer):|$) : 前瞻断言,匹配到下一个"Action:"或"Answer:"或字符串结尾
        # - re.DOTALL : 让.匹配换行符,支持多行Thought
        thought_match = re.search(r'Thought:\s*(.+?)(?=\n(?:Action|Answer):|$)', 
                                  response, re.DOTALL)
        if thought_match:
            result['thought'] = thought_match.group(1).strip()
        
        # 🔑 提取Action
        # 正则解释:
        # - Action:\s* : 匹配"Action:"后面的空白字符
        # - (.+?) : 非贪婪匹配任意字符(捕获组)
        # - (?=\n|$) : 前瞻断言,匹配到换行符或字符串结尾
        action_match = re.search(r'Action:\s*(.+?)(?=\n|$)', response)
        if action_match:
            result['action'] = action_match.group(1).strip()
        
        # 🔑 提取Answer(任务完成的标志)
        # 正则解释:
        # - Answer:\s* : 匹配"Answer:"后面的空白字符
        # - (.+?)$ : 非贪婪匹配任意字符直到字符串结尾(捕获组)
        # - re.DOTALL : 让.匹配换行符,支持多行Answer
        answer_match = re.search(r'Answer:\s*(.+?)$', response, re.DOTALL)
        if answer_match:
            result['answer'] = answer_match.group(1).strip()
        
        return result
    
    def execute_action(self, action):
        """
        执行Action(本篇用模拟数据,下一篇接入真实工具)
        
        参数:
            action: 字符串,如 "read_file('main.py')"
        
        返回:
            执行结果(字符串)
        """
        # 🔑 这里先用硬编码模拟,下一篇会替换成真实工具
        if 'read_file' in action:
            return "文件内容:\ndef main():\n    print('Hello World')\n    user_id = get_user()  # Bug: 应该是user_Id"
        elif 'write_file' in action:
            return "文件已保存"
        elif 'run_cmd' in action:
            return "命令执行成功:测试通过"
        else:
            return f"未知工具:{action}"
    
    def run(self, user_input):
        """
        运行ReAct循环
        
        参数:
            user_input: 用户的指令
        
        返回:
            最终回答
        """
        # 🔑 初始化对话历史
        messages = [
            {"role": "system", "content": self.system_prompt},
            {"role": "user", "content": user_input}
        ]
        
        print(f"用户:{user_input}\n")
        print("=" * 60)
        
        # 🔑 ReAct循环
        for iteration in range(self.max_iterations):
            print(f"\n[第 {iteration + 1} 轮]")
            
            # 步骤1:Think - 调用模型
            response = self.client.chat(messages)
            parsed = self.parse_response(response)
            
            # 打印Thought
            if parsed['thought']:
                print(f"💭 Thought: {parsed['thought']}")
            
            # 步骤2:检查是否完成
            if parsed['answer']:
                print(f"✅ Answer: {parsed['answer']}")
                return parsed['answer']
            
            # 步骤3:Act - 执行Action
            if parsed['action']:
                print(f"🔧 Action: {parsed['action']}")
                observation = self.execute_action(parsed['action'])
                print(f"👀 Observation: {observation}")
                
                # 步骤4:Observe - 将结果加入对话历史
                # 🔑 关键:为什么Observation用role="user"?
                # 因为从模型的视角看,Observation是"外部世界告诉我的信息",
                # 就像用户的输入一样。模型需要把Observation当作"新的输入"来处理。
                messages.append({"role": "assistant", "content": response})
                messages.append({"role": "user", "content": f"Observation: {observation}"})
            else:
                # 没有Action也没有Answer,说明模型输出格式错误
                print("⚠️ 模型输出格式错误,没有Action或Answer")
                break
        
        print(f"\n⚠️ 达到最大轮数 ({self.max_iterations}),循环终止")
        return "任务未完成"

代码解读

  • __init__:初始化LLM客户端和System Prompt
  • parse_response:用正则表达式解析模型输出,提取Thought、Action、Answer
  • execute_action:执行Action(本篇用硬编码模拟)
  • run:核心循环,包含Think→Act→Observe三个步骤

第二步:测试循环

创建测试文件test_react.py

from react_agent import ReActAgent

# 创建Agent
agent = ReActAgent(max_iterations=5)

# 测试任务:让Agent"修复"一个Bug
result = agent.run("帮我检查main.py里有没有Bug,如果有就修复它")

print("\n" + "=" * 60)
print(f"最终结果:{result}")

运行这段代码,你会看到类似这样的输出:

用户:帮我检查main.py里有没有Bug,如果有就修复它

============================================================

[第 1 轮]
💭 Thought: 用户想让我检查main.py文件,我应该先读取文件内容看看有什么问题
🔧 Action: read_file('main.py')
👀 Observation: 文件内容:
def main():
    print('Hello World')
    user_id = get_user()  # Bug: 应该是user_Id

[第 2 轮]
💭 Thought: 我看到了问题,注释里提示user_id应该是user_Id(大写I),这是个变量名拼写错误
🔧 Action: write_file('main.py', "def main():\n    print('Hello World')\n    user_Id = get_user()")
👀 Observation: 文件已保存

[第 3 轮]
💭 Thought: 文件已修改,我应该运行测试确保没有破坏其他功能
🔧 Action: run_cmd('pytest')
👀 Observation: 命令执行成功:测试通过

[第 4 轮]
💭 Thought: 测试通过了,任务完成
✅ Answer: Bug已修复。问题是main.py3行的变量名user_id写错了,应该是user_Id(大写I)。已修改并通过测试。

============================================================
最终结果:Bug已修复。问题是main.py3行的变量名user_id写错了,应该是user_Id(大写I)。已修改并通过测试。

恭喜!你已经实现了第一个ReAct循环!

虽然工具是模拟的,但循环的骨架已经完整了:

  1. ✅ 模型能输出结构化的Thought和Action
  2. ✅ 我们能解析模型的输出
  3. ✅ 我们能执行Action(虽然是模拟的)
  4. ✅ 我们能把Observation反馈给模型
  5. ✅ 循环能正确终止

与真实代码的对照

在真实的Claude Code实现中(rust版本),这部分对应的是:

我们的实现真实代码位置关键差异
ReActAgent.run()crates/runtime/src/conversation.rsConversationRuntime::run_turn()真实版支持流式输出、错误重试
parse_response()crates/runtime/src/conversation.rs 的消息解析逻辑真实版用结构化的JSON,不是正则
execute_action()crates/runtime/src/conversation.rsToolExecutor trait真实版有完整的工具注册机制
循环终止条件ConversationRuntime 的状态机真实版有更复杂的状态管理

想深入研究的读者

  • 打开crates/runtime/src/conversation.rs,搜索run_turn,你会看到完整的循环逻辑
  • 打开crates/runtime/src/session.rs,可以看到对话历史的管理

为什么我们用正则解析,真实版用JSON?

方案优点缺点适用场景
正则解析文本简单,易理解不够健壮,格式错误时容易失败教学版、原型
JSON结构化健壮,易扩展需要模型支持Function Calling生产环境

我们的教学版用正则是为了让你看清楚"模型输出什么,我们怎么解析"。真实的Claude Code用OpenAI的Function Calling API,模型直接输出JSON格式的工具调用。

ReAct循环的3个关键洞察

通过上面的实现,我们总结出ReAct循环的3个关键洞察:

洞察1:循环是Agent的"灵魂"

没有循环,模型只能"一次性回答",无法根据结果调整策略。

对比

  • 普通对话:用户问题 → 模型回答 → 结束
  • ReAct Agent:用户问题 → [思考→行动→观察] × N → 最终回答

洞察2:Observation是"眼睛"

模型必须能"看到"每一步的执行结果,才能做出正确的下一步决策。

如果没有Observation会怎样?

模型:Action: read_file('main.py')
[没有Observation]
模型:Action: read_file('main.py')  # 模型不知道上一步成功了,会重复执行

洞察3:格式化输出是"协议"

模型和我们的代码之间需要一个"协议"——模型按照约定的格式输出,我们按照约定的格式解析。

协议的两端

  • 模型端:System Prompt规定输出格式
  • 代码端:parse_response按格式解析

如果两端不一致,循环就会失败。

📝 自检清单(读完本篇请确认)

在进入下一篇之前,请确认你能回答以下问题:

  • ReAct循环的三个步骤是什么?(Think、Act、Observe)
  • 为什么Agent需要循环,而不是一次性回答?
  • 循环的终止条件有哪些?
  • Observation在循环中的作用是什么?
  • 你能解释S_{t+1} = S_t + (Thought, Action, Observation)这个公式吗?

如果都能回答,恭喜你,Agent的"灵魂"部分你已经掌握了。下一篇见!

⚠️ 新手容易踩的坑

  1. 坑1:忘记把Observation加入对话历史

    • 后果:模型看不到执行结果,会重复执行相同的Action
    • 正确做法:每次执行后,必须messages.append({"role": "user", "content": f"Observation: {observation}"})
  2. 坑2:没有设置最大轮数

    • 后果:如果模型陷入死循环(一直输出相同的Action),程序会卡死
    • 正确做法:设置max_iterations,通常10-20轮足够
  3. 坑3:正则表达式写得太严格

    • 后果:模型输出稍有偏差(多个空格、换行),解析就失败
    • 正确做法:用re.DOTALL\s*处理空白字符
  4. 坑4:以为模型会"记住"之前的Action

    • 真相:模型没有记忆,它只能看到messages列表里的内容
    • 正确做法:确保每一轮的Thought、Action、Observation都加入了messages

下一步:给Agent装上"双手"

现在你已经学会了:

  • ReAct循环的完整流程
  • 如何解析模型输出
  • 如何把Observation反馈给模型
  • 如何判断循环何时结束

但有一个关键问题还没解决:

我们的execute_action还是硬编码的模拟数据,不是真正的工具。

下一篇,我们将实现真正的工具——让Agent能够:

  1. 读取真实的文件(read_file
  2. 写入真实的文件(write_file
  3. 看到真实的执行结果

这就是Agent从"纸上谈兵"到"真刀真枪"的关键一步。

预告一个核心问题:如何安全地让Agent写文件?如果它不小心删除了重要文件怎么办?答案在下一篇揭晓。


系列进度

  • ✅ 第1篇:总览与前置准备——Claude Code到底是什么?
  • ✅ 第2篇:地基篇——让模型开口说话(System Prompt的艺术)
  • ✅ 第3篇:灵魂篇——ReAct循环的骨架
  • ⏭️ 第4篇:双手篇——赋予读写文件的能力
  • 第5篇:终端篇——赋予执行命令的超能力
  • 第6篇:整合篇——组装Mini Claude Code
  • 第7篇:上下文篇——让Agent看懂整个文件夹
  • 第8篇:反思与展望——我们得到了什么,还缺什么?