去年开始,圈子里聊 AI Agent 的人越来越多,但翻来覆去,能落地的方案大都是 Python 那一套:LangChain、CrewAI、Dify 确实把 Agent 的门槛拉低了不少。可一旦你要把它塞进一个以 Java 为主的存量技术体系里,麻烦就跟着来了——依赖冲突、并发模型对不上、和 Spring Boot 的代码风格格格不入。我自己的几个项目因为这事来回折腾,最后干脆决定从零用 Java 自己写一个 Agent 框架,也就是这篇文章的主角 JavaManus。它不是什么颠覆发明,就是一个完全构建在 Java 生态上的 Agent 编排内核,把 ReAct 循环、工具调用、记忆管理、并行任务拆解这些能力从框架层做实。
这篇文章不讲空概念,只讲我在设计和实现 JavaManus 过程中踩过的坑、定下来的接口、以及为什么这么做。如果你想在自己的 Java 工程里集成 AI Agent,或者单纯想弄明白 Agent 框架底层到底有什么,这篇应该能给你不少可复用的思路。
1. 为什么偏偏用 Java 做 Agent 框架
1.1 类型安全在 Agent 编排里到底意味着什么
写 Agent 的时候,Python 那一套最大的隐患是消息对象、工具参数、模型返回都是“任意结构”,靠 dict 和 JSON 在不同层之间倒来倒去。小例子:工具返回 int 还是 string,模型生成 JSON 少一个字段,这些在动态类型语言里往往是运行到一半才炸,而一旦炸,排查链路要横跨模型输出、JSON 解析、工具执行三层,特别费劲。
JavaManus 从一开始就把这些结构建模成强类型。模型侧返回的 tool call 被拆成ToolCall记录类,里面有 id、name、arguments 三类信息,arguments 存成JsonNode。工具注册表里每个工具的入参都有 JsonSchema,参数校验发生在模型输出进入工具之前,不匹配直接抛参数错误并回传给模型,让它修正。编译期能力只是表面的,最值钱的是“边界被显式化”:状态转移、工具签名、会话历史都变成一个可验证的类型系统,这在 Agent 复杂到一定程度后会救命。
JVM 生态里做 Agent 还有一层很容易被忽略的好处:可观测性。Java 的指标、日志、链路追踪体系已经沉淀了十几年,Agent 框架只要把 SLF4J 和 Micrometer 接好,就能和存量系统的监控完全打通。生产环境里遇到 Agent 行为诡异,第一个要查的就是日志和 trace,这点上 Java 有天然优势。
1.2 Java 生态对 Agent 落地的三个关键支撑
第一是并发。Agent 一旦支持并行子任务,就会大量出现“等一组工具完成再进入下一步”的编排。CompletableFuture和 JDK 21 的虚拟线程让这类代码写起来比 Python 的 asyncio 还要直观,而且不容易被第三方库的 event loop 带偏。
第二是存量集成。大部分公司内部系统都是 Spring / Spring Boot 写的,配置管理、数据库访问、消息队列、定时任务全都长在 Spring 上。JavaManus 的核心内核刻意不依赖 Spring,但提供了一个 Starter 包,让工具可以注入到现有 Bean 里,Agent 会话走 MySQL / Redis 持久化,成本非常低。
第三是部署与治理。Java 应用在 Docker、Kubernetes、Java Flight Recorder、Arthas 这些工具链上的成熟度远高于很多语言生态。Agent 这种“长时运行、交互频繁、状态复杂”的服务,恰好最需要这种成熟的治理能力。
1.3 与 Python 系方案对照的取舍
| 对比项 | Python 系(LangChain / CrewAI / Dify) | JavaManus |
|---|---|---|
| 上手速度 | 快,文档多,社区热 | 核心要自己写,前期慢 |
| 类型约束 | 动态类型,灵活但脆 | 强类型,边界清晰 |
| 并发模型 | asyncio,受 GIL 影响 | 虚拟线程 + CompletableFuture |
| 企业集成 | 通常需要起独立服务 | 可直接嵌入 Spring 工程 |
| 依赖体积 | 依赖繁杂,版本易冲突 | 核心内核依赖极少 |
我不是要劝所有人都换 Java,而是想说:如果你的 Agent 只是跑个 Demo,Python 很好;但如果它要长期运行在企业系统里,Java 的约束和生态会让你省掉很多后期维护成本。JavaManus 就是在“必须融进 Java 体系”这个前提约束下设计出来的,所以它从一开始就考虑了状态持久化、日志、并发控制和扩展机制。
2. JavaManus 整体架构:先画边界再写代码
2.1 门面层 AgentRuntime 与三层配置
Agent 框架最容易掉进的坑是什么?是把“Agent”做成“提示词胶水”——两层循环,全是字符串拼接,没有边界,也没有状态。JavaManus 的第一版设计讨论了很久,最后定下来一个原则:一切和模型相关的输入输出必须经过抽象层,一切和业务相关的执行必须经过工具层。
门面类叫AgentRuntime,它不关心你用的是云端大模型还是本地小模型,也不关心工具是数据库查询还是 HTTP 调用。它只负责把“一次用户请求”转成一个可以被编排和追踪的任务。
public final class AgentRuntime implements AutoCloseable { private final ModelProvider model; private final ToolRegistry toolRegistry; private final MemoryStore memory; private final AgentPlanner planner; private final AgentOptions options; public AgentResult run(AgentRequest request) { // 加载会话与历史 // 进入 Agent 主循环 // 返回最终答案 } }配置被分成三层来管理:
- 模型层:模型地址、API Key、温度、max tokens、超时时间;
- 执行层:最大迭代次数、单轮超时、工具并行开关、上下文裁剪策略;
- 安全层:允许的工具白名单、敏感操作审核开关、输出脱敏规则。
分层的好处是:调参的时候不用去翻一大堆常量,线上出问题时也能快速定位是模型层的问题、编排层的问题,还是工具本身的问题。
2.2 核心接口:Provider、Tool、Memory、Planner
整个框架只有四类核心接口,所有扩展都落在这四个边界上。
ModelProvider是模型接入的统一抽象,负责把消息列表变成模型回复,也负责把工具 schema 传给模型。ToolExecutor是一个执行器抽象,每个 Java 方法经过注册后变成一个 ToolExecutor 实例。MemoryStore管历史会话的读写,不关心具体存储介质。AgentPlanner负责在任务复杂时把一个大目标拆成子任务,拆出来的子任务最终还是要回到主循环执行。
public interface ModelProvider { ChatResponse call(List<ChatMessage> messages, AgentOptions options); Stream<ChatDelta> stream(List<ChatMessage> messages, AgentOptions options); } public interface ToolExecutor { ToolMeta meta(); ToolResult execute(JsonNode arguments, ToolContext context); }边界画清楚之后,很多问题会自动消失。比如:换模型只在 Provider 层做适配,加业务能力只写工具方法,改记忆只动 MemoryStore 实现。JavaManus 后面能稳定迭代,靠的就是这份克制。
2.3 模块化与 SPI 扩展机制
框架本身是分模块的:manus-core只放编排内核,manus-tools放常用工具集,manus-spring-boot-starter负责和 Spring 工程集成。模块之间用 SPI 解耦,避免编译期硬依赖。
我用了一个简单的扩展点:AgentExtension接口,框架启动时会扫描META-INF/services下的实现,把自定义的工具、模型 Provider、记忆插件全部注册进来。这个机制在自研框架里非常重要,因为你不确定团队后面会接什么内部系统,SPI 可以保证框架内核不被改烂。
提示:设计 Agent 框架时,宁可少一个“自动加载”的花哨功能,也要保住核心循环的简单性。扩展机制越晚加越好,先让主循环跑通,再加 SPI。
3. Agent 主循环与 ReAct 范式的 Java 实现
3.1 一次对话怎么变成循环
ReAct 的核心思想是 Reasoning + Acting:模型先思考,再决定调哪个工具,拿到工具结果后继续思考,直到给出最终答案。这个过程翻译成 Java,就是一个while循环,每一次循环包含四个步骤:
- 模型观察当前完整上下文,输出两种可能:最终答案,或者工具调用指令;
- 如果是最终答案,跳出循环;
- 如果是工具调用,框架解析参数并分发到注册好的工具;
- 工具执行结果作为观察消息追加到历史,进入下一轮思考。
public AgentResult run(AgentSession session) { List<ChatMessage> history = memory.load(session); int iteration = 0; while (iteration < options.getMaxIterations()) { AgentDecision decision = model.decide(history, toolRegistry.schemas()); if (decision.isFinal()) { memory.save(session, history); return AgentResult.finished(decision.content(), iteration); } ToolCall call = decision.toolCall(); ToolResult result = toolRegistry.dispatch(call, session.context()); history.add(Message.toolObservation(call, result)); iteration++; } throw new AgentOverLimitException("迭代次数到达上限: " + options.getMaxIterations()); }这段代码看起来简单,实际落地时坑特别多。第一版我直接拿String拼接历史记录,后来发现模型经常输出非标准 JSON,工具参数解析直接崩掉。后来才把模型输出从“自由文本”改成强约束的决策结构。
3.2 让模型稳定输出结构化指令:Function Calling 封装
主循环的稳定性,很大程度取决于“模型输出工具调用指令”这一步稳不稳。最可靠的方式不是让模型输出任意 JSON,而是走大模型服务商提供的 Function Calling / Tool Calling 能力。这个能力在协议层面保证模型要么回答普通文本,要么返回严格格式化的 tool call。
JavaManus 在ModelProvider里把这个语义封装得很干净。模型调用方不需要关心底层是 HTTP 还是流式,只需要传入List<ToolSchema>,然后从AgentDecision里取结果。
AgentDecision是一个密封接口,只有两个实现:FinalAnswer和ToolInvocation。这样编译期就能保证决策类型是闭合的,不会出现“模型返回了一个我们都不认识的类型”这种运行期尴尬。
3.3 循环终止条件与异常分支
没有终止条件的循环就是烧钱机器。JavaManus 里我设了四道闸门:
- maxIterations:单次任务最多迭代多少轮,默认 10,复杂任务可调高到 30;
- tokenBudget:整个循环消耗的 token 上限,由 Provider 侧统计,超了直接终止;
- stuckThreshold:连续 N 轮工具结果都是同一类错误,判定为“卡死”,主动终止;
- humanInTheLoop:遇到 adminOnly 工具时,挂起等待人工确认,确认后才继续循环。
if (iteration >= maxIterations) { throw new AgentOverLimitException(...); } if (history.tokenUsage() > tokenBudget) { throw new TokenBudgetExceededException(...); } if (stuckDetector.isStuck(history)) { throw new AgentStuckException("连续多轮工具返回相同错误"); }这些闸门看起来保守,但生产环境里没有它们,半夜被 oncall 叫醒的就是你自己。
4. 工具调用体系:把 Java 方法变成 Agent 的手脚
4.1 注解驱动注册与参数 Schema 生成
JavaManus 最常用的 API 是@AgentTool注解。一个普通的 Java 方法,只要加上注解,就会自动被解析成 Agent 可调用的工具。解析过程中还会根据方法签名生成 JsonSchema,直接影响模型理解工具的方式。
@AgentTool( name = "calculate_total_price", description = "根据商品ID和购买数量计算订单总价,返回含运费结果" ) public ToolResult calculateTotalPrice( @ToolParam(desc = "商品ID") Long itemId, @ToolParam(desc = "购买数量,必须大于0") Integer quantity) { Order order = orderRepository.findById(itemId); if (order == null) { return ToolResult.failure("订单不存在"); } BigDecimal total = order.getPrice().multiply(BigDecimal.valueOf(quantity)); if (total.compareTo(BigDecimal.valueOf(99)) < 0) { total = total.add(BigDecimal.valueOf(10)); // 运费 } return ToolResult.success(Map.of("total", total)); }参数 Schema 的生成我踩过不少坑。最初想用反射把 Java 类型直接映射成 JSON Schema 类型,后来发现不够:Long和Integer都映射成 integer,但模型很容易传错范围。所以@ToolParam里除了描述,还支持 min/max/枚举值,生成 Schema 时会带上校验信息。
4.2 执行隔离、超时与熔断
工具执行不能裸奔。尤其是那些会写数据库、发消息、调外部接口的工具,一旦模型发疯,后果可能比丢 token 严重得多。JavaManus 对每个工具做了三层保护:
第一层是超时。工具方法在线程池里执行,Future 带get(timeout),到点不回就打断,默认 10 秒。第二层是熔断。连续工具执行失败超过阈值,框架会临时禁用这个工具,并把禁用原因回传给模型,让模型换一个工具。第三层是并发隔离。不同工具用独立的线程池,避免一个慢工具把整个 Agent 循环拖死。
提示:工具超时一开始我没做,直到有一天模型在循环里连续调了 5 次同一个慢接口,每次等 30 秒,整个任务的耗时就失控了。后来统一加上了超时与失败分类,一次慢调用不会再拖死整个会话。
4.3 工具副作用清单:哪些能开,哪些必须管
工具开放策略在 JavaManus 里是用ToolPolicy控制的,每个工具都能声明自己的副作用级别:
| 级别 | 含义 | 示例 |
|---|---|---|
| READ_ONLY | 只读,可直接执行 | 查询订单、检索商品 |
| WRITE | 写入但可回滚 | 更新草稿、保存配置 |
| MUTATING | 高影响,需人工确认 | 取消订单、发短信 |
| INTERNAL | 仅限内部调用 | 变更限流、开关切换 |
启动时还会做一次白名单校验:模型能够调用的工具必须同时出现在配置的enabled-tools列表中。这一步是防止开发环境漏配导致的误用,线上事故往往都是细节上漏掉的。
5. 记忆与上下文:从“金鱼”到“有记性”
5.1 短期上下文窗口:装不下的历史怎么办
模型上下文窗口是有限的,但用户不会按窗口大小来聊天。JavaManus 里短期记忆的核心就是“上下文裁剪”。我把历史消息按对话轮次分组,超过窗口时不是无脑截断,而是做三层压缩:
- 老消息摘要化:超过一半窗口的历史消息先交给模型生成一段摘要,替换掉原文;
- 工具结果裁剪:超过一定长度的工具返回只保留摘要和关键字段;
- 用户目标常驻:在每一轮裁剪后,把最初的用户请求重新注入开头,防止模型“忘了用户要什么”。
这个策略最初来自线上观察:不是模型变笨了,而是对话太长后,用户最初的诉求被大量工具结果淹没了。裁剪之后,准确率提升很明显。
5.2 长期记忆与 RAG 检索的落地
长期记忆的关键是“该记的留下,不该记的别占窗口”。JavaManus 把长期记忆和 RAG 绑在一起:重要的事实片段写入向量库,新对话开始时用向量检索召回相关内容。
我在实现的时候没有引太重的依赖,只封装了一个VectorStore接口:
public interface VectorStore { void upsert(String key, String content, Map<String, Object> metadata); List<SearchHit> search(String query, int topK); }默认实现可以基于内存嵌入服务,也可以接已有的向量数据库。因为接口只暴露 upsert 和 search,业务系统完全不用关心底层是 ES、PG 还是专用向量库。长期记忆的写入时机也做过控制:不是每一轮工具结果都写长期记忆,而是在 Agent 判断“这是一个新的持久事实”时,才触发 upsert。
5.3 会话快照与状态持久化
Agent 跑到一半,服务重启了怎么办?JavaManus 的会话快照解决的就是这个问题。每个会话在每轮循环结束后会写一次快照,快照里包含历史消息、当前迭代数、全局变量、工具上下文。重启后,只要拿到 sessionId,就能从快照继续执行,而不是让用户再重头说一遍。
快照持久化的默认实现是 Redis,理由很简单:快照是可丢弃的,不需要强一致,但需要低延迟读写。如果业务要求会话必须事务性保存,也可以换 MySQL 实现,MemoryStore接口天然支持多套实现。
6. 并行任务编排:别让 Agent 只会串行思考
6.1 为什么 Agent 一定会有并行需求
单机 Agent 最常见的形态是串行:模型想一步,工具做一步。但真实业务里很多任务天然可以拆开。比如“帮我整理这份报表,同时查一下昨天的异常订单,再对比竞品价格”,这三件事互不依赖,串行做不仅慢,还会白白消耗模型上下文。JavaManus 的 Planner 会在主循环之外生成一个轻量 DAG,把可并行的子任务标记出来。
6.2 DAG 依赖 + 虚拟线程执行
第一版并行编排我用的是CompletableFuture.allOf,后来 JDK 21 普及了虚拟线程,就把执行层换成了Executors.newVirtualThreadPerTaskExecutor()。虚拟线程的好处是:工具调用里有大量阻塞 I/O(HTTP、数据库查询),虚拟线程能把阻塞成本降到极低,代码却保持同步风格,不需要回调地狱。
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor(); List<Future<ToolResult>> futures = subTasks.stream() .map(task -> executor.submit(() -> dispatch(task))) .toList(); for (Future<ToolResult> future : futures) { candidates.add(future.get(timeout, TimeUnit.SECONDS)); }并行执行时有个容易踩的坑:多个工具结果回来之后,模型的后续决策依赖“结果的顺序”。所以我在 DAG 里给每个节点都固定了输出位置,最后合并结果时按节点编号排序,而不是按完成时间排序。否则模型看到的工具结果顺序每次都不一样,推理稳定性会很差。
6.3 并行结果合并与冲突处理
并行的副作用是冲突。比如两个工具同时修改同一个全局变量,或者一个工具读的数据还没被另一个工具写完。JavaManus 的处理方式比较保守:允许并行的只有READ_ONLY级别工具;WRITE级别工具默认串行,需要显式声明concurrent = true且带幂等逻辑。
冲突检测还做了版本号机制。每次工具写操作会带一个 contextVersion,如果写操作执行时发现版本号落后了,就拒绝写入并回传给模型重新决策。这个机制最初是为“先更新订单状态再查询库存”这类时序敏感场景设计的,实测里帮我们避免了好几次脏数据。
7. 流式输出与 Provider 抽象
7.1 SSE 输出的 Java 实现
Agent 不像普通接口,一次响应可能要十几秒甚至更久,用户不可能干等。JavaManus 的流式输出走 SSE,用Flux在响应里持续推送事件,事件类型分三种:content(模型增量文本)、tool_start(开始调用工具)、tool_end(工具执行完成)。
SSE 在 Spring Boot 里的实现很简单,但有个细节很容易被忽视:工具调用期间不能阻塞下一步推送。所以我把模型流和工具执行流拆开,模型的 token 继续往前端推,工具执行结果则单独放进事件队列,一旦准备好立刻追加推给前端。这样用户看到的效果是:模型在说话的同时,工具状态也在刷新。
7.2 Provider 适配层:兼容不同协议
JavaManus 没有绑定单一厂商。ModelProvider的实现对象负责屏蔽协议差异,目前维护了几种常见实现:兼容 OpenAI 协议的服务、本地部署模型服务、以及企业内部网关。适配层的核心是一个ChatRequest结构体,它统一了消息格式、工具 Schema 定义和采样参数。
换模型厂商的代价基本被压到一个类里边。原因是工具调用的协议细节各有差异,但核心“消息列表 + 工具列表 + 抽样参数”的语义是一致的。适配层做的事就是协议翻译,翻译完直接复用主循环。
7.3 Token 计量与限流
流式接口最大的隐藏成本是 Token 不计费。JavaManus 在流式回调的每个增量上都累加 usage,并在两个维度上设置护栏:
- 单次任务 token 上限(阻止模型无限思考);
- 每轮流式之间的间隔超时(防止连接挂死)。
另外我还把“流式输出”和“非流式决策”统一成了一个模型接口:内部决策阶段用非流式(因为只要最终解析结果),对外响应阶段用流式(因为需要体验)。回头看这个设计很值得:它让“决策”和“呈现”两个语义彻底分开了。
8. 实测调优与避坑笔记
8.1 模型输出 JSON 不稳定,循环直接崩掉
这是 JavaManus 第一个版本踩的最大坑。当时没有走 Function Calling,而是让模型输出纯 JSON,结果发现换一个模型版本,输出格式就变一次,arguments字段名偶尔变成params,把整个工具分发逻辑搞崩了。
后来痛定思痛,把所有模型输出都套上了 Function Calling 的强约束,并在解析层做了容错:如果 JSON 解析失败,不是直接抛异常,而是把“解析失败”作为一个观察结果回传给模型,让它重新生成。这一招把循环崩溃率从 15% 拉到了 1% 以下。
8.2 工具调用死循环与预算失控
有一次测试,我让 Agent 计算一个非常复杂的费用分摊,结果模型反复调用同一个工具,因为工具的返回始终“差一点”,模型就一遍遍重试。最后 token 花掉了几百万,任务还没结束。复盘之后加了两个机制:stuckThreshold 检测连续相同错误,预算上限直接熔断。从那以后,我把maxIterations的默认值从 50 降到了 10,让任务必须快速收敛,收敛不了的走人工兜底。
8.3 Spring Boot 集成时的线程池坑
JavaManus 接 Spring Boot 时遇到过一个问题:工具方法通过线程池异步执行,但事务管理跟着线程走了,导致 Spring 事务注解失效。原因是@Transactional默认绑定在调用线程上,异步线程里拿不到原线程的事务上下文。后来统一规定:工具层不允许声明事务,事务全部提升到工具方法的 Service 层内聚处理。简单说就是让每个工具自己保证原子性,框架不跨工具做事务。
8.4 上下文膨胀:看不见但烧钱的元凶
上下文膨胀和 token 预算是两回事。预算设置得再高,如果历史里堆了大量无用的工具输出,模型的注意力也会被稀释。我在日志里加了一项contextCompressionRate(压缩前后 token 比例),发现超过 60% 之后,模型的回答质量明显下降。于是把裁剪策略改成了主动式:每完成一次大任务,就对历史做一次摘要提取,摘要里保留决策依据,而不是保留全过程文本。压缩后,模型回答的准确率反而提升了不少。
最后分享一个经历过多次故障后养成的小习惯:给 Agent 循环里所有外发调用打上 traceId,把每一步模型 token、工具执行耗时、JSON 解析错误全部记到结构化日志里。出现诡异行为时,先别急着怀疑模型,把所有日志按 traceId 串起来看一遍,八成的问题当场就能定位。JavaManus 发展到现在,团队跟我说得最多的不是“又多了一个框架”,而是“终于有个能查日志的 Agent 了”。如果你也在 Java 工程里做 Agent,我建议你先别急着抄大而全的方案,把 Agent 的最小内核——循环、工具、记忆、编排——自己写一遍,理解透彻了再谈扩展,收益会远超预期。