1. 为什么我要用 Java 从头造一个 Agent 框架第一次接触 AI Agent 这个概念的时候我脑子里冒出来的第一个念头其实很朴素这不就是一个能自己决定下一步干什么的程序吗后来真正动手做起来才发现事情远没有想象中那么简单。市面上的 Agent 框架大多以 Python 为主LangChain、AutoGPT 这些项目确实成熟但如果你是一个长期在 Java 生态里摸爬滚打的工程师想把这些东西无缝接进现有的 Spring Boot 服务体系里那种割裂感是非常明显的——依赖管理、线程模型、序列化方式、监控体系几乎每一层都要重新适配。JavaManus 就是在这个背景下诞生的。它的目标很明确用纯 Java 技术栈构建一个可插拔、可观测、可扩展的 AI Agent 执行框架核心围绕 ReActReasoning Acting范式展开同时深度对接 Spring AI 的模型抽象层。说白了就是让 Java 工程师能用自己熟悉的工具链把大模型的能力真正落到业务系统里而不是停留在写个 Demo 调调 API 的阶段。这篇文章我会把整个设计和实现过程拆开来讲包括我为什么选择 ReAct 而不是别的范式、工具调用链路是怎么串起来的、记忆管理踩了哪些坑、以及怎么让 Agent 的执行过程变得可观测。如果你正在做 AI Agent 开发或者想把自己手里的 Java 项目接上大模型能力这篇内容应该能帮你少走不少弯路。2. ReAct 范式在 Java 里的落地方式2.1 为什么是 ReAct而不是 Plan-and-Execute在动手之前我对比过几种主流的 Agent 架构。Plan-and-Execute 的思路是先让模型生成一个完整的执行计划然后逐步执行ReAct 则是让模型在每一步都进行思考—行动—观察的循环。两者各有优劣但我最终选了 ReAct原因有三个。第一ReAct 对模型的指令遵循能力要求相对低一些。Plan-and-Execute 需要模型一次性输出结构完整的计划一旦中间某步失败整个计划就得推倒重来。而 ReAct 每一步都是独立的决策点容错性更好。第二ReAct 的调试体验更友好。你可以清楚地看到模型在每一步想了什么调了什么工具拿到了什么结果出了问题容易定位。第三Java 生态里工具调用的响应时间差异很大有的查数据库几毫秒有的调外部接口好几秒ReAct 的逐步决策模式天然适合这种异步场景。当然 ReAct 也有代价就是 token 消耗会更多因为每一轮都要把历史对话重新塞进上下文。这个问题后面我会讲怎么通过记忆压缩来缓解。2.2 核心循环的 Java 实现骨架ReAct 的核心循环用 Java 表达出来其实很简洁。我定义了一个AgentExecutor接口核心方法就是execute(String userInput)内部维护一个while循环每一轮做四件事构造 Prompt、调用模型、解析输出、执行工具。public class ReActAgentExecutor implements AgentExecutor { private final ChatClient chatClient; private final ToolRegistry toolRegistry; private final MemoryManager memoryManager; private final int maxIterations 10; Override public AgentResponse execute(String userInput) { memoryManager.addUserMessage(userInput); for (int i 0; i maxIterations; i) { String prompt PromptBuilder.build(memoryManager, toolRegistry); String modelOutput chatClient.call(prompt); AgentStep step OutputParser.parse(modelOutput); if (step.isFinalAnswer()) { memoryManager.addAssistantMessage(step.getContent()); return AgentResponse.success(step.getContent()); } ToolResult result toolRegistry.invoke(step.getToolName(), step.getToolInput()); memoryManager.addObservation(result); } return AgentResponse.failure(达到最大迭代次数); } }这段代码看起来简单但每一行背后都有讲究。maxIterations设成 10 是我反复测试后的经验值设太小复杂任务跑不完设太大一旦模型陷入死循环会烧掉大量 token。OutputParser的健壮性直接决定了整个框架的稳定性因为模型输出格式偶尔会跑偏解析失败必须有兜底策略。2.3 Prompt 模板的设计细节ReAct 的 Prompt 模板是整个框架的灵魂。我参考了原始论文的格式但做了几处针对 Java 场景的调整。标准格式是 Thought、Action、Action Input、Observation 四段式但实际用下来我发现模型经常在 Action Input 里塞入非 JSON 的内容导致解析失败。我的解决方案是在 Prompt 里明确给出工具的参数 Schema并且用 few-shot 示例强化格式。比如对于查询用户订单这个工具我会在 Prompt 里写清楚它需要userId和orderStatus两个参数类型都是字符串然后给一个完整的调用示例。实测下来加了 few-shot 之后解析成功率从 70% 左右提升到了 95% 以上。另外一个小技巧是在 Prompt 末尾加一句如果你已经能回答用户的问题请直接以 Final Answer 开头输出结果。这句话能显著减少模型不必要的工具调用尤其是在简单问答场景下。3. 工具注册与调用链路的工程化处理3.1 工具抽象层的设计取舍工具是 Agent 的手和脚。在 Java 里设计工具抽象层我考虑过两种方案一种是基于注解的声明式注册另一种是基于接口的实现式注册。最终我选了注解方案因为对业务开发者更友好。AgentTool(name queryOrder, description 根据用户ID和订单状态查询订单列表) public class QueryOrderTool implements Tool { ToolParam(name userId, description 用户唯一标识) private String userId; ToolParam(name orderStatus, description 订单状态可选值PAID/SHIPPED/COMPLETED) private String orderStatus; Override public ToolResult execute() { // 实际业务逻辑 return ToolResult.success(orderList); } }框架启动时会扫描所有带AgentTool注解的类通过反射提取参数信息自动生成工具的 JSON Schema 描述注入到 Prompt 里。这样业务方只需要写一个普通的 Spring Bean加几个注解就能把自己的服务暴露给 Agent 使用。这里有个坑我踩过反射提取参数名在 Java 8 之后默认是拿不到的编译时需要加-parameters参数。如果你用的是 Maven记得在maven-compiler-plugin里配置compilerArgsarg-parameters/arg/compilerArgs否则参数名会变成arg0、arg1模型根本看不懂。3.2 工具调用的超时与熔断工具调用是 Agent 执行中最不可控的环节。外部 API 可能超时数据库可能慢查询如果某个工具卡住了整个 Agent 循环就会挂起。我在ToolRegistry里给每个工具调用都包了一层超时控制默认 30 秒可以通过注解参数覆盖。public ToolResult invoke(String toolName, String input) { Tool tool toolMap.get(toolName); CompletableFutureToolResult future CompletableFuture.supplyAsync( () - tool.execute(input), toolExecutor ); try { return future.get(tool.getTimeoutSeconds(), TimeUnit.SECONDS); } catch (TimeoutException e) { future.cancel(true); return ToolResult.failure(工具执行超时); } }超时之后返回的不是异常而是一个ToolResult.failure这样模型能收到工具执行超时这个观察结果然后自己决定是重试还是换一种方式。这比直接抛异常中断整个流程要优雅得多。另外我还加了一个简单的熔断机制如果某个工具连续失败 5 次就暂时把它从可用工具列表里摘掉避免模型反复调用一个已经挂掉的服务。这个阈值可以根据实际业务调整我一般建议设在 3 到 5 之间。3.3 工具返回结果的格式化工具返回给模型的内容不能太啰嗦也不能太简略。我见过有的实现直接把数据库查询结果的 JSON 原样塞回去几千个字符模型根本抓不住重点。我的做法是在ToolResult里加一个summarize方法对结果做一层压缩。比如查询订单返回 100 条记录我会只保留前 5 条的完整信息后面的用等共 100 条记录概括。如果是数值型结果直接返回关键数字。这个压缩逻辑可以针对不同工具定制框架提供默认实现业务方也可以覆盖。提示工具返回结果的 token 消耗往往被低估。一个设计不好的工具返回值可能比整个 Prompt 还长直接导致上下文爆炸。4. 记忆管理让 Agent 记住该记的忘掉该忘的4.1 短期记忆与长期记忆的分层Agent 的记忆管理是我花时间最多的模块。最初我只有一个简单的ListMessage存所有对话跑几轮之后上下文就爆了。后来我把它拆成了两层短期记忆和长期记忆。短期记忆就是当前会话的完整消息列表包括用户输入、模型思考、工具调用、观察结果。这部分会完整地塞进 Prompt保证模型能看清当前任务的来龙去脉。长期记忆则是跨会话的存储用户偏好、历史任务摘要这类信息通过向量检索的方式按需召回。public interface MemoryManager { void addUserMessage(String content); void addAssistantMessage(String content); void addObservation(ToolResult result); ListMessage getShortTermMemory(); ListMessage recallLongTermMemory(String query, int topK); }短期记忆我设了一个上限默认 20 轮对话。超过之后会触发压缩把最早的几轮对话交给模型做摘要用一段简短的文字替代原始消息。这样既保留了关键信息又控制了上下文长度。4.2 上下文压缩的触发时机与策略压缩时机的选择很关键。压得太早模型会丢失必要的细节压得太晚可能已经超出模型的上下文窗口了。我的策略是双阈值触发当消息数量超过 20 条或者估算 token 数超过模型窗口的 70% 时启动压缩。压缩本身也是一次模型调用所以不能太频繁。我一般会把最早的 10 条消息压缩成一段 200 字以内的摘要保留最近 10 条原始消息。摘要的 Prompt 我会明确要求保留所有工具调用结果中的关键数据去掉冗余的思考过程。实测下来这个策略能把一个 50 轮的长对话压缩到模型窗口的 40% 左右同时任务完成率没有明显下降。当然如果你的业务场景对细节要求极高可以调低压缩比例代价就是 token 成本上升。4.3 长期记忆的向量化存储长期记忆我用的是向量数据库方案。每次会话结束时把这次会话的摘要和关键结论做 embedding存进向量库。下次用户提问时先用问题去检索最相关的几条历史记忆拼接到 Prompt 的开头。这里有个细节不是所有历史记忆都值得召回。我加了一个相关性阈值只有相似度超过 0.75 的记忆才会被使用。否则宁可让模型基于当前上下文回答也不要塞入不相关的历史信息干扰判断。向量库的选型上我建议优先考虑你现有技术栈里已经有的组件。如果已经在用 Elasticsearch直接用它的向量检索功能就行没必要为了一个 Agent 框架再引入一套新的存储系统。5. 可观测性让 Agent 的每一步都看得见5.1 执行链路的埋点设计Agent 最让人头疼的地方就是黑盒感。用户问一个问题模型在后台转了好几圈调了一堆工具最后给个答案中间发生了什么完全不知道。我在框架里内置了一套埋点机制每个关键节点都会记录事件。事件类型包括AGENT_START、MODEL_CALL_START、MODEL_CALL_END、TOOL_CALL_START、TOOL_CALL_END、AGENT_END。每个事件都带上时间戳、耗时、输入输出摘要。这些事件通过一个AgentEventListener接口对外暴露业务方可以自己实现把数据打到日志、监控系统或者数据库里。public interface AgentEventListener { void onEvent(AgentEvent event); } public class AgentEvent { private String traceId; private String eventType; private long timestamp; private long durationMs; private MapString, Object payload; }traceId是整个执行链路的唯一标识从用户发起请求到最终返回所有事件共享同一个 traceId。这样在排查问题时你可以把一次 Agent 执行的所有事件串起来看非常直观。5.2 关键指标的采集与告警光有事件还不够我还采集了几个核心指标单次执行的总耗时、模型调用次数、工具调用次数、token 消耗量、失败率。这些指标按分钟聚合可以接入 Prometheus 或者你现有的监控体系。我特别关注两个指标一是平均迭代轮数如果这个数字突然升高说明模型可能在反复尝试同一个工具通常是 Prompt 或者工具描述出了问题二是工具调用失败率某个工具失败率超过 20% 就要去看是不是接口不稳定或者参数设计有歧义。告警阈值我设的是单次执行超过 60 秒、token 消耗超过 10000、迭代轮数超过 8 轮。这几个数字是根据我的业务场景调的你可以根据自己的实际情况调整。5.3 回放与调试工具为了排查问题我还做了一个简单的回放工具。把一次执行的完整事件序列存下来可以在本地重新播放逐步查看模型在每一步的输入输出。这个工具在调 Prompt 的时候特别有用你能清楚地看到模型是因为哪句话理解错了才走了弯路。回放数据我建议存成 JSON Lines 格式每行一个事件方便用命令行工具快速过滤和查看。存储周期看你的合规要求我一般保留 7 天足够排查大部分问题了。6. 和 Spring AI 的集成方式与踩坑记录6.1 模型抽象层的对接Spring AI 提供了ChatClient和ChatModel两层抽象我直接基于ChatClient做封装。好处是切换模型提供商的时候上层 Agent 代码完全不用改只需要换配置。Configuration public class AgentConfig { Bean public ChatClient chatClient(ChatModel chatModel) { return ChatClient.builder(chatModel) .defaultSystem(你是一个严谨的助手请按照 ReAct 格式输出) .build(); } }这里有个坑不同模型对 System Prompt 的遵循程度差异很大。有些模型会把 System Prompt 里的格式要求当成建议而不是命令输出格式经常跑偏。我的应对方式是在 User Prompt 里再强调一遍格式要求双保险。6.2 流式输出的处理Agent 场景下流式输出比较麻烦因为你需要等模型输出完整的 Action 才能解析和执行工具。我的做法是分两阶段思考阶段用流式让用户能看到模型在想工具执行阶段用阻塞等结果回来再继续。Spring AI 的流式 API 返回的是FluxString我用collectList().block()把它转成完整字符串再解析。虽然牺牲了一点实时性但保证了格式解析的可靠性。如果你对实时性要求极高可以自己实现一个增量解析器边收边解析但复杂度会高不少。6.3 版本兼容性问题Spring AI 目前还在快速迭代不同版本之间的 API 变动比较大。我建议在pom.xml里锁定具体版本不要用LATEST或者范围版本。我自己用的是 1.0.0-M 系列的一个稳定版本升级之前一定要先在测试环境跑一遍完整的回归用例。另外 Spring AI 对 JDK 版本有要求至少 JDK 17。如果你还在用 JDK 8需要先升级这个成本要提前评估。7. 实际跑起来之后的性能调优经验7.1 并发场景下的线程模型Agent 执行是 IO 密集型任务大部分时间在等模型响应和工具返回。我用的是虚拟线程JDK 21来处理并发请求每个 Agent 执行跑在一个虚拟线程里阻塞等待不会占用平台线程。try (var executor Executors.newVirtualThreadPerTaskExecutor()) { FutureAgentResponse future executor.submit(() - agentExecutor.execute(input)); return future.get(60, TimeUnit.SECONDS); }如果你的 JDK 版本还不支持虚拟线程用ThreadPoolExecutor也行但线程数要设得大一些我建议是 CPU 核数的 4 到 8 倍因为大部分时间线程都在等待。7.2 Token 消耗的优化手段Token 成本是 Agent 落地的一大障碍。我总结了几个有效的优化手段一是工具描述要精简只保留模型决策必需的信息二是历史消息压缩前面讲过了三是缓存模型响应对于相同或相似的输入直接返回缓存结果。缓存这块要注意Agent 的输入往往包含时间戳、随机 ID 这类每次都变的内容直接做精确匹配命中率很低。我的做法是对输入做归一化去掉易变部分再做语义相似度匹配。命中率大概能到 30% 左右成本节省还是很可观的。7.3 失败重试与降级策略Agent 执行失败是常态关键是怎么优雅地失败。我的策略是分级处理模型调用失败重试 2 次工具调用失败重试 1 次都失败之后返回一个友好的错误提示而不是把异常堆栈直接抛给用户。降级方面如果 Agent 整体不可用可以降级到传统的规则引擎或者直接返回预设话术。这个降级开关我放在配置中心里出问题的时候可以一键切换不用重新发版。8. 一些我认为值得分享的实操心得做这个框架的过程中有几个体会我觉得挺有价值的分享出来供参考。第一不要试图让 Agent 解决所有问题。我一开始的想法很激进什么任务都想让 Agent 自己决策结果就是 Prompt 越来越复杂稳定性越来越差。后来我把场景收敛到需要多步工具调用且步骤不完全确定的任务上效果立刻好了很多。简单问答直接走传统检索复杂决策才交给 Agent。第二工具的描述比工具的实现在某些时候更重要。模型能不能正确调用一个工具很大程度上取决于你的 description 写得清不清楚。我现在的习惯是每加一个工具先让模型试调 20 次看它能不能理解这个工具是干什么的理解不了就改描述改到能理解为止。第三日志一定要打全。Agent 出问题的时候没有完整的执行日志基本没法排查。我现在的日志里会记录每一轮的完整 Prompt、模型原始输出、解析结果、工具调用参数和返回值。虽然日志量大了点但排查问题时真的救命。第四迭代速度比完美设计更重要。这个框架我改了十几版很多设计是跑起来之后才发现问题的。如果你也在做类似的东西建议先跑通最小闭环再逐步优化不要一开始就追求架构完美。最后说一个具体的技巧在 Prompt 里给模型一个退出条件。比如告诉它如果你连续两次调用同一个工具都没有得到有用信息请直接给出当前能给出的最佳答案。这一句话能显著减少模型陷入死循环的概率实测有效。