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返回的Map<String, Object>会因字段缺失导致AgentState反序列化失败——这种坑,只有亲手写Executor才能提前堵死。 - Memory层(记忆中枢):不是简单存Redis。JavaManus强制区分
ShortTermMemory(当前Session的Conversation History,存在ThreadLocal里防并发污染)和LongTermMemory(用户画像、设备指纹等,走JPA+PostgreSQL,带TTL和冷热分离)。这里有个血泪教训:某次压测发现Agent响应延迟突增300ms,最后定位到是LongTermMemory的JPA查询没加@QueryHint,Hibernate自动生成了N+1查询——所以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类用@Data+@Builder生成,但关键字段如thoughtHistory用CopyOnWriteArrayList而非ArrayList,避免多线程修改时的ConcurrentModificationException;Action枚举值绑定Executor实现类,通过ServiceLoader动态加载,新增工具只需打jar包放/lib目录,不用重启应用;Observation结果强制走ObservationParser接口,比如调用支付接口返回JSON,AlipayObservationParser会提取trade_status、out_trade_no等字段,丢弃sign、sign_type等无关信息——这步过滤直接决定后续Reasoning的准确率。我们曾因漏掉支付宝回调里的charset字段,导致ObservationParser用UTF-8解码GBK编码的subject,Agent把“订单已支付”误读成乱码进而触发错误重试。
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-jpa:LongTermMemory用原生JDBC+HikariCP,因为JPA的@Transactional在ReAct循环里会导致事务跨多轮Action,一旦第3轮失败,前2轮的DB变更全回滚——而业务上可能要求“支付成功就记账,不管后续通知是否发出去”; - 自研
AgentProperties配置类:不走@ConfigurationProperties,而是用java.util.Properties加载agent-config.properties,因为Agent配置常需运行时动态刷新(比如切换LLM供应商),Spring的@RefreshScope在复杂对象上容易失效。我们实测过,当AgentProperties里嵌套了List<ToolConfig>时,@RefreshScope刷新后List长度变成0,最终改用AtomicReference<AgentConfig>配合WatchService监听文件变更。
3. 核心模块实现:从代码到生产的每一处细节
3.1 AgentState:状态管理的生死线
AgentState是JavaManus的基石,它必须同时满足:线程安全、可序列化、低GC压力、易调试。很多团队用Map<String, Object>存状态,结果在压测时发现Object数组频繁扩容导致Young GC飙升。JavaManus的方案是:
public class AgentState implements Serializable { private static final long serialVersionUID = 1L; // 不用List<Message>,改用预分配数组+size计数 private final Message[] messageHistory; private int messageSize; // LongTermMemory不存对象,只存ID,加载由MemoryService按需触发 private final String userId; private final String sessionId; // Thought必须结构化,不是String private final List<Thought> thoughtHistory; // Thought类含timestamp、content、confidence // 工具调用结果用专用容器,避免Map泛型擦除 private final Map<String, 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数组里空位会存null,Jackson默认序列化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:{...}语法遍历Message,StringTemplate4会自动处理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(); Map<String, 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()方法必须处理Page<T>分页参数。MyBatis-Plus的selectPage()方法第2个参数是Page<T>,但Agent的input是Map,需把{"page": 1, "size": 10, "condition": {...}}转成Page<User>对象——这里用BeanUtils.copyProperties()会丢records字段,必须手写转换器;ObservationResult.data存JSON字符串而非Object,因为不同Mapper返回类型各异(List<User>、User、Integer),统一JSON便于ObservationParser解析。曾有团队存Object,结果ObservationParser用instanceof List判断时,因泛型擦除永远返回false。
3.4 Memory Service:企业级记忆的冷热分离实践
LongTermMemory的实现是JavaManus最重的模块。客户要求“用户历史订单能秒级召回,但三年前的浏览记录可接受2秒延迟”。我们的方案是:
| 存储层 | 数据类型 | TTL | 访问方式 | 技术选型 |
|---|---|---|---|---|
| Redis | 用户实时画像(最近1小时行为) | 1h | GET/SET | Redis Cluster |
| PostgreSQL | 结构化业务数据(订单、商品) | 永久 | JDBC PreparedStatement | PG 14 + TimescaleDB分区 |
| MinIO | 非结构化数据(截图、语音转文字) | 30d | S3 API | MinIO集群 |
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天订单(走索引) List<Order> 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; // 上报Metrics:input_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 further" | Prompt过长,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()返回int,MyBatis-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用ThreadLocal<AgentState>存储,测试时单线程完美,压测时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(); // 关键! } }更彻底的方案是用TransmittableThreadLocal(TTTL),它能穿透线程池传递值。JavaManus默认启用TTTL,并在application.yml里配置:
javamanus: memory: short-term: ttl-enabled: true # 启用TTTL cleanup-on-exit: true # 线程退出时自动remove4.4 安全红线:Agent工具权限的“最小必要”原则
网络热词“agent安全”直指要害。JavaManus强制工具权限管控:
- 工具注册时声明Scope:
@Tool(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的scope=SYSTEM而拒绝执行,日志只记"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:MaxGCPauseMillis=100 # 目标停顿时间 -XX:G1HeapRegionSize=2M # Region大小,适配大对象(Observation JSON) -XX:G1NewSizePercent=30 -XX:G1MaxNewSizePercent=60 # 新生代占比 -XX:+ExplicitGCInvokesConcurrent # System.gc()转为并发GC -XX:+AlwaysPreTouch # 启动时预触内存,避免运行时缺页中断压测对比(1000并发,持续10分钟):
| GC策略 | Full GC次数 | 平均响应时间 | P99延迟 |
|---|---|---|---|
| 默认G1 | 12次 | 420ms | 1.2s |
| 定制G1 | 0次 | 280ms | 650ms |
5.2 连接池:别让数据库拖垮Agent
Agent的DB调用特点是:突发、高频、短连接。HikariCP默认配置maximumPoolSize=10,1000并发时瞬间打满,后续请求排队。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.time | Micrometer Timer | P95 > 2s | Agent单次ReAct循环超时,可能LLM卡顿或工具慢 |
agent.thought.confidence.avg | Thought.confidence平均值 | < 0.6 | Agent“思考”不自信,需优化Prompt或换模型 |
agent.tool.failure.rate | ToolExecutor异常计数 | > 5% | 工具层故障,如DB连不上、HTTP超时 |
agent.memory.cache.hit.rate | RedisINFO stats | < 80% | LongTermMemory缓存失效,DB压力大 |
agent.token.input.overflow | AiModel输入Token计数 | > 90% of limit | Prompt过长,需裁剪历史或启用摘要 |
告警规则示例(Prometheus):
# Agent反应迟钝 ALERT AgentReactionSlow IF rate(agent_reaction_cycle_time_seconds_count{job="javamanus"}[5m]) > 0.1 AND histogram_quantile(0.95, rate(agent_reaction_cycle_time_seconds_bucket[5m])) > 2 FOR 10m LABELS {severity="critical"} ANNOTATIONS {summary="Agent reaction cycle P95 > 2s for 10m"} # Agent思考不自信 ALERT AgentThoughtUnconfident IF avg(avg_over_time(agent_thought_confidence_avg[1h])) < 0.6 FOR 30m LABELS {severity="warning"} ANNOTATIONS {summary="Agent average thought confidence < 0.6 for 30m"}我在某电商项目上线JavaManus后,第一个月就靠agent.tool.failure.rate告警发现支付网关证书过期,比业务方自己发现早了17小时。真正的Agent运维,不是看服务器负载,而是看Agent的“思考质量”。
6. 从JavaManus到你的业务:一条不绕路的落地路径
别急着clone仓库。先问自己三个问题:
- 你的Agent要解决什么具体问题?是客服自动填单(结构化输入),还是运营智能选品(半结构化分析)?前者用JavaManus的
DatabaseToolExecutor就够了,后者需要自研ImageAnalysisToolExecutor调CV模型; - 现有技术栈能兼容吗?如果你们还在用JDK8,JavaManus的
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,只关心“我的退款到底办没办成”。