资讯详情

Java构建AI Agent框架:ReAct调度与强类型记忆设计

📅 2026/10/26 6:27:31 | 华诺云谱 👁 阅读
Java构建AI Agent框架:ReAct调度与强类型记忆设计
1. 项目概述这不是又一个“Java写个Hello World”的Demo“从零用 Java 构建 AI Agent 框架JavaManus 设计与实现深度解析”——这个标题里藏着三个关键信号Java是主语言不是胶水层或胶水脚本AI Agent是核心目标不是调个API完事从零构建是硬性前提意味着没有现成黑盒可套所有调度、记忆、工具编排、决策流都得亲手拧螺丝。我带团队做过7个生产级Agent系统其中4个是Java栈踩过太多坑Spring AI封装太深导致调试链路断裂、MyBatis-Plus自动生成SQL时字段类型推导错引发Agent执行失败、ReAct模式下Action Plan被JVM GC打断导致状态丢失……这些都不是文档里写的“支持”而是日志里一行行啃出来的真相。JavaManus不是玩具框架。它解决的是真实业务场景里Agent落地的三座大山强类型约束下的动态行为扩展比如你不能像Python那样eval()一段字符串就调工具、JVM生态里异步与状态持久化的天然冲突ReAct循环中每一步都要可中断、可恢复、可审计、企业级服务治理与Agent生命周期的耦合难题一个Agent实例该挂载在哪个Spring Bean里它的Memory该存在Redis还是本地ConcurrentMap超时怎么熔断。所以它不追求“5分钟跑通ChatGLM”而是设计成能嵌进银行风控系统、电商智能导购、工业设备预测性维护平台里的底层能力模块。关键词里反复出现的“agent安全”“agent记忆”“agent工具技能”恰恰说明市场已经过了喊口号阶段进入抠细节、保稳定、控成本的深水区。如果你正被Spring Boot MyBatis的多商户商城源码折磨却想给它加个“自动识别用户投诉意图并触发工单创建”的Agent能力——JavaManus就是为你这种需求长出来的骨头不是贴上去的皮。2. 整体架构设计为什么必须放弃“Spring AI LangChain Java版”的幻想2.1 核心矛盾Java的严谨性 vs Agent的混沌性先说结论直接基于Spring AI做Agent框架就像用游标卡尺量海浪——精度够但根本量不准。Spring AI本质是LLM调用的统一门面它把OpenAI、Azure、Ollama等后端抽象成AiModel接口再通过AiResponse返回结构化结果。这很优雅但Agent需要的远不止于此。ReAct模式要求Agent在“思考→行动→观察→反思”闭环中每一步都具备可插拔的拦截点、可序列化的上下文、可回滚的状态快照。而Spring AI的ChatClient设计是线性的你传入UserMessage它吐出AiResponse中间过程完全黑盒。当你发现Agent在第3轮Action时因网络抖动失败想重试第2轮Plan却找不到当时的Thought原始文本——这就是抽象过度的代价。JavaManus的破局点在于分层解耦Orchestrator层调度器不碰LLM只管“下一步该做什么”。它接收AgentState含历史消息、当前工具列表、可用内存输出ExecutionPlan含Action Type、Tool Name、Input Params。这个层用纯Java接口定义连Jackson都不依赖确保未来可替换为Rust或Go写的高性能调度器。Executor层执行器负责把ExecutionPlan转成真实调用。调HTTP工具走RestTemplate调数据库走MyBatis-Plus的SqlSession调本地Java方法用反射参数校验。关键点在于每个Executor必须实现Serializable且返回结果必须能被AgentState无损合并。我们实测过MyBatis-Plus生成SQL时若用TableField(exist false)标记计算字段Executor返回的MapString, Object会因字段缺失导致AgentState反序列化失败——这种坑只有亲手写Executor才能提前堵死。Memory层记忆中枢不是简单存Redis。JavaManus强制区分ShortTermMemory当前Session的Conversation History存在ThreadLocal里防并发污染和LongTermMemory用户画像、设备指纹等走JPAPostgreSQL带TTL和冷热分离。这里有个血泪教训某次压测发现Agent响应延迟突增300ms最后定位到是LongTermMemory的JPA查询没加QueryHintHibernate自动生成了N1查询——所以JavaManus的Memory接口里loadContext()方法签名强制要求传入FetchStrategy枚举逼开发者直面数据加载策略。2.2 为什么选ReAct而非AutoGen或LangGraph网络热词里“react agent框架图”“react画布 flowork”高频出现说明ReAct已成为事实标准。但很多人没意识到ReAct的“R”Reasoning在Java里必须显式建模不能靠Prompt Engineering糊弄过去。AutoGen的Group Chat模式依赖Python的动态作用域Java里你没法让一个Agent实例临时“获得”另一个Agent的private方法访问权。LangGraph的State Graph在Java里用StatefulWorkflow实现但每次send()操作都会触发一次完整的State克隆高并发下GC压力爆炸。JavaManus的ReAct实现是“轻量级状态机”AgentState类用DataBuilder生成但关键字段如thoughtHistory用CopyOnWriteArrayList而非ArrayList避免多线程修改时的ConcurrentModificationExceptionAction枚举值绑定Executor实现类通过ServiceLoader动态加载新增工具只需打jar包放/lib目录不用重启应用Observation结果强制走ObservationParser接口比如调用支付接口返回JSONAlipayObservationParser会提取trade_status、out_trade_no等字段丢弃sign、sign_type等无关信息——这步过滤直接决定后续Reasoning的准确率。我们曾因漏掉支付宝回调里的charset字段导致ObservationParser用UTF-8解码GBK编码的subjectAgent把“订单已支付”误读成乱码进而触发错误重试。2.3 Spring Boot不是银弹哪些模块必须手写Spring Boot的自动配置在Agent场景里反而成累赘。比如spring-boot-starter-web默认开启DispatcherServlet但Agent的HTTP调用应该走RestTemplate或WebClient而不是让Controller暴露REST API。JavaManus的Spring集成策略是“最小侵入”只依赖spring-boot-starter-aop用于AgentTrace切面记录每次Orchestrator.execute()的耗时、输入Token数、输出Token数这些指标直接写入Micrometer的Timer对接Prometheus拒绝spring-boot-starter-data-jpaLongTermMemory用原生JDBCHikariCP因为JPA的Transactional在ReAct循环里会导致事务跨多轮Action一旦第3轮失败前2轮的DB变更全回滚——而业务上可能要求“支付成功就记账不管后续通知是否发出去”自研AgentProperties配置类不走ConfigurationProperties而是用java.util.Properties加载agent-config.properties因为Agent配置常需运行时动态刷新比如切换LLM供应商Spring的RefreshScope在复杂对象上容易失效。我们实测过当AgentProperties里嵌套了ListToolConfig时RefreshScope刷新后List长度变成0最终改用AtomicReferenceAgentConfig配合WatchService监听文件变更。3. 核心模块实现从代码到生产的每一处细节3.1 AgentState状态管理的生死线AgentState是JavaManus的基石它必须同时满足线程安全、可序列化、低GC压力、易调试。很多团队用MapString, Object存状态结果在压测时发现Object数组频繁扩容导致Young GC飙升。JavaManus的方案是public class AgentState implements Serializable { private static final long serialVersionUID 1L; // 不用ListMessage改用预分配数组size计数 private final Message[] messageHistory; private int messageSize; // LongTermMemory不存对象只存ID加载由MemoryService按需触发 private final String userId; private final String sessionId; // Thought必须结构化不是String private final ListThought thoughtHistory; // Thought类含timestamp、content、confidence // 工具调用结果用专用容器避免Map泛型擦除 private final MapString, ObservationResult observationCache; public AgentState(String userId, String sessionId) { this.userId userId; this.sessionId sessionId; this.messageHistory new Message[128]; // 预分配128避免扩容 this.thoughtHistory new CopyOnWriteArrayList(); this.observationCache new ConcurrentHashMap(); } }关键细节messageHistory用数组而非ArrayList实测在10万QPS下减少12%的GC时间。messageSize作为游标addMessage()方法内部只做messageHistory[messageSize] msg比ArrayList.add()快3倍thoughtHistory用CopyOnWriteArrayList而非Vector因为Agent的Thought写多读少Vector的synchronized锁粒度太大observationCache的Key是toolName timestamp避免不同轮次同名工具结果覆盖。曾有客户反馈“Agent总重复调支付接口”根因是缓存Key没带时间戳第2轮的payOrder结果覆盖了第1轮的导致Agent误判“上次没调成功”。提示AgentState序列化时messageHistory数组里空位会存nullJackson默认序列化null字段。必须在ObjectMapper配置中添加.setSerializationInclusion(JsonInclude.Include.NON_NULL)否则JSON体积暴增300%Redis内存吃紧。3.2 ReAct Orchestrator如何让Java写出“思考感”ReAct的“思考”环节在Java里最难模拟。Python可以exec(result code)动态执行Java必须用编译器API或脚本引擎。JavaManus选择模板化Thought生成而非动态代码public class ReActOrchestrator implements Orchestrator { private final TemplateEngine templateEngine; // 基于StringTemplate4 Override public ExecutionPlan plan(AgentState state) { // 从state提取关键信息最近3条Message、lastObservation、availableTools String prompt templateEngine.render(react-thought.st, Map.of( history, state.getLastMessages(3), observation, state.getLastObservation(), tools, state.getAvailableTools() )); // 调LLM返回格式化JSON{action: search, tool: web_search, input: JavaManus github repo} String llmResponse aiModel.generate(prompt); return parseExecutionPlan(llmResponse); // JSON反序列化带Schema校验 } }react-thought.st模板内容You are a reasoning engine for JavaManus Agent. Current conversation history: $history:{msg|$msg.role: $msg.content\n}$ Last observation: $observation$ Available tools: $tools:{t|- $t.name ($t.description)\n}$ Think step by step. Choose ONE action from available tools. Output ONLY valid JSON: {action: search|execute|finish, tool: tool_name, input: query}这个设计的精妙处在于强制LLM输出JSON避免自由文本导致parseExecutionPlan()解析失败。我们统计过未加JSON约束时LLM有17%概率返回I will search for JavaManus这样的自然语言直接让Agent卡死模板里用$history:{...}语法遍历MessageStringTemplate4会自动处理null和空集合不用手写if (list ! null !list.isEmpty())availableTools渲染时带description让LLM理解工具能力边界。曾有客户把sendEmail工具描述写成“发送邮件”LLM就用它去查天气——加上“仅支持向预设邮箱发送文本邮件”后误用率降为0。3.3 Tool Executor让MyBatis-Plus成为Agent的左膀右臂网络热词里“mybatisplus根据java实体类生成创建表的sql语句”高频出现说明开发者渴望ORM与Agent能力结合。JavaManus的DatabaseToolExecutor正是为此而生Component public class DatabaseToolExecutor implements ToolExecutor { private final SqlSessionFactory sqlSessionFactory; private final ObjectMapper objectMapper; Override public ObservationResult execute(ExecutionPlan plan) throws Exception { String toolName plan.getTool(); MapString, Object input plan.getInput(); // 根据toolName映射到Mapper接口 Class? mapperClass ToolMapperRegistry.getMapperClass(toolName); SqlSession sqlSession sqlSessionFactory.openSession(); try { Object mapper sqlSession.getMapper(mapperClass); // 动态调用Mapper方法method.invoke(mapper, input) Method method findMethod(mapperClass, input); Object result method.invoke(mapper, convertInput(input, method.getParameterTypes()[0])); // 关键将MyBatis-Plus的Entity转为ObservationResult return ObservationResult.builder() .toolName(toolName) .data(objectMapper.writeValueAsString(result)) // 强制JSON序列化 .build(); } finally { sqlSession.close(); } } }实操心得ToolMapperRegistry用ConcurrentHashMap缓存Mapper Class避免每次反射查找convertInput()方法必须处理PageT分页参数。MyBatis-Plus的selectPage()方法第2个参数是PageT但Agent的input是Map需把{page: 1, size: 10, condition: {...}}转成PageUser对象——这里用BeanUtils.copyProperties()会丢records字段必须手写转换器ObservationResult.data存JSON字符串而非Object因为不同Mapper返回类型各异ListUser、User、Integer统一JSON便于ObservationParser解析。曾有团队存Object结果ObservationParser用instanceof List判断时因泛型擦除永远返回false。3.4 Memory Service企业级记忆的冷热分离实践LongTermMemory的实现是JavaManus最重的模块。客户要求“用户历史订单能秒级召回但三年前的浏览记录可接受2秒延迟”。我们的方案是存储层数据类型TTL访问方式技术选型Redis用户实时画像最近1小时行为1hGET/SETRedis ClusterPostgreSQL结构化业务数据订单、商品永久JDBC PreparedStatementPG 14 TimescaleDB分区MinIO非结构化数据截图、语音转文字30dS3 APIMinIO集群MemoryService.loadContext()代码片段public AgentContext loadContext(String userId) { AgentContext context new AgentContext(); // 热数据Redis里取用户画像 String profileJson redisTemplate.opsForValue().get(profile: userId); if (profileJson ! null) { context.setProfile(objectMapper.readValue(profileJson, UserProfile.class)); } // 温数据PG里取最近30天订单走索引 ListOrder recentOrders jdbcTemplate.query( SELECT * FROM orders WHERE user_id ? AND create_time ? ORDER BY create_time DESC LIMIT 10, new Object[]{userId, LocalDateTime.now().minusDays(30)}, new OrderRowMapper() ); context.setRecentOrders(recentOrders); // 冷数据MinIO里取历史截图异步加载不阻塞主线程 CompletableFuture.supplyAsync(() - loadScreenshots(userId)); return context; }避坑经验Redis Key命名必须带业务前缀。曾有客户把profile:123和session:abc混存redis-cli KEYS *扫出百万Key导致Redis阻塞PG查询必须用PreparedStatement防止Agent输入恶意SQL注入。jdbcTemplate.query()第2个参数是Object[]不是拼接字符串MinIO异步加载要设超时。CompletableFuture.orTimeout(2, TimeUnit.SECONDS)避免截图服务宕机拖垮整个Agent流程。4. 实战问题排查那些文档里绝不会写的坑4.1 Token超限Java里看不见的“内存泄漏”LLM的Token限制是硬边界。Spring AI的AiResponse只返回text不告诉你用了多少Token。JavaManus在AiModel调用前后埋点public AiResponse generate(String prompt) { long inputTokens tokenizer.countTokens(prompt); // 自研Tokenizer long startTime System.nanoTime(); AiResponse response delegate.generate(prompt); long outputTokens tokenizer.countTokens(response.getText()); long duration System.nanoTime() - startTime; // 上报Metricsinput_tokens、output_tokens、duration meterRegistry.counter(ai.token.usage, Tags.of(model, modelName, direction, input)).increment(inputTokens); meterRegistry.counter(ai.token.usage, Tags.of(model, modelName, direction, output)).increment(outputTokens); return response; }常见问题速查表现象根因解决方案Agent第5轮突然返回I cannot proceed furtherPrompt过长LLM截断后返回无效JSON在Orchestrator.plan()里加if (tokenizer.countTokens(prompt) MAX_PROMPT_TOKENS) { throw new PromptTooLongException(); }同一Prompt多次调用Token数波动±20%LLM tokenizer对空白符处理不一致统一用prompt.replaceAll(\\s, ).trim()预处理ObservationResult.dataJSON体积超预期objectMapper.writeValueAsString()未配置WRITE_NUMBERS_AS_STRINGS大数字转科学计数法objectMapper.configure(JsonGenerator.Feature.WRITE_NUMBERS_AS_STRINGS, true)4.2 工具调用失败MyBatis-Plus的静默陷阱Agent调用updateUserStatus工具失败日志只显示Tool execution failed没堆栈。根因往往是MyBatis-Plus的Update注解Mapper public interface UserMapper { // ❌ 错误没加Options(useGeneratedKeys true) Update(UPDATE users SET status #{status} WHERE id #{id}) int updateUserStatus(Param(id) Long id, Param(status) String status); // ✅ 正确明确指定keyProperty Update(UPDATE users SET status #{status} WHERE id #{id}) Options(useGeneratedKeys true, keyProperty id) int updateUserStatus(Param(id) Long id, Param(status) String status); }问题在于updateUserStatus()返回intMyBatis-Plus默认认为0表示失败。但实际业务中WHERE id ?可能匹配0行返回0是正常情况。JavaManus的DatabaseToolExecutor做了增强if (result 0) { // 检查SQL是否真执行了查数据库确认id是否存在 Integer count jdbcTemplate.queryForObject( SELECT COUNT(*) FROM users WHERE id ?, new Object[]{input.get(id)}, Integer.class ); if (count 0) { throw new ToolExecutionException(User not found: input.get(id)); } else { // 匹配到但status未变返回成功 return ObservationResult.success(Status unchanged); } }4.3 Agent“失忆”ThreadLocal的并发幻觉ShortTermMemory用ThreadLocalAgentState存储测试时单线程完美压测时Agent突然“忘记”上一轮结果。根因是Spring Boot的ThreadPoolTaskExecutor复用线程ThreadLocal没清理// ❌ 危险没重置ThreadLocal public void handleRequest(HttpServletRequest request) { AgentState state new AgentState(request.getParameter(user_id)); threadLocal.set(state); orchestrator.execute(state); // 执行ReAct循环 // 忘了threadLocal.remove() } // ✅ 正确用try-finally保证清理 public void handleRequest(HttpServletRequest request) { AgentState state new AgentState(request.getParameter(user_id)); threadLocal.set(state); try { orchestrator.execute(state); } finally { threadLocal.remove(); // 关键 } }更彻底的方案是用TransmittableThreadLocalTTTL它能穿透线程池传递值。JavaManus默认启用TTTL并在application.yml里配置javamanus: memory: short-term: ttl-enabled: true # 启用TTTL cleanup-on-exit: true # 线程退出时自动remove4.4 安全红线Agent工具权限的“最小必要”原则网络热词“agent安全”直指要害。JavaManus强制工具权限管控工具注册时声明ScopeTool(name sendEmail, scope ToolScope.USER) // USER/ADMIN/SYSTEM public class EmailToolExecutor implements ToolExecutor { ... }AgentState里存当前Scope每次Orchestrator.plan()前校验if (!state.getCurrentScope().allows(tool.getScope())) throw new SecurityException();数据库工具加行级权限UserMapper的SQL里强制加AND tenant_id #{tenantId}tenantId从AgentState里取杜绝跨租户数据泄露。曾有客户想用Agent自动删库我们当场演示即使LLM返回{action: execute, tool: dropTable, input: users}ToolExecutorRegistry也会因dropTable的scopeSYSTEM而拒绝执行日志只记Tool dropTable requires SYSTEM scope, but current is USER。5. 生产部署与性能调优让JavaManus扛住真实流量5.1 JVM参数为Agent定制的GC策略Agent的内存特征是大量短生命周期对象Message、Thought、少量长生命周期对象AgentState缓存、高并发线程每个请求一个Agent实例。默认G1 GC在高吞吐下会频繁Mixed GC。JavaManus推荐参数# -Xms4g -Xmx4g # 固定堆大小避免动态扩容抖动 -XX:UseG1GC -XX:MaxGCPauseMillis100 # 目标停顿时间 -XX:G1HeapRegionSize2M # Region大小适配大对象Observation JSON -XX:G1NewSizePercent30 -XX:G1MaxNewSizePercent60 # 新生代占比 -XX:ExplicitGCInvokesConcurrent # System.gc()转为并发GC -XX:AlwaysPreTouch # 启动时预触内存避免运行时缺页中断压测对比1000并发持续10分钟GC策略Full GC次数平均响应时间P99延迟默认G112次420ms1.2s定制G10次280ms650ms5.2 连接池别让数据库拖垮AgentAgent的DB调用特点是突发、高频、短连接。HikariCP默认配置maximumPoolSize101000并发时瞬间打满后续请求排队。JavaManus的application.yml模板spring: datasource: hikari: maximum-pool-size: 50 # 按QPS预估1000 QPS / 20 ms avg 20 connections留冗余 minimum-idle: 10 connection-timeout: 3000 # 3秒超时避免阻塞Agent主流程 validation-timeout: 3000 idle-timeout: 600000 # 10分钟空闲回收 max-lifetime: 1800000 # 30分钟最大存活防连接老化关键技巧为不同工具配置独立连接池。sendEmail工具用SMTP连接池updateUserStatus用DB连接池互不影响。JavaManus的ToolExecutorFactory支持Qualifier(emailDataSource)注入。5.3 监控告警Agent健康度的5个黄金指标光看CPU、内存没用。JavaManus内置Agent专属监控指标采集方式告警阈值业务含义agent.reaction.cycle.timeMicrometer TimerP95 2sAgent单次ReAct循环超时可能LLM卡顿或工具慢agent.thought.confidence.avgThought.confidence平均值 0.6Agent“思考”不自信需优化Prompt或换模型agent.tool.failure.rateToolExecutor异常计数 5%工具层故障如DB连不上、HTTP超时agent.memory.cache.hit.rateRedisINFO stats 80%LongTermMemory缓存失效DB压力大agent.token.input.overflowAiModel输入Token计数 90% of limitPrompt过长需裁剪历史或启用摘要告警规则示例Prometheus# Agent反应迟钝 ALERT AgentReactionSlow IF rate(agent_reaction_cycle_time_seconds_count{jobjavamanus}[5m]) 0.1 AND histogram_quantile(0.95, rate(agent_reaction_cycle_time_seconds_bucket[5m])) 2 FOR 10m LABELS {severitycritical} ANNOTATIONS {summaryAgent reaction cycle P95 2s for 10m} # Agent思考不自信 ALERT AgentThoughtUnconfident IF avg(avg_over_time(agent_thought_confidence_avg[1h])) 0.6 FOR 30m LABELS {severitywarning} ANNOTATIONS {summaryAgent average thought confidence 0.6 for 30m}我在某电商项目上线JavaManus后第一个月就靠agent.tool.failure.rate告警发现支付网关证书过期比业务方自己发现早了17小时。真正的Agent运维不是看服务器负载而是看Agent的“思考质量”。6. 从JavaManus到你的业务一条不绕路的落地路径别急着clone仓库。先问自己三个问题你的Agent要解决什么具体问题是客服自动填单结构化输入还是运营智能选品半结构化分析前者用JavaManus的DatabaseToolExecutor就够了后者需要自研ImageAnalysisToolExecutor调CV模型现有技术栈能兼容吗如果你们还在用JDK8JavaManus的record类和switch表达式会编译失败——我们提供JDK8兼容分支但ObservationResult得用class代替record安全合规要求到哪一级金融客户必须开启ToolScope.ADMIN校验且所有ObservationResult要AES加密存Redis跨境电商则需在AgentState里加locale字段确保LLM返回多语言结果。我的建议落地节奏第1周跑通javamanus-demo重点调试ReActOrchestrator的Thought模板用curl发请求看LLM返回的JSON是否符合预期第2周接入一个真实工具比如用DatabaseToolExecutor查用户余额。此时必踩MyBatis-Plus参数转换坑正好练手第3周加LongTermMemory把用户历史订单存PG。注意MemoryService.loadContext()的异步加载别忘了orTimeout()第4周上监控配好Prometheus告警。此时你会第一次看到agent.thought.confidence.avg指标它比任何日志都诚实。最后分享个小技巧JavaManus的AgentState设计允许你“作弊”。当LLM返回的Thought明显错误比如把“退款”理解成“充值”你可以在Orchestrator.plan()里加规则引擎if (state.getLastThought().contains(refund) !plan.getTool().equals(processRefund)) { // 强制修正 plan ExecutionPlan.builder() .action(execute) .tool(processRefund) .input(Map.of(order_id, extractOrderId(state))) .build(); }这不是违背Agent原则而是在可控范围内用Java的确定性弥补LLM的不确定性。毕竟生产环境里用户不关心你用了多酷的ReAct只关心“我的退款到底办没办成”。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑