直接切入正题。
这两年AI Agent的概念被炒得很热,但如果你去翻一下生态,会发现一个很尴尬的事实:几乎所有主流的Agent框架都被Python圈给包圆了。Java开发者要么去蹭Python环境,要么用HTTP硬调别人的服务包装一层“伪Agent”,要么干脆放弃治疗。我自己在团队里折腾了几个月,忍无可忍决定从零写一个Java版的Agent框架,代号JavaManus。这既是一个内部工具,也算是一个Java生态AI化的实验性项目,我把整个设计和实现过程做了深度复盘,这篇内容就把核心思路和代码细节全部摊开讲。
这篇文章适合谁:想在Java后端里直接集成Agent能力、又不愿意引入重Python服务的开发者;对Agent框架内部原理好奇、想弄明白消息调度、工具调用、思维链管理到底怎么实现的人;以及正在选型Agent框架、想评估自研成本和风险的架构师。
1. 整体设计与思路拆解
1.1 为什么非要用Java重写一个Agent框架
先回答一个绕不开的问题:Java里调AI接口的库不少,LangChain也有Java移植版,为什么还要自研一个?
我的判断是:现有Java生态的Agent框架有几个致命伤。第一,多数只是API绑定,把请求发给大模型、返回完就完事,Agent核心的“循环决策-工具调用-结果观察”闭环根本没有做完整。第二,项目活跃度参差不齐,出了问题就是自生自灭。第三,多数框架把Agent结构写死了,你想插入自己的工具调用协议或者自定义记忆策略,改起来生不如死。
我需要的是一个能够在业务线程池里像普通服务一样部署、可以用Spring配置、能够灵活扩展工具注册表、还能把我团队现有的Java代码直接暴露成Agent工具的框架。换句话说,我要的是“Java原生Agent运行时”,而不是一个要看Python脸色、跨语言维护两套逻辑的胶水层。
另外还有一点很现实:性能。Python的GIL让并发Agent调用变成噩梦,Java的虚拟线程和高性能线程池在并发调度上有天然优势。既然大模型推理已经成为瓶颈,Agent框架自身的调度开销自然越低越好。
1.2 Agent核心循环:从“单次对话”到“自主决策”
在JavaManus里,最核心的设计不是怎么调用大模型,而是怎么把Agent的决策循环跑起来。一个标准的Agent循环是这个样子的:
接收用户输入,组装系统提示词。
调用大模型获取结构化输出(决策帧)。
解析决策帧:如果模型认为任务完成,就结束循环,返回最终答案。
如果模型决定调用工具,则从工具注册表里找到对应的可执行方法。
执行工具,获得观察结果。
把决策帧和观察结果一起拼回上下文,再次调用大模型。
重复,直到达到最大迭代轮数或模型主动终止。
这个循环在Python里实现只需要几十行代码,但在Java里要做得好用、可维护、可扩展,难度是另一个量级的。核心难点在于:消息历史如何在内存里高效组织、工具描述如何结构化地传给大模型、大模型返回的决策怎么安全地反序列化并映射到InvocationHandler上。
我的方案是用一个AgentEnv类来承载整个循环状态:内部维护一个List<AgentMessage>动态数组、一个ToolRegistry工具注册表、一个AgentPolicy策略对象。每次循环按“环境推导”模式工作:由Policy生成当前轮的决策,环境负责执行并记录结果。
public class AgentEnv { private final List<AgentMessage> messageHistory; private final ToolRegistry toolRegistry; private final AgentPolicy policy; public AgentRunResult run(String userInput) { addMessage(AgentMessage.user(userInput)); for (int step = 0; step < policy.getMaxSteps(); step++) { AgentDecision decision = policy.decide(messageHistory, toolRegistry); if (decision.isFinished()) { return new AgentRunResult(decision.getAnswer(), step); } AgentObservation observation = toolRegistry.execute(decision.getToolCall()); messageHistory.add(AgentMessage.observation(observation)); } throw new AgentTerminationException("超过最大迭代轮数"); } }不要再问“为什么不用状态机”这种问题了。Agent的决策循环天然就是while循环加分支,强行套状态机只会增加理解成本。框架设计第一课:用最简单直接的方式表达业务本质。
1.3 模块划分:目录结构决定架构边界
JavaManus的模块划分我花了两天时间反复调整。一开始把所有类都塞进一个包子包下面,结果Utils类越来越多,依赖越来越乱,最后痛下决心按职责边界拆分。最终的结构是清晰的分层:
agent-core:Agent循环内核、提示词装配、消息历史管理。agent-tool:工具定义注解、工具注册表、参数校验和调用分发。agent-llm:大模型客户端抽象层,目前实现了OpenAI兼容协议的适配。agent-memory:短期线程内存和长期持久化存储接口。agent-runtime:面向业务方的门面类,负责把上面所有组件拼装成可直接使用的Agent实例。
这个分层的出发点是“依赖倒置”:agent-core只依赖接口,不依赖任何具体的LLM厂商实现。这样以后想接入国内模型、开源模型、甚至本地部署的模型,只要新增一个agent-llm适配器就可以,完全不用动核心判断逻辑。
2. 核心细节解析与实操要点
2.1 消息系统的设计:Java类型如何映射到LLM消息协议
所有LLM API的底层逻辑都是“发一串消息,收一条回复”。消息类型无非三种:系统消息、用户消息、助手消息。但Agent场景下还有一种特殊消息:工具调用记录和工具结果。这个东西在OpenAI协议里叫tool角色,在小程序里叫function message。
Java是强类型语言,我直接用record来定义消息类型,利用Java 17的不可变特性保证线程安全。每个AgentMessage有一个role字段和一个content字段,另外对于工具调用,额外携带toolCallId和toolName。
public sealed interface AgentMessage permits SystemMessage, UserMessage, AssistantMessage, ToolCallMessage, ToolResultMessage { MessageRole role(); String content(); } public record ToolCallMessage(String toolCallId, String toolName, String arguments) implements AgentMessage { @Override public MessageRole role() { return MessageRole.ASSISTANT; } } public record ToolResultMessage(String toolCallId, String result) implements AgentMessage { @Override public MessageRole role() { return MessageRole.TOOL; } }有人会问:为什么不用继承用sealed interface?因为Java 17之后,sealed类型能把消息体系的合法子类型锁定在编译期,switch时不用写多余的default分支,未来的维护者想扩展一个消息类型必须显式改定义,这正好符合开闭原则。
这里有个实操中的关键坑:大模型平台对tool-related消息的格式要求可能不一样。有些平台要求工具调用消息必须紧跟用户消息,有些要求每轮工具调用必须携带完整参数。Java层如果只按“Role + Content”这种最小模型设计,遇到平台差异就得打补丁。我最终的方案是在LLMClientAdapter接口层做格式转换,内部统一用Java类型交互,适配器负责把Java消息列表翻译成目标平台的消息结构。这个设计让我后来接入新模型时几乎零成本。
2.2 工具注册表:把任意Java方法变成Agent的“手”
Agent不能只聊天,它得能干活。干活就得调工具。JavaManus的工具系统设计目标是:业务方只需要写一个普通Java方法,加个注解,就能被Agent发现和调用。
@AgentTool(name = "getWeather", description = "获取指定城市实时天气") public String getWeather(@ToolParam(description = "城市名称,如北京") String city) { WeatherService service = WeatherService.getInstance(); return service.fetch(city).toString(); }ToolRegistry启动时会扫描所有标注了@AgentTool的Bean方法,生成一个ToolSpecification列表,同步给大模型作为function calling的schema。大模型返回“我想调用getWeather,参数是city=北京”,框架解析这段JSON,通过反射定位到Bean实例,用参数转换器把JSON字符串转成Java类型,然后invoke方法。
这个过程有三个容易翻车的地方。第一个是参数类型转换:JSON里传的是字符串“23.5”,你的Java方法要的是Double,转换器必须做宽松类型适配,否则Agent一调用带小数点的数字就报错。我在框架里内置了一组默认转换规则,还允许业务方提供自定义Converter。第二个是方法入口的统一:不能用“每个工具方法单独解析参数”这种脑残设计,必须强制所有工具方法都符合String入参或注解参数模型。第三是安全校验:工具方法的执行如果没有超时控制和异常兜底,一个网络超时能把Agent循环整个卡死。我用CompletableFuture包装了工具调用,默认超时60秒,超时直接返回"工具执行超时"作为观察结果,而不是抛异常让循环崩掉。
这里提一句设计教训:给Agent使用的工具和给人用的API在签名设计上完全是两回事。人用API可以返回一个对象,客户端自己决定怎么处理;Agent没有“自己决定”的能力,它的上下文窗口有限,所有工具返回值应该尽量是精简的字符串。所以我在@AgentTool里设计了compactMode选项,返回值过大的时候自动截断,并按固定格式保留摘要部分避免污染上下文——这个对控制Token消耗非常有效。
2.3 思维链与决策帧:让模型的“想法”成为可解析数据
普通的单次模型调用返回一段文本,文本里有任何非结构化的思维过程,程序都难以可靠地处理。JavaManus的一个关键设计就是把“思维过程”和“具体行动”分离开,要求大模型输出一段固定格式的JSON“决策帧”。
决策帧包含四个字段:thought(对当前情况的推理)、toolName(要调用哪个工具)、toolArgs(工具参数)、finished(是否完成整个任务)。为了保证输出一定合法,我在提示词里做了严格规定,同时在后端加上了解析失败的重试逻辑。
正是因为Java天然就有成熟的JSON生态,处理决策帧非常顺手。不像Python社区还要纠结pydantic要写多少校验代码,我直接上Jackson的ObjectMapper加自定义类型反序列化器。对于模型输出的干扰信息,比如前后多余的说明文字,我用正则先裁剪再解析——这就是和纯Python方案不同的工程细节:免疫力不依赖模型能力,而是依赖框架的健壮性。
这里特别强调一点:决策帧里的thought字段不是给人看的装饰品。它在每一轮都会被完整追加到消息历史里,作为下一轮模型推理的上下文。相当于Agent在“自言自语”,这种自我对话是提升工具调用准确率的重要杠杆。后面版本我还打算把thought单独抽出来做长期记忆摘要,当前这个版本先保持简单,整个思维链直接放在短期消息历史中。
3. 实操过程与核心环节实现
3.1 搭建项目骨架与依赖选型
JavaManus是一个标准的Maven多模块项目,JDK要求17以上。根POM里最核心的几个依赖没有搞得太重:Jackson负责JSON序列化和反序列化,Spring Context可选引入(用来做Bean扫描),SLF4J负责日志,HTTP客户端用的是Java 11内置的java.net.http.HttpClient。
这里我想说一个反常识的结论:框架本身对第三方依赖的克制,甚至比功能丰富更重要。很多开源框架动不动引入一整套Spring Boot、Guava、Apache Commons,让使用者被迫接受重量级依赖。我在JavaManus里能不用就不用,核心模块尽量只依赖Jackson,这样某些不想引Spring的纯后端服务也能轻松使用。
项目创建后的第一步,不是写代码,而是把大模型调用抽象成接口。我定义了一个LLMClient接口,只有两个方法:chat(List<AgentMessage> messages)和chatWithTools(...)。整个核心循环就依赖这两个方法,不关心底层是哪个模型、用了什么超参。
3.2 实现AgentPolicy:决策循环的引擎
AgentPolicy是做决策的核心接口,有两个实现:一个是直连大模型的ReactivePolicy,一个是可编程控制流程的ScriptedPolicy。前者适合通用Agent,后者适合特定工作流场景,比如先检索后回答。
ReactivePolicy的内部实现最考验细节,核心步骤是:
把
List<AgentMessage>序列化成目标平台的请求体。把工具注册表里的
ToolSpecification列表附加到请求中。调用LLMClient拿到原始响应。
判断响应里有没有工具调用块。如果有,解析出
ToolCallMessage,返回一个带工具调用的AgentDecision;如果没有,提取文本内容,返回一个finished的AgentDecision。
在具体实现中,最“脏”的活是JSON容错解析。不同模型返回的JSON格式千奇百怪,有的在JSON前后加Markdown代码块标记,有的参数名大小写不一致,有的整个把函数调用写成普通文本格式。我专门写了一个DecisionParser,内置三层解析策略:标准JSON解析、正则提取后解析、字符串启发式兜底。三层都失败才抛异常并重试,重试超过两次后就返回一个要求用户澄清的消息。
不要低估这三层兜底的价值。实测下来,如果把解析失败率从5%降到了0.3%,整个Agent任务的完成率能提升接近20个百分点——因为一次JSON解析失败不是“重试一次”那么简单,它很可能破坏后续的消息历史上下文。框架级代码一定得考虑最脏的输入。
3.3 线程模型与并发调度的取舍
JavaManus是给后端服务用的,并发能力是刚需。Agent一次决策循环要等大模型响应,这个等待时间通常是几秒,用普通线程就是干等,浪费严重。Java 21的虚拟线程是完美方案,但我们生产环境还有Java 17的机器,所以我做了一个折中。
默认实现用固定大小线程池,核心线程数设置为CPU核数x2,等待任务时线程可以让出。这个方案的问题在于:线程池一旦被Agent调用占满,其他业务请求就得排队。解决办法是拆成两个队列:一个是Agent决策任务队列,一个是工具执行队列,前者用有界队列做背压,后者用缓存线程池加超时兜底。
参数选择上,决策队列我设置为corePoolSize个线程、1000个有界容量,超过就拒绝并返回“系统繁忙,请稍后再试”。工具执行队列用Executors.newCachedThreadPool(),因为工具执行并发度通常不高,但要防止异步线程泄漏,所以在工具方法执行完成后强制清理Future引用。
强烈建议你在生产环境给Agent运行时加上“最大并发Agent会话数”的全局信号量,默认16就够了。不要相信单个Agent实例能无限并行,大模型API的rate limit是你绕不过去的现实。用信号量限流比直接抛异常友好得多——至少能排队等待。
3.4 记忆管理:短期缓冲与长期持久化
Agent跑着跑着,之前的对话就没了吗?JavaManus的记忆模块区分了短期会话记忆和长期事实记忆。
短期记忆就是一个消息历史的滑动窗口。每一轮决策帧、工具结果都往里面塞,但窗口大小必须控制,不然上下文Token轻松破万。我用两种策略控制:按轮数截断(保留最近8轮决策)和按内容长度截断(超过6000字符就把最早的消息标记为摘要填充)。当前版本用轮数截断为主,因为实现简单且效果稳定,后续版本再引入语义压缩。
长期记忆走的是接口化设计,MemoryStore接口定义了save和search。默认实现是一个基于内存HashMap的假存储,方便测试;生产环境可以扩展成把对话摘要和关键事实写入数据库或向量库,每次新会话开始时,先按用户标识检索历史记忆,作为系统提示词的一部分注入。
我实际试过的方案里,效果最稳的不是“把历史对话全文存下来检索”,而是让Agent每轮结束时自动生成一个“记忆点”,格式是:“时间+用户意图+工具调用序列+最终结果”。这些记忆点拼起来就是一份结构化操作日志,检索时匹配的是意图而不是原文,准确率高很多,而且节省Token。这个模式后来被我固化成了MemoryNote对象。
public record MemoryNote(String sessionId, String userIntent, List<String> toolSequence, String outcome) {}3.5 完整跑通一个Agent任务
把上面的组件全部组装好,一个JavaManus Agent就能跑通了。我以“查询北京和上海今天是否适合跑步”这个任务为例,拆解整个执行流程:
用户输入进入AgentEnv,作为UserMessage写入历史。
ReactivePolicy调用LLMClient,带上工具列表,模型返回决策帧:thought=“用户需要天气信息,先查询北京天气”,toolName=“getWeather”,args={"city": "北京"}。
JavaManus把决策帧转成ToolCallMessage,追加到历史。
ToolRegistry执行getWeather方法,获得“晴,25度,微风”的观察结果,转成ToolResultMessage追加。
再次调用LLMClient,模型发现北京结果已拿到,继续调用getWeather查询上海。
两个城市天气都拿到后,第三轮模型返回finished=true,answer=“北京晴,25度适合跑步;上海有小雨,不建议户外跑步”。
整个任务消耗3轮模型调用、2次工具执行,8秒内完成。这个对话模板被JavaManus完整记录在消息历史里,每一步都可视、可回放。这也是自研框架相对黑盒商业框架的最大优势:你能精确知道Agent每一步做了什么、为什么这么做。
4. 常见问题与排查技巧实录
4.1 模型返回格式不符导致解析崩溃
这是我上线后遇见的第一个严重问题。某天模型升级了版本,结果它开始在决策帧的thought字段里夹带换行符和Markdown格式,我的JSON解析器直接炸了。排查过程有点曲折:最初算法工程师说是框架问题,因为我返回的错误码是JSON解析错误;后来把原始响应打出来,才发现是模型输出的格式规范变了。
解决方案有两层。第一层是提高解析器容错度,把ObjectMapper配置成忽略未知字段、允许单引号、允许控制字符,之前三层兜底策略就是这里被逼出来的。第二层是在提示词里加上“zero-shot格式约束示例”和“禁止输出任何JSON之外的文字”,实测能把格式跑偏率从30%降到2%以下。
所有踩过坑的团队都知道一个道理:容器里跑大模型应用,最重要的不是模型能力多强,而是你对模型输出有多大的“格式控制力”。提示词工程表面上是写给模型看的,实际上是给你自己的解析代码铺路的。
4.2 并发环境下工具状态互相污染
JavaManus第二版上线后,生产环境偶尔出现一个用户查天气返回另一个用户的城市。一看就是有状态变量在并发请求间串了。排查后发现,工具方法被缓存为单例Bean,方法内部用了一个实例级Map缓存最近查询结果,Agent A写入北京,Agent B读取到了北京的数据——典型的共享可变状态问题。
解决办法也简单:所有JavaManus工具方法必须设计为无状态纯函数,参数通过@ToolParam传入,返回值通过方法返回,绝不读写实例字段。如果业务方确实需要共享缓存,那缓存必须设计为可接受并发不一致或使用并发安全的Map并做key维度的隔离。框架层面能做的是在工具方法执行前检查方法签名,如果方法有实例变量引用就报一个配置警告,后续版本考虑直接编译期校验。
4.3 Token耗尽与消息历史失控
一个真实场景:Agent需要连续调研5个城市天气,每个城市调用2个工具,整整10轮工具调用,每轮消息平均500字符,最终消息历史轻松突破6000字符。上下文放不下,模型就开始丢三落四,最后的答案连城市名都对不齐。
这个问题没有一劳永逸的解法。我目前的策略是:判定工具调用轮数大于8轮后,启动消息裁剪:把最早的用户输入和工具结果替换成“用户请求对多个城市进行天气查询,前两轮已完成”这类摘要句。裁剪后Token占用直接降60%,效果立竿见影。但要注意裁剪只能对“已完成并且不再需要的工具结果”做,绝对不能对当前工作进行中的消息做,否则Agent会失去执行线索。
高Token消耗是Agent框架绕不开的成本问题。我自己在生产环境的经验是:先预估任务复杂度,再给不同用户层级配置不同的最大迭代轮数和上下文窗口,防止无限烧钱。别让一两次失误跑出几千次模型调用,账单会让你怀疑人生。
4.4 排查技巧速查表
为了方便后来者,我把线下和线上高频问题整理成一个速查表。
| 症状 | 可能原因 | 排查手段 | 解决办法 |
|---|---|---|---|
| JSON解析异常 | 模型输出格式漂移 | 打印原始响应日志 | 增强解析器兜底能力,提示词加格式约束 |
| Agent只答不调用工具 | 提示词缺工具使用说明 | 查看决策帧是否有toolName | 补全工具描述和示例,显式要求“必须调用工具” |
| 工具返回值脏 | 返回对象toString格式混乱 | 打印工具方法返回值 | 工具方法统一返回精简字符串 |
| 任务中断无响应 | 工具执行超时挂死 | 查看线程堆栈和超时设置 | 设置工具执行超时和控制并发数 |
| 上下文Token爆炸 | 消息历史无裁剪 | 统计消息数量和Token量 | 采用记忆点摘要和轮数截断方案 |
| 多用户数据串线 | 工具方法有状态 | Code Review实例字段 | 强制工具方法无状态,引入静态扫描 |
这条表格里的内容每条都是真金白银的坑。保存起来吧,总有一天用得上。
5. 从JavaManus到业务落地的扩展与反思
5.1 如何把它接入现有的Java服务
JavaManus不是一个独立的Agent服务,它更像一个可以被嵌入任何Java进程的组件。接入步骤很简单:加Maven依赖、初始化AgentFactory、注入LLMClient和ToolRegistry、调用agent.execute()。
我自己最推荐的落地方式是做成Spring Boot Starter,自动配置Agent实例,把业务Bean自动扫描注册成工具。这样团队现有的几百个Service方法,只要加上@AgentTool注解,立刻能变成Agent能调用的“手”。这个体验非常爽,业务同学只要关注方法本身,Agent的调度和决策完全交给框架。
不过这里有一个落地铁律要强调:不能把敏感操作直接暴露成Agent工具。删除、转账、修改权限这类高危动作,必须在框架层加二次确认机制,或者只暴露查询类工具。Agent会基于错误推理调用工具的,这是概率模型的天性,锅不能让框架背。我做了两层保护:工具注册表里可以标记requires_confirm属性,如果Agent调用了这类工具,框架会暂停循环并回问用户确认。
5.2 Java与Python Agent框架的生态位差异
JavaAgent框架永远不可能替代Python的,但Java框架有自己的独特生态位。Python的优势是学术生态,机器学习新算法基本都是Python先行,Agent的新玩法、新Prompt模式迭代速度极快。Java的优势是工程生态,已有服务都是Java写的,嵌入式部署、结构化业务逻辑整合、性能和类型安全,Java天然胜出。
如果你是在做一个预研性质、离线上环境的Agent Demo,用Python没毛病;但如果你要做一个跟现有OAuth、RBAC权限体系深度绑定的Agent功能,Java的嵌入能力就是无可替代的。JavaManus在这一点上完全踩准了定位:它不是“另一个LangChain”,而是“Java业务系统的Agent能力扩展包”。
根据我个人的实际操作体会,JavaManus最难的部分不在代码,而在抽象边界的把握。Agent框架很容易越做越重,什么都要往里塞:向量库、RAG、编排流程、多Agent通信。我选择把核心保持在“决策循环+工具调用+记忆”这个最小可运行内核上,其他能力全部放扩展包里,谁需要谁引入。这个克制在项目后期给我省下大量维护成本,也是我最想传达的一个设计理念。
最后再聊一个后续方向。JavaManus目前对多Agent协作的支持还比较初级,只是允许多个Agent角色各自拥有独立消息历史和工具集,然后通过一个简单的工作流引擎按顺序调用。下一版我打算引入事件驱动的Agent通信总线,让Agent之间可以像微服务一样异步收发事件。这个改动如果完成,JavaManus就能支撑更复杂的组织协作类任务了,不过那是另一个一万行代码的故事了。