1. 为什么我要用 Java 从头造一个 AI Agent 框架
先说说背景。过去大半年,AI Agent 这个词几乎成了技术圈的硬通货,各种演示视频里,一个模型能自己规划任务、调用工具、读写文件、甚至操作浏览器,看起来像是给大模型装上了手脚。但如果你真去翻开源实现,会发现一个很尴尬的现实:绝大多数 Agent 框架都是 Python 写的。LangChain、AutoGPT、MetaGPT、CrewAI,清一色 Python。Python 在这个领域确实有先发优势,生态成熟、胶水能力强、迭代快,这些我都认。
问题在于,真正要把 Agent 落到企业级的生产环境里,Python 的那套东西就开始露怯了。我所在的团队维护着一套跑了七八年的 Java 微服务体系,订单、风控、结算、消息队列全是 Spring 生态,监控用的是 Micrometer 加 Prometheus,配置中心、服务注册、链路追踪一应俱全。你让我在这种体系里塞一个 Python 的 Agent 服务进去,先不说运维同学会不会翻白眼,光是跨语言的服务治理、线程模型、内存管理、依赖隔离这几件事,就够喝一壶的。更别提很多企业的核心业务逻辑本身就封装在 Java 的 SDK 和内部库里,Python 想调只能走 HTTP 或者 RPC,绕一大圈,延迟和稳定性都受影响。
所以我的想法很直接:既然 Java 生态这么完整,为什么不干脆用 Java 从零构建一个 AI Agent 框架?这就是 JavaManus 这个项目的由来。名字里的 Manus 取自拉丁语里“手”的意思,寓意给大模型装上一双能干活的手。它不是要做一个 Python 框架的翻译版,而是想认认真真回答一个问题:在一个强类型、多线程、面向企业级治理的语言环境里,一个 Agent 框架应该长什么样?
这篇文章我会把整个设计和实现过程拆开讲。从整体架构怎么定、核心模块怎么切、工具调用怎么设计、记忆和规划怎么做,到实际跑起来踩过的坑,我都会尽量讲透。适合两类人看:一类是 Java 后端工程师,想在自己的技术栈里引入 Agent 能力但不知道从哪下手;另一类是对 Agent 原理感兴趣,想看看抛开 Python 那套惯用写法之后,一个 Agent 框架的骨架到底由哪些部件组成。哪怕你暂时不写 Java,读完也能对 Agent 的工程实现有个更立体的认识。
2. 整体架构设计与技术选型思路
2.1 一个 Agent 框架到底需要哪些核心部件
在动手写第一行代码之前,我花了大概一周时间把主流 Agent 框架的源码翻了一遍,试图抽象出一个最小可用的核心模型。翻完之后我发现,不管框架大小,一个能真正干活的 Agent,本质上就是四个部件的循环协作:大脑(LLM 调用层)、手脚(工具调用层)、记忆(上下文管理层)、调度(执行循环层)。
大脑负责和模型对话,把自然语言指令翻译成结构化的动作意图。手脚负责真正去执行这些动作,比如查数据库、发请求、读写文件。记忆负责在有限的上下文窗口里,决定哪些历史信息该保留、哪些该压缩、哪些该丢弃。调度则是那个把所有东西串起来的循环:思考、行动、观察结果、再思考,直到任务完成或者触发终止条件。
这个模型听起来简单,但真正难的地方在于每一层的边界怎么划。比如工具调用的结果要不要进记忆?进了记忆之后怎么保证不把上下文撑爆?模型返回的 JSON 解析失败了怎么办,是重试还是降级?这些问题在 Python 框架里往往被动态类型的灵活性掩盖了,但在 Java 里,你必须在编译期就把这些契约想清楚。这其实是个好事,强类型逼着你把每个接口的输入输出定义明白,后期维护成本低很多。
2.2 为什么不用现成的 Java Agent 库
市面上不是没有 Java 的 Agent 相关库,比如 Spring AI 就提供了一些模型调用的封装,还有一些公司开源的轻量 Agent 工具。我评估过几个,最后决定自己写,原因有三个。
第一是可控性。Agent 的执行循环是整个框架的心脏,它的重试策略、超时控制、并发模型直接决定了系统的稳定性。用别人的库,你只能在其给定的扩展点上做文章,一旦遇到它没考虑到的场景,比如需要在一个步骤里并行调用多个工具再聚合结果,就会很别扭。自己写的话,整个循环的逻辑完全透明,想怎么改就怎么改。
第二是依赖治理。很多现成的库会引入一大堆传递依赖,版本冲突在企业项目里是家常便饭。JavaManus 我刻意把核心依赖压到最低,除了 JSON 处理和一个 HTTP 客户端,几乎不依赖别的东西。模型调用层做成可插拔的接口,你想接哪家的模型自己实现就行,框架本身不绑定任何厂商。
第三是学习价值。说实话,造轮子这件事,如果只是为了用,确实没必要。但如果你想把 Agent 的原理吃透,自己从零实现一遍是最快的路径。每当你被迫去解决一个具体问题,比如“工具调用的参数怎么从模型输出里安全地反序列化”,你对整个机制的理解就会深一层。
2.2 分层架构的划分
最终我把 JavaManus 分成了五层,从下往上依次是:
| 层级 | 名称 | 职责 |
|---|---|---|
| L1 | 模型接入层 | 统一不同 LLM 提供商的调用接口,处理流式与非流式响应 |
| L2 | 工具层 | 定义工具契约、注册机制、参数校验与执行沙箱 |
| L3 | 记忆层 | 上下文窗口管理、历史压缩、向量检索(可选) |
| L4 | 执行引擎层 | Agent 主循环、规划、反思、终止条件判断 |
| L5 | 应用层 | 对外暴露的 API、会话管理、可观测性埋点 |
这个分层的关键在于依赖方向单一,上层依赖下层,下层不知道上层的存在。模型接入层完全不知道工具的存在,工具层也不关心是谁在调度它。这样做的好处是每一层都可以独立测试和替换。比如我想把模型从一家换成另一家,只需要在 L1 加一个实现类,上面四层一行代码都不用动。
2.3 技术选型的几个关键决策
选型这块我纠结最久的是三件事:JSON 库、HTTP 客户端、并发模型。
JSON 库我最后选了 Jackson。原因很简单,它在 Java 生态里最成熟,对泛型和多态的支持最好,而 Agent 框架里到处都是需要反序列化成不同工具参数对象的场景,Jackson 的TypeReference和多态反序列化能省很多事。虽然它比一些轻量库重一点,但这点开销在 Agent 这种 IO 密集型的场景里可以忽略。
HTTP 客户端我用了 JDK 11 自带的HttpClient。这个决定可能有点反直觉,很多人会选 OkHttp 或者 Apache HttpClient。我选 JDK 自带的原因是想把外部依赖降到最低,而且HttpClient支持异步和流式响应,对于处理 LLM 的 SSE 流式输出完全够用。实测下来性能也不差,没必要为了一个 HTTP 客户端再引一个第三方库。
并发模型是重点。Agent 的执行循环天然是串行的:思考、行动、观察,一步一步来。但工具执行本身可能是并行的,比如一个步骤里需要同时查三个数据源。所以我用了CompletableFuture来做工具层的并行编排,主循环保持单线程顺序执行。这样既保证了 Agent 决策逻辑的确定性,又能在工具层榨取并发性能。线程池的大小我设成了 CPU 核数的两倍,因为工具调用大多是 IO 等待,线程多一点能提高吞吐。
3. 核心模块的深度拆解与实现要点
3.1 模型接入层:如何优雅地屏蔽不同厂商的差异
模型接入层要解决的核心问题是:不同厂商的 API 长得完全不一样。有的用messages数组,有的用prompt字符串;有的工具调用返回tool_calls字段,有的叫function_call;流式响应的格式更是五花八门。如果让上层直接面对这些差异,代码会变成一堆 if-else。
我的做法是定义一个统一的ChatModel接口,把差异全部收敛在实现类里:
public interface ChatModel { ChatResponse chat(ChatRequest request); void chatStream(ChatRequest request, StreamCallback callback); }ChatRequest里封装了消息列表、工具定义、温度、最大 token 这些通用参数。ChatResponse里则是统一后的结果:要么是文本回复,要么是一组工具调用请求。每个厂商写一个实现类,在实现类里做请求和响应的双向转换。
这里有个细节值得说:工具定义的格式转换。Agent 框架里工具是用 Java 类描述的,但发给模型时必须转成模型能理解的 JSON Schema。我在工具层定义了一套注解,比如@ToolParam标注参数描述,然后写了一个 Schema 生成器,把 Java 类反射成 JSON Schema。这个生成器是模型无关的,生成出来的 Schema 再由各个模型实现类转成自己需要的格式。这样加一个新工具,只需要写一个 Java 类加注解,Schema 自动生成,非常省心。
注意:反射生成 Schema 的时候一定要处理嵌套对象和数组,否则遇到复杂参数类型会生成错误的 Schema,模型调用工具时就会传错参数。我一开始就踩了这个坑,一个接收
List<Filter>的工具死活调不对,排查半天才发现是 Schema 里数组的 items 没写对。
3.2 工具层:契约、注册与安全执行
工具层是整个框架里我最花心思的部分,因为它是 Agent 真正“干活”的地方,也是最容易出安全问题的地方。
先说契约。一个工具在 JavaManus 里就是一个实现了Tool接口的类:
public interface Tool { String name(); String description(); String parameterSchema(); ToolResult execute(Map<String, Object> params); }name和description是给模型看的,模型靠这两个信息决定什么时候调用这个工具。parameterSchema是参数的 JSON Schema。execute是真正的执行逻辑,接收反序列化后的参数,返回结果。
工具注册用一个ToolRegistry来管理,支持按名字查找。这里我加了一个命名空间的设计,工具名可以带前缀,比如db.query、file.read,避免不同模块的工具重名。注册的时候会做一次校验,确保 Schema 是合法的 JSON Schema,名字不重复。
安全执行是重点。工具执行可能抛异常、可能超时、可能返回超大结果。我在ToolRegistry外面包了一层ToolExecutor,统一处理这些情况:
- 超时控制:每个工具可以配置超时时间,默认 30 秒,超时直接中断并返回错误结果给模型。
- 异常捕获:工具抛出的任何异常都被捕获,转成结构化的错误信息返回给模型,让模型有机会自我修正。
- 结果截断:如果工具返回的结果超过一定长度(比如 8000 字符),自动截断并提示模型结果被截断,避免撑爆上下文。
- 参数校验:执行前用 Schema 校验参数,不合法直接拒绝,不浪费一次模型调用。
实操心得:结果截断这个功能看起来不起眼,但在实际使用中救了我好几次。有一次一个工具返回了几万行的日志,如果不截断,直接就把上下文窗口撑爆了,模型后面的对话全部报错。截断之后,模型看到提示会主动缩小查询范围,问题自然就解决了。
3.3 记忆层:上下文窗口的精细化管理
上下文窗口是 Agent 最稀缺的资源。模型的窗口再大也是有限的,而 Agent 跑长任务时,历史消息会迅速堆积。记忆层要做的就是在这个有限空间里,尽可能保留对当前任务有用的信息。
我把记忆分成了三层:
第一层是短期记忆,就是最近几轮的对话,原样保留。这部分信息最新鲜,对当前决策影响最大。
第二层是摘要记忆,把较早的历史消息压缩成一段摘要。压缩的触发条件是 token 数超过阈值,比如窗口的 70%。压缩的时候我会保留工具调用的关键结果,丢弃中间的思考过程,因为思考过程对后续决策的价值不大。
第三层是长期记忆,可选,用向量检索实现。把重要的历史信息存进向量库,需要的时候按语义相似度召回。这一层我做成可插拔的,不强制使用,因为引入向量库会增加部署复杂度。
token 计数这块有个坑:不同模型的 tokenizer 不一样,精确计数需要引入对应的 tokenizer 库。为了保持轻量,我用了一个近似算法,按字符数除以一个系数来估算。中文大概 1.5 个字符一个 token,英文大概 4 个字符一个 token。这个估算不精确,但用于触发压缩足够了,反正压缩阈值本身也留了余量。
3.4 执行引擎:Agent 主循环的设计
执行引擎是框架的心脏,它把前面三层串起来,形成一个完整的“思考-行动-观察”循环。核心逻辑用伪代码表示大概是这样:
while (!finished && step < maxSteps) { ChatResponse response = model.chat(buildContext()); if (response.hasToolCalls()) { List<ToolResult> results = toolExecutor.executeAll(response.toolCalls()); memory.add(results); } else { // 模型给出了最终答案 finished = true; } step++; }看起来简单,但魔鬼在细节里。我列几个关键的设计点。
终止条件。除了模型主动给出最终答案,还要有兜底:最大步数限制(防止死循环)、总超时限制(防止任务卡死)、连续错误次数限制(防止模型反复调用同一个失败的工具)。这几个条件任何一个触发,循环都会终止并返回当前状态。
工具调用的并行执行。模型一次可能返回多个工具调用,如果这些工具之间没有依赖关系,可以并行执行。我用CompletableFuture.allOf来编排,等所有工具执行完再一起把结果加进记忆。这里要注意,并行执行的结果顺序要和模型请求的顺序一致,否则模型会困惑。
反思机制。我加了一个可选的反思步骤:当工具执行失败时,不直接把错误丢给模型,而是先让模型分析一下失败原因,再决定下一步。这个机制能显著提高复杂任务的完成率,代价是多一次模型调用。我把它做成了配置项,简单任务关掉,复杂任务打开。
踩过的坑:一开始我没做最大步数限制,结果有一次模型陷入了一个死循环,反复调用同一个查询工具,因为查询结果总是空的,它就一直重试。跑了几十步才被我发现,白白烧了一堆 token。加上步数限制之后,这种情况最多跑 20 步就停了,损失可控。
4. 完整实操:从零跑通一个 Agent 任务
4.1 环境准备与依赖配置
先把项目骨架搭起来。我用 Maven 管理依赖,核心依赖就三个:
<dependencies> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>2.17.0</version> </dependency> <dependency> <groupId>org.slf4j</groupId> <artifactId>slf4j-api</artifactId> <version>2.0.12</version> </dependency> <dependency> <groupId>org.slf4j</groupId> <artifactId>slf4j-simple</artifactId> <version>2.0.12</version> </dependency> </dependencies>JDK 版本要求 17 以上,因为用到了 record 和一些新的集合 API。项目结构按前面说的五层来分:
javanus/ ├── model/ # L1 模型接入层 ├── tool/ # L2 工具层 ├── memory/ # L3 记忆层 ├── engine/ # L4 执行引擎层 └── app/ # L5 应用层4.2 实现一个最简单的工具
我们从一个最简单的工具开始:一个计算器,接收两个数字和一个运算符,返回计算结果。这个工具足够简单,能让我们把整个链路跑通。
public class CalculatorTool implements Tool { @Override public String name() { return "calc.evaluate"; } @Override public String description() { return "执行简单的四则运算,输入两个数字和一个运算符(+ - * /)"; } @Override public String parameterSchema() { return """ { "type": "object", "properties": { "a": {"type": "number", "description": "第一个操作数"}, "b": {"type": "number", "description": "第二个操作数"}, "op": {"type": "string", "enum": ["+", "-", "*", "/"], "description": "运算符"} }, "required": ["a", "b", "op"] } """; } @Override public ToolResult execute(Map<String, Object> params) { double a = ((Number) params.get("a")).doubleValue(); double b = ((Number) params.get("b")).doubleValue(); String op = (String) params.get("op"); double result = switch (op) { case "+" -> a + b; case "-" -> a - b; case "*" -> a * b; case "/" -> { if (b == 0) throw new IllegalArgumentException("除数不能为零"); yield a / b; } default -> throw new IllegalArgumentException("不支持的运算符: " + op); }; return ToolResult.success(String.valueOf(result)); } }注意execute里对除零的处理。工具内部抛异常是允许的,ToolExecutor会捕获它并转成错误结果返回给模型。模型看到“除数不能为零”这个提示,下次就会换个参数。
4.3 组装 Agent 并运行
把工具注册进去,配好模型,就可以跑起来了:
ToolRegistry registry = new ToolRegistry(); registry.register(new CalculatorTool()); ChatModel model = new OpenAiCompatibleModel( "https://api.example.com/v1/chat/completions", System.getenv("API_KEY"), "your-model-name" ); Agent agent = Agent.builder() .model(model) .toolRegistry(registry) .maxSteps(15) .timeout(Duration.ofMinutes(3)) .build(); String result = agent.run("帮我算一下 (123 * 456) + 789 等于多少"); System.out.println(result);跑起来之后,你会看到 Agent 的执行日志:模型先思考,决定调用calc.evaluate计算 123 乘 456,拿到结果 56088,然后再调用一次计算 56088 加 789,最后给出答案 56877。整个过程模型自己规划了两步,我们只提供了一个工具,没有写任何编排逻辑。
4.4 参数计算与关键配置的取值依据
几个关键参数的取值我解释一下背后的逻辑。
maxSteps 设为 15。这个数字不是拍脑袋定的。我统计过一批典型任务,简单查询类任务平均 2 到 3 步,中等复杂度的多步推理任务平均 5 到 8 步,超过 15 步的任务基本可以判定为模型陷入了循环或者任务本身描述不清。设 15 是一个平衡点,既能覆盖绝大多数正常任务,又能在异常时及时止损。
工具超时设为 30 秒。这个取决于工具的性质。查询类工具通常几百毫秒就返回了,30 秒是给那些可能慢的接口留的余量。如果某个工具确实需要更长时间,可以在工具级别单独配置。
上下文压缩阈值设为窗口的 70%。留 30% 的余量是因为压缩本身也需要消耗 token,而且压缩后的摘要加上后续对话,需要保证还有空间。如果设成 90%,很可能压缩完发现还是超了,就得再压一次,浪费。
线程池大小设为 CPU 核数乘 2。工具调用是 IO 密集型,线程在等待网络响应时是阻塞的,所以线程数可以超过核数。乘 2 是个经验值,实测下来对于几十个并发工具调用的场景够用。如果工具调用特别频繁,可以调大,但要注意下游服务的承受能力。
5. 常见问题排查与避坑经验实录
5.1 模型返回的 JSON 解析失败怎么办
这是最常见的问题。模型有时候会在 JSON 外面包一层 markdown 代码块,有时候会多写一个逗号,有时候干脆返回一段自然语言而不是 JSON。我的处理策略是分级降级:
第一级,尝试直接解析。第二级,如果失败,用正则把 JSON 部分抠出来再解析。第三级,如果还失败,把解析错误信息返回给模型,让它重新生成。第四级,连续失败三次,终止任务并报错。
这里有个技巧:在系统提示词里明确要求模型“只返回 JSON,不要有任何其他文字”,能大幅降低解析失败率。但也不能完全依赖提示词,解析层的容错必须做。
5.2 工具调用陷入死循环的排查
死循环的表现是 Agent 反复调用同一个工具,参数几乎一样,结果也一样。原因通常有两个:一是工具返回的结果模型理解不了,它以为没成功就重试;二是任务本身无解,模型在瞎试。
排查方法很简单,把执行日志打出来,看连续几步的工具调用和结果。如果是第一种,检查工具返回的结果格式是不是清晰,错误信息是不是明确。如果是第二种,说明任务描述有问题,需要人工介入。
预防措施就是前面说的最大步数限制,加上一个“相同工具相同参数连续调用超过 3 次就强制终止”的检测。
5.3 上下文爆炸的应急处理
上下文爆炸的典型症状是模型开始返回 400 错误,提示 token 超限。这时候如果任务还没完成,就很尴尬。我的应急处理是:立即触发一次强制压缩,把历史消息压到最小,然后重试当前步骤。如果压缩后还是超限,说明单条消息本身就太大了,那就只能截断单条消息。
根本的解决办法还是做好预防:工具结果截断、定期压缩、控制单条消息长度。我建议在开发阶段就把 token 计数打点,监控上下文的使用率,超过 80% 就告警。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 | 解决方案 |
|---|---|---|---|
| 模型不调用工具,直接回答 | 工具描述不清或提示词没强调 | 检查工具 description 和系统提示词 | 优化工具描述,在提示词里明确要求优先使用工具 |
| 工具参数解析失败 | Schema 定义有误或模型输出格式不对 | 打印模型原始输出和 Schema | 修正 Schema,增加解析容错 |
| 任务跑很久不结束 | 死循环或工具超时 | 看执行日志的步数和工具耗时 | 加步数限制和工具超时 |
| 结果不准确 | 工具返回错误或模型理解偏差 | 核对工具返回的原始数据 | 修正工具逻辑,优化结果格式 |
| 并发工具调用结果错乱 | 结果顺序与请求顺序不一致 | 检查并行执行的编排逻辑 | 保证结果按请求顺序聚合 |
5.5 几个我踩过的坑
第一个坑是工具描述写得太技术化。我一开始写工具描述用的是给程序员看的语言,比如“执行 SQL 查询并返回 ResultSet”。模型根本不知道 ResultSet 是什么,调用率很低。后来改成“根据 SQL 语句查询数据库,返回查询到的数据行”,调用率立刻上去了。工具描述是写给模型看的,要用模型能理解的自然语言。
第二个坑是忽略了模型的输出长度限制。有些模型对单次输出的 token 数有限制,如果工具调用的参数特别长,模型可能输出到一半就被截断了,导致 JSON 不完整。解决办法是在提示词里提醒模型参数要简洁,同时在解析层做好截断检测。
第三个坑是线程安全问题。工具注册表我用的是HashMap,在多线程环境下并发注册和查找会出问题。后来换成了ConcurrentHashMap。这个坑很隐蔽,单线程测试完全正常,一上并发就偶发失败。
第四个坑是日志打太多导致性能下降。Agent 执行过程中会产生大量日志,如果把每一步的完整上下文都打出来,日志量会非常恐怖,而且序列化上下文本身也耗性能。我的做法是分级打日志,正常流程只打关键节点,调试模式下才打完整上下文。
6. 这套框架还能怎么扩展
写到这里,JavaManus 的核心已经能跑通一个完整的 Agent 任务了。但一个框架的生命力在于扩展性,我留了几个明确的扩展点,这里也分享一下思路。
多 Agent 协作。目前的执行引擎是单 Agent 的,但接口设计上已经预留了空间。可以定义一个AgentTeam,里面包含多个 Agent,每个 Agent 有自己的工具集和角色定位,通过一个协调者来分配任务。这个模式适合复杂任务,比如一个 Agent 负责调研,一个负责写代码,一个负责测试。
持久化会话。现在的记忆是内存态的,进程重启就没了。可以加一个MemoryStore接口,把记忆持久化到数据库或者 Redis,支持会话恢复。这个对企业应用很重要,用户不希望每次打开都要重新描述任务。
可观测性增强。Agent 的执行过程是个黑盒,出了问题很难排查。可以接入 OpenTelemetry,把每一步的思考、工具调用、结果都做成 span,这样在链路追踪系统里就能看到完整的执行链路。对于生产环境,这个几乎是必须的。
工具生态。框架本身只提供工具契约,具体工具可以做成插件包。比如数据库工具包、文件工具包、HTTP 工具包,按需引入。这样框架保持轻量,用户按需组合。
流式输出。现在的结果是等任务全部完成才返回,用户体验不够好。可以改成流式,模型每产生一个 token 就推给前端,让用户看到 Agent 的实时思考过程。这个在交互式场景里体验提升很明显。
我个人在实际操作中的体会是,造 Agent 框架这件事,难点从来不在模型调用本身,而在那些工程细节:上下文怎么管、工具怎么安全执行、循环怎么不失控、错误怎么优雅降级。这些东西没有捷径,只能一个个场景去踩、去调。JavaManus 现在还不完美,但它的每一行代码我都知道为什么这么写,出了问题我也知道去哪找。这种掌控感,是直接用现成框架换不来的。如果你也在 Java 生态里折腾 Agent,希望这篇东西能帮你少走点弯路。