这两年AI Agent的概念被炒得火热,但大部分开源框架都扎堆在Python生态里。如果你的技术栈刚好是Java,或者你所在的公司基础设施、团队技能树都在Java这边,想直接照搬那套方案往往会遇到一堆集成和部署上的麻烦。我去年从零开始用Java写了一个轻量级的Agent框架,代号JavaManus,花了不少时间调优,也踩了不少坑。这篇文章就把整个设计和实现过程做一个深度拆解,包括Agent运行的核心循环、工具注册机制、上下文管理和工程化落地,给同样想在JVM生态里做Agent的同学一个可参考的路径。
先说清楚这个项目能解决什么问题:它不是一个完整的商业化产品,而是一个可复用的Agent运行时内核。你拿到这套框架之后,可以自己扩展工具、调整Prompt策略、接入不同的模型服务,不需要被某个重量级框架绑死。适合谁看?已经写过几个简单Agent Demo、打算深入搞懂Agent内部机制的后端开发者,以及想在Java项目里落地智能体能力的架构师。
1. 整体设计与架构拆解
1.1 为什么用Java从零写一套Agent框架
市面上现成的Agent框架其实不少,但大部分是Python写的。不是说Python不好,而是在不少企业环境里,Java仍然是后端服务的主力语言。如果你把Python Agent塞进Java微服务体系,会带来几个现实问题:跨语言调试麻烦、依赖管理冲突、部署环境要额外维护一套Python运行时,以及和内部基础设施(配置中心、监控系统、RPC框架)对接成本高。
用Java写,最大的好处是能直接融入现有技术栈。比如你的团队已经用Spring Boot搭建了微服务,那Agent框架可以作为一个普通模块嵌进去,工具层直接调用现有的Service方法,不需要暴露HTTP接口。再加上Java的类型系统比较严格,在定义工具参数Schema、解析模型输出结构时,类型安全会带来很多便利,能提前暴露很多Python里要到运行时才能发现的问题。
还有一点是并发优势。Agent运行时的核心是在一个循环里多次调用模型、执行多个工具,这些操作天然适合异步化。Java的虚拟线程让并发编程的成本大幅下降,几千个并发Agent实例不再是梦想。Python的GIL在这一点上完全没法比。
1.2 Agent核心循环:从感知到行动的状态机
JavaManus的核心抽象是Agent循环,本质是一个状态机,围绕以下状态流转:
- IDLE:初始状态,等待用户输入
- THINKING:调用大模型,让模型分析当前任务并决定下一步动作
- ACTING:执行模型选择的工具调用
- OBSERVING:获取工具执行结果,反馈给模型
- COMPLETED:模型判断任务完成,输出最终答案
- FAILED:达到最大循环次数或出现不可恢复的错误
整个循环的入口是Agent.run(task),内部会循环执行上述状态,直到达到终止条件。这里的核心设计决策是:Agent的每一步行动都必须基于上一轮的可观察结果,这就跟传统的单次大模型调用区分开来了,也让它能解决多步推理问题。
在设计循环时最容易犯错的地方是:让模型在天马行空自己发挥,没有强约束。我见过不少Agent Demo跑着跑着就失控了,模型开始“幻想”工具结果。我的做法是,在Prompt中明确告诉模型只能使用注册表中的工具,输出必须是合法的JSON格式,且每个输出必须包含thought和action字段,从机制上防范模型乱来。
1.3 模块划分与数据流
整体架构拆成五个核心模块,数据流严格单向传递:
| 模块 | 职责 | 关键接口 |
|---|---|---|
| 规划器(Planner) | 解析任务、生成执行计划 | Plan createPlan(String task) |
| 执行引擎(Executor) | 循环编排、状态管理 | AgentResult execute(String task) |
| 工具层(Toolkit) | 工具注册、发现、调用 | void register(Tool tool) |
| 上下文管理器(ContextManager) | 消息历史存储、裁剪、摘要 | void addMessage(Message msg) |
| 模型网关(LLMGateway) | 统一的LLM接入层,负责重试、限流 | LLMResponse call(List<Message> history) |
数据流是:用户任务进入规划器,拆成子任务后交给执行引擎;执行引擎在循环中调用模型网关,从上下文管理器读取历史消息,拼装Prompt;模型返回的Action交给工具层解析、执行;结果再写回上下文管理器;重复直到终止。
各模块之间都是接口依赖,不互相直接引用实现类。这样后续替换任何模块都不会影响其他部分,比如把模型网关从某模型商换成开源的本地模型,只需要改一个适配器。
2. 核心模块设计与关键难点
2.1 规划器:把任务拆成可执行步骤
规划器负责把一个抽象任务(比如“帮我分析这份财报并生成摘要”)拆解成具体的步骤(拉取文件、解析文本、分析指标、生成总结)。实际实现中我并没有做特别复杂的任务编排,而是依赖模型自身的能力,让模型以JSON数组格式输出计划。框架层面只负责解析计划、调度执行。
这里有个重要的设计取舍:有没有必要引入单独规划模型?我的结论是,中小型任务不要用独立规划模型,成本高、延迟大,效果还不一定更好。直接在系统Prompt中让主模型先给出简约的计划,然后进入循环即可。只有当任务非常复杂(需要并行执行多条路径)时,才值得拆出独立的规划模型。
规划器输出的Plan结构如下:
[ { "step": "1", "description": "读取上传的财报PDF文件", "requires_tool": "file_reader", "status": "pending" }, { "step": "2", "description": "提取财务关键指标", "requires_tool": "data_extractor", "status": "pending" } ]这么做的好处是执行引擎可以按步骤调度,同时给用户展示Agent当前执行到哪一步了,体验上更透明。实际运行中,规划器本身也可能出错,所以我在实现时加了一个纠错机制:如果规划输出不是合法JSON,就强制回退为单一步骤任务,把整个任务交给循环处理。
2.2 工具注册与动态调用:让Agent具备行动能力
工具层是整个框架的“手”。JavaManus把工具抽象成接口,任何Java方法都可以通过注解暴露为Agent工具:
@AgentTool(name = "web_search", description = "搜索互联网信息") public String webSearch(@ToolParam(name = "query", description = "搜索关键字") String query) { return searchService.search(query); }框架启动时,通过反射扫描带@AgentTool注解的方法,解析方法名、参数列表、注解描述,自动生成一个JSON Schema描述文件,和系统Prompt拼装在一起发给模型。模型看到的就是一个工具清单,需要调用工具时输出JSON格式的调用请求,框架再通过反射把参数绑定到方法上执行。
这个思路并不新鲜,但有几个容易被忽略的细节:
- 参数类型转换:模型输出JSON参数都是字符串,需要框架根据方法签名自动转换成int、double、List等类型。转换失败时不要直接抛异常,把错误信息返回给模型让它纠正。
- 工具超时控制:每个工具执行都要有超时时间,否则一个慢工具会卡住整个Agent循环。我用
CompletableFuture包了一层,设置默认超时10秒。 - 工具结果截断:有的工具返回结果很大(比如读取了整个文件),直接塞进上下文会把Token撑爆,所以我在工具层设置了结果长度上限(默认2000字符),超出部分截断并附加说明。
工具注册表在设计上采用Map存储,按名称索引。这里我选择了Spring的ApplicationContext来做自动注册:任何被Spring容器管理的Bean,只要类上有@AgentComponent注解,就会在启动时自动扫描其中所有带@AgentTool的方法。这样业务代码不需要感知Agent框架的存在,把工具当作普通类写就行。
2.3 上下文管理器:Agent的记忆机制
Agent的记忆机制直接决定了效果上限。多轮工具调用会产生大量中间结果,如果把所有历史消息全部发给模型,很快会超出Token上限。JavaManus的上下文管理器实现了三层策略:
- 滑动窗口:只保留最近N轮消息(默认20轮),更早的消息从窗口移除。
- 摘要压缩:当早期消息被移出窗口时,调用LLM对这些消息生成一个摘要,作为系统消息的附加部分保留下来。
- 关键信息提取:对于工具执行结果,额外维护一个“关键事实”列表,每次循环时把最新的事实注入Prompt,即使原始结果已经被裁剪掉。
实际测试下来,摘要压缩策略效果最明显。比如让Agent搜索资料并生成报告,如果不做摘要,第二轮对话就已经有大量重复文本;做了摘要之后,Token占用可以降低40%以上,而且模型对全局信息的把握反而更好了。
还有个容易忽视的点:上下文管理器要区分“系统消息”“用户消息”“助手消息”“工具结果消息”。不同角色的消息在进Prompt时有着不同的拼接格式,如果搞混了,模型很容易失去方向感。我在实现时用一个枚举类型严格约束消息角色,在网关层再统一渲染成模型要求的格式。
2.4 模型网关:统一接入与容错
模型网关承担两个任务:把内部消息格式转换成模型Provider要求的格式,以及处理调用过程中的异常。JavaManus在设计时参考了Java生态里常见的仓储模式,定义了一个LLMProvider接口,不同的模型服务商只需实现这个接口就能接入。
网关内部的容错策略值得多说几句:
- 指数退避重试:模型接口超时或返回5xx错误时,按
1s、2s、4s、8s的间隔重试,最多3次。 - Token限流:在网关层做并发控制,防止瞬间请求量过大打爆配额。我用Guava的
RateLimiter做了简单的令牌桶,保证每秒请求数不超过设定阈值。 - 超时熔断:单次模型调用如果超过60秒没有响应,直接中断当前Agent循环并返回错误,而不是无限等待。
这些容错机制决定了Agent在生产环境的稳定度。没有这些机制之前,我这个框架在演示时经常因为偶发网络超时直接挂掉,整个体验非常糟糕。
3. 实操实现与核心环节代码走读
3.1 Agent主循环的Java实现
下面这段代码是JavaManus最核心的Agent主循环,剥离了业务细节之后大概长这样:
public AgentResult execute(String task, AgentContext context) { // 1. 生成初始计划 Plan plan = planner.createPlan(task); context.setPlan(plan); // 2. 进入主循环 for (int step = 0; step < maxSteps; step++) { // 2.1 让模型决策下一步动作 LLMResponse response = llmGateway.call(context.buildMessages()); AgentAction action = parser.parseAction(response.getContent()); // 2.2 判断终止条件 if ("FINISH".equals(action.getType())) { context.addAssistantMessage(action.getFinalAnswer()); return AgentResult.success(context.getFullTranscript()); } // 2.3 执行工具调用 if ("TOOL_CALL".equals(action.getType())) { ToolResult result = toolkit.execute(action.getToolName(), action.getParameters()); context.addToolResult(action.getToolName(), result.getSummary()); } else { // 模型输出了无法识别的动作,记录错误并继续 context.addSystemMessage("模型输出无法识别的动作,请重新规划"); } } // 3. 超出最大步数,返回失败 return AgentResult.failure("超出最大执行步数"); }循环的核心判断点就是action.getType(),模型输出的Action只有三种可能:调用工具、输出最终答案、无法识别。这个设计的优势是简单可控,没有复杂的路由逻辑,不容易出错。
需要注意,maxSteps不能设置太大。我实际测试过,一个中等复杂度的任务,循环10到15步基本能完成。设成30步以上反而容易让Agent在冗余动作里浪费时间,而且Token消耗会成倍上涨。默认我设为12步,特殊情况可以通过构造参数调整。
3.2 主循环的并发与异步优化
刚才的代码是同步执行的,Agent跑起来会阻塞线程。生产环境通常需要同时跑多个任务,这时候我用虚拟线程做了异步化改造。Java 21的虚拟线程非常轻量,可以直接在每个Agent任务中创建一个虚拟线程来跑循环:
public CompletableFuture<AgentResult> executeAsync(String task) { return CompletableFuture.supplyAsync(() -> execute(task, new AgentContext()), Executors.newVirtualThreadPerTaskExecutor()); }这样单个任务阻塞时不会占满物理线程,框架可以同时支撑几百个Agent实例并行工作。实测下来,在普通8核机器上跑100个并发任务,CPU和内存占用都在可接受范围内。这在重构之前用普通线程池时是不敢想象的,普通线程池每个线程占1MB栈空间,100个并发就是100MB开销,虚拟线程每个栈只有几十KB。
3.3 提示词模板设计:Agent表现力的关键
提示词模板是Agent框架最容易被低估的模块,它对最终效果的影响甚至超过模型选型。我的系统Prompt在其设计上遵循一套固定的结构,而非简单到只用一句话的消息:
private static final String SYSTEM_PROMPT = """ 你是一个智能助手,可以调用工具来完成任务。 工具列表: {% for tool in tools %} - {{ tool.name }}: {{ tool.description }},参数:{{ tool.parameters_schema }} {% endfor %} 你的输出必须始终是合法JSON,包含两个字段: - "thought": 你的思考过程 - "action": 你的具体动作,类型可以是 "TOOL_CALL" 或 "FINISH" 当任务完成时,输出 FINISH 并提供 final_answer 字段。 如果工具调用失败,请分析错误并调整策略。 """;注意几个设计要点:
- 工具清单插在系统Prompt前面,让模型先看到工具能力,再看到输出约束。
- 明确终止条件,“任务完成”是什么,模型得有一个清晰的标准,否则它会一直循环下去。
- 强调JSON格式对于Java后端项目尤其重要,因为我们要用Jackson解析模型输出。
模板渲染我用的是Java 21自带的StringTemplate,它比字符串拼接优雅得多,而且支持结构化渲染。如果你还在用老版本Java,也可以用Velocity或Freemarker替代。
3.4 结构化输出的解析与校验
模型输出的JSON解析,在整个框架里是出问题最多的地方。我封了一个ActionParser,解析流程分三步:
- 先尝试直接解析整个输出文本为JSON。
- 如果失败,用正则把JSON部分从文本中抠出来(很多模型会在JSON外面包一层Markdown代码块)。
- 如果还是失败,返回一个特殊Action对象,消息反馈给模型重新输出。
校验逻辑也很重要。解析出来的Action要检查:toolName是否存在于注册表?parameters是否和工具Schema要求匹配?把这些校验逻辑都做掉之后,再传递给调用方。我见过很多Agent Demo在解析这里挂掉,报各种JsonParseException,其实加一层兜底逻辑,问题就迎刃而解了。
另一个实用技巧是给模型提供“少样本示例”。在系统Prompt后面附上几个JSON输出示例,模型出错的概率会显著降低。实测中,加三个示例JSON之后,解析失败率从15%降到了2%以内。
4. 常见问题与排查技巧实录
4.1 模型输出JSON不稳定怎么办
这是所有Agent框架都会遇到的经典问题。我的处理方案分三层:
- 提示词层面:在模板末尾强制加上“只输出JSON,不要输出任何其他文字。”,并提供示例。
- 解析层面:用正则先剥离所有非JSON字符,再尝试解析,避免被Markdown代码块干扰。
- 兜底层面:解析失败时不要把异常抛出给用户,而是把错误信息作为系统消息返回给模型,让它重新生成。这一招能挽回绝大多数失败案例。
这里有一个实测数据:只靠提示词约束,解析失败率大约5%;加了正则剥离之后降到1%左右;再加上错误返回机制,实际能到0.1%以下。
4.2 Agent陷入死循环或重复调用同一工具
有时候Agent会反复调用同一个工具,比如搜索同一个关键词三次,或者反复读取同一个文件。我在排查后发现原因通常是:模型没有从工具结果中获取到足够信息,只能一遍遍重试。解决办法有两个方向:
- 提升工具结果的信息量:让工具返回更丰富的结构化信息,而不是简单的纯文本。比如搜索工具返回标题、摘要、URL,文件读取工具返回文件名、大小、最后修改时间等元数据。
- 识别并惩罚重复动作:在上下文中检测到同一工具的同一参数出现多次时,往消息里追加一条警告:“你已经多次调用该工具但未取得进展,请尝试其他方式”。
第二种方式在多数情况下能打破死循环,但会消耗额外的Token。我通常只在检测到重复调用两次以上才触发。
4.3 上下文Token加载过多,模型逐渐“失忆”
这个问题用一句话描述就是:消息历史太长了,模型注意力被分散,Agent忘了最初的任务目标。我发现直接扩展窗口长度不是好办法,费用上划不来,而且模型对长上下文的注意力会衰减。
我在实践中摸索出一套组合拳:
- 工具结果截断:超过500字符的结果直接截断,末尾加上“(结果已截断,完整内容可在需要时重新读取)”。
- 中间摘要:每完成一个子任务,让模型输出一个简要总结,后续循环只保留这个总结。
- 关键信息强化:把用户最初的指令拆成不变字段,每轮循环都重新注入到用户消息开头,确保模型不“忘本”。
这三种策略配合使用之后,即使任务执行了20轮,模型依然能准确理解目标,回答质量保持稳定。
4.4 并发场景下的工具冲突与幂等性
当多个Agent并发执行时,工具调用可能会相互干扰。比如两个Agent同时写同一个文件,后写的结果覆盖了先写的。这个问题常被忽略,但在生产环境非常致命。
我的建议是给每个Agent任务分配独立的上下文ID,工具层在写入文件时带上上下文前缀。更稳妥的做法是尽量让工具保持无状态,只做读取、查询等纯操作,对外部系统的修改动作加锁或走消息队列串行化。
还有幂等性问题:如果模型因为超时重试,同一个“发邮件”工具可能被调用两次,造成重复发送。我在工具注册表里加了简单的调用记录,对写操作做了去重:同一个Agent在同一轮次中,同一个工具的相同参数不重复执行第二次。
5. 从Demo到生产:性能优化与工程化经验
5.1 模块化扩展:如何接入新的工具和模型
JavaManus的扩展性主要体现在两个方向:新增工具和接入新模型。
新增工具很简单,只需在业务代码中写一个方法,加上@AgentTool注解,框架在启动时会自动发现并注册。不需要修改Agent核心代码,不需要额外配置,这非常适合团队协作场景:业务同学负责写工具,Agent开发者专注优化循环和提示词。
接入新模型则需要实现LLMProvider接口,核心方法就一个:
public interface LLMProvider { String call(List<Message> messages, ToolRegistry toolRegistry, LLMParams params); }我在代码库里内置了一个基于HTTP调用某模型OpenAI兼容接口的Provider,对接国内大模型平台时只需替换BaseURL和鉴权信息。如果你要用本地部署的模型,可以在此基础上做适配,也能直接实现一个走gRPC的Provider。这个接口抽象得越薄越好,因为模型服务商各家接口差异较大,底层细节隔离做得越好,上层替换成本就越低。
5.2 Token成本控制策略
Agent框架在生产落地时,Token消耗是个绕不开的话题。一次多步推理可能消耗几万Token,如果模型按Token计费,这个成本很快就变得难以忽略。
我做了三件事来控制成本:
- 精简系统Prompt:把工具描述写得精炼准确,不必要的修饰词全部删掉。测试中发现,一个50字以内的工具描述和50字的or200字的描述,模型理解能力差别不大,但Token消耗差了四倍。
- 延迟工具描述注入:只在模型需要时注入某个工具的详细描述,而不是把所有工具的全部描述都塞进每轮Prompt。实现方式是模型先输出一个简短的“意图”,框架根据意图注入对应的工具详情。
- 结果缓存:对于相同参数的相同工具调用,在当次会话内直接返回上次结果,不重复执行。
这套组合策略落地后,我的测试项目Token平均节省了35%,而且Agent效果没有明显变差。
5.3 可观测性:给Agent加监控
调试Agent比调试普通程序难得多,因为它是一个非确定性的循环过程。我一开始只靠日志排错,非常痛苦,后来给框架加了三层可观测能力:
- 过程追踪:记录每轮循环的思考、动作、工具结果,方便事后回放整个决策链路。
- Token计数:统计每次调用的输入Token和输出Token,按Agent任务维度汇总。
- 步骤耗时:记录模型调用耗时、工具执行耗时、总耗时,用于定位性能瓶颈。
具体实现上,我简单地在AgentContext里维护一个List<StepRecord>,每轮循环追加一条记录,任务结束后统一输出。如果要更专业,可以将这些数据接入监控系统打点,或者通过日志框架输出结构化日志。
这一步看着不起眼,却是从“能跑”到“能维护”的分水岭。没有完整的决策链路回放,Agent出了问题就只能靠猜,在复杂任务上几乎没法排查。
写在最后的一个小技巧
在做这个框架的过程中,我最大的经验教训是:Agent框架的复杂度不来自并发或架构,而来自模型行为的不可控。与其把精力花在追求复杂调度算法上,不如先把工具层、上下文管理、可观测性这些基础模块做扎实。框架真正稳定之后,模型升级就是水到渠成的事。
最后分享一个实操小技巧:给每个Agent实例加上一个唯一ID,在所有日志和上下文记录中带上这个ID。排查问题的时候,你会发现这个小小的改动让你从“大海捞针”变成“按图索骥”。如果你也在做Java侧的Agent框架,希望这些设计和踩坑记录能帮你少走一些弯路。