
去年开始圈子里聊 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 / DifyJavaManus上手速度快文档多社区热核心要自己写前期慢类型约束动态类型灵活但脆强类型边界清晰并发模型asyncio受 GIL 影响虚拟线程 CompletableFuture企业集成通常需要起独立服务可直接嵌入 Spring 工程依赖体积依赖繁杂版本易冲突核心内核依赖极少我不是要劝所有人都换 Java而是想说如果你的 Agent 只是跑个 DemoPython 很好但如果它要长期运行在企业系统里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(ListChatMessage messages, AgentOptions options); StreamChatDelta stream(ListChatMessage 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) { ListChatMessage 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 还是流式只需要传入ListToolSchema然后从AgentDecision里取结果。AgentDecision是一个密封接口只有两个实现FinalAnswer和ToolInvocation。这样编译期就能保证决策类型是闭合的不会出现“模型返回了一个我们都不认识的类型”这种运行期尴尬。3.3 循环终止条件与异常分支没有终止条件的循环就是烧钱机器。JavaManus 里我设了四道闸门maxIterations单次任务最多迭代多少轮默认 10复杂任务可调高到 30tokenBudget整个循环消耗的 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, MapString, Object metadata); ListSearchHit 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/OHTTP、数据库查询虚拟线程能把阻塞成本降到极低代码却保持同步风格不需要回调地狱。ExecutorService executor Executors.newVirtualThreadPerTaskExecutor(); ListFutureToolResult futures subTasks.stream() .map(task - executor.submit(() - dispatch(task))) .toList(); for (FutureToolResult 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 的最小内核——循环、工具、记忆、编排——自己写一遍理解透彻了再谈扩展收益会远超预期。