ReAct 模式的工程实现——不用框架,自己写一个看看里面是什么
ReAct 模式的工程实现——不用框架,自己写一个看看里面是什么
去年我在做一个内部知识库问答系统的时候,遇到一个很典型的问题:用户问"我们公司 Q3 季度的销售目标是多少,和去年同期相比增长了多少",直接问 LLM 它不知道,这种问题需要先查销售数据,再做计算,再给出对比结论。
当时我们用的是 LangChain4j 的 Agent,配上工具调用,跑起来确实能用。但有一次线上出问题,日志一翻,完全看不懂里面在干什么,Agent 在哪一步出了岔子,工具调用顺序对不对,根本追不清楚。
那次之后我决定花一个周末,自己从零实现一个 ReAct 循环,不用任何框架,就用 Spring AI 的基础 API。写完之后我才算真正理解了 Agent 框架里面在干什么事,以及为什么它有时候会出那些莫名其妙的问题。
这篇文章就是把那个周末的经历整理出来。
ReAct 是什么——先把概念讲清楚
ReAct 这个名字来自 2022 年的一篇论文:《ReAct: Synergizing Reasoning and Acting in Language Models》。核心思想很朴素:让模型在做事情之前先说出它的推理过程(Reasoning),然后再执行动作(Acting),然后观察结果(Observation),循环下去直到得出答案。
这听起来很像人解决问题的方式。比如你要查一个问题:
- 思考:这个问题需要我先搜索一下相关信息
- 行动:调用搜索工具,搜索"XXX"
- 观察:搜索结果返回了 A、B、C 三条信息
- 思考:根据搜索结果,我还需要进一步计算 A 和 B 的差值
- 行动:调用计算器工具
- 观察:计算结果是 X
- 思考:现在我有了足够的信息,可以给出最终答案了
- 行动:返回最终答案
这个循环的关键在于"思考"步骤——这就是 Reasoning。它让模型在行动之前先规划,而不是盲目调用工具。
和纯 Function Calling 有什么区别
很多人会把 ReAct 和 Function Calling 搞混。它们有本质区别:
Function Calling 是 LLM 直接决定调用哪个函数,没有显式的推理步骤。模型看到问题,直接输出"我要调用 search_tool,参数是 XXX"。这对简单的单步任务够用,但对复杂的多步推理任务,没有思考过程就容易乱。
ReAct 强制要求模型先输出思考过程,再给出动作。这个思考过程不只是装饰,它实际上影响了后续的行动决策——就像你写代码时先在脑子里理清思路,写出来的代码通常比直接打字要好。
ReAct 的 Prompt 格式
ReAct 的 Prompt 有固定格式,论文里是这样的:
Question: {用户问题}
Thought: {模型的思考}
Action: {要执行的动作,格式通常是 ActionName[参数]}
Observation: {工具执行结果}
Thought: {下一步思考}
Action: {下一个动作}
Observation: {结果}
...
Thought: 我现在知道最终答案了
Final Answer: {最终答案}这个格式的妙处在于,它把对话历史本身就包含了完整的推理链,模型每次续写的时候,上下文已经包含了所有的思考过程和观察结果。
工程实现前的准备工作
在动手写代码之前,我先把架构想清楚了。
ReAct 循环需要以下几个组件:
- Prompt 模板:包含 ReAct 格式的 System Prompt,以及组装对话历史的逻辑
- LLM 调用层:调用 LLM,解析输出,判断是 Thought/Action 还是 Final Answer
- Tool Registry:工具注册表,根据 Action 名称找到对应的工具执行
- 循环控制器:管理 ReAct 循环,包含最大步数限制、异常处理
- 观察结果注入:把工具执行结果注入回对话历史
我用 Mermaid 画了一下完整的执行流程:
核心代码实现
1. Maven 依赖
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai-spring-boot-starter</artifactId>
<version>1.0.0-M6</version>
</dependency>我用的是 Spring AI 1.0.0-M6,OpenAI 兼容接口,实际上后端可以是任何支持 OpenAI 协议的模型。
2. Tool 接口定义
先定义工具的抽象接口。我不用 Spring AI 自带的 @Tool 注解那套,因为那套是给框架用的,自己实现 ReAct 要更底层的控制权:
public interface ReActTool {
/**
* 工具名称,用于在 Action 中匹配
*/
String getName();
/**
* 工具描述,注入到 System Prompt 中让 LLM 知道如何使用
*/
String getDescription();
/**
* 参数格式描述,告诉 LLM 参数长什么样
*/
String getParameterDescription();
/**
* 执行工具,返回观察结果字符串
*/
String execute(String input);
}3. 实现几个示例工具
@Component
public class SearchTool implements ReActTool {
@Override
public String getName() {
return "Search";
}
@Override
public String getDescription() {
return "搜索互联网上的信息。当你需要查找最新信息、事实性内容时使用此工具。";
}
@Override
public String getParameterDescription() {
return "搜索关键词,字符串格式,例如:Search[Spring AI 最新版本]";
}
@Override
public String execute(String input) {
// 实际场景中这里调用搜索 API
// 这里模拟返回结果
return "搜索 '" + input + "' 的结果:\n" +
"1. Spring AI 1.0.0 GA 已于 2024 年发布...\n" +
"2. 支持 OpenAI、Anthropic、Ollama 等多种模型...";
}
}
@Component
public class CalculatorTool implements ReActTool {
@Override
public String getName() {
return "Calculator";
}
@Override
public String getDescription() {
return "执行数学计算。支持加减乘除和基本数学函数。";
}
@Override
public String getParameterDescription() {
return "数学表达式,例如:Calculator[100 * 1.15 - 50]";
}
@Override
public String execute(String input) {
try {
// 使用 JavaScript 引擎计算表达式(实际生产中用专门的计算库)
ScriptEngineManager manager = new ScriptEngineManager();
ScriptEngine engine = manager.getEngineByName("JavaScript");
Object result = engine.eval(input);
return "计算结果:" + result;
} catch (Exception e) {
return "计算出错:" + e.getMessage();
}
}
}
@Component
public class DatabaseQueryTool implements ReActTool {
// 实际场景中注入 JdbcTemplate 或其他 DAO
@Override
public String getName() {
return "DatabaseQuery";
}
@Override
public String getDescription() {
return "查询内部数据库获取业务数据,如销售数据、用户数据等。";
}
@Override
public String getParameterDescription() {
return "查询描述,例如:DatabaseQuery[Q3 2024 总销售额]";
}
@Override
public String execute(String input) {
// 实际场景:解析 input,转成 SQL,执行查询
// 这里模拟
if (input.contains("Q3") && input.contains("销售")) {
return "Q3 2024 总销售额:¥5,280,000\nQ3 2023 总销售额:¥4,100,000";
}
return "未找到相关数据";
}
}4. Tool Registry
@Component
public class ToolRegistry {
private final Map<String, ReActTool> tools = new HashMap<>();
// Spring 自动注入所有 ReActTool 实现
public ToolRegistry(List<ReActTool> toolList) {
for (ReActTool tool : toolList) {
tools.put(tool.getName(), tool);
}
}
public Optional<ReActTool> findTool(String name) {
return Optional.ofNullable(tools.get(name));
}
/**
* 生成工具描述文本,用于注入 System Prompt
*/
public String generateToolsDescription() {
StringBuilder sb = new StringBuilder();
sb.append("你可以使用以下工具:\n\n");
for (ReActTool tool : tools.values()) {
sb.append("工具名称:").append(tool.getName()).append("\n");
sb.append("功能描述:").append(tool.getDescription()).append("\n");
sb.append("使用格式:").append(tool.getParameterDescription()).append("\n\n");
}
return sb.toString();
}
public Set<String> getToolNames() {
return tools.keySet();
}
}5. ReAct Prompt 构建器
这部分是关键,System Prompt 写得好不好直接影响 ReAct 循环的稳定性:
@Component
public class ReActPromptBuilder {
private static final String SYSTEM_PROMPT_TEMPLATE = """
你是一个能够使用工具解决复杂问题的 AI 助手。
%s
解题规则:
1. 每次只能执行一个 Action
2. 必须严格按照以下格式输出,不要添加额外的解释:
Thought: [你的思考过程,分析当前情况和下一步计划]
Action: [工具名称][参数]
或者,当你有了最终答案时:
Thought: [最终分析]
Final Answer: [完整的最终答案]
3. 每次输出只包含一个 Thought 和一个 Action(或 Final Answer)
4. 工具名称必须完全匹配,可用工具:%s
5. 如果工具返回了错误,请换一种方式重试或使用其他工具
开始解决问题!
""";
public String buildSystemPrompt(ToolRegistry toolRegistry) {
return String.format(
SYSTEM_PROMPT_TEMPLATE,
toolRegistry.generateToolsDescription(),
String.join(", ", toolRegistry.getToolNames())
);
}
/**
* 构建完整的对话历史文本
* ReAct 的精髓:所有历史都在 User 消息里,以文本形式拼接
*/
public String buildConversationText(String question, List<ReActStep> steps) {
StringBuilder sb = new StringBuilder();
sb.append("Question: ").append(question).append("\n");
for (ReActStep step : steps) {
if (step.getThought() != null) {
sb.append("Thought: ").append(step.getThought()).append("\n");
}
if (step.getAction() != null) {
sb.append("Action: ").append(step.getAction()).append("\n");
}
if (step.getObservation() != null) {
sb.append("Observation: ").append(step.getObservation()).append("\n");
}
}
return sb.toString();
}
}6. LLM 输出解析器
解析 LLM 的输出是 ReAct 实现中最容易出问题的地方,LLM 不总是按照格式来:
@Component
public class ReActOutputParser {
private static final Pattern THOUGHT_PATTERN =
Pattern.compile("Thought:\\s*(.+?)(?=\\nAction:|\\nFinal Answer:|$)", Pattern.DOTALL);
private static final Pattern ACTION_PATTERN =
Pattern.compile("Action:\\s*(\\w+)\\[(.+?)\\]", Pattern.DOTALL);
private static final Pattern FINAL_ANSWER_PATTERN =
Pattern.compile("Final Answer:\\s*(.+)", Pattern.DOTALL);
public ReActParseResult parse(String llmOutput) {
ReActParseResult result = new ReActParseResult();
// 提取 Thought
Matcher thoughtMatcher = THOUGHT_PATTERN.matcher(llmOutput);
if (thoughtMatcher.find()) {
result.setThought(thoughtMatcher.group(1).trim());
}
// 检查是否有 Final Answer
Matcher finalAnswerMatcher = FINAL_ANSWER_PATTERN.matcher(llmOutput);
if (finalAnswerMatcher.find()) {
result.setFinalAnswer(finalAnswerMatcher.group(1).trim());
result.setFinished(true);
return result;
}
// 提取 Action
Matcher actionMatcher = ACTION_PATTERN.matcher(llmOutput);
if (actionMatcher.find()) {
result.setToolName(actionMatcher.group(1).trim());
result.setToolInput(actionMatcher.group(2).trim());
} else {
// LLM 没按格式来,尝试降级处理
result.setParseError(true);
result.setRawOutput(llmOutput);
}
return result;
}
}
// 解析结果数据类
@Data
public class ReActParseResult {
private String thought;
private String toolName;
private String toolInput;
private String finalAnswer;
private boolean finished;
private boolean parseError;
private String rawOutput;
}
// ReAct 步骤记录
@Data
@AllArgsConstructor
@NoArgsConstructor
public class ReActStep {
private String thought;
private String action; // 完整的 Action 文本,如 "Search[关键词]"
private String observation; // 工具执行结果
}7. 核心 ReAct 循环
终于到了最核心的部分:
@Service
@Slf4j
public class ReActAgent {
private static final int MAX_STEPS = 10;
private final ChatClient chatClient;
private final ToolRegistry toolRegistry;
private final ReActPromptBuilder promptBuilder;
private final ReActOutputParser outputParser;
public ReActAgent(ChatClient.Builder chatClientBuilder,
ToolRegistry toolRegistry,
ReActPromptBuilder promptBuilder,
ReActOutputParser outputParser) {
this.chatClient = chatClientBuilder.build();
this.toolRegistry = toolRegistry;
this.promptBuilder = promptBuilder;
this.outputParser = outputParser;
}
public ReActResult execute(String question) {
log.info("ReAct Agent 开始处理问题:{}", question);
String systemPrompt = promptBuilder.buildSystemPrompt(toolRegistry);
List<ReActStep> steps = new ArrayList<>();
for (int step = 0; step < MAX_STEPS; step++) {
log.info("第 {} 步开始", step + 1);
// 构建当前对话内容
String conversationText = promptBuilder.buildConversationText(question, steps);
// 调用 LLM
String llmOutput = callLLM(systemPrompt, conversationText);
log.info("LLM 输出:\n{}", llmOutput);
// 解析输出
ReActParseResult parseResult = outputParser.parse(llmOutput);
// 如果解析出错,尝试恢复
if (parseResult.isParseError()) {
log.warn("第 {} 步解析失败,LLM 原始输出:{}", step + 1, parseResult.getRawOutput());
ReActStep errorStep = new ReActStep(
"解析上一步输出时遇到格式问题,重新规划",
null,
"上一步输出格式不正确,请严格按照 Thought/Action 或 Thought/Final Answer 格式回答"
);
steps.add(errorStep);
continue;
}
// 如果已经得出最终答案
if (parseResult.isFinished()) {
log.info("ReAct 循环完成,共 {} 步", step + 1);
return ReActResult.success(
parseResult.getFinalAnswer(),
steps,
question
);
}
// 执行工具
String toolName = parseResult.getToolName();
String toolInput = parseResult.getToolInput();
String actionText = toolName + "[" + toolInput + "]";
String observation = executeToolSafely(toolName, toolInput);
log.info("工具 {} 执行结果:{}", toolName, observation);
// 记录这一步
ReActStep currentStep = new ReActStep(
parseResult.getThought(),
actionText,
observation
);
steps.add(currentStep);
}
// 超过最大步数
log.warn("ReAct 循环超过最大步数 {},强制终止", MAX_STEPS);
return ReActResult.maxStepsExceeded(question, steps);
}
private String callLLM(String systemPrompt, String userContent) {
return chatClient.prompt()
.system(systemPrompt)
.user(userContent)
.call()
.content();
}
private String executeToolSafely(String toolName, String toolInput) {
try {
Optional<ReActTool> tool = toolRegistry.findTool(toolName);
if (tool.isEmpty()) {
return "错误:工具 '" + toolName + "' 不存在。可用工具:" +
String.join(", ", toolRegistry.getToolNames());
}
return tool.get().execute(toolInput);
} catch (Exception e) {
log.error("工具 {} 执行异常", toolName, e);
return "工具执行出错:" + e.getMessage();
}
}
}8. 结果封装和 Controller
@Data
public class ReActResult {
private String question;
private String finalAnswer;
private List<ReActStep> steps;
private boolean success;
private String failureReason;
private int totalSteps;
public static ReActResult success(String answer, List<ReActStep> steps, String question) {
ReActResult result = new ReActResult();
result.setQuestion(question);
result.setFinalAnswer(answer);
result.setSteps(steps);
result.setSuccess(true);
result.setTotalSteps(steps.size());
return result;
}
public static ReActResult maxStepsExceeded(String question, List<ReActStep> steps) {
ReActResult result = new ReActResult();
result.setQuestion(question);
result.setSteps(steps);
result.setSuccess(false);
result.setFailureReason("超过最大步数限制(" + steps.size() + " 步)");
result.setTotalSteps(steps.size());
return result;
}
}
@RestController
@RequestMapping("/api/react")
public class ReActController {
private final ReActAgent reActAgent;
public ReActController(ReActAgent reActAgent) {
this.reActAgent = reActAgent;
}
@PostMapping("/ask")
public ResponseEntity<ReActResult> ask(@RequestBody Map<String, String> request) {
String question = request.get("question");
if (question == null || question.isBlank()) {
return ResponseEntity.badRequest().build();
}
ReActResult result = reActAgent.execute(question);
return ResponseEntity.ok(result);
}
}跑起来看看
我写了一个简单的测试,问的正是文章开头那个问题:
@SpringBootTest
public class ReActAgentTest {
@Autowired
private ReActAgent reActAgent;
@Test
void testSalesQuery() {
String question = "我们公司 Q3 季度的总销售额是多少,和去年同期相比增长了多少百分比?";
ReActResult result = reActAgent.execute(question);
System.out.println("问题:" + result.getQuestion());
System.out.println("\n执行步骤:");
for (int i = 0; i < result.getSteps().size(); i++) {
ReActStep step = result.getSteps().get(i);
System.out.println("\n--- 第 " + (i+1) + " 步 ---");
System.out.println("思考:" + step.getThought());
System.out.println("行动:" + step.getAction());
System.out.println("观察:" + step.getObservation());
}
System.out.println("\n最终答案:" + result.getFinalAnswer());
System.out.println("总步数:" + result.getTotalSteps());
}
}实际输出大概是这样的:
问题:我们公司 Q3 季度的总销售额是多少,和去年同期相比增长了多少百分比?
执行步骤:
--- 第 1 步 ---
思考:我需要先查询 Q3 2024 和 Q3 2023 的销售数据,然后计算增长百分比
行动:DatabaseQuery[Q3 销售额数据]
观察:Q3 2024 总销售额:¥5,280,000
Q3 2023 总销售额:¥4,100,000
--- 第 2 步 ---
思考:我已经有了两年的销售数据,现在需要计算增长百分比。公式是:(今年-去年)/去年*100
行动:Calculator[(5280000 - 4100000) / 4100000 * 100]
观察:计算结果:28.78048780487805
最终答案:Q3 季度(2024年)公司总销售额为 ¥5,280,000。与去年同期(Q3 2023 的 ¥4,100,000)相比,增长了约 28.78%,增长幅度显著。
总步数:2两步就搞定了,推理过程清晰,每步做了什么一目了然。
和直接用 LangChain4j Agent 的区别
现在来聊聊我为什么值得自己实现一遍,以及两种方式的实际差异。
LangChain4j Agent 的方式
LangChain4j 提供了开箱即用的 Agent 支持:
// LangChain4j 的方式
@AiService
public interface SalesAssistant {
@SystemMessage("你是一个销售数据分析助手,可以查询数据库和执行计算")
String analyze(String question);
}
// 工具定义
public class SalesTools {
@Tool("查询销售数据库")
public String querySalesData(String description) {
// 实现
}
@Tool("执行数学计算")
public String calculate(String expression) {
// 实现
}
}
// 组装
SalesAssistant assistant = AiServices.builder(SalesAssistant.class)
.chatLanguageModel(model)
.tools(new SalesTools())
.build();这种方式代码简洁很多,但问题在于:
- 黑盒度高:框架内部的推理过程你看不到,调试困难
- 控制力弱:工具的调用顺序、错误处理策略都是框架决定的
- Prompt 难定制:框架内置的 ReAct Prompt 你改不了,或者改起来很绕
- 版本风险:LangChain4j 还在快速迭代,API 经常变
自实现的优势
自己实现 ReAct 最大的好处是可观测性和可控性:
- 每一步的 Thought、Action、Observation 都在你的掌控中,可以随时打日志、插入监控
- 可以在每步之间加入自定义的验证逻辑
- System Prompt 完全自己写,可以针对特定业务场景深度优化
- 遇到问题,调试思路非常清晰
当然,自实现的代价是代码量多,还要处理很多边界情况——比如 LLM 不按格式输出怎么办、工具超时怎么办、某步出错要不要重试等等。
我的建议:如果是学习或者对可观测性要求高的生产系统,自己实现;如果是快速验证 POC,用 LangChain4j 没问题,但一定要做好日志。
那些没说清楚的坑
坑一:LLM 不按格式输出
这是最常见的问题。尤其是在 Prompt 比较长、工具比较多的情况下,LLM 有时候会输出类似这样的内容:
我需要先查询数据库...
DatabaseQuery: Q3 销售数据注意它用了冒号而不是方括号,你的正则就匹配不上了。我的处理方式是:
- 主正则匹配标准格式
- 备用正则尝试匹配变体格式
- 还不行就把错误当 Observation 注回去,让模型重试
坑二:最大步数的设置
MAX_STEPS 设多少合适?这取决于你的业务场景。我的经验是:
- 简单查询问答:3-5 步够了
- 复杂分析任务:10 步
- 数据处理流水线类:可以到 15-20 步,但成本要注意
超过最大步数不一定是模型出错了,有时候就是任务本身就需要那么多步。这时候返回一个"未完成"的结果,附上已有的中间结果,让调用方决定怎么处理,比直接报错要好。
坑三:Token 成本
ReAct 的代价是 Token 消耗随步数线性增长。每步都要把完整的对话历史传给模型,第一步传 500 个 Token,第五步可能已经是 2000 个 Token 了。
对于需要 10 步以上的长链,要考虑对中间的 Observation 做摘要压缩,或者用更便宜的小模型来处理中间步骤。
总结
ReAct 的本质不复杂:就是强迫模型在行动前先说出思考过程,然后观察结果,循环往复。
工程实现上,关键是三个东西:格式化的 Prompt 让模型知道说什么、健壮的输出解析器处理模型不按规矩来的情况、清晰的工具注册体系。
自己实现一遍最大的收获是:你会发现框架里那些"魔法"其实都是朴素的字符串拼接和条件判断。理解了底层,遇到问题才不会慌。
完整的代码我放在知识星球里了,包括带完整异常处理和监控埋点的生产版本。
