Appearance
ReAct 范式拆解:Agent 到底在循环什么
1. 本节产出
用不到 100 行 Java 手写一个 ReAct 循环(不用任何 Agent 框架),跑通「查天气 → 判断 → 给建议」的多步任务,并打印出每一轮的 Thought / Action / Observation。手写一遍是理解 Agent 的唯一捷径。
2. 前置依赖
3. 为什么必须手写一遍
用框架写 Agent 只要几行,但你不会知道:
| 不知道的事 | 后果 |
|---|---|
| 循环在哪里终止 | 出现死循环时不知道怎么排查 |
| Observation 是怎么塞回上下文的 | 上下文爆炸时不知道该砍哪里 |
| 工具结果格式对模型有什么影响 | 工具返回一堆 JSON 时模型开始乱答 |
| 「一轮」到底包含几次模型调用 | 成本估算完全不准 |
手写一遍之后,你会对「一个 Agent 任务要调多少次模型」有准确的直觉——这直接决定了成本估算和超时设置。
4. 核心原理
4.1 ReAct 的循环
用户目标:「帮我查一下北京和深圳今天哪里更适合户外活动」
轮次 1
Thought: 需要查两个城市的天气
Action: getWeather(city="北京")
Observation: 北京:晴,26℃,微风
轮次 2
Thought: 还需要深圳的
Action: getWeather(city="深圳")
Observation: 深圳:雷阵雨,31℃,东南风 4 级
轮次 3
Thought: 两地都拿到了,可以比较并给出建议
Action: finish
Answer: 北京更适合(晴 26℃ vs 深圳雷阵雨 31℃)三个要素:
| 要素 | 是谁产生的 | 作用 |
|---|---|---|
| Thought | 模型 | 让模型「想清楚再动手」,显著提升多步任务成功率 |
| Action | 模型(结构化输出) | 决定调用什么工具、传什么参数 |
| Observation | 你的代码 | 工具执行结果,回填给模型 |
关键认知:Observation 不是模型产生的,是你的代码执行后产生的。 这就是 Agent 和外部世界交互的唯一通道。
4.2 为什么 Thought 有用
一个反直觉的实验结论:让模型先输出「思考」再输出「动作」,多步任务成功率提升明显。
原因不是模型真的在「思考」,而是:
- 输出 Thought 相当于给自己一个「草稿区」,后续生成 Action 时前文更丰富;
- Thought 里通常会复述目标,起到「不忘初衷」的作用(对抗长上下文中的目标漂移)。
代价:每个 Thought 都要花 Token。所以在成本敏感场景,可以只在复杂任务上开启 Thought。
4.3 循环终止的三种方式
| 方式 | 机制 | 风险 |
|---|---|---|
| 模型主动结束 | 输出 finish 动作 | 不可靠:模型可能永远不结束 |
| 迭代上限 | maxIterations | 必须有,但要给足余量 |
| 目标达成检测 | 你的代码判断 | 最可靠,但要实现判定逻辑 |
生产上必须三者都有,且以「迭代上限」为硬兜底。永远不要相信模型会自己停下来——这是 Agent 开发的第一原则。
4.4 一次 Agent 任务的成本
轮次 N,每轮至少 1 次模型调用(含 Thought + Action)
若工具返回后还要判断,可能再加调用
成本 ≈ N × (输入上下文 + 输出) × 单价
而上下文随轮次线性增长(历史 + 所有 Observation)
典型:10 轮 × 平均 3000 Token × 2(输入+输出)= 6 万 Token这就是为什么 Agent 比 RAG 贵一个数量级,也为什么「上下文管理」在 Agent 里是核心问题(见 03A-05)。
5. 代码走查
5.1 动作的结构化定义
java
// ch03-agent/src/main/java/com/aitech/agent/agent/Action.java
public record Action(
@JsonPropertyDescription("这一步的思考,说明为什么这么做")
String thought,
@JsonPropertyDescription("要执行的动作:CALL_TOOL 或 FINISH")
Step step,
@JsonPropertyDescription("工具名,step=CALL_TOOL 时必填")
String toolName,
@JsonPropertyDescription("工具参数,JSON 对象;step=FINISH 时为空对象")
Map<String, Object> args,
@JsonPropertyDescription("step=FINISH 时的最终答案")
String finalAnswer
) {
public enum Step { CALL_TOOL, FINISH }
}5.2 ReAct 循环(手写版)
java
// ch03-agent/src/main/java/com/aitech/agent/agent/ReactLoop.java
@Service
public class ReactLoop {
private final ChatClient client;
private final ToolRegistry tools;
private static final int MAX_ITERS = 10;
public String run(String goal) {
List<String> transcript = new ArrayList<>(); // 累积的 Thought/Action/Observation
transcript.add("目标:" + goal);
for (int i = 1; i <= MAX_ITERS; i++) {
// 1. 让模型决定下一步
Action action = decide(transcript);
log.info("轮次 {} | Thought: {}", i, action.thought());
// 2. 终止判定
if (action.step() == Action.Step.FINISH) {
log.info("模型主动结束,共 {} 轮", i);
return action.finalAnswer();
}
// 3. 执行工具(Observation 由代码产生)
String observation;
try {
observation = tools.execute(action.toolName(), action.args());
} catch (Exception e) {
observation = "工具执行失败:" + e.getMessage(); // 转成文本,不中断
}
log.info("轮次 {} | Action: {}({}) → {}", i,
action.toolName(), action.args(), abbreviate(observation));
// 4. 回填
transcript.add("Thought: " + action.thought());
transcript.add("Action: " + action.toolName() + " " + action.args());
transcript.add("Observation: " + observation);
}
// 5. 硬兜底:到达上限仍未结束
log.warn("达到迭代上限 {} 仍未完成", MAX_ITERS);
return "任务未能在限定步数内完成,已完成部分见过程记录。";
}
private Action decide(List<String> transcript) {
var conv = new BeanOutputConverter<>(Action.class);
return conv.convert(client.prompt()
.system(REACT_SYSTEM)
.user(u -> u.text("{format}\n\n{transcript}\n\n下一步:")
.param("format", conv.getFormat())
.param("transcript", String.join("\n", transcript)))
.options(OpenAiChatOptions.builder().temperature(0.0).build())
.call().content());
}
}这段 60 行代码就是 Agent 的全部核心。框架做的只是把它包装得更好用,本质一样。
5.3 系统提示
java
static final String REACT_SYSTEM = """
你是一个能调用工具完成任务的助手。按 ReAct 范式工作:
每一轮输出一个 JSON,包含:
- thought:这一步为什么这么做
- step:CALL_TOOL(继续)或 FINISH(结束)
- toolName / args:要调用的工具与参数
- finalAnswer:step=FINISH 时的答案
规则:
1. 一次只调用一个工具,等 Observation 回来再决定下一步
2. 工具失败时,换一种方式或换个工具,不要重复同样的失败调用
3. 信息足够就立刻 FINISH,不要做过多的步骤
4. 不要编造 Observation 里没有的信息
""";第 3 条「信息足够就立刻 FINISH」很重要。不加这句,模型会倾向于多绕几圈,成本和延迟都翻倍。
6. 跑起来
bash
git checkout ch03-01-react
mvn -q test -Dtest=ReactLoopTest期望输出(本节的核心演示,一定要看完整日志):
轮次 1 | Thought: 需要查询两个城市的天气
轮次 1 | Action: getWeather({city=北京}) → 北京:晴,26℃,微风
轮次 2 | Thought: 已获得北京天气,还需查询深圳
轮次 2 | Action: getWeather({city=深圳}) → 深圳:雷阵雨,31℃,东南风4级
轮次 3 | Thought: 两地天气都已获得,可以比较
模型主动结束,共 3 轮
答案:北京更适合户外活动(晴 26℃),深圳有雷阵雨且 31℃ 较闷热。
统计:模型调用 3 次,累计 8742 Token,耗时 6.2s| 检查项 | 通过标准 |
|---|---|
| 多步执行 | 至少调用 2 次工具才结束 |
| 循环终止 | 模型主动 FINISH,3~5 轮 |
| 上限兜底 | 把 MAX_ITERS 改成 1,验证返回「未完成」而不是崩溃 |
| 工具失败 | 模拟工具异常,验证 Observation 是文本且循环继续 |
| 成本可见 | 日志输出累计 Token |
「把上限改成 1」这个测试必须做:它验证硬兜底真的生效。
7. 生产避坑
- 永远不要相信模型会自己停下来。必须有迭代上限,且上限要留足余量(太少会频繁中断正常任务,太多则死循环时损失巨大)。经验起点 10,用真实任务统计实际轮次分布后再调。
- 工具异常必须转成 Observation 文本,不能抛异常中断循环。中断的后果是整个任务失败;转成文本后,模型还有机会换个方式重试——这也更接近真实人类处理问题的方式。
- transcript 会线性增长,这是 Agent 成本失控的主因。10 轮之后上下文可能有几万 Token,而其中大部分是早期的 Observation。必须做上下文压缩(见 03A-05),否则长任务必然爆窗口或烧爆预算。
8. 延伸与锚点
- 思考题:手写循环这么简单,为什么还需要框架?(框架解决的是状态持久化、并发、可观测、多 Agent 协作——答案在下一课时)
- 代码锚点:
git checkout ch03-01-react - 下一课时:03-02 框架选型边界
- 对应课件:L03-01 ReAct 范式