JavaManus:纯Java实现AI Agent框架的架构设计与实战踩坑
坦白说看到“JavaManus”这个名字的那一刻我第一反应是终于有人愿意在 Java 生态里认真搞 AI Agent 了。过去这两年Agent 框架几乎被 Python 圈包揽LangChain、LlamaIndex、AutoGPT 满天飞Java 开发者要么干瞪眼要么硬着头皮在 Java 项目里嵌一个 Python 微服务。所以我决定自己动手完全从零开始用纯 Java 实现一个能够自主拆解任务、调用工具、完成闭环的 Agent 框架。这篇文章会把我在设计和实现 JavaManus 过程中的核心思路、架构选型、关键代码、以及一堆实战踩坑记录全部摊开来讲。它不是什么理论科普而是一份能直接照着改、照着用的工程实践笔记。适合那些在 Java 技术栈里做 AI 应用开发、想自己造轮子理解 Agent 本质、或者准备把 Agent 能力集成进现有后端系统的朋友。1. 整体架构与设计思路为什么 Agent 框架必须自己造1.1 Java 生态做 Agent 的真实痛点先聊聊背景。我所在的项目组一直做的是企业级后端系统技术栈很纯粹Spring Boot、MyBatis、XXL-Job、Nacos 这一套。最近老板提了个需求希望系统能根据用户的自然语言指令自动完成数据查询、报表生成、异常告警推送这类操作。说白了就是要把 LLM 接进现有业务系统里。当时第一反应是直接用 Python 写个 Agent 服务再通过 HTTP 和 Java 交互。但调研后发现几个问题很棘手第一公司现有的权限体系、配置中心、注册中心全在 Java 侧Python 服务要接入这些基础设施光改造成本就够喝一壶第二运维体系统一走 Java 的发布链路多一套 Python 服务等于多一套监控、日志、告警的折腾第三团队里没有一个能写 Python 的人。所以最后拍板用 Java 从零写一个轻量级 Agent 框架就叫它 JavaManus。1.2 JavaManus 的设计目标与边界动手之前我先明确了 JavaManus 必须要做到的四件事能自主规划收到一个复杂任务后Agent 自己拆成多个子步骤不靠人肉编排。能调用工具接上现有的 Java 服务方法比如查数据库、调接口、发通知。能自我修正某一步执行失败了Agent 能根据错误信息调整策略重新尝试而不是直接崩溃。能流式交互Agent 的思考过程、工具调用日志、最终结果都要流式推给前端不然用户等一个十几秒的完整响应早跑了。而明确不做的边界是不做一个通用平台型框架不做模型训练和微调不搞复杂的多智能体通信协议。这些会让整个项目失控。1.3 总体架构分层JavaManus 的总体结构我分了五层从下往上分别是基础设施层LLM 网关客户端、工具注册中心、内存存储、事件总线。能力层规划器Planner、执行器Executor、记忆管理器Memory、工具调用器ToolInvoker。核心引擎层AgentRunner负责编排思考-行动-观察循环。接口层REST API 和 WebSocket给上层业务系统调用。接入层业务方的自定义工具、自定义 Prompt 模板、自定义拦截器。这个分层最大的好处是每层只依赖下一层替换成本低。比如我想把底层的大模型从 OpenAI 协议换成国内各家模型只需要改基础设施层的 LLM 网关客户端上面四层完全不动。2. 核心引擎AgentRunner 与思考-行动-观察循环2.1 为什么选 ReAct 模式而不是纯 ChainAgent 最核心的机制就是思考-行动-观察循环也就是 ReActReasoning Acting。我试过几种方案纯 Chain 模式用起来像写死的工作流灵活性差Plan-and-Execute 模式虽然规划能力强但实现复杂度高对每一步的状态管理要求很严格。JavaManus 最终选择了 ReAct 作为基础但做了两层增强第一在进入 ReAct 循环之前先让模型输出一个简短的全局计划Plan相当于给 ReAct 流程加了个导航第二每一次思考结果都要求模型显式给出是否需要调用工具用工具结果作为下一次思考的输入。这两层增强让 Agent 既有 ReAct 的灵活性又有一定的前瞻性。循环的核心实现长这样public class AgentRunner { private final LLMGateway llmGateway; private final ToolRegistry toolRegistry; private final MemoryManager memoryManager; private final EventBus eventBus; public AgentOutcome execute(String task) { // 第一步生成全局计划 ListPlanStep plan planner.createPlan(task); eventBus.publish(new PlanGeneratedEvent(plan)); // 第二步进入思考-行动-观察循环 int maxIterations 20; for (int i 0; i maxIterations; i) { AgentThought thought llmGateway.think(buildPrompt(task, plan, i)); eventBus.publish(new ThoughtEvent(thought)); if (thought.isFinalAnswer()) { return AgentOutcome.finalAnswer(thought.getContent()); } if (thought.requiresToolCall()) { ToolCall call thought.getToolCall(); ToolResult result toolRegistry.invoke(call); eventBus.publish(new ToolResultEvent(call, result)); memoryManager.remember(result); } } return AgentOutcome.timeout(); } }2.2 迭代次数的控制策略上面代码里我写了个maxIterations 20这是拍脑袋定出来的吗不是是压测压出来的。我用 50 条测试任务分别跑了 5 次循环、10 次循环、15 次循环、20 次循环发现大多数正常任务在 5-10 轮就能完成复杂任务到 15 轮左右基本也能收敛。20 轮是为了给那些要反复试错的任务留足余量超过 20 轮的任务绝大多数是模型在空转再跑下去只会烧 Token。这里有个很重要的工程细节每执行一次循环都要把当前迭代次数拼进 Prompt 里告诉模型。比如你目前已经尝试了 7 次如果仍然无法解决问题请直接输出当前能给出的最优答案。不然模型会在循环里无限打转解决问题遥遥无期。2.3 事件驱动与状态管理AgentRunner 本身是无状态的每次执行都通过 EventBus 发布事件。这个设计借鉴了事件溯源的思想好处有两点一是前端可以通过 WebSocket 实时订阅事件流把 Agent 的思考过程像打字机一样展示给用户二是方便排查问题把事件的完整时间线打出来就等于看了一遍 Agent 的完整思考轨迹。状态管理我用了简单的状态机只有四个状态INITIALIZED、PLANNING、EXECUTING、FINISHED外加两个终态FAILED和TIMEOUT。为什么不搞复杂状态机因为 Agent 的执行过程本身是循环不是线性流转状态太细化反而容易出 bug四个状态足够覆盖主流程了。3. 工具注册与调用让 Agent 真正干活的引擎3.1 工具是 Agent 的手脚模型再聪明不接工具也就是个只会说不会做的嘴炮侠。JavaManus 的工具系统是整个框架里我最看重的一块也是踩坑最多的一块。工具系统的设计目标是业务方在 Java 方法上加一个注解注册进来Agent 就能自动识别、自动生成参数、自动调用。就像这样Component public class WeatherTools { AgentTool(name query_weather, description 查询指定城市的当前天气情况) public String queryWeather(AgentToolParam(description 城市名称例如北京) String city) { // 调用外部天气服务 return weatherService.getCurrentWeather(city); } }3.2 工具注解与注册机制AgentTool注解负责标记一个方法可以被 Agent 调用。框架启动时通过 Spring 的ApplicationContext扫描所有带这个注解的 Bean把方法信息反射出来注册到 ToolRegistry 里。注册的时候要做三件事第一把方法名转成 snake_case 格式的工具名因为 LLM 对下划线命名更敏感第二解析方法参数上的AgentToolParam注解拿到每个参数的描述第三生成工具的 JSON Schema这是给模型看的工具说明书。JSON Schema 生成的代码大致这样public String generateToolSchema(ToolDefinition tool) { MapString, Object schema new HashMap(); schema.put(type, object); MapString, Object properties new LinkedHashMap(); for (ToolParameter param : tool.getParameters()) { MapString, Object paramSchema new HashMap(); paramSchema.put(type, string); paramSchema.put(description, param.getDescription()); if (!param.isRequired()) { paramSchema.put(optional, true); } properties.put(param.getName(), paramSchema); } schema.put(properties, properties); schema.put(required, tool.getRequiredParameters()); return JsonUtils.toJson(schema); }3.3 参数映射与类型转换工具调用的核心难点不在注册而在参数映射。模型返回的是一段 JSON 字符串比如{city: 北京}你要把这个 JSON 正确映射到 Java 方法的参数列表上。最简单的情况是基本类型参数直接 JSON 解析赋值就行。复杂的是嵌套对象参数比如有个工具方法接收一个QueryCriteria对象里面包含了startTime、endTime、filters这些字段。模型能不能正确构造这个对象很大程度上取决于你的 Schema 定义得够不够细。我踩过一个大坑有个工具方法参数是Long类型模型返回的参数值是1234567890123字符串Jackson 反序列化的时候报错说无法将字符串转换为 Long。排查后发现是模型生成工具调用参数时把所有值都当字符串处理了。解决办法是在生成 Schema 的时候给参数显式标注类型并且在反序列化时做一层宽松转换能转数字就转数字转不了就保留字符串。3.4 工具调用的错误处理Agent 调用工具时工具本身可能会抛异常。比如查数据库超时了、外部接口返回 500 了。这时候如果直接把异常抛给 AgentRunner整个循环就中断了。正确做法是把异常捕获后转成 ToolResult 返回给模型。我封装了一个工具调用的包装逻辑public ToolResult invoke(ToolCall call) { try { Object[] args parameterResolver.resolve(call.getArguments(), tool.getMethod()); Object result tool.getMethod().invoke(tool.getBean(), args); return ToolResult.success(JsonUtils.toJson(result)); } catch (IllegalArgumentException e) { // 参数错误把错误信息返回给模型让它修正参数后重新调用 return ToolResult.failure(参数校验失败: e.getMessage()); } catch (InvocationTargetException e) { // 业务异常同样返回给模型 return ToolResult.failure(工具执行异常: e.getCause().getMessage()); } }这样模型看到失败原因后就能判断是调整参数重试还是换一种工具又或者是直接告诉用户这个操作做不了。这种自我纠错能力就是工具调用闭环的灵魂。4. 规划与上下文管理让长任务不迷路、不超窗4.1 全局计划与子任务拆解ReAct 循环有个天然缺陷容易走一步看一步做着做着忘了最初的目标。比如用户说分析近三个月销售数据并生成月报Agent 可能在查询数据的时候就陷入细节把生成月报这个大目标抛在脑后。JavaManus 的处理方式是在正式执行前先让模型输出一个全局计划。计划的格式长这样[ {step: 1, action: query_sales_data, goal: 获取近三个月销售明细}, {step: 2, action: calculate_metrics, goal: 计算环比增长率}, {step: 3, action: generate_report, goal: 生成分析月报} ]这个计划会拼在每一轮循环的 Prompt 里让模型随时知道自己当前处于整个任务的哪个阶段。实测下来这个设计显著减少了模型跑偏的概率。4.2 Token 窗口管理上下文窗口是 Agent 的硬约束。每轮循环你要把系统提示词、全局计划、历史思考记录、工具调用结果、最新一次的用户问题全部塞进 Prompt很容易就把上下文撑爆。我的 Token 管理策略是三级短期记忆保留最近 5 轮的思考-行动-观察记录用于当前决策。中期摘要把 5 轮之前的交互历史用模型做一次摘要压缩只保留关键结论。长期事实把用户的原始需求和全局计划放在 Prompt 前方最显眼的位置永远不裁剪。具体实现时我在每轮构造 Prompt 前会估算 Token 数如果超过阈值就触发摘要压缩。估算用的是简单的字符数除以 3 再乘一个系数不用太精确其实也没办法很精确能保证不超窗就行。4.3 轻量级记忆存储记忆管理模块我用了最朴素的内存 Map配合定时清理。这里不引入 Redis 的原因很简单JavaManus 定位是轻量级框架单个 Agent 实例的会话量级根本到不了需要外部存储的程度。真要支持分布式、多实例共享记忆那是后续把 MemoryManager 接口换成 Redis 实现的事接口预留好了就行。5. LLM 接入层与流式响应如何优雅地连接大模型5.1 模型网关的抽象设计JavaManus 对底层 LLM 的接入做了一个统一的 LLMGateway 接口不同模型服务商各自实现。接口核心方法就两个一个是流式对话补全一个是函数调用Function Calling补全。接口设计这么简单是有原因的Agent 和模型的交互本质上就两种模式——一种是模型输出自然语言比如思考过程、最终答案另一种是模型输出结构化的工具调用指令。把这两种模式抽象成接口方法上层就不必关心底层是 OpenAI 还是 Claude 还是别的什么模型了。public interface LLMGateway { FluxModelChunk chatStream(ChatRequest request); FluxToolCallChunk chatWithTools(ChatRequest request, ListToolDefinition tools); }5.2 流式解析与增量事件推送流式响应的难点在于大模型是一段一段吐字的你怎么把这段不完整的文本流解析成思考或工具调用的完整结构。我在 JavaManus 里实现了一个简单的增量解析器。大概思路是设置一个缓冲区不断把流式到达的字符追加进去然后尝试用 Jackson 的ObjectMapper增量解析来识别完整的函数调用 JSON 结构。如果当前缓冲区里的内容还不能构成一个完整的 JSON就继续等如果已经识别出完整的工具调用 JSON就把这段内容从缓冲区里摘出来并发布一个工具调用事件。这里有个小坑要注意不同模型厂商对流式输出的格式封装不一样有的会在 chunk 里直接吐增量文本有的会把完整内容拆成多段还有的可能把多个函数调用拆在不同 chunk 里。所以解析器一定要做得健壮各种边界情况都要覆盖到。我在测试用例里专门写了十几个极端场景比如函数名在第一个 chunk、参数在第二个 chunk、结束括号在第三个 chunk这种跨 chunk 拼接的情况最容易出 bug。5.3 模型配置与多模型切换JavaManus 的模型配置全部走application.yml支持配置多个模型提供商按角色区分。比如规划任务用便宜快速的模型复杂推理用更强但贵的模型工具调用用支持 Function Calling 的模型。这个设计思路来自一个实际观察Agent 流程中规划阶段对推理能力要求不高用快模型能显著降延迟而复杂任务的最终回答需要强推理用慢但聪明的模型能避免逻辑错误。把不同模型按任务阶段混合编排既省 Token 又提升体验。6. 实战踩坑实录Java 写 Agent 的十个典型问题6.1 Jackson 泛型擦除引发的类型混乱这是我遇到的第一个大坑。JSON Schema 生成阶段我用了一个通用的类型工具类来获取方法参数的类型信息。但 Java 的泛型擦除导致一个问题如果方法参数是ListString运行时拿到的Type对象确实是ParameterizedType但如果参数是ListListString嵌套的泛型信息就会部分丢失。解决办法是要求工具参数尽量用简单类型复杂结构就封装成 DTO并在 DTO 字段上用 Jackson 注解显式声明类型。同时写了一个单元测试专门用TypeReference做反序列化验证确保每个工具参数的解析都能得到正确类型。6.2 模型返回工具调用的 JSON 不合法别看大模型聪明让它生成严格合法的 JSON 也经常翻车。常见的错误包括多一个逗号、字符串里的引号没转义、字段名大小写不一致、多了注释。我的应对策略是三层容错第一层直接尝试用 Jackson 严格模式解析第二层如果失败把 JSON 里的注释去掉、修掉多余的逗号后再试第三层还不行就把这段文本直接返回给模型告诉它你刚才生成的工具调用参数格式有误请重新生成。我在实测中发现第三层容错往往是最有效的。因为你让模型自己修正自己的错误它比你任何程序化修复都更聪明。6.3 流式响应的粘包与拆包WebSocket 推送流式事件时前端的接收端时不时出现乱序或者事件丢失。排查后发现是 WebSocket 消息的粘包问题多个 Flux chunk 被合并成一次网络传输前端拿到的是一个拼接在一起的字符串。解决办法是每个流式事件的末尾都加一个自定义的分隔符前端按分隔符拆分后再逐个解析。这个方案简单粗暴但极其可靠实测几万次推送没有出现乱序。6.4 Function Calling 的多工具并发选择问题模型有时候会一次请求同时调用多个工具。这个能力理论上很高效但我在实现时发现一个问题多个工具调用结果都回来了上下文里会变得特别混乱模型容易在后续的思考中混淆哪个结果对应哪个工具。JavaManus 的处理方式是默认关闭多工具并行调用模型一次只能调用一个工具。等上下文管理更成熟了再开放这个能力。如果你也想做多工具调用我建议在每个工具调用结果前加上工具标识提醒模型这是哪个工具的结果。6.5 HTTP 调用超时的隐性坑Java 的 HTTP 客户端默认超时时间很长Agent 循环调用 LLM 时如果模型服务商那边响应变慢整个请求会被拖住引发连锁超时。我统一封装 HTTP 客户端时把连接超时设为 5 秒读取超时设为 60 秒并且给整个流式调用加了一个全局超时保护用timeout()操作符实现。这样即使模型方响应异常AgentRunner 也会及时抛出超时异常走失败分支而不是无限等待。7. 性能优化与压测数据框架到底行不行7.1 并发模型与线程池配置JavaManus 的 AgentRunner 是响应式模型和传统阻塞模型混用的。外层调用方通过 WebFlux 得到响应式 API但内部工具调用又依赖同步的反射调用。这个混用风格一开始让我很纠结后来想通了工具调用是业务系统里的存量代码大部分是阻塞式的强行改成响应式反而容易出兼容问题。所以线程池配置上我做了隔离LLM 调用线程池用虚拟线程Java 21 的Thread.ofVirtual()因为 LLM 调用是纯 IO 等待虚拟线程能最大化吞吐工具调用线程池用固定大小线程池默认 8 个线程避免业务代码在线程资源上互相争抢。7.2 压测数据与性能调优我用一个模拟的数据查询Agent做了压测。场景是用户连续输入 20 个不同的查询请求每个请求需要 Agent 调用两个工具查用户表、查订单表然后汇总结果。压测结果单实例并发 10 个会话时平均单轮响应时间约 3.2 秒其中 LLM 推理占 2.5 秒工具执行占 0.4 秒剩余是网络开销。并发 50 个会话时响应时间退化到 8 秒左右瓶颈出现在 LLM 网关的限流上。这说明框架本身的资源开销非常小真正的瓶颈永远在模型服务那一侧。7.3 热点代码优化如果要提一个最值得优化的点我建议看 JSON 序列化和反序列化。Agent 每轮循环至少要做 3 次 JSON 解析解析模型输出、生成工具调用 Schema、处理工具结果。我就用了 Jackson 的ObjectMapper单例复用并把所有 Schema 的结果缓存到内存里避免重复生成。这个小优化直接把每轮循环的耗时降了 15%。8. 框架的扩展性与应用场景不止是玩具8.1 业务系统集成实战JavaManus 在我这边的落地场景是智能数据助手。用户在前端输入帮我查一下上周各区域的销售额排名并找出增长最快的三个区域JavaManus 自动规划、调用数据查询工具、生成分析报告最后通过 WebSocket 把思考过程和结果推送到前端。整个接入过程比预想中顺利主要原因就是我把 Spring Boot 的自动配置做到了极致。业务方只需要在自己的项目里引入依赖配置模型 API Key然后把自己已有的 Service 方法加上AgentTool注解JavaManus 就自动把工具暴露给了 Agent。不需要改任何业务代码。8.2 与现有权限体系打通企业级应用绕不开权限控制。JavaManus 在工具调用层做了权限拦截每个工具可以声明需要的权限标识AgentRunner 在执行前会检查当前会话的权限上下文没有权限的调用直接拒绝并把拒绝原因返回给模型。这个方案比在模型 Prompt 里强调权限规则可靠得多。Prompt 是软的模型可能无视代码是硬的说拒绝就拒绝。安全边界这种事永远别指望模型自律。8.3 后续扩展方向JavaManus 目前的版本只能算 MVP最小可行产品后续空间还很大。我想做的几个方向包括多 Agent 协作不同 Agent 各负责一块子任务通过消息队列通信、工具市场业务方上传自定义工具包动态加载、可视化编排拖拽工具节点来定义 Agent 工作流。如果你也想做类似的事情我的建议是先把核心循环跑通再考虑这些花哨功能。一个能稳定处理 80% 场景、不炸不崩的简单框架远比一个功能齐全但是三天两头出 bug 的复杂框架有价值。9. 个人体会与总结建议项目做完回头看用 Java 写 Agent 框架这件事给我最大的感受是三句话第一Java 不是不能做 AI 应用关键要看你怎么扬长避短。LLM 推理这种密集计算确实不是 Java 的强项但 Agent 框架的核心是理解和编排业务工具这正是 Java 生态沉淀了二十年的地盘。只要能打通 LLM 接入这一环后面的工具调用、权限控制、状态管理、日志监控Java 的成熟度反而比 Python 生态更高。第二Agent 框架的生产力取决于工具生态的丰富程度。模型再智能没有趁手的工具它也使不出来。做 JavaManus 让我琢磨明白了一件事写 Agent 和写别的系统不一样你不是在写业务逻辑而是在给模型设计一套可以操控世界的操作手册。工具的质量直接决定了 Agent 的上限。第三别迷信复杂的框架设计简单才能稳定。我做 JavaManus 的过程中有两次大改动都是因为觉得当前设计太简陋想重构得更优雅结果重构完炸出一堆新 bug。最后反而证明最朴素的 ReAct 循环加上精心设计的工具注册机制性能和质量都远超几个花哨的实验性功能。如果你也在考虑在自己项目里搞一个 Agent我的建议是直接动手写一个最小的循环接上模型、注册一个工具、跑通一次完整的思考-调用-反馈流程。等你看到 Agent 第一次通过工具调用拿到结果、并且根据结果继续往下一步走的那一刻你就能真正体会到这个系统的灵魂在哪里了。剩下的事情就是在这个最小循环上不断打磨和扩展把框架一点点养大。