☰
JavaManus:用Java从零构建轻量级AI Agent框架
2026/10/22 4:24:11 网站建设 项目流程

做 Agent 框架这件事,我一直觉得不该只是 Python 圈的专利。Java 生态里其实有一大批人想做 AI 应用,但一搜教程全是 Python 写的,要么让你上 LangChain,要么让你装 Conda 环境,对于长期在 Spring 和 JVM 体系里干活的人来说,多少有点水土不服。所以我自己动手写了一个轻量级的 Agent 框架,取名 JavaManus。manus 在拉丁语里是"手"的意思,起这个名字是想提醒自己:框架这东西,光看不练没用,必须亲手拆开再拼起来。

这篇文章会完整拆解 JavaManus 从零到能跑通一个任务的整个过程。内容覆盖架构分层、核心接口设计、模型接入、工具调用、上下文管理和一轮完整的 Agent 执行循环,同时会把我在开发过程中踩过的坑和排查套路一并写出来。无论你是想在 Java 项目里内嵌一个 Agent,还是单纯想搞懂 AI Agent 的内部机制,这篇都应该能给你提供一份可直接抄作业的参考。

1. 项目定位与整体架构

1.1 为什么我用 Java 而不是 Python 来写 Agent 框架

很多人都觉得 AI 相关的东西天生属于 Python,这话在科研和算法验证阶段没错,但落地到工程系统里就另说了。我所在的团队长期维护的是基于 Java 的微服务集群,数据源、权限体系、消息中间件全都是 JVM 生态的东西。如果为了接一个 Agent 功能,单独起一套 Python 服务,意味着运维要管两套环境、两套监控、两套发布流程,成本直接翻倍。

Java 这边有个常被忽视的优势:Java 21 引入的虚拟线程,让 IO 密集型任务的并发能力上了一个台阶。Agent 应用恰好就是典型的 IO 密集型场景——大部分时间都花在等大模型接口返回、等工具执行结果上,真正占 CPU 的计算少得可怜。虚拟线程可以很轻松地拉起上千个并发调用而不需要吃满线程资源,这一点在构建多 Agent 协作场景时非常重要。

另外,Java 的类型系统在 Agent 框架里其实是个隐形优势。工具的参数定义、调用结果、上下文消息,这些在 Python 里往往是裸字典,全靠文档约束。到了 Java 里,用 record 定义不可变消息结构,用接口约束工具行为,编译期就能挡住一大批低级错误。Agent 框架的逻辑分支非常多,没有编译器帮忙兜底,光靠运行时排查会非常痛苦。

1.2 JavaManus 的整体分层设计

我在设计 JavaManus 时没有一上来就堆功能,而是先画了一条清晰的分层边界。整体分为五层:模型接入层、记忆管理层、工具层、编排层和交互层。每层只依赖下一层,不允许跨层调用,这样后续换模型、加工具、改策略都不会牵一发动全身。

分层核心职责关键抽象典型实现
交互层接收用户输入、展示执行过程会话接口控制台交互、HTTP 接口
编排层控制 Agent 循环、任务拆解AgentLoop、Planner顺序执行、计划-执行模式
工具层注册与执行外部能力Tool计算器、HTTP 请求、文件读写
记忆层管理消息历史与上下文裁剪AgentMemory窗口记忆、摘要记忆
模型层统一访问大模型 APIChatModelOpenAI 兼容协议适配器

分层之后,最直接的收益是测试变得好写了。模型层可以被一个模拟实现替代,返回固定的 JSON,这样我就能在没有网络、没有 API Key 的情况下单独验证编排层的逻辑。工具层也能单独注入 mock,验证执行循环对工具结果的正确处理。这在 Agent 这种不确定性很强的系统里太重要了。

1.3 四个核心接口如何定义

JavaManus 的核心抽象只有四个接口:ChatModel、Tool、AgentMemory、AgentLoop。我刻意控制接口数量,因为抽象一旦多了,新手会迷失,维护者也会头疼。

public interface ChatModel { // 非流式对话,返回消息与可能的工具调用 ChatResponse chat(List<ChatMessage> messages, List<ToolSpec> tools); } public interface Tool { String name(); // 供模型识别的工具名 String description(); // 自然语言描述,决定模型是否会调用 JsonNode parameters(); // JSON Schema 格式的参数定义 JsonNode execute(JsonNode arguments); // 实际执行逻辑 } public interface AgentMemory { void addUserMessage(String text); void addAssistantMessage(String text); void addToolMessage(String callId, String result); List<ChatMessage> snapshot(); void compactIfNeeded(); } public interface AgentLoop { String run(String userInput); }

ChatModel 里的 chat 方法我把工具列表也传进去,这是 Agent 和普通聊天最大的区别。模型看到工具描述后,不是直接回答,而是返回"我要调用某个工具",编排层拿到这个意图再去执行,把结果回传给模型继续推理。Tool 接口的参数用 JsonNode 而不是强类型对象,因为模型返回的参数本质上是一个不稳定的 JSON,强类型绑定反而会让框架变得脆弱。AgentMemory 用快照的方式把内部消息列表暴露给调用方,里面的 message 对象不可变,避免执行循环中不小心改了历史数据。

2. 核心模块实现与关键原理

2.1 模型接入网关怎么做到统一 API

市面上的大模型接口风格五花八门,但主流厂商基本都提供 OpenAI 兼容协议。JavaManus 的策略是只对接一种协议,用最小的适配成本覆盖最多的模型。如果你要接私有化部署的模型,只要它支持兼容协议,一行配置就能切过去。

public class OpenAiCompatibleChatModel implements ChatModel { private final HttpClient httpClient; private final String endpoint; private final String apiKey; private final String modelName; public ChatResponse chat(List<ChatMessage> messages, List<ToolSpec> tools) { Map<String, Object> payload = new HashMap<>(); payload.put("model", modelName); payload.put("messages", messages); payload.put("tools", tools); payload.put("temperature", 0.7); // ... 构建 HTTP 请求 } }

这里我特意用了 JDK 自带的 HttpClient,没有引入 RestTemplate 或 WebClient。原因很简单:项目刚起步时依赖越少越好,Java 11 以后的 HttpClient 已经支持 HTTP/2、异步请求和流式响应,足够用了。请求超时的配置要特别注意,大模型接口在高峰期经常十几秒才返回,超时时间我默认设成 120 秒。

流式输出在 Agent 场景里不是锦上添花,而是刚需。一个复杂任务要让用户等 30 秒没有反馈,体验上是灾难。我用 HttpRequest 的流式响应一行一行地读 SSE 事件,每拿到一个 data 块就解析增量内容并推送出去,用户能看到"正在思考""正在调用工具"的实时状态,整个交互节奏会完全不同。

2.2 工具注册机制与 Function Calling 的工作原理

Function Calling 是 Agent 框架的发动机。模型本身并不会真正执行任何外部操作,它只负责在需要外部信息时,输出一个结构化的调用意图。JavaManus 要做的就是把这种调用意图安全地变成真实操作。

工具注册表的核心实现非常简单,就是一个 ConcurrentHashMap。但真正有价值的是工具描述信息的编写。模型是否在合适的时机调用工具,很大程度上取决于 description 写得好不好。比如一个搜索工具的 description 如果只写"搜索工具",模型大概率不知道怎么用;如果写成"当用户需要查询实时信息、新闻、股票价格等最新数据时使用,输入应是一个简洁的搜索关键词",模型就会在正确的时机选择它。

Map<String, Tool> toolRegistry = new ConcurrentHashMap<>(); public Tool lookup(String name) { return toolRegistry.get(name); } public void register(Tool tool) { toolRegistry.put(tool.name(), tool); }

参数定义是另一块容易翻车的地方。模型返回的 tool_calls 中会带一个 arguments 字段,是 JSON 字符串。我用 Jackson 把这个字符串解析成 JsonNode,再传给工具执行。这里有个常见的坑:模型偶尔会生成不合法的 JSON,比如字符串引号没闭合、末尾多了一个逗号。我后面在常见问题章节会详细讲怎么容错。

整个 Function Calling 的调用闭环是这样的:编排层把当前消息和工具描述发给模型,模型返回一个 assistant 消息,里面带着 tool_calls;编排层逐个执行工具,把执行结果包装成 tool 角色消息回传给模型;模型拿到工具结果后继续推理,直到不再请求工具,才给出最终答案。

2.3 记忆与上下文管理怎么避免尺度爆炸

大模型有上下文窗口限制,而 Agent 的特点恰恰是会产生大量中间消息。每次工具调用都会新增至少几条消息:模型发起的工具调用消息、工具执行结果消息、模型基于结果的下一步推理消息。一个稍微复杂的任务跑下来,轻松累积上百条消息。

JavaManus 采用了一个分层记忆策略。默认是最简单的窗口记忆:只保留最近 N 条消息,超过的部分直接丢弃。窗口大小根据模型的上下文长度调整,一般保留最近 20000 token 左右的内容。更高级的模式是摘要记忆:当消息条数超过阈值时,调用模型对旧消息做一次摘要,把摘要作为 system 消息注入,既能保留关键信息,又不会撑爆上下文。

class WindowMemory implements AgentMemory { private final List<ChatMessage> messages = new ArrayList<>(); private final int maxTokens = 20000; public void compactIfNeeded() { int currentTokens = estimateTokens(messages); while (currentTokens > maxTokens && messages.size() > 1) { messages.remove(0); currentTokens = estimateTokens(messages); } } }

token 估算这块不需要做得很精准。我用的估算方法很简单:中文按一个字符 1~1.5 token 估算,英文按三个字符 1 token 估算,数字和符号按半个 token 算。实际测试下来误差在 10% 以内。真正的 token 统计只有官方 tokenizer 才能做到,但在框架层没必要引入重量级依赖,够用就行。

2.4 Agent 执行闭环是怎么一轮轮思考的

Agent 的执行核心是一个循环:思考、调用、观察、再思考。JavaManus 把 ReAct 模式化简成了一个可控制的循环结构,每一轮是这样的:

  1. 编排层把当前消息快照发给模型。
  2. 模型返回回答内容,可能带着工具调用意图。
  3. 如果有工具调用,就执行工具,把结果作为新的消息追加到记忆里,回到第 1 步。
  4. 如果没有工具调用,说明模型认为信息已足够,返回最终答案,循环结束。

这里必须设置两个保命参数:最大迭代次数和执行超时时间。我默认最大迭代 10 次,超过就强制终止并返回当前已有的推理结果。为什么必须限制?因为模型在复杂任务中可能陷入循环,比如反复搜索同一个关键词,或者在两个工具之间来回切换。还有一个技巧是检测重复调用:如果同一工具用相同参数被调用了超过 3 次,就中断并提示模型换一种思路。

对于更复杂的任务,JavaManus 提供了 Plan-and-Execute 模式。在进入循环之前,先让模型把用户目标拆解成一个步骤清单,然后每完成一个步骤就回到编排层校验,而不是在一条推理链上越走越深。这种模式对多步骤任务的成功率提升非常明显,代价是会多几次模型调用,实际使用中需要根据场景取舍。

3. 从零到一的实操构建过程

3.1 工程初始化与依赖选型

我用 Maven 搭建项目,Java 版本直接用 21,原因前面说过:虚拟线程。这里不引入 Spring Boot,保持框架的轻量性和可移植性。如果后面需要接入 Web,再单独加一个适配层。

<properties> <maven.compiler.source>21</maven.compiler.source> <maven.compiler.target>21</maven.compiler.target> </properties> <dependencies> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>2.17.2</version> </dependency> <dependency> <groupId>org.slf4j</groupId> <artifactId>slf4j-simple</artifactId> <version>2.0.13</version> </dependency> </dependencies>

依赖就这两样。Jackson 负责所有 JSON 的序列化和解析,SLF4J 用来打日志。连单元测试框架我都没着急加,等框架跑通第一个任务后再补测试也不迟。我见过太多项目在前期过度工程化,还没写业务逻辑先搭了一堆基础设施,结果核心功能做了一半就搁浅了。

工程结构方面,我按照之前的分层设计组织了包结构。模型层的代码在 model 包下,工具在 tool 包下,记忆在 memory 包下,编排在 agent 包下。每个包的职责单一,后续新增功能时能快速定位到对应位置。

3.2 手写一个可用的模型适配器

为了让框架跑起来,第一步是实现 ChatModel 接口。这里直接对接某主流大模型服务商提供的兼容协议,主要逻辑就是构建请求、发送 HTTP、解析响应。

public ChatResponse chat(List<ChatMessage> messages, List<ToolSpec> tools) { Map<String, Object> requestBody = new HashMap<>(); requestBody.put("model", modelName); requestBody.put("messages", messages.stream().map(ChatMessage::toMap).toList()); if (tools != null && !tools.isEmpty()) { requestBody.put("tools", tools.stream().map(ToolSpec::toMap).toList()); requestBody.put("tool_choice", "auto"); } String json = objectMapper.writeValueAsString(requestBody); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(endpoint)) .header("Content-Type", "application/json") .header("Authorization", "Bearer " + apiKey) .timeout(Duration.ofSeconds(120)) .POST(HttpRequest.BodyPublishers.ofString(json)) .build(); HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); JsonNode root = objectMapper.readTree(response.body()); return parseChatResponse(root); }

这里最核心的是 parseChatResponse 方法。响应中可能有两种情况:一种是普通的文本回复,content 字段里有内容;另一种是工具调用回复,tool_calls 字段里有函数名和参数。我在解析时把这两种情况统一封装成 ChatResponse 对象,编排层不需要关心响应格式的细节,只判断"这里面有没有工具调用"就够了。

需要提醒的是,很多新手会忽略 tool_choice 这个参数。默认值 auto 表示由模型自行决定是否调用工具。如果设置成 required,模型每轮都会被强制调用工具,这在某些场景下有用,比如你明确知道这个任务必须查一次数据库才能进行下一步,否则模型会自作主张地猜测答案。

3.3 实现 Agent 执行循环和任务拆解

万事俱备,终于到了最核心的 Agent 循环实现。这一节是三段式执行,展示如何将前面的组件串成真正能工作的 Agent。

public String run(String userInput) { memory.addUserMessage(userInput); int iterations = 0; while (iterations < maxIterations) { List<ToolSpec> specs = toolRegistry.values().stream() .map(tool -> ToolSpec.fromTool(tool)) .toList(); ChatResponse response = chatModel.chat(memory.snapshot(), specs); iterations++; if (response.hasToolCalls()) { for (ToolCall call : response.getToolCalls()) { Tool tool = toolRegistry.lookup(call.name()); JsonNode result; try { result = tool.execute(call.arguments()); } catch (Exception e) { result = objectMapper.createObjectNode().put("error", e.getMessage()); } memory.addToolMessage(call.id(), result.toString()); } continue; } // 没有工具调用,返回最终答案 return response.getContent(); } return "执行超过最大迭代次数,已停止。"; }

这个循环的巧妙之处在于它非常短。Agent 的智能不体现在复杂的流程代码里,而在模型对上下文的推理中。循环只是提供了一个容器,让模型分批地思考、调用工具、观察结果。我做了一个延展性的项目跑通后,尝试加了多任务规划功能:在 run 方法之前插入一个 planner 阶段,让模型先输出步骤列表,再针对每个步骤调用单任务的 run。两种模式用枚举切换,整体代码改动很小。

3.4 内置工具集与注册方式

JavaManus 的一开始自带四个工具:计算器、HTTP 查询器、文件读取器、文件写入器。不要小看这几个工具,它们是验证框架正确性的基本盘。

计算器工具的实现极其简单,但价值在于验证 Function Calling 的闭环:模型看到"计算 15 乘 24 再除以 6"这种问题,会生成一个 calculator 调用,参数是一个数学表达式字符串,编

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询