1. 为什么我要用 Java 从头造一个 Agent 框架
第一次接触 AI Agent 这个概念的时候,我脑子里冒出来的第一个念头其实很朴素:这不就是一个能自己决定下一步干什么的程序吗?后来真正动手做起来才发现,事情远没有想象中那么简单。市面上的 Agent 框架大多以 Python 为主,LangChain、AutoGPT 这些项目确实成熟,但如果你是一个长期在 Java 生态里摸爬滚打的工程师,想把这些东西无缝接进现有的 Spring Boot 服务体系里,那种割裂感是非常明显的——依赖管理、线程模型、序列化方式、监控体系,几乎每一层都要重新适配。
JavaManus 就是在这个背景下诞生的。它的目标很明确:用纯 Java 技术栈,构建一个可插拔、可观测、可扩展的 AI Agent 执行框架,核心围绕 ReAct(Reasoning + 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里配置<compilerArgs><arg>-parameters</arg></compilerArgs>,否则参数名会变成arg0、arg1,模型根本看不懂。
3.2 工具调用的超时与熔断
工具调用是 Agent 执行中最不可控的环节。外部 API 可能超时,数据库可能慢查询,如果某个工具卡住了,整个 Agent 循环就会挂起。我在ToolRegistry里给每个工具调用都包了一层超时控制,默认 30 秒,可以通过注解参数覆盖。
public ToolResult invoke(String toolName, String input) { Tool tool = toolMap.get(toolName); CompletableFuture<ToolResult> 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 的记忆管理是我花时间最多的模块。最初我只有一个简单的List<Message>存所有对话,跑几轮之后上下文就爆了。后来我把它拆成了两层:短期记忆和长期记忆。
短期记忆就是当前会话的完整消息列表,包括用户输入、模型思考、工具调用、观察结果。这部分会完整地塞进 Prompt,保证模型能看清当前任务的来龙去脉。长期记忆则是跨会话的,存储用户偏好、历史任务摘要这类信息,通过向量检索的方式按需召回。
public interface MemoryManager { void addUserMessage(String content); void addAssistantMessage(String content); void addObservation(ToolResult result); List<Message> getShortTermMemory(); List<Message> 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 Map<String, 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 返回的是Flux<String>,我用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()) { Future<AgentResponse> 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 里给模型一个"退出条件"。比如告诉它"如果你连续两次调用同一个工具都没有得到有用信息,请直接给出当前能给出的最佳答案"。这一句话能显著减少模型陷入死循环的概率,实测有效。