这段时间我用 Java 21 从零手写了一套可运行的 AI Agent 框架,项目代号叫 JavaManus。今天这篇不聊虚的,直接把设计思路、核心实现、踩坑过程全部摊开,说说一个纯 JVM 背景的开发者,不依赖 Python 生态,是怎么把大模型调用、工具注册、任务循环、上下文管理这几块东西捏合成一个完整框架的。如果你正在纠结“Java 到底能不能做 AI 应用”,或者想自己动手写一个 Agent 引擎而不是只做个 API 搬运工,这篇文章值得你花十分钟看完。
1. 为什么选 Java 写 Agent:一次技术选型复盘
1.1 Java 技术栈与 Agent 需求是否匹配
先说结论:非常匹配,而且比很多人想象中匹配得多。
AI Agent 的核心本质是“让大模型做决策、让程序做执行”。这个闭环里,大模型只负责输出文本和 JSON,真正干活的是周边系统。周边系统是什么?是 HTTP 调用、数据库读写、消息队列、内部接口、文件处理、权限控制——这些恰恰是 Java 企业级生态深耕了二十年的地盘。我见过很多团队,为了接入一个 Agent 功能,硬生生在 Python 侧再搭一套服务,然后和 Java 主系统之间用 HTTP 来回对接,链路长、排查难、类型还不安全。
Java 侧做 Agent,真正的短板只有一个:大模型应用层的现成框架少,很多组件事都得自己拼。但如果你把 Agent 拆开看,每一块在 Java 生态里都有非常成熟的答案。我用一张痛点对照表说明我当时是怎么判断的:
| Agent 需要的能力 | Java 生态对应方案 | 成熟度 |
|---|---|---|
| HTTP 调用大模型接口 | JDK 11+ 内置 HttpClient / 虚拟线程 | 很成熟 |
| JSON 解析与生成 | Jackson / Gson | 非常成熟 |
| API 参数 Schema 定义 | Jackson + JSON Schema 校验库 | 足够用 |
| 并发执行多个工具 | Java 21 虚拟线程 / 线程池 | 很成熟 |
| 流式响应(SSE) | HttpClient 逐行读取 / WebFlux | 成熟 |
| 长期记忆和向量检索 | 自建向量表 / 对接已有中间件 | 视场景而定 |
这里我要特别提一下 Java 21 的虚拟线程。Agent 执行过程中经常需要同时调用多个工具,比如“查天气+查日历+查待办”,这三个调用的耗时是串行累加的。用传统线程池,线程数是硬限制;用虚拟线程,你可以为每个工具调用单独开一个线程,数量级可以到十万甚至百万,而且阻塞 IO 时不会占着真正的操作系统线程。这个特性对写 Agent 框架的人来说简直是量身定做的。
1.2 明确边界:这个框架不做什么
项目立项最容易犯的错就是野心太大。我一开始也想做个“Java 版 LangChain”,后来发现完全不现实,Agent 编排、记忆、检索、多智能体协作、评估、监控,每一块都是无底洞。所以我给 JavaManus 划了一条非常干净的边界:
- 不做模型训练、微调,只做推理调用
- 不做向量数据库实现,只预留接入点
- 不做工作流可视化编排界面,只提供编程式 API
- 不做多智能体通信协议,先把单体 Agent 跑稳定
这个框架解决的核心问题是:让 JVM 开发者用最少的代码,把一个能调用工具、能多步推理、能流式输出的 Agent 跑起来,并且跑得稳、看得见每步在干嘛。
1.3 关键选型清单:JDK 21 + 虚拟线程 + Jackson
依赖越少,维护成本越低。JavaManus 核心模块最终只依赖三样东西:
- JDK 21:虚拟线程、模式匹配、Record 语法,全是 Java Agent 开发的利器
- Jackson:JSON 序列化、反序列化、JSON Schema 生成
- SLF4J + 一个日志实现:观测 Agent 每一步的行为,这是排障的基础
选完这个清单我反而是松了口气。因为这意味着整个框架的核心代码量是可控的,任何一个 Java 开发者拿到源码都能读得懂、改得动。比起引入一个几百 MB 的第三方框架,自己写这套东西换来的是完全可控。
2. 先搭骨架:JavaManus 核心模块与数据模型设计
2.1 六个核心模块的职责划分
JavaManus 的整体架构我分了六个模块,每个模块做一件事,模块之间靠接口通信,不互相依赖具体实现。这六个模块分别是:
- 模型层(model):定义 Message、ToolCall、Completion 等基础数据结构
- 接入层(client):封装与大模型的 HTTP 通信,支持流式与非流式
- 工具层(tool):提供工具定义注解、工具注册表、参数校验器
- 引擎层(engine):Agent 主循环,负责“调模型→执行工具→回顾结果→再调模型”
- 上下文层(context):管理多轮对话消息,做裁剪和压缩
- 观测层(observe):记录每次调用的 token 用量、耗时、中间输出
这里最关键的设计决策是:引擎层不关心你用的是哪家大模型。接入层对外暴露一个统一的 ChatClient 接口,所有模型差异都被挡在接口后面。模型侧输出的是“最终回答”还是“工具调用请求”,数据结构上做了统一。这样整体框架就变成了一条干净的流水线,哪一环想换,替换对应模块即可。
2.2 核心数据模型设计与代码实现
先上代码。整个框架的数据模型我全部用 Java Record 实现,不可变对象天然适合在多线程、多轮迭代中安全传递。
定义基础消息结构:
public record Message( String role, // system / user / assistant / tool String content, List<ToolCall> toolCalls, String toolCallId ) { public static Message user(String content) { return new Message("user", content, null, null); } public static Message system(String content) { return new Message("system", content, null, null); } public static Message toolResult(String content, String toolCallId) { return new Message("tool", content, null, toolCallId); } }定义工具调用结构:
public record ToolCall( String id, String name, String arguments // 模型返回的参数,是 JSON 字符串 ) {}定义模型返回的统一结构:
public record Completion( boolean finalAnswer, String content, List<ToolCall> toolCalls, Usage usage ) { public record Usage(int promptTokens, int completionTokens, int totalTokens) {} }这个模型设计的核心洞察在于:把“模型回复”分成两种语义——最终答案和工具调用请求。在 Agent 循环里,这两种语义的处理方式完全不同:最终答案直接返回给用户;工具调用请求则触发工具执行,然后把结果作为新消息喂回模型。用 Record 让这个判断通过finalAnswer布尔值显式表达,后续代码的可读性会大大提高。
2.3 一次完整请求的数据流转
用一个真实场景描述数据流:用户说“帮我查一下北京今天的天气,如果下雨就提醒我带伞”。这条请求在 JavaManus 里会走这样一条路径:
- 系统将用户消息封装成
Message("user", ...),历史上下文合并 - 引擎调用 ChatClient,把消息列表和已注册工具的元信息发给模型
- 模型返回
Completion(finalAnswer=false, toolCalls=[查天气]) - 引擎从注册表找到“查天气”工具,解析参数中的城市名,执行真实 API 调用
- 工具调用结果封装成
Message("tool", ...)追加到上下文 - 引擎带着新上下文再次请求模型
- 模型综合工具结果,返回
finalAnswer=true,给出带伞建议 - 引擎把最终内容返回给调用方
这就是 Agent 最经典的一个闭环。循环的终止条件就两个:模型返回了最终答案,或者迭代次数达到上限。这套模型设计把整个闭环的数据语义统一了,后面怎么加功能都不会乱。
3. 手写核心链路:LLM 接入、工具调用与循环引擎实现
3.1 模型接入层:如何做到供应商无关
接入层是 Agent 框架最容易写崩的地方。各家模型服务的接口细节千差万别:有的走 OpenAI 兼容协议,有的有独立协议;有的支持流式,有的支持工具调用;还有的超时时间、错误码、限流策略都不同。如果接入逻辑散落在业务代码里,换一个模型服务商等于重写一遍。
所以我定义了一个很薄的接口:
public interface ChatClient { Completion chat(List<Message> messages, List<ToolMeta> tools); Stream<CompletionChunk> streamChat(List<Message> messages, List<ToolMeta> tools); }第一版我只实现了 CompatibleClient,对接的是 OpenAI 兼容协议。这个协议现在已经是事实标准,很多部署模型的服务平台都支持它,连本地部署的一些开源模型也能通过这个协议暴露接口。这意味着我的接入层天然能用。后来加新供应商时,只需要新增一个类,实现同一个接口,然后在工厂里注册一下即可。
这里有个细节值得注意:流式请求不能直接返回Completion,得用Stream<CompletionChunk>按块返回。因为工具调用参数可能是逐字逐块吐出来的,你要是等全部拼完才返回,用户体验会感觉非常卡。Java 的Stream在这里做流式输出很顺手,配合对端 SSE 事件流,逐行读取、逐块解析、逐块下推。
3.2 工具注册机制:从 JSON Schema 到注解扫描
工具是 Agent 的执行器官。JavaManus 里一个工具的本质是“一段带元信息的 Java 方法”。元信息包括工具名称、用途描述、参数 JSON Schema,这三个信息是给大模型看的,模型靠它们决定要不要调、传什么参数。
我的设计是让工具定义尽可能少的侵入业务代码,用一个注解搞定:
@AgentTool( name = "weather_query", description = "查询指定城市当前的天气情况,参数 city 为城市名", jsonSchema = """ { "type": "object", "properties": { "city": { "type": "string", "description": "城市名,比如北京" } }, "required": ["city"] } """ ) public class WeatherTool implements Tool { @Override public ToolResult execute(Map<String, Object> args) { String city = (String) args.get("city"); // 这里接真实天气 API 或走 mock return ToolResult.success("北京当前多云,气温 24°C,风力 3 级"); } }工具注册表的核心逻辑是:启动时扫描指定包路径下的@AgentTool注解类,把注解里的 name、description、jsonSchema 反射出来,存进一个 Map。模型请求发出前,引擎把注册表里所有工具的元信息组装成模型需要的格式,一并传给模型;模型返回toolCall时,按名字查表找到对应的 Tool 实例,用 Jackson 把参数 JSON 解析成Map<String, Object>,再反射调用execute。
这个机制有几个容易踩的坑,我后面在踩坑实录里会详说,这里先提醒一个:参数校验不能省。模型大几率会传错参数类型,甚至传一个不在 Schema 里的字段。我后来引入了 JSON Schema 校验库,在execute之前先校验一遍,校验不过直接返回一个错误信息给模型,模型看到错误信息会重新规划参数。
3.3 Agent 主循环与终止策略实现
主循环是整个框架的心脏,简化后的核心逻辑非常直白:
public String run(String userInput) { List<Message> messages = new ArrayList<>(context.getSnapshot()); messages.add(Message.user(userInput)); for (int step = 0; step < maxIterations; step++) { Completion completion = chatClient.chat(messages, toolRegistry.listAll()); log.info("Step {}: finalAnswer={}, toolCalls={}, tokens={}", step, completion.finalAnswer(), completion.toolCalls() == null ? 0 : completion.toolCalls().size(), completion.usage().totalTokens()); if (completion.finalAnswer()) { return completion.content(); } if (completion.toolCalls() != null && !completion.toolCalls().isEmpty()) { for (ToolCall call : completion.toolCalls()) { ToolResult result = toolRegistry.execute(call.id(), call.name(), call.arguments()); messages.add(Message.toolResult(result.content(), call.id())); } continue; } // 模型既没有标记最终答案,也没有工具调用,直接视为兜底结束 return completion.content(); } throw new AgentLoopExceededException("Exceeded max iterations: " + maxIterations); }两个边界条件特别重要。
第一个是maxIterations,我默认设置为 6。你可能会奇怪为什么设这么小,因为一个 Agent 如果连续跑十几步还没收敛,要么是模型在反复犯同一个错误,要么是工具没有正确传递失败信息。让框架快速失败,好过让它空转到资源耗尽。
第二个是“兜底结束”。有时候模型没有输出finalAnswer标记,也没有输出工具调用,这种情况在弱模型上很容易出现。直接返回这个内容虽然不优雅,但比卡死循环要好。我在日志里会记录一条 warn,告诉调用方这次返回可能不完整。
3.4 上下文与记忆管理
每次迭代,我都要把全部消息重新发给模型,token 用量是指数级增长最快的部分。一个简单的多步任务,三轮对话就可能吃掉几万 token。所以上下文管理不是可选项,是刚需。
JavaManus 的上下文策略分三层:
- 第一层是滑动窗口:保留系统提示词 + 最近 N 轮消息,最老的用户/助手消息丢弃
- 第二层是压缩:当窗口内 token 总量仍超过预算,把中间的历史对话一次性用模型摘要成一小段,替换原文
- 第三层是告警:单次调用 token 超过预算时,直接抛出参数错误,而不是硬截断导致 JSON 格式破损
压缩这一层我特别推荐做,因为 Agent 任务中间几步产生的“脏话”很多——工具失败信息、模型错误的中间推理、无效的尝试,这些对最终结果没有价值,但占上下文。做一次摘要,能省掉一半 token,而且还能减少模型被错误历史带偏的概率。
4. 实战演练:用 JavaManus 跑通一个多步任务
4.1 工程初始化与依赖配置
这一节是纯实操环节。我用 Maven 建工程,JDK 版本 21,核心依赖只需要 jackson-databind 和 slf4j-api。如果项目要跑 HTTP,还需要一个轻量的 HTTP 框架,但演示场景用 JDK 自带的HttpServer就够了。
配置文件我用了最直白的application.properties风格,方便无 Spring 环境也能加载:
javamans.llm.base-url=http://127.0.0.1:8080/v1 javamans.llm.model=local-chat-model javamans.llm.api-key=test-key javamans.llm.temperature=0.3 javamans.llm.max-tokens=2048 javamans.agent.max-iterations=6 javamans.agent.system-prompt=你是 JavaManus 助手的调度器,请根据用户问题选择工具并逐步完成。这里的模型我接的是一个本地部署的开源模型,走 OpenAI 兼容协议。用本地模型的好处是调试时不花钱、不限流,几秒一轮,非常适合开发期反复试。你如果要用某家商用大模型,把base-url和api-key换成对应的即可,框架代码一行不用改,这就是接入层抽象带来的好处。
4.2 定义两个真实工具并注册
我注册了三个工具做演示:获取当前时间、查询城市天气、计算两个日期之间的天数。其中“获取当前时间”最简单,用来验证工具调用链路;“查询天气”是模拟数据,背后没有接真实天气 API;“计算日期”用来演示多个工具参数都较长时模型怎么分配。
注册方式有两种,我推荐注解扫描。启动时一行代码:
ToolRegistry registry = ToolRegistry.scan("com.example.demo.tools");扫描后一个非常关键的调试动作是:把注册表里所有工具的元信息打出来看一眼。我试过无数次,模型一直返回“没有合适的工具可调用”,最后发现是工具的description写得太含糊,模型看不懂。工具描述写作是有讲究的,必须写清楚这三个信息:
- 工具是干什么的
- 参数是什么类型、什么含义
- 什么时候该调用、什么时候不该调用
比如天气工具的描述我后来改成了:
查询指定城市当天的天气情况,包括温度、天气状况、风力。当用户询问天气、要不要带伞、适不适合户外活动时使用。城市参数必须是中文名称,例如“北京”。
模型看到这种描述,调用准确率高很多。这个细节是纯经验,写死的代码里体现不出来,但直接影响体验。
4.3 执行 Agent 并观察日志
下面是启动 Agent 并执行的代码:
AgentEngine engine = AgentEngine.builder() .chatClient(factory.create()) .toolRegistry(registry) .context(new SlidingWindowContext(12, 3000)) .build(); String answer = engine.run("北京今天下雨吗?如果下雨我该穿什么?"); System.out.println(answer);运行后典型日志如下:
14:03:21.201 [main] INFO engine - Step 0: finalAnswer=false, toolCalls=1, tokens=186 14:03:21.203 [main] INFO tool - Execute weather_query, args={"city":"北京"} 14:03:22.104 [main] INFO tool - Weather tool success, costMs=901 14:03:22.205 [main] INFO engine - Step 1: finalAnswer=true, toolCalls=0, tokens=426 14:03:22.206 [main] INFO engine - Final answer: 北京当前多云,气温 24°C,风力 3 级,不需要带伞。建议穿短袖。看到 Step 0 表示模型决定调用工具,Step 1 表示模型已经收到工具结果并给出最终答案。这个日志结构非常重要,它的价值在于:每一轮到底是哪里出了问题,能一眼定位。如果 Step 0 之后没有 Step 1,说明工具执行可能报错了;如果工具执行成功但 Step 1 的toolCalls还是大于 0,说明模型对工具结果不满意,还在尝试调用其他工具。
这个调试体验比直接看一个黑盒应答舒服太多。我甚至会在生产环境保留同样的日志输出,方便值班同学快速定位是模型问题还是工具问题。
5. 填坑实录:我踩过的 5 个典型问题与解决办法
5.1 问题一:模型返回的 JSON 格式不稳定
这是我碰到最多的问题,没有之一。模型在返回工具调用参数时,偶尔会把 JSON 包装在 Markdown 代码块里,偶尔会多出一行解释文字,偶尔会用裸True/False而不是 JSON 标准的true/false。直接丢给 Jackson 解析,有一半概率抛异常。
我的解决方案是在解析工具调用参数之前,先做一层“容错清洗”:
static String sanitize(String raw) { String s = raw.trim(); // 去掉包裹的 markdown 代码块标记 if (s.startsWith("```")) { s = s.replaceFirst("^```[a-zA-Z]*\\n?", "").replaceFirst("\\n?```$", ""); } // 截取第一个 { 到最后一个 } 之间的内容 int begin = s.indexOf('{'); int end = s.lastIndexOf('}'); if (begin >= 0 && end > begin) { s = s.substring(begin, end + 1); } return s; }再配合 Jackson 的FAIL_ON_TRAILING_TOKENS关闭和UNKNOWN_PROPERTIES忽略,基本能覆盖绝大多数脏输出。解析失败时我还会把原始字符串原样记到 warn 日志里,方便定位是不是模型抽风了。
5.2 问题二:多轮对话上下文快速膨胀
实测过一个最夸张的任务,要求 Agent 连续查三个城市天气再订一个会议室,整个对话只有 5 步,但上下文 token 从 200 涨到了 9000 多。原因有两个:一是每轮工具结果都在累积,二是模型生成的 reasoning 内容也会叠进下一轮的 prompt。
我后来做了两件事。第一件是把系统提示词单独拎出来,永远不清除;第二件是设定一个硬预算,当消息列表总 token 超过预算时,把最前面的非系统、非最近一轮消息做摘要压缩。摘要是由模型自己生成的,压缩效果非常好,但注意摘要消耗的 token 要计算进当次调用的 budget,避免出现“为了省 token 反而超 token”的尴尬。
5.3 问题三:多个工具并行调用时的超时与限流
当模型一次返回两个以上工具调用时,串行执行会拖慢整体响应。比如“查三个城市的天气”,如果串行,最坏情况是 3 倍的单次查询耗时。Java 21 的虚拟线程在这里直接派上用场:
List<Callable<ToolResult>> tasks = calls.stream() .map(call -> (Callable<ToolResult>) () -> registry.execute(call.id(), call.name(), call.arguments())) .toList(); List<ToolResult> results = Executors.newVirtualThreadPerTaskExecutor() .invokeAll(tasks, 10, TimeUnit.SECONDS);用invokeAll的带超时重载非常重要,不然某个外部 API 迟迟不返回,整个 Agent 卡死。超时未返回的工具,我会往上下文里塞一条“工具执行超时”的结果消息,让模型自己决定是重试还是换一条路,而不是直接抛异常打断整个对话。
5.4 问题四:流式中断导致连接泄漏
我踩过最隐蔽的坑。流式输出时,客户端如果中途断开(比如用户切走页面),我这边虽然感知到SseEmitter的异常,但没有及时关闭底层 HTTP 连接和虚拟线程池。结果跑一上午,连接数暴涨,服务被拖垮。
解决办法是统一封装一个StreamingSession类,在finally块里无论正常结束还是异常中断,都强制关闭连接、中断线程、释放资源。顺手在日志里记录中断次数,如果在生产环境频繁出现,就说明前端超时时间设得太短,需要前后端一起调参。
5.5 问题五:工具执行失败的错误信息被模型忽略
刚开始工具执行失败,我返回的错误信息写得很干瘪,比如“查询失败”。模型拿到这个结果后,经常不知道下一步该怎么办,会傻乎乎地再调同一个工具,反复失败直到迭代上限。
后来我把错误信息改成“带原因 + 带建议”的格式:
天气查询失败:城市参数“Beijing”无法识别。请使用中文城市名,例如“北京”。当前支持查询的城市列表:北京、上海、广州。
这么一改,模型在下一轮就能自我纠错,或者直接向用户说明情况。这个经验我认为是 Agent 框架里非常值钱的一个细节——工具结果里必须有让模型“接得住”的信息,干瘪的 success/failure 只会让 Agent 变笨。
写在最后的个人建议
做完 JavaManus 这段实践,我最深的体会是:写 Agent 框架最难的不是调用大模型,而是把每一环的边界和容错设计好。模型是不可控的,工具是可失败的,网络是不稳定的,你能做的就是把每一层都设计成“可观测、可降级、可重试”。如果你也想从零写一个自己的 Java Agent 框架,我的建议是先别想着做大全,把一个“模型→工具→模型”的闭环跑通,再逐步加记忆、加多工具并行、加流式输出,每一步都配上日志,你会发现自己对 AI 应用的理解比调包要深得多。后续我还打算给 JavaManus 加上长期记忆存储和简单的多 Agent 通信能力,到时候再回来分享。