
1. 为什么要在Java生态里重造Agent轮子JavaManus的定位与设计目标先说个真实的尴尬场景我在GitHub上搜Java AI Agent排前面的几个仓库要么是Python项目的翻译版要么只是封装了某个大模型API的SDK根本没有一个能把“目标分解、工具调用、结果归因”串起来的完整框架。而公司这边全是Java技术栈Spring Boot跑得好好的如果为了让业务方用上Agent能力就去额外起一个Python微服务运维成本直接翻倍模型调用、鉴权、工具注册还得跨服务做协议对齐。于是萌生了一个想法在Java生态里自己写一个能跑通闭环的Agent框架名字就叫JavaManus。JavaManus不是要复刻LangChain那套庞然大物而是对标Manus这类通用Agent产品的核心执行思路用Java实现一个高内聚、低依赖、可扩展的骨架。它的核心引擎大概只有五千多行代码但已经能完成“用户给一个模糊目标→模型规划步骤→选择工具→执行并观察结果→调整计划→给出最终答案”的完整流程。如果你也想在Java项目里直接用Agent能力或者想搞懂Agent框架内部到底怎么运作这篇内容可以帮你节省大量踩坑时间。1.1 Java生态的Agent空白与Spring AI的现状现在主流的Agent框架清一色是PythonLangChain、CrewAI、AutoGPT都建立在Python庞大的机器学习生态之上。Java这边其实也不是没有动作Spring AI确实提供了ChatClient、PromptTemplate、Function Calling的封装但它更多是“大模型访问SDK”而不是一个真正意义上的Agent执行框架。用Spring AI做Agent你仍然需要自己去维护循环、状态管理、工具调度、上下文裁剪这些恰恰是最容易出错的地方。我一开始也想直接基于Spring AI做二次开发后来发现它的抽象层为了兼容多家模型供应商把很多执行细节遮住了。比如工具调用后的中间结果如何回填给模型它只做了最基础的Message拼接对于多轮工具调用的场景需要我自己写大量胶水代码。说白了Spring AI解决的是“怎么调大模型”而JavaManus要解决的是“怎么让大模型像一个员工一样持续干活”。所以我最终决定不依赖Spring AI核心模块只依赖一个轻量级HTTP客户端保证框架可以嵌入任何Java应用。1.2 JavaManus整体架构一个最小可运行的Agent闭环JavaManus的架构可以拆成四层每一层职责单一层与层之间通过接口解耦分层核心模块职责交互层AgentRunner接收用户目标维护会话状态对外暴露同步/异步入口执行层ExecutorEngineAgent主循环控制Thought-Action-Observation的迭代工具层ToolRegistry工具注册、参数解析、执行调用、结果格式化回传记忆层MessageStore上下文管理、Token估算、滚动摘要、会话持久化这四层对应的就是Agent运行时的四个核心问题入口怎么接、循环怎么跑、模型怎么调工具、上下文怎么存。值得强调的是工具层和记忆层是整个框架的地基。因为Agent本质上是“大模型在Java世界里通过一系列可调用工具来完成目标”工具定义的质量直接决定了模型能不能正确选择上下文管理则决定了多轮执行时模型会不会“失忆”。1.3 核心设计目标低依赖、可插拔、模型无关设计JavaManus的时候我给自己定了三个硬性约束第一个是低依赖。核心引擎除了一个JSON解析库和一个HTTP客户端不引入任何框架级依赖这样你在任何Java项目里都能直接引包使用不管是Spring Boot还是纯命令行应用。第二个是可插拔。工具通过接口暴露业务方只需要实现ToolHandler接口或者用注解声明一个方法就能把现有Java能力挂载到Agent上。第三个是模型无关。JavaManus只依赖模型返回的文本流和Function Calling能力对接OpenAI兼容接口或者国内大模型推理服务都可以只需实现一个ModleClient接口。关于“模型无关”我要多说一句。很多人写Agent框架喜欢直接把一个具体模型的SDK绑死在核心代码里换模型就要改框架。我在JavaManus里定义了一个统一的ModelClient接口只暴露chatCompletion(messages)和chatWithFunctionCalling(messages, tools)两个方法底层用什么模型是运行时注入的这样既能用DeepSeek这类Function Calling支持很好的模型也能退回到纯文本指令解析的普适模式。2. Agent主循环从自然语言到行动计划这个循环是怎么转起来的JavaManus的执行核心是一个循环而不是一锤子买卖。你给Agent一个目标它不会一次性返回最终答案而是反复经历“思考→行动→观察→再思考”的过程直到它有把握给出结论。这个模式在学术上叫ReActReasoning and ActingJavaManus的主循环就是对ReAct约简后的工程实现。2.1 任务入队与意图解析所有用户请求会先进入AgentRunner在这里被包装成一个AgentTask对象。这个对象包含原始目标文本、会话ID、自定义上下文Map、以及最大迭代次数。JavaManus在任务启动时不会立刻调用大模型而是先做一个浅层意图判断目标是单纯问答还是需要调用工具。这个判断可以减少无谓的Token消耗。浅层判断的实现很简单我维护了一个内置的意图规则列表比如“搜索”“查询天气”“执行某段代码”“计算”这类强工具指向的动词会被提取出来如果命中则直接进入工具执行分支。如果匹配不到就交给大模型做一次轻量分类。这里我用了两个Prompt模板一个是标准Agent执行模板一个是纯问答模板。我在实测中发现如果不做这一步很多简单问题也会被模型按“规划-执行”的完整链路走一遍浪费大量Token和时间。2.2 ReAct模式的Java实现Thought-Action-ObservationJavaManus的ExecutorEngine是整个循环的发动机核心逻辑在一个while循环里。每一轮迭代框架向模型发送当前会话的消息列表同时附带可用工具的Schema列表。模型返回的内容可能是两种形态如果模型支持Function Calling返回一个结构化的tool_calls字段如果不支持返回一段文本文本里包含Action标签。JavaManus会把这两种形态统一转成内部Action对象。Action对象有三个重要字段thought思考过程、actionName工具名、actionInput工具参数。得到Action后ExecutorEngine会去ToolRegistry查到对应的工具并执行然后把执行结果包装成Observation消息追加到消息列表里。这个过程会一直重复直到模型返回FinalAction或者循环次数达到上限。下面是一段简化后的核心循环代码public class ExecutorEngine { private static final int MAX_ITERATIONS 10; public AgentResult execute(AgentTask task) { MessageStore store task.getMessageStore(); int step 0; while (step MAX_ITERATIONS) { step; ModelResponse response modelClient.chatWithFunctionCalling( store.getCurrentMessages(), toolRegistry.getAllToolSchemas() ); Action action ActionParser.parse(response); if (action.isFinalAnswer()) { return AgentResult.success(action.getOutput()); } if (!toolRegistry.contains(action.getActionName())) { store.appendObservation(工具不存在请尝试其他工具); continue; } String observation toolRegistry.execute(action.getActionName(), action.getActionInput()); store.appendObservation(observation); } return AgentResult.error(达到最大迭代步数); } }这段代码最大的好处是把“模型决策”和“工具执行”完全解耦。模型只负责给出ActionJavaManus负责保证Action一定会在可控制的并发模型下执行并把结果标准化。我在设计时有意把复杂的状态管理收敛到MessageStore里执行引擎不需要关心上下文是否超长因为那是记忆层的事情。2.3 循环终止条件与防失控最大步数与软中断Agent最怕的就是陷入死循环。我在测试时遇到过模型反复调用同一个工具、每次都返回同样的错误提示然后它继续重试白白烧掉几十次调用费用。所以JavaManus在主循环里强制加了两个终止条件最大迭代次数默认10步可配置每一步执行都有超时时间默认120秒。此外我还实现了一个“软中断”机制。如果连续三步模型给出的Action是同一个工具且参数几乎相同JavaManus会主动插入一条Observation“你连续多次执行同一操作但未得到新结果请更换策略或直接给出答案”。这个小技巧非常管用它像项目经理在提醒一个钻牛角尖的实习生而不是死板地直接把循环杀掉。3. 工具调用层如何把Java方法变成大模型能理解并调用Tool工具调用层是JavaManus里最关键的模块也最容易翻车。这里的核心难点有两个一是怎么把Java方法的信息无损失地暴露给模型二是怎么把模型返回的JSON参数安全地反序列化成Java对象。如果工具描述不清楚模型就会给出错误的工具名或参数类型导致执行失败。3.1 工具注册与描述生成用注解把方法变成ToolJavaManus定义了一个AgentTool注解你可以把它标注在一个普通Java方法上框架启动时自动扫描并注册成可调用工具。这个方法会被解析成一个ToolHandler内部保存方法句柄、参数列表、返回类型。为了让大模型清楚这个工具是干什么的注解允许你指定工具名称、描述和每个参数的说明。下面是一个工具定义的例子Component public class WeatherTools { AgentTool( name query_weather, description 查询指定城市当前天气情况, paramDescriptions { ParamDescription(name city, description 城市中文名例如北京、上海) } ) public String queryWeather(String city) { // 调用气象服务或者本地查询 return 北京晴25度微风; } }这里我特别强调一个经验工具的description一定要具体到“这个工具能解决什么问题、需要什么格式的输入、什么时候不要使用”。大模型选择工具时主要靠描述文本的相关性匹配。描述写得太简单比如“查询天气”模型在多个工具并存时可能选错。我把实际经验里的好描述总结成一个模板“当用户想了解xxx时使用本工具输入必须是xxx格式如果不符合条件请返回错误信息。”宁可啰嗦也不要让模型猜。3.2 JSON Schema生成与参数绑定Java反射实现的参数协议模型调用工具时返回的actionInput是一段JSON。JavaManus需要把它绑定到Java方法的参数上。这中间的桥梁就是JSON Schema。我在ToolRegistry里为每个工具生成一份OpenAPI风格的Schema交给模型模型根据Schema生成参数JSON。生成Schema时我用反射遍历方法的参数类型基本类型String、Integer、Double、Boolean直接映射为JSON类型复杂对象则递归解析其字段。绑定的时候有个大坑LLM返回的JSON经常是宽松的比如数字用了字符串形式或者多了些无意义字段。我在实现里做了宽容反序列化使用Jackson的ObjectMapper并开启FAIL_ON_UNKNOWN_PROPERTIESfalse同时把整数类型配置成接受字符串输入。这样基本不会被格式问题卡死。下面的代码展示了参数绑定和执行的简要逻辑public String execute(String name, String inputJson) { RegisteredTool tool registry.get(name); Object[] args bindArguments(tool, inputJson); try { Object result tool.getMethod().invoke(tool.getTarget(), args); return serializeResult(result); } catch (InvocationTargetException e) { Throwable cause e.getCause(); return 工具执行异常: cause.getMessage(); } }3.3 执行结果回流与长度控制Observation的格式化策略工具执行完毕结果要作为Observation塞回消息列表给模型。这里有一个很容易被忽略的点工具返回值可能非常长比如一个数据库查询返回几十行数据全部塞给模型会占用大量上下文窗口。JavaManus的处理策略是限制单次观察结果的长度默认1000个字符超过部分用省略号截断并在前面加上结果总量提示比如“查询返回2000行已显示前20行主要字段”。模型一般不会需要全量数据它会根据这些摘要决定下一步查询条件。如果工具执行抛异常JavaManus不会直接把异常栈丢给模型而是把异常消息转成一句可理解的Observation比如“调用数据库时连接超时请确认数据库服务是否可用”。因为模型对原始堆栈的理解能力有限过多的异常细节反而会误导它。把异常翻译成人话这也是Agent框架工程化的重要细节。4. 记忆与上下文管理Agent的“短期工作记忆”是如何被撑住的Agent每轮迭代都要把“完整的思考过程、工具调用过程、观察结果”发给模型而这个累计序列会不断膨胀。如果不做控制几轮之后就会超出大模型的上下文窗口。JavaManus在设计上把上下文管理做成了独立模块不是简单粗暴地截断而是分层处理。4.1 上下文窗口溢出问题与Token估算策略首先必须解决一个前置问题我们怎么知道当前上下文已经快满了不同的模型上下文窗口不一样JavaManus没有依赖模型返回的usage字段而是实现了一个轻量Token估算器按中英文字符和标点粗略估算。英文按4个字符约等于1个Token中文按1.5个字符约等于1个Token实际误差在20%左右用于触发裁剪足够用。主循环在每轮模型调用前都会调用estimateTokenCount()如果估算值超过阈值默认是模型最大上下文的70%就会进入上下文整理流程。70%而不是100%是因为需要给模型的回复预留空间。尤其在Function Calling场景下模型除了生成回复还要生成结构化的工具调用JSON这部分Token开销很容易被忽视。4.2 滚动窗口与摘要压缩JavaManus的MemoryManager设计MemoryManager采用“滑动窗口摘要压缩”的双层策略。第一层是窗口保留最近N轮消息原样保留这些是最新的推理依据不能丢。第二层是历史压缩更早的消息会被合成一段摘要放回系统提示词里。JavaManus会在需要压缩时把旧消息单独发给模型要求用不超过500字概括这段过程中的关键信息和已经完成的操作然后把摘要作为一条System消息插入会话开头。这个方案在实测中特别有效。有一次我让Agent处理一个涉及15个工具调用的复杂任务如果不压缩到第8轮就快超限了用上滚动摘要后撑到了13轮最后一次调用的关键信息都在。唯一的代价是压缩时多花一次模型调用费用但换来的是整体任务能继续跑完这笔账是划算的。4.3 会话状态持久化与多轮衔接除了单次任务的运行上下文JavaManus还支持session级的持久化。用户在一次对话中可能给Agent布置多个连续任务比如“先查一下订单总量再按地区汇总”。这要求每一轮任务的中间状态都能被后续请求继续使用。我的实现是把MessageStore的底层存储挂到了可持久化的接口上默认用内存Map也可以替换成Redis或者数据库。持久化的粒度按sessionId隔离每个用户会话一小块空间存储所有历史消息和工具执行结果。重启应用后Agent可以从上次断点继续执行。这在生产环境很重要特别是Agent正在执行长任务时服务重启没有持久化就只能从头再来。JavaManus会在每轮迭代结束时异步保存消息快照兼顾性能和数据安全。5. 初版落地时最容易翻车的细节并发、模型返回格式、超时控制JavaManus第一个能跑通的版本只用了三天但真正达到“能给别人用”的程度又花了两周。这期间解决了一堆只在真实场景才会暴露的问题。我把其中最典型、对后来影响最大的几个问题写在这里希望能帮你绕开同样的坑。5.1 并发安全Agent不能被多个线程共享一个状态第一版JavaManus的AgentRunner是个单例Bean内部持有一个messageList作为状态容器。结果上线一压测用户A发来的任务历史串到了用户B的会话里AI回答的内容混乱不堪差点出生产事故。原因很典型Agent是一个有状态的执行单元不能让多个请求共享同一个实例的会话状态。修复方案是每个请求在AgentRunner内部创建一个独立的ExecutionContext所有状态MessageStore、临时变量、步数计数器都装在这个上下文对象里执行完释放。如果你的Agent实例需要被Spring容器管理请把状态放到ThreadLocal或者请求作用域里绝对不能把会话相关的成员变量放到单例Bean上。这也是JavaManus从V0.1到V0.2最大的架构变更。5.2 大模型返回格式不稳定的兜底策略即使声称支持Function Calling的模型在高负载时偶尔也会返回格式怪异的响应。我见过的情况包括返回的JSON被json标签包裹、参数中的布尔值变成字符串“true”、工具名包含多余空格、甚至把多个工具调用拼在一个数组里。JavaManus的ActionParser专门写了一套“清洗管道”在解析前先去除markdown围栏、修复嵌套引号、统一布尔值解析失败时不会直接抛出异常而是返回一个Observation“模型返回格式异常请重新生成Action”。这里要特别提醒千万不要直接信任模型的Function Calling输出。即使它带了tool_calls字段也要对字段做完整性校验尤其是arguments必须是合法的JSON对象。我见过不少框架把模型的返回直接扔给JSON解析器解析失败就整个会话崩溃。JavaManus的兜底逻辑是解析失败不终止循环而是告诉模型“你给的参数有问题”让模型自我纠正一轮。5.3 工具执行超时与熔断不能因为一个工具卡死整个Agent有些工具是IO密集型的比如调外部HTTP API网络抖动时响应可能几十秒甚至更长。JavaManus执行工具时统一用Future包装设置超时时间。一旦超时工具会被标记为失效状态并向模型返回“该工具调用超时请换一种方式或稍后重试”。更进一步我实现了工具熔断器如果同一个工具在10分钟内连续失败5次它会被暂时从工具列表中移除模型这期间就看不到这个工具自然不会再选它。熔断恢复后重新注册。这个机制参照了微服务里的熔断思想在Agent场景里同样有效。要知道Agent的执行链路很长一个脆弱工具可能连累整个任务失败宁可暂时不用它也不要让它拖垮主循环。5.4 性能调优减少无效Token消耗的四个实操手段跑了几轮真实任务后我开始关注成本问题。大模型按Token计费的背景下一次复杂的Agent任务可能要消耗几万甚至十几万Token。JavaManus采取的调优手段主要有四个第一工具描述里只写“何时用、怎么用、注意什么”不写冗长的示例第二把系统提示词压缩到600字以内只强调角色和基本规则第三每一步的结果尽量精简方法论同第3.3节第四简单请求直接走快速问答分支不进主循环。这四点加起来我的月度模型调用成本下降了将近40%。6. 从单Agent到协作式框架JavaManus的可扩展思路与Spring Boot集成要点JavaManus目前已经能稳定支撑中等复杂度的任务但真正的Agent系统往往需要多个角色配合比如一个Agent负责计划、一个Agent负责写代码、另一个Agent负责检查输出。这一章我结合实际使用讲讲如何在JavaManus基础上扩展出多Agent协作机制。6.1 多Agent协作用消息总线替代硬编码依赖多Agent协作最忌讳的是让Agent之间直接互相调用方法那会形成强耦合、顺序依赖根本没法并行。JavaManus扩展了一套AgentBus消息总线Agent实例只发布和订阅消息不感知其他Agent的存在。比如计划Agent把一个“生成单元测试代码”的任务发布到总线上执行Agent订阅后开始干活完成后把结果发布回去。分工就像公司里不同岗位之间通过工单系统协作而不是互相盯屏幕。消息总线的核心是一个异步队列每个Agent有自己的消费线程组。JavaManus里还引入了任务优先级可以对高优任务插队同时限制每个Agent的最大并发数避免资源耗尽。这块实现起来不算复杂主要用的是ConcurrentHashMap和BlockingQueue但效果很直观。6.2 插件化机制与SPI扩展为了让第三方能往JavaManus里挂工具而不修改框架代码我使用了Java标准SPI。在META-INF/services下放一个ToolProvider接口的实现启动时通过ServiceLoader加载即可。这样项目与项目之间的工具包可以完全解耦JavaManus核心只负责执行循环工具全由外部提供。这一点对商业项目很实用你可以把自己的私有工具封装成独立jar包分发给不同的Agent实例。6.3 与Spring Boot集成三个必须处理的配置点如果你要在Spring Boot项目里使用JavaManus需要注意三个配置点。第一把AgentRunner注册为Spring Bean时务必加上Scope(prototype)或者用ObjectProvider注入新实例否则会出现并发问题。第二工具注册要利用Spring的BeanPostProcessor在容器启动后扫描所有带AgentTool的方法而不是用包扫描这样可以兼容ComponentScan路径不同导致的遗漏。第三配置连接池模型调用和工具调用都会占HTTP连接单独为Agent执行器配置一个ThreadPoolTaskExecutor避免与其他业务接口争抢Tomcat线程。我在一个内部系统中的做法是AgentRunner直接注入Spring容器但每次请求通过ObjectProvider拿新实例工具方法用Spring的Component标记并由JavaManus启动扫描器在ApplicationReadyEvent里完成注册。个人经验是这个框架最妙的点是它没有把“状态”集中在某一个大Manager手里而是交给了纯Java对象。这样既容易测试也方便你做二次开发时把某个模块替换成自己的实现。我建议你在动手前先想清楚自己的工具列表大概有多少个、模型Function Calling的稳定性如何再决定是否引入完整的重循环。JavaManus让我省去了重复造轮子的时间也希望这份解析能帮你减少排查Bug的周期。