从零搭建 Agent Harness 系列(一)ReAct的心脏——MainLoop

所有顶级的 Agent 引擎(无论是早期的 AutoGPT,还是如今最先进的 Claude Code、OpenClaw),它们表面上看起来像魔法一样能在你的本地项目里来回穿梭、修改代码、执行测试。但在代码的最底层,

它们都在跑着一个极其朴素、但极其强健的无限循环。这个循环,在学术界通常被称为 ReAct (Reason + Act) 范式,而在工程界,我们通常称之为 Agent Loop 或 Main Loop。

在传统的软件开发中,程序的执行流是确定且线性的(如下图所示)。你写下 if-else,程序就严格按照路径执行。

但大模型(LLM)面对的是一个开放的、动态的、需要不断探索的环境。当它拿到一个宏大的任务(比如:“找出项目中计算错误的原因并修复”)时,它不可能像传统的纯问答(QA)机器人助手那样,在一次 API 调用中就吐出最终的完美代码。因为它缺少实时信息——它不知道当前目录下有什么文件,也不知道运行 go test 会报什么错。

所有的Agent的设计都是任务导向的,最终目的都是为了完成任务

为了解决大模型在执行任务过程中信息不足,以及在信息不足的情况下就执行操作的问题,经历过几次范式的改变

  1. 纯推理(Reasoning Only)与纯行动(Acting Only)的局限性
  • 纯推理模式(如 Chain of Thought, CoT):通过在 Prompt 中加入“Let’s think step by step”,强迫模型把思考过程写出来。这极大地提升了模型的逻辑推导能力,但致命缺陷是它无法与外部世界交互。如果代码库更新了,或者报错信息变了,模型依然在用过时的、基于训练数据的“幻觉”在推理。

  • 纯行动模式(Acting Only):直接给模型一堆工具(Tools),让它直接预测下一个要执行的动作。这种模式下,模型缺乏深度的状态跟踪和自我反思,往往就像一个横冲直撞的莽夫,很容易因为上一步的报错而陷入迷茫。

  1. ReAct:智能体的觉醒时刻

直到 2022 年 10 月,普林斯顿大学博士生 Shunyu Yao(在 Google 实习期间)与 Google 研究人员联合发表了预印本论文《ReAct: Synergizing Reasoning and Acting in Language Models》,并于 2023 年正式发表在 ICLR 2023 上。

ReAct 核心思想

这篇论文提出了一个极其优雅但影响深远的范式:将“思考(Reasoning)”与“行动(Acting)”在一个循环中交织起来。ReAct 范式认为,一个真正的智能体,必须像人类解决问题一样,在每次行动前先思考,在每次行动后观察结果:

  • 思考(Reason / Thought):分析当前拿到的线索,规划下一步的意图。例如:“我看到了 calc.go 这个文件,里面可能有 Bug,下一步我要读取它。

  • ”行动(Act / Action):向外部环境发出指令。例如:调用 read_file 工具。

  • 观察(Observe / Observation):外部环境(比如我们的 Harness 引擎)将工具执行的结果返回给模型。例如返回了 calc.go 的具体代码。

  • 然后再回到第 1 步,结合新获得的 Observation 再次思考,形成闭环。

在驾驭工程(Harness Engineering)中,我们将这套理论抽象为一个底层的 for 循环。我们可以用下面这张状态机图来精确描述它

Main Loop

只要大模型返回的结果中包含“工具调用请求(Tool Call Request)”,这个 Loop 就会一直循环下去。每一次从“组装 Prompt”到“追加观察结果”,我们称之为一个 Turn(轮次)。

在顶级引擎(如 Claude Code、OpenClaw)中,这个 Main Loop 的设计有几个极其鲜明的特征:

  1. 极度纯粹,没有预设分支:循环中没有业务逻辑,全凭模型决定走向。

  2. 不设硬性的最大步骤限制:传统的玩具框架喜欢设置 max_turns=10,但真实的工业任务可能需要 50 步。顶级引擎不在此处做生硬的截断,而是依赖后续我们将会讲到的 Context Compaction(内存压缩) 和 System Reminders(系统级防死循环干预)来维持稳定。

  3. 上下文(Context)是唯一的记忆载体:在这个循环中,数据会像滚雪球一样不断累加,记录下每一次的思考、动作和观察结果。

以下是核心代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
package engine

import (
"context"
"fmt"
"log"

"github.com/yourname/go-tiny-claw/internal/provider"
"github.com/yourname/go-tiny-claw/internal/schema"
"github.com/yourname/go-tiny-claw/internal/tools"
)

// AgentEngine 是微型 OS 的核心驱动
type AgentEngine struct {
provider provider.LLMProvider
registry tools.Registry

// WorkDir (工作区): 借鉴 OpenClaw 的理念,Agent 必须有一个明确的物理边界
WorkDir string
}

func NewAgentEngine(p provider.LLMProvider, r tools.Registry, workDir string) *AgentEngine {
return &AgentEngine{
provider: p,
registry: r,
WorkDir: workDir,
}
}

// Run 启动 Agent 的生命周期
func (e *AgentEngine) Run(ctx context.Context, userPrompt string) error {
log.Printf("[Engine] 引擎启动,锁定工作区: %s\n", e.WorkDir)

// 1. 初始化会话的 Context (上下文内存)
// 在真实的场景中,这里会由动态 Prompt 组装器加载 AGENTS.md。目前我们先硬编码。
contextHistory := []schema.Message{
{
Role: schema.RoleSystem,
Content: "You are go-tiny-claw, an expert coding assistant. You have full access to tools in the workspace.",
},
{
Role: schema.RoleUser,
Content: userPrompt,
},
}

turnCount := 0

// 2. The Main Loop: 心跳开始 (标准的 ReAct 循环)
for {
turnCount++
log.Printf("========== [Turn %d] 开始 ==========\n", turnCount)

// 获取当前挂载的所有工具定义
availableTools := e.registry.GetAvailableTools()

// 向大模型发起推理请求 (包含 Reasoning)
log.Println("[Engine] 正在思考 (Reasoning)...")
responseMsg, err := e.provider.Generate(ctx, contextHistory, availableTools)
if err != nil {
return fmt.Errorf("模型生成失败: %w", err)
}

// 将模型的响应完整追加到上下文历史中
contextHistory = append(contextHistory, *responseMsg)

// 如果模型回复了纯文本,打印出来 (这通常是它的思考过程,或是最终结果)
if responseMsg.Content != "" {
fmt.Printf("🤖 模型: %s\n", responseMsg.Content)
}

// 3. 退出条件判断
// 如果模型没有请求任何工具调用,说明它认为任务已经完成,跳出循环。
if len(responseMsg.ToolCalls) == 0 {
log.Println("[Engine] 任务完成,退出循环。")
break
}

// 4. 执行行动 (Action) 与 获取观察结果 (Observation)
log.Printf("[Engine] 模型请求调用 %d 个工具...\n", len(responseMsg.ToolCalls))

for _, toolCall := range responseMsg.ToolCalls {
log.Printf(" -> 🛠️ 执行工具: %s, 参数: %s\n", toolCall.Name, string(toolCall.Arguments))

// 通过 Registry 路由并执行底层工具
result := e.registry.Execute(ctx, toolCall)

if result.IsError {
log.Printf(" -> ❌ 工具执行报错: %s\n", result.Output)
} else {
log.Printf(" -> ✅ 工具执行成功 (返回 %d 字节)\n", len(result.Output))
}

// 将工具执行的观察结果 (Observation) 封装为 User Message 追加到上下文中
// 注意:ToolCallID 必须携带!这是维系大模型推理链条的关键
observationMsg := schema.Message{
Role: schema.RoleUser,
Content: result.Output,
ToolCallID: toolCall.ID,
}
contextHistory = append(contextHistory, observationMsg)
}

// 循环回到开头,模型将带着新加入的 Observation 继续它的下一轮思考...
}

return nil
}

问题:大模型很“冲动”

看似我们已经掌握了智能体运行的终极密码。但是,如果现在就把真实的前沿大模型(如 Claude 4.x 或 GPT-5.x)接入这个基础循环,并且给它挂载上能够修改本地代码的 edit 和 bash 工具,你会遭遇一个极其普遍但又令人抓狂的现象:大模型变得极其“冲动”。

当你给它一个复杂的任务:“帮我分析整个订单模块的并发逻辑,并重构它”时。它可能连其他文件都没看,瞬间就发出了一个 edit 工具的调用请求,去盲目修改它看到的第一个文件。

诺贝尔经济学奖得主丹尼尔·卡尼曼在《思考,快与慢》中提出,人类的大脑包含两套系统:

  1. 系统 1(快思考):直觉的、本能的、自动的。比如你看到 2+2 立刻就能想到 4。

  2. 系统 2(慢思考):逻辑的、深思熟虑的、需要消耗精力的。比如让你计算 17 × 24,你必须拿出一张草稿纸,一步步推导。

大语言模型的本质是预测下一个 Token。从架构上看,它天生就是一个完美的“系统 1”。

当你向大模型发起一次普通的 API 请求时,它只能顺着当前的上下文,凭借概率直觉“一口气”把答案生成出来。如果问题极其复杂,它无法在生成第一个字之前,在脑海里预演几十步的完整计划。

“冲动的解决方案”

强制CoT

为了激发大模型的“系统 2(慢思考)”,学术界发明了 Chain of Thought(思维链,CoT) 技术。我们会在提示词里加上一句咒语:“Let’s think step by step(让我们一步步思考)”。这就相当于给了大模型一张“草稿纸”,让它在输出最终答案前,先把中间推理过程写出来。

但在 Agent 的工具调用(Function Calling)场景下,这种单纯的提示词工程彻底破产了。因为 AI 工程师在构建长程 Coding Agent 时,发现了一个致命的规律:

“When tools are available, models tend to act quickly rather than think deeply.”(当工具可用时,模型倾向于迅速采取行动,而不是深入思考。)

Harness(机制决定行为)

流程设计与管理这个科目中有个核心哲学:功能蕴含在流程之中,一个系统的功能由其流程实现,也由其功能体现。

如果你在系统提示词里写:“请你先仔细规划,然后再调用工具”。大模型往往会无视这句话。只要它在上下文的 Schema 里看到了诱人的 bash 或 edit 工具,它的预测概率就会瞬间坍塌,转而生成一段 JSON 参数去调用工具。

如何解决呢?既然提示词管不住它的“手”,那我们就用架构锁住它的“手”!

驾驭工程(Harness Engineering)给出的解法是:机制决定行为。

在每一次大模型采取行动前,Harness 引擎会向它发起一次没有附带任何工具 Schema 的纯文本 API 请求。在这个绝对没有工具诱惑的“小黑屋”里,模型别无选择,只能乖乖地输出一段纯文本的深度推理与规划。

等它想清楚了,Harness 会把这段推理记录追加到上下文中,然后再发起第二次附带工具的请求,让它去执行。

这就是工业级 Agent 循环中的 Two-Stage ReAct(两阶段 ReAct 循环)

Two-Stage ReAct

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
// internal/engine/loop.go (续)

func (e *AgentEngine) Run(ctx context.Context, userPrompt string) error {
log.Printf("[Engine] 引擎启动,锁定工作区: %s\n", e.WorkDir)
log.Printf("[Engine] 慢思考模式 (Thinking Phase): %v\n", e.EnableThinking)

contextHistory := []schema.Message{
{
Role: schema.RoleSystem,
Content: "You are go-tiny-claw, an expert coding assistant. You have full access to tools in the workspace.",
},
{
Role: schema.RoleUser,
Content: userPrompt,
},
}

turnCount := 0

for {
turnCount++
log.Printf("\n========== [Turn %d] 开始 ==========\n", turnCount)

// 获取当前挂载的所有工具定义
availableTools := e.registry.GetAvailableTools()

// ====================================================================
// Phase 1: 慢思考阶段 (Thinking) - 剥夺工具,强制规划
// ====================================================================
if e.EnableThinking {
log.Println("[Engine][Phase 1] 剥夺工具访问权,强制进入慢思考与规划阶段...")

// 核心机制:传入的 availableTools 为 nil!
// 大模型看不到任何 JSON Schema,被迫只能输出纯文本的思考过程。
thinkResp, err := e.provider.Generate(ctx, contextHistory, nil)
if err != nil {
return fmt.Errorf("Thinking 阶段生成失败: %w", err)
}

// 如果模型输出了思考过程,我们将其作为 Assistant 消息追加到上下文中
if thinkResp.Content != "" {
fmt.Printf("🧠 [内部思考 Trace]: %s\n", thinkResp.Content)
contextHistory = append(contextHistory, *thinkResp)
}
}

// ====================================================================
// Phase 2: 行动阶段 (Action) - 恢复工具,顺着规划执行
// ====================================================================
log.Println("[Engine][Phase 2] 恢复工具挂载,等待模型采取行动...")

// 此时的 contextHistory 中已经包含了上一阶段模型自己的 Thinking Trace。
// 模型会顺着自己的逻辑,结合恢复的 availableTools 发起精准的工具调用。
actionResp, err := e.provider.Generate(ctx, contextHistory, availableTools)
if err != nil {
return fmt.Errorf("Action 阶段生成失败: %w", err)
}

contextHistory = append(contextHistory, *actionResp)

if actionResp.Content != "" {
fmt.Printf("🤖 [对外回复]: %s\n", actionResp.Content)
}

// ====================================================================
// 退出与执行逻辑 (与上一讲保持一致)
// ====================================================================
if len(actionResp.ToolCalls) == 0 {
log.Println("[Engine] 模型未请求调用工具,任务宣告完成。")
break
}

log.Printf("[Engine] 模型请求调用 %d 个工具...\n", len(actionResp.ToolCalls))

for _, toolCall := range actionResp.ToolCalls {
log.Printf(" -> 🛠️ 执行工具: %s, 参数: %s\n", toolCall.Name, string(toolCall.Arguments))

result := e.registry.Execute(ctx, toolCall)

if result.IsError {
log.Printf(" -> ❌ 工具执行报错: %s\n", result.Output)
} else {
log.Printf(" -> ✅ 工具执行成功 (返回 %d 字节)\n", len(result.Output))
}

// 将工具执行的观察结果追加到 Context,准备进入下一轮
observationMsg := schema.Message{
Role: schema.RoleUser,
Content: result.Output,
ToolCallID: toolCall.ID,
}
contextHistory = append(contextHistory, observationMsg)
}
}

return nil
}

Two-Stage ReAct的一点实现细节

上面的代码其实有个问题,如果在Think阶段完全不传递tool的信息,大模型的推理也是有问题的,所以一般的做法是通过prompt的方式传入而非通过 tools 字段传入结构化的 JSON Schema

在 Phase 1 时,我们将工具的宏观描述(自然语言)作为 System Prompt 或上下文的一部分发给大模型,让它知道自己有什么能力去“做规划”;
但是,我们故意不传结构化的 tools 字段。这就相当于给模型戴上了“手铐”,逼迫它必须用纯文本把步骤想清楚,而无法直接“扣动扳机”