1. 项目概述:当Java遇上AgentScope的ReAct智能体
最近在智能体开发领域,AgentScope这个框架的热度是越来越高了。作为一个专注于多智能体应用开发的平台,它让构建复杂的协作式AI应用变得前所未有的简单。而今天我想和大家深入聊聊的,是其中一个非常核心且强大的设计模式——ReAct(Reasoning and Acting)智能体,以及如何用我们最熟悉的Java语言来实现它。
你可能已经看过很多用Python演示的ReAct Agent,比如在LangChain里,几行代码就能跑起来。但现实是,很多成熟的企业级系统、高并发后台服务,其技术栈的基石依然是Java。把前沿的AI智能体模式,用Java这套久经考验的工业级语言实现出来,不仅意味着更好的性能、更易维护的工程结构,也代表着AI能力能更平滑、更可靠地集成到现有的生产环境中。这不仅仅是“翻译”代码,更是对设计思想的一次深度落地和工程化实践。
所以,这篇内容我会带你从零开始,拆解一个Java版本的ReActAgent。我们会抛开那些笼统的概念,直接深入到代码层面,看看一个能“思考-行动”的智能体,其内部的状态机如何流转,工具如何被调用,记忆如何被管理,以及如何优雅地处理各种边界情况。无论你是对AgentScope框架感兴趣,还是想在自己的Java项目中引入ReAct模式,我相信这些从一线实践中总结出来的代码和思路,都能给你带来直接的参考价值。
2. ReAct模式核心思想与Java实现的架构设计
2.1 理解ReAct:不只是链式调用,而是有状态的推理循环
在动手写代码之前,我们必须先吃透ReAct到底在做什么。它的全称是“Reasoning and Acting”,中文可以理解为“推理-行动”。这个模式的核心思想是模仿人类解决问题的方式:先观察、思考(Reasoning),然后根据思考结果采取行动(Acting),再根据行动的结果进行新一轮的观察和思考,如此循环,直至问题解决。
这听起来有点像简单的“if-else”循环,但其精妙之处在于“推理”部分。智能体在每一步的“思考”中,并不是随机猜测,而是基于当前所有的观察(包括初始问题、历史对话、工具执行结果等),生成一段自然语言格式的推理过程。这段过程会明确分析现状、提出假设、并规划下一步行动。然后,再从这个推理文本中,解析出要执行的具体“动作”(通常是调用某个工具并传入参数)。
所以,一个典型的ReAct循环步骤是:
- 观察(Observe):获取当前状态,包括用户输入和上一步工具执行的结果。
- 思考(Think):基于所有观察,让大语言模型(LLM)生成一段包含推理和下一步行动计划的文本。
- 解析(Parse):从“思考”产生的文本中,结构化地提取出要执行的
action(工具名)和action_input(工具参数)。 - 行动(Act):调用对应的工具,并获取执行结果。
- 更新观察:将工具执行的结果作为新的“观察”,进入下一轮循环。
这个循环会一直持续,直到LLM在“思考”步骤中明确输出代表任务结束的标记(例如Final Answer:),或者达到预设的最大迭代次数。
在Java中实现这个模式,我们不能把它写成一段简单的线性代码。它必须是一个有状态的、可管理的、可监控的状态机。这也是我们设计ReActAgent类的出发点。
2.2 Java版ReActAgent的类结构设计
基于上述理解,我们可以勾勒出核心类的骨架。一个健壮的ReActAgent需要包含以下几个关键部分:
// 引入必要的包,这里以常见的工具库为例 import java.util.*; import java.util.concurrent.*; public class ReActAgent { // 1. 核心依赖:与大模型交互的客户端 private final LLMClient llmClient; // 2. 工具集:智能体可以调用的所有能力 private final Map<String, Tool> tools; // 3. 记忆体:保存对话历史和工具执行轨迹 private final List<Message> memory; // 4. 配置参数:最大步数、推理模板等 private final int maxSteps; private final String promptTemplate; // 5. 当前执行状态 private String currentObservation; private int currentStep; // 构造函数 public ReActAgent(LLMClient llmClient, Map<String, Tool> tools, int maxSteps) { this.llmClient = Objects.requireNonNull(llmClient, "LLMClient must not be null"); this.tools = new HashMap<>(tools); this.memory = new ArrayList<>(); this.maxSteps = maxSteps; this.promptTemplate = buildDefaultPromptTemplate(); this.currentStep = 0; this.currentObservation = ""; } // 核心执行方法 public String run(String userInput) { // 初始化观察 this.currentObservation = "User: " + userInput; this.memory.add(new Message("user", userInput)); this.currentStep = 0; // ReAct 主循环 while (currentStep < maxSteps) { // 步骤1:思考 (Think) String thought = think(); // 步骤2:解析思考,获取行动指令 (Parse) Action action = parseAction(thought); // 步骤3:判断是否为最终答案 if (action.isFinalAnswer()) { return action.getAnswer(); } // 步骤4:执行行动 (Act) String result = act(action); // 步骤5:更新观察,进入下一轮 updateObservation(result); currentStep++; } return "已达到最大执行步数(" + maxSteps + "),未能得出最终结论。"; } // 其他私有方法:think(), parseAction(), act(), updateObservation() 等将在下文详细实现 }这个设计有几个关键考量:
- 依赖注入:
LLMClient和Tool集合通过构造函数注入,保证了类的可测试性和灵活性。你可以轻松替换不同的模型后端(如OpenAI、通义千问、本地部署模型)或工具集。 - 状态封装:将循环状态(当前观察、当前步数)和持久状态(记忆)封装在对象内部,每次
run方法调用都是一次独立的执行会话。 - 清晰的流程:
run方法中的while循环清晰地对应了ReAct的步骤,逻辑一目了然。
注意:这里的
LLMClient和Tool是我们定义的接口,Message和Action是简单的数据类(Record)。这样做是为了解耦,让你可以根据自己的项目情况实现具体的HTTP客户端、工具逻辑和数据结构。
3. 核心模块的代码实现与详解
3.1 思考(Think)模块:Prompt工程与LLM调用
思考模块是整个智能体的“大脑”,它的质量直接决定了智能体是否“聪明”。这里的关键在于构造一个能引导LLM进行有效推理的Prompt。
private String think() { // 1. 构建完整的Prompt String prompt = buildPrompt(); // 2. 调用LLM LLMResponse response = llmClient.complete(prompt); // 3. 记录到记忆 memory.add(new Message("assistant", response.getContent())); return response.getContent(); } private String buildPrompt() { StringBuilder sb = new StringBuilder(); // 第一部分:系统指令,定义角色和流程 sb.append("你是一个善于思考并解决问题的助手。请遵循以下步骤:\n"); sb.append("1. 观察:基于之前的对话和工具返回的结果。\n"); sb.append("2. 思考:分析当前情况,推理下一步该做什么。\n"); sb.append("3. 行动:如果需要使用工具,请严格按照格式输出:\n"); sb.append(" Action: 工具名称\n"); sb.append(" Action Input: 工具的输入参数(JSON格式)\n"); sb.append("4. 如果问题已解决,请输出:\n"); sb.append(" Final Answer: 你的最终答案\n\n"); // 第二部分:可用工具描述 sb.append("你可以使用的工具有:\n"); for (Map.Entry<String, Tool> entry : tools.entrySet()) { sb.append("- ").append(entry.getKey()) .append(": ").append(entry.getValue().getDescription()).append("\n"); } sb.append("\n"); // 第三部分:对话和工具执行历史(记忆) sb.append("历史记录:\n"); for (Message msg : memory) { sb.append(msg.getRole()).append(": ").append(msg.getContent()).append("\n"); } sb.append("\n"); // 第四部分:当前观察 sb.append("当前观察:").append(currentObservation).append("\n\n"); // 第五部分:引导词 sb.append("现在,请开始你的思考过程:\nThought: "); return sb.toString(); }实操要点与避坑指南:
- 格式的强制性:Prompt中必须明确、严格地规定输出格式(如
Action:和Final Answer:)。LLM的“对齐”能力很强,清晰的格式指令能极大提高输出解析的成功率。我通常会加粗或使用特殊符号强调格式。 - 工具描述的清晰度:工具描述不能只写名字,必须包含其功能、输入参数的格式和示例、输出是什么。例如,
search_web: 用于搜索网络信息。输入应为包含‘query’键的JSON对象,如 {\"query\": \"Java最新特性\"}。返回搜索结果的摘要。 - 历史记录的裁剪:在实际应用中,记忆
memory可能会很长,需要做裁剪或总结,以避免超出模型的上下文长度限制。一个简单的策略是只保留最近N轮交互,或者用一个单独的“总结智能体”来压缩历史。 - LLM调用的稳定性:生产环境中,
llmClient.complete()必须包含重试、超时、熔断等机制。网络波动或模型服务暂时不可用是常态,不能因为一次调用失败就导致整个智能体崩溃。
3.2 解析(Parse)模块:从自由文本到结构化指令
LLM返回的是一段自由文本,我们需要从中精准地提取出结构化指令。这里推荐使用正则表达式,它比简单的字符串查找更健壮,能处理一些格式上的微小变异。
private Action parseAction(String thought) { // 先检查是否是最终答案 Pattern finalAnswerPattern = Pattern.compile("Final Answer:\\s*(.*?)(?=\\n\\n|\\nAction:|$)", Pattern.DOTALL); Matcher finalMatcher = finalAnswerPattern.matcher(thought); if (finalMatcher.find()) { String answer = finalMatcher.group(1).trim(); return Action.finalAnswer(answer); // 返回一个标记为最终答案的Action对象 } // 解析工具调用指令 Pattern actionPattern = Pattern.compile("Action:\\s*(\\w+)"); Pattern inputPattern = Pattern.compile("Action Input:\\s*(\\{.*?\\})", Pattern.DOTALL); Matcher actionMatcher = actionPattern.matcher(thought); Matcher inputMatcher = inputPattern.matcher(thought); if (actionMatcher.find() && inputMatcher.find()) { String toolName = actionMatcher.group(1).trim(); String inputJson = inputMatcher.group(1).trim(); try { // 使用如Jackson、Gson等库解析JSON ObjectMapper mapper = new ObjectMapper(); Map<String, Object> params = mapper.readValue(inputJson, new TypeReference<Map<String, Object>>() {}); return Action.toolAction(toolName, params); } catch (JsonProcessingException e) { // 如果JSON解析失败,将错误信息作为观察,让LLM在下轮修正 return Action.finalAnswer("解析Action Input时出错,输入不是有效的JSON格式。请重新思考并确保Action Input是合法的JSON。"); } } // 如果既不是最终答案,也没找到有效指令,则视为需要继续思考 return Action.finalAnswer("未能从你的回复中识别出有效的‘Action’或‘Final Answer’格式。请严格按照要求的格式回复。"); }注意事项:
- 正则的贪婪与非贪婪:在匹配
Action Input的JSON时,我们使用了\\{.*?\\}(非贪婪模式),这可以防止匹配到多个JSON块或文本末尾。Pattern.DOTALL标志让.也能匹配换行符,因为JSON可能跨行。 - 健壮的JSON解析:LLM生成的JSON有时会有格式问题(如尾随逗号、注释)。使用严格的解析器(如Jackson)会直接抛异常。这里我们选择捕获异常,并将错误信息反馈给LLM,让它自我修正,这比直接让智能体崩溃更友好。
Action数据类设计:Action类最好设计成不可变的(使用Record),并包含一个类型字段来区分是工具调用还是最终答案。例如:public record Action(ActionType type, String toolName, Map<String, Object> input, String finalAnswer) { public static Action toolAction(String name, Map<String, Object> input) { return new Action(ActionType.TOOL, name, input, null); } public static Action finalAnswer(String answer) { return new Action(ActionType.FINAL, null, null, answer); } public boolean isFinalAnswer() { return type == ActionType.FINAL; } } enum ActionType { TOOL, FINAL }
3.3 行动(Act)模块:工具执行与结果处理
行动模块是智能体与外部世界交互的“手”。它的职责是安全、高效地执行工具调用。
private String act(Action action) { if (action.type() != ActionType.TOOL) { return "内部错误:尝试执行一个非工具类型的Action。"; } String toolName = action.toolName(); Map<String, Object> input = action.input(); Tool tool = tools.get(toolName); if (tool == null) { String availableTools = String.join(", ", tools.keySet()); return String.format("错误:工具‘%s’不存在。可用工具有:[%s]。", toolName, availableTools); } try { // 执行工具,并设置超时防止工具卡死 CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> tool.execute(input)); String result = future.get(30, TimeUnit.SECONDS); // 设置30秒超时 // 记录工具调用和结果到记忆 memory.add(new Message("tool_call", String.format("Called %s with input: %s", toolName, input))); memory.add(new Message("tool_result", result)); return result; } catch (TimeoutException e) { return String.format("错误:工具‘%s’执行超时(30秒)。", toolName); } catch (InterruptedException | ExecutionException e) { return String.format("错误:工具‘%s’执行失败。原因:%s", toolName, e.getCause() != null ? e.getCause().getMessage() : e.getMessage()); } } private void updateObservation(String result) { this.currentObservation = "Tool Result: " + result; }核心经验与技巧:
- 工具接口设计:
Tool接口应该非常简单,例如只有一个execute(Map<String, Object> input)方法。具体的工具实现(如搜索、计算、查询数据库)再去实现这个接口。这符合“依赖倒置”原则。 - 超时控制是必须的:任何外部调用(网络IO、复杂计算)都必须设置超时。这里用
CompletableFuture.get(timeout)是一种方式。在生产环境中,你可能需要更复杂的线程池和断路器模式。 - 结果格式化:工具返回的结果应该是简洁、信息丰富且格式化的文本。避免返回原始的、冗长的JSON或HTML。最好在工具内部就做好结果的处理和摘要,方便LLM在下轮思考时理解。
- 副作用与安全性:对于会修改数据的工具(如写入数据库、发送邮件),必须进行严格的权限校验和参数验证。智能体不应该拥有不受限制的“写”权限。可以在
Tool.execute方法内部或通过一个代理层来实现。
4. 工程化进阶:让Java ReActAgent更健壮、更易用
一个能跑通的Demo和一個能在生产环境使用的组件之间,隔着许多工程细节。下面我们来完善它。
4.1 配置化与可观测性
硬编码的Prompt模板和参数不利于维护。我们可以引入一个AgentConfig类,通过配置文件或环境变量来管理。
@ConfigurationProperties(prefix = "agent.react") // 如果你用Spring Boot public class ReActAgentConfig { private int maxSteps = 10; private String systemPrompt; private long toolTimeoutSeconds = 30; private boolean enableMemorySummary = false; private int maxMemoryLength = 20; // ... getters and setters }同时,可观测性(Observability)对于调试和监控AI应用至关重要。我们需要在关键节点埋点。
import org.slf4j.Logger; import org.slf4j.LoggerFactory; public class ReActAgent { private static final Logger logger = LoggerFactory.getLogger(ReActAgent.class); private final MeterRegistry meterRegistry; // 假设使用Micrometer private String think() { long start = System.currentTimeMillis(); String prompt = buildPrompt(); logger.debug("Generated prompt for step {}: \n{}", currentStep, prompt); LLMResponse response = llmClient.complete(prompt); long duration = System.currentTimeMillis() - start; // 记录指标 meterRegistry.timer("agent.think.time").record(duration, TimeUnit.MILLISECONDS); meterRegistry.counter("agent.think.calls").increment(); logger.info("Step {} Think completed in {}ms", currentStep, duration); memory.add(new Message("assistant", response.getContent())); return response.getContent(); } private String act(Action action) { // ... 工具执行 ... meterRegistry.counter("agent.tool.calls", "tool", toolName).increment(); if (!success) { meterRegistry.counter("agent.tool.errors", "tool", toolName).increment(); } // ... } }记录日志和指标可以帮助你:分析每一步的耗时、统计工具调用成功率、在出错时快速定位是Prompt问题、工具问题还是模型问题。
4.2 记忆管理与上下文优化
随着对话轮数增加,记忆会越来越长,最终会突破LLM的上下文窗口限制。我们需要一个记忆管理策略。
- 滑动窗口:只保留最近N条消息。简单有效,但可能丢失关键的长程依赖信息。
- 总结性记忆:这是更高级的策略。当记忆达到一定长度时,触发一个“总结智能体”,让它用一段话总结之前的对话历史和工具执行结果,然后用这个总结替换掉旧的历史记录。
private void manageMemory() { if (memory.size() > config.getMaxMemoryLength()) { if (config.isEnableMemorySummary()) { summarizeMemory(); } else { // 简单裁剪,保留最近的系统消息、用户消息和助理消息对 int toRemove = memory.size() - config.getMaxMemoryLength(); // 实现一个更智能的裁剪逻辑,避免剪掉关键的工具结果 memory.subList(0, toRemove).clear(); } } } private void summarizeMemory() { // 构建一个请求,让LLM总结之前的对话 String summaryPrompt = "请将以下对话历史简要总结成一段话,保留关键的事实、决策和结果:\n" + getRecentMemoryText(); String summary = llmClient.complete(summaryPrompt).getContent(); // 用一条新的系统消息存储总结,并清除大部分旧记忆 Message summaryMsg = new Message("system", "对话历史总结:" + summary); // 清空旧记忆,但保留最近一两轮和总结 memory.clear(); memory.add(summaryMsg); // 可以选择性地再保留最近一轮完整交互 }4.3 工具的动态注册与发现
在大型应用中,工具可能由不同的团队或模块开发。我们可以设计一个工具注册中心。
public class ToolRegistry { private final ConcurrentHashMap<String, Tool> toolMap = new ConcurrentHashMap<>(); public void register(String name, Tool tool) { toolMap.put(name, tool); } public void registerAll(Map<String, Tool> tools) { toolMap.putAll(tools); } public Optional<Tool> getTool(String name) { return Optional.ofNullable(toolMap.get(name)); } public Map<String, String> getToolDescriptions() { return toolMap.entrySet().stream() .collect(Collectors.toMap(Map.Entry::getKey, e -> e.getValue().getDescription())); } } // 在Agent中注入ToolRegistry public ReActAgent(LLMClient llmClient, ToolRegistry registry, ReActAgentConfig config) { this.llmClient = llmClient; this.toolRegistry = registry; this.config = config; // ... } private String buildPrompt() { // ... sb.append("你可以使用的工具有:\n"); toolRegistry.getToolDescriptions().forEach((name, desc) -> { sb.append("- ").append(name).append(": ").append(desc).append("\n"); }); // ... }这样,新的工具可以通过Spring的@PostConstruct、监听应用启动事件等方式动态注册进来,Agent无需重启即可感知新能力。
5. 实战:构建一个简单的问答智能体并排查问题
让我们用一个具体的例子把上面的代码串起来。假设我们要构建一个能回答“今天天气如何”和进行简单计算的智能体。
第一步:定义工具
@Component // 假设使用Spring管理Bean public class WeatherTool implements Tool { @Override public String getDescription() { return "获取指定城市的当前天气。输入应为包含‘city’键的JSON对象,如 {\"city\": \"北京\"}。返回天气概况。"; } @Override public String execute(Map<String, Object> input) { String city = (String) input.get("city"); if (city == null) { return "错误:缺少‘city’参数。"; } // 这里模拟一个API调用 return String.format("%s的天气是晴,温度22-28°C,微风。", city); } } @Component public class CalculatorTool implements Tool { @Override public String getDescription() { return "执行数学计算。输入应为包含‘expression’键的JSON对象,如 {\"expression\": \"3 + 5 * 2\"}。支持加减乘除和括号。返回计算结果。"; } @Override public String execute(Map<String, Object> input) { String expr = (String) input.get("expression"); // 警告:实际项目中切勿直接用ScriptEngine等执行未经净化的用户输入!此处仅为演示。 try { ScriptEngineManager mgr = new ScriptEngineManager(); ScriptEngine engine = mgr.getEngineByName("JavaScript"); Object result = engine.eval(expr); return "计算结果: " + result.toString(); } catch (ScriptException e) { return "计算错误: " + e.getMessage(); } } }第二步:组装并运行Agent
@SpringBootApplication public class ReActDemoApplication implements CommandLineRunner { @Autowired private LLMClient llmClient; // 假设已配置好 @Autowired private WeatherTool weatherTool; @Autowired private CalculatorTool calculatorTool; public static void main(String[] args) { SpringApplication.run(ReActDemoApplication.class, args); } @Override public void run(String... args) { ToolRegistry registry = new ToolRegistry(); registry.register("get_weather", weatherTool); registry.register("calculator", calculatorTool); ReActAgentConfig config = new ReActAgentConfig(); config.setMaxSteps(5); ReActAgent agent = new ReActAgent(llmClient, registry, config); String question1 = "上海今天天气怎么样?"; System.out.println("Q: " + question1); String answer1 = agent.run(question1); System.out.println("A: " + answer1); System.out.println("-----"); // 重置Agent状态(或新建一个实例)进行下一个问题 agent = new ReActAgent(llmClient, registry, config); String question2 = "如果北京温度是25度,上海比北京高3度,那么上海温度是多少?"; System.out.println("Q: " + question2); String answer2 = agent.run(question2); System.out.println("A: " + answer2); } }预期执行流程(对于问题2):
- LLM思考:“用户想知道上海温度。已知北京25度,上海高3度。这是一个计算问题,我需要用计算器。先计算25+3。”
- 输出:
Action: calculatorAction Input: {"expression": "25 + 3"} - 计算器返回:“计算结果: 28”
- 新观察:“Tool Result: 计算结果: 28”
- LLM思考:“计算得到上海是28度。这是最终答案。”
- 输出:
Final Answer: 上海的温度是28度。
5.1 常见问题排查与调试技巧
在实际运行中,你肯定会遇到各种问题。下面是一个快速排查指南:
| 问题现象 | 可能原因 | 排查步骤与解决方案 |
|---|---|---|
LLM不输出Action格式 | Prompt指令不清晰或LLM没对齐。 | 1.检查Prompt:确保格式指令非常醒目(如用###包裹)。2.在思考步骤后加示例:在Prompt末尾加一个完整的示例循环。 3.调整温度(temperature):尝试调低温度(如0.1)以获得更确定性的输出。 |
| 解析JSON失败 | LLM生成的JSON格式有误(如缺少引号、尾随逗号)。 | 1.增强解析器:使用JsonNode或更宽松的解析模式。2.在Prompt中强调:“Action Input必须是严格、有效的JSON,不能包含注释或尾随逗号。” 3.让LLM自我修正:像我们代码里做的那样,将解析错误信息反馈给下一轮思考。 |
| 智能体陷入死循环 | 工具结果无法满足终止条件,或LLM推理出现逻辑循环。 | 1.设置最大步数:这是最基本的保护。 2.检查工具输出:工具是否返回了错误或模糊信息,导致LLM无法理解?确保工具输出清晰。 3.引入循环检测:记录历史动作序列,如果发现重复调用相同工具和参数,则强制终止或返回错误。 |
| 工具执行超时或出错 | 工具依赖的外部服务不稳定,或工具本身有Bug。 | 1.查看日志:在act方法中详细记录工具调用的开始、结束和结果。2.实现熔断机制:如果某个工具连续失败,暂时将其禁用。 3.提供友好的错误反馈:工具返回的错误信息应能帮助LLM理解问题(如“网络超时,请稍后再试”比“IOException”更好)。 |
| 上下文长度超限 | 对话轮次太多,记忆过长。 | 1.实现记忆管理:如上文所述,采用滑动窗口或总结机制。 2.选择更长上下文的模型。 |
一个关键的调试技巧:记录完整的思维链。不要只记录最终输入输出。在开发阶段,把每一轮的Prompt、LLM回复(Thought)、解析出的Action、工具结果都打印或记录到日志文件中。这就像飞机的黑匣子,能让你完整复盘智能体的“心路历程”,是定位问题最有效的方法。
最后,我想分享一点个人体会。用Java实现ReAct Agent,最大的挑战不是语法,而是将非确定性的LLM输出与确定性的程序逻辑可靠地结合。这要求我们的代码必须有极高的鲁棒性和可观测性。每一个与LLM交互的边界,都要做好“防御性编程”,假设任何奇怪的输出都可能出现,并为之设计降级或修正路径。当你看到自己编写的智能体,能够像人类一样一步步推理、调用工具、最终解决问题时,那种成就感是非常独特的。这不仅仅是完成了一个功能,更像是赋予了一段代码自主思考和行动的能力。希望这篇详细的实现讲解,能帮助你顺利跨出这一步。