☰
JavaManus:用Java从零构建AI Agent框架的核心实现与避坑指南
2026/10/27 3:12:27 网站建设 项目流程

1. 项目缘起:为什么是 Java,为什么是 Agent 框架

写这个 JavaManus,最初不是脑袋一热。做后端这些年,我一直在用 Java 写业务系统,Spring Boot 那套东西用得滚瓜烂熟。但是 2024 年底到 2025 年,AI Agent 这个词在技术圈里彻底爆了,GitHub 上到处是 Python 写的 Agent 框架,LangChain、CrewAI、AutoGen 这些轮子一个比一个响亮。我当时的处境很尴尬:Python 会一点,但谈不上精通;用 Python 去搭 Agent 框架,效率未必比得过那些开源项目。可我手头有大量 Java 基建,有现成的服务治理、配置中心、监控体系,就差一个能把这些东西接进 AI 流程的“粘合剂”。

于是问题就变成了:能不能用 Java 从零构建一个 AI Agent 框架?不依赖 LangChain 那套 Python 生态,也不去调那些重型的 Java 版 Agent 库,而是把 Agent 的核心机制,比如工具调用、任务规划、上下文管理、多智能体协作,用 Java 面向对象的方式重新实现一遍。JavaManus 就是在这个背景下动手的。

这个项目对谁有用?首先是那些想在 Java 技术栈里做 AI 应用,但不想引入 Python 微服务、不想跨语言调试的人。其次是希望理解 Agent 框架本质的开发者——市面上框架太多,但大多数人是“用框架”而不是“懂框架”。自己写一遍,你会清楚地知道 function calling 的请求报文长什么样,知道 ReAct 循环里哪一步最容易出错,知道多智能体协作的消息路由到底是怎么回事。

另外提一句,JavaManus 的名字明显是向 Manus 致敬。Manus 在大众视野里把“通用 AI Agent”这个概念推到了一个高度,很多人误以为 Agent 一定是 Python 的专利。实际上,只要能把大模型的能力、工具的能力、状态管理的能力用代码组织起来,任何成熟的编程语言都能构建 Agent。Java 的优势在于稳定、类型安全、生态成熟,劣势在于表达力不如 Python 灵活,但这个劣势在框架设计层面反而能逼你做出更清晰的结构。

2. 核心价值拆解:一个 Java Agent 框架必须解决哪些问题

2.1 Agent 不等于“调大模型接口”

很多刚接触 Agent 的人,以为 Agent 就是调用一次 LLM API,拿到结果就完事。这种理解非常危险。真正的 Agent 是一个循环:模型输出决策 → 解析决策 → 调用工具 → 把工具结果喂回模型 → 模型再输出决策。这个循环持续到模型认为任务完成。JavaManus 的核心设计目标,就是把这个循环用 Java 类型系统稳定地表达出来,让每个环节都有明确的接口和可替换的实现。

我把 Agent 框架要解决的问题拆成五块:

  • 模型接入层:统一封装不同厂商的 LLM API。JavaManus 当初只支持 OpenAI 协议,后来发现很多国产模型也兼容这个协议,所以抽象出一个 ModelClient 接口,从请求构造到响应解析全部规范化。
  • 工具抽象层:Agent 要操作外部世界,必须给模型提供工具。工具在 Java 里就是一个普通方法,但需要附加 JSON Schema 描述,让模型知道“这个工具叫什么、参数是什么”。
  • 记忆与上下文层:多轮任务中,模型能不能记住之前的中间结果,直接决定任务成败。上下文不是简单把对话历史拼在一起,还要处理长度上限、关键信息提取、摘要压缩这些工程问题。
  • 规划与执行逻辑:这是 Agent 的“大脑回路”。最简单的规划是 ReAct 循环,复杂一点的是任务分解、子任务编排、并行执行。
  • 事件与观测层:Agent 每一步做了什么,工具返回了什么,模型为什么这么决策,都要有记录。否则出了问题你根本没法排查。

2.2 技术选型背后的理由:不追新,求稳

JavaManus 没有用任何高深的技术栈,核心就是 Java 17、HttpClient、Jackson、Spring Boot 可选集成。为什么这么选?

Java 17 是长期支持版本,Stream、Record、Sealed Interface 这些特性足够让代码简洁不少。HttpClient 是 JDK 自带的,发 HTTP 请求不需要引入 OkHttp 或 RestTemplate 这类外部依赖,减少一层麻烦。Jackson 是 JSON 处理的事实标准,Agent 框架里大量涉及结构化数据的序列化与反序列化,Jackson 的 ObjectMapper 配上自定义 Deserializer,能很好处理 LLM 返回的松散 JSON。

很多做 AI 的人会问:为什么不用 LangChain4j?不是不能用,而是 JavaManus 的定位是“教学与轻量生产兼顾”。LangChain4j 确实把大量细节封装好了,但你想修改内部逻辑的时候,会被它的抽象层级绕得头疼。自研框架虽然前期代码多一些,但每一行都是你控制的,排查问题的时候不用翻第三方源码。在需要快速迭代的场景下,“直观可控”比“功能丰富”重要得多。

提示:如果你只是想快速做一个 Demo,直接用 LangChain4j 就好,没必要重复造轮子。JavaManus 适合的场景是你想理解 Agent 机制、想定制属于自己的框架、或者团队有长期维护 AI 能力的规划。

3. 整体架构设计:从单 Agent 到多 Agent 的一张蓝图

3.1 模块划分与依赖方向

JavaManus 的模块划分,我参考了信息流的方向来定:用户输入进来,经过规划器拆解,交给执行器调用工具,结果回到记忆区,再触发下一轮规划。代码上分成五个 Maven Module,互相之间的依赖关系非常干净:

模块职责关键类
manus-core核心接口与模型定义Agent, Message, Tool, Memory
manus-modelLLM 协议封装OpenAiModelClient, ChatRequest
manus-tool内置工具集合WebSearchTool, CalculatorTool, ShellTool
manus-orchestrator多智能体编排PlannerAgent, ExecutorAgent, RouterAgent
manus-springboot-starterSpring Boot 快速集成ManusAutoConfiguration

依赖方向是从上到下单向的,core 模块不依赖任何外部框架,model 依赖 core,tool 依赖 model,orchestrator 依赖 tool 和 model。这样的分层保证了一件事:你可以只依赖 manus-core 自己实现一套模型接入,也可以只依赖 manus-tool 给自己现有的服务加上工具能力,模块之间没有循环引用。

3.2 核心接口设计:Agent 的“公约数”

我定义了一个最小的 Agent 接口,所有智能体,不管是单 Agent 还是多 Agent 的成员,都实现这个接口:

public interface Agent { String getId(); String getName(); AgentReply execute(AgentRequest request); void registerTool(Tool tool); List<Tool> listTools(); }

AgentRequest 里包含用户输入、会话 ID、上下文快照、可选的附加参数。AgentReply 里包含最终回复文本、工具调用记录、耗时、Token 消耗。不要小看这不到十行的接口,它约束了所有 Agent 的行为边界。之后无论是实现一个简单的 ChatAgent,还是一个复杂的 PlannerAgent,外部调用方只需要面向 Agent 接口编程,不需要关心内部是 ReAct 循环还是 Plan-and-Execute 模式。

为什么强调“公约数”这个概念?因为多智能体系统里最尴尬的事情,就是每个 Agent 的行为模式不一致。有的 Agent 只输出文本,有的 Agent 必须调用工具,有的 Agent 需要返回中间状态。如果你不在接口层面统一,后面的消息路由、结果汇总逻辑会变得极其混乱。把行为收敛到统一的 Agent 接口上,是 Java 面向对象设计给 Agent 系统最大的礼物。

Tool 接口的设计也值得一提:

public interface Tool { String getName(); String getDescription(); JsonNode getParametersSchema(); JsonNode execute(JsonNode arguments); }

getParametersSchema 返回的是 JSON Schema,用来告诉模型这个工具能接收什么参数。execute 接收的参数也是 JsonNode,这样工具实现者不需要写任何解析代码,框架层已经把模型返回的参数转好了。好处很明显:新增一个工具,你只需要实现一个类,注册到 Agent 里,模型立刻就知道怎么用它。这比很多 Python 框架里用装饰器自动生成 schema 的方式要稳,JSON Schema 是你手写的,绝不会和实际参数类型对不上。

4. 核心实现解析:ReAct 循环、工具注册与记忆管理

4.1 ReAct 循环的 Java 实现:关键就是“状态机”

ReAct 是 Reasoning and Acting 的缩写,逻辑非常直白:模型先推理,再行动,观察结果,再推理。用 Java 实现这个状态机,我把它拆成了四个状态:READY、THINKING、ACTING、FINISHED。

循环的主体在 chat() 方法里:

public AgentReply chat(AgentRequest request) { String userInput = request.getUserInput(); memory.addUserMessage(userInput); while (true) { // 构造发送给模型的消息列表 List<Message> messages = memory.buildMessages(maxContextTokens); // 调用 LLM,声明可用的工具 ChatResponse response = modelClient.chat(messages, toolSchemas); // 模型决定调用工具 if (response.hasToolCalls()) { for (ToolCall call : response.getToolCalls()) { Tool tool = toolRegistry.get(call.getName()); JsonNode result = tool.execute(call.getArguments()); memory.addToolResult(call.getId(), result.toString()); } continue; } // 模型给出最终回复 String finalAnswer = response.getContent(); memory.addAssistantMessage(finalAnswer); return AgentReply.success(finalAnswer); } }

核心的循环就这三四十行,但里面的细节需要展开说。

第一,memory.buildMessages(maxContextTokens) 这一步特别关键。你不能永远把对话历史全丢给模型,有 token 上限。我的做法是按消息从新到旧排列,优先保留最近的用户消息、最近的工具结果、最近的模型回复,如果超过了 maxContextTokens,就把更早的消息压缩成一段摘要,摘要在每次构建消息时放在消息列表最前面。

第二,modelClient.chat() 里的第二个参数 toolSchemas 是一个 List<ToolFunction>,每个 ToolFunction 包含 name、description、parameters。这是和模型约定的“能力契约”。模型看到这些描述后,会在需要的时候返回一个 tool_calls 字段,告知框架要调用哪个工具、参数是什么。

第三,循环里必须设置最大迭代次数。我默认设置为 10 轮工具调用,超过就强制结束。“防呆”设计不能省,否则模型在某些场景下会无限循环调用工具,把 token 烧光。

注意:ReAct 循环是 Agent 框架的“单线程版本”。它简单、容易理解,但效率不高——每一步都要等模型返回,不能并行。多智能体框架的意义之一,就是把可以并行的子任务摘出来同时执行,这部分我在后面讲编排器时再展开。

4.2 工具注册与动态发现:为什么“描述信息”比“实现代码”更重要

工具注册机制,是 Agent 框架能不能用得起来的分水岭。JavaManus 用了一个工具注册表,Agent 启动时把所有工具扫描登记,模型调用时从注册表按名字查找。

@Component public class ToolRegistry { private final Map<String, Tool> tools = new ConcurrentHashMap<>(); public void register(Tool tool) { tools.put(tool.getName(), tool); } public Tool get(String name) { return tools.get(name); } }

真正容易踩坑的地方不在注册表本身,而在于“工具描述”的质量。模型是根据工具的 name 和 description 来决定要不要调用它的,所以描述写得含糊,模型就会乱调工具。我总结了一个描述公式:

工具描述 = 一句话说明工具能做什么 + 使用场景 + 典型输入输出示例

举个例子,一个计算工具的描述,如果只写“计算两个数”,模型遇到“帮我算一下 3 的 17 次方等于多少”可能根本不会调它。但如果你写“精准执行数学运算,支持加减乘除、幂运算、开方等,输入为数学表达式字符串,例如 'pow(3, 17)'”,模型就清楚多了。工具描述是给模型看的产品说明书,不是给程序员看的 API 文档。

还有一个老生常谈但必须强调的点:工具的参数 JSON Schema,一定要和 execute 方法里实际解析的参数对得上。很多初学 Agent 的人在这个环节栽跟头,模型的 tool_calls 返回了参数,执行器一解析却报错。JavaManus 在每个工具注册时做了一个校验:用 JsonSchemaValidator 校验示例参数,这个动作在测试阶段帮我抓出了至少五六十个 schema 与实现不一致的 bug。

4.3 记忆管理的三层结构:短期工作记忆、长期存储、会话摘要

记忆是 Agent 框架里最容易被忽视、却最影响体验的部分。JavaManus 把记忆拆成三层来处理。

第一层是短期工作记忆,本质上就是当前对话轮次里的消息列表,保存在内存里,任务结束就清空。第二层是长期存储,用起来像是一个键值数据库,存储跨会话的关键事实,比如用户偏好、历史任务结果,可以对接 Redis、MySQL,也可以先做成内存版。第三层是会话摘要,这是解决上下文长度问题的核心手段。

会话摘要实现起来并不复杂:当消息列表总长度超过阈值后,把最早的一批消息发给一个 summarize 模型,让它生成 200-300 字的摘要,然后把摘要作为一条 system 消息插入到消息列表头部。这里有一个选型细节:摘要模型可以不是同一个能力很强的模型,用一个便宜快速的模型就好。我在 JavaManus 里把摘要模型和主模型拆成了两个 ModelClient 实例,配置项可以分别指定 model 名称与 API Key,这样主模型用强模型保证推理质量,摘要模型用小模型控制成本。

记忆层还有一个重要设计:工具结果要怎么存。工具返回的 JSON 往往又长又乱,全部塞进上下文既浪费 token 又干扰模型注意力。我的做法是先让执行器对工具结果做一次“瘦身”:把结果截断到前 2000 个字符,如果是 JSON,就把内部嵌套的数组或对象做截断,保证顶层结构仍然完整。截断后的结果才是进入短期记忆的内容。

public class ToolResultTrimmer { public String trim(String rawResult) { if (rawResult.length() <= MAX_TOOL_RESULT_LENGTH) { return rawResult; } // 尝试按 JSON 节点截断 try { JsonNode node = objectMapper.readTree(rawResult); return trimNode(node).toString(); } catch (Exception e) { // 非 JSON,直接截断字符串 return rawResult.substring(0, MAX_TOOL_RESULT_LENGTH) + "..."; } } }

这一步非常实用。模型要的不是工具返回的全量数据,而是能从数据里得出结论的关键信息。把工具回复压缩到合理长度,既控制 token 成本,又减少模型被无用信息干扰的概率。

5. 多智能体编排:Planner 拆任务、Executor 干活、Router 做路由

5.1 从单 Agent 到多 Agent:为什么需要编排层

单 Agent 能解决的场景有限。最简单的问题“明天上海天气怎么样并提醒我带伞”,单 Agent 也能做——调天气工具,然后输出提醒。但如果任务是“帮我调研一下 Spring Boot 3.4 的特性,整理成一篇带代码示例的博文框架,再翻译成英文摘要”,单 Agent 就很难处理了,因为这里包含了多个性质和顺序不同的子任务:搜索信息、阅读文档、输出中文框架、翻译。

多智能体编排器的核心思路,就是“规划者不做事,做事者不规划”。JavaManus 里我实现了三个角色:

  • PlannerAgent(规划者):接收用户的复杂任务,把它拆解成多个依赖关系明确的子任务。子任务分为两类:可并行的,必须串行的。
  • ExecutorAgent(执行者):专门负责调用工具、跑代码、查资料。它不思考大方向,只负责把具体动作做完。
  • RouterAgent(路由者):负责任务分发的活,判断一个子任务应该交给哪个执行者,并聚合结果。

这里可能有人觉得 PlannerAgent 也是 Agent 接口实现,和单 Agent 的差别不大。是的,接口一样,但内部逻辑完全不同。单 Agent 的循环是“边想边做”,而 PlannerAgent 的工作方式偏“先想后做”:先把任务拆成一个 DAG(有向无环图)——这个图是我用邻接表实现的一个小型数据结构,不引入额外图计算库。

public class TaskGraph { private final Map<String, TaskNode> nodes = new HashMap<>(); private final Map<String, List<String>> dependencies = new HashMap<>(); public void addTask(TaskNode node) { nodes.put(node.getId(), node); } public void addDependency(String taskId, String dependsOn) { dependencies.computeIfAbsent(taskId, k -> new ArrayList<>()).add(dependsOn); } // 根据依赖关系返回可以并行执行的任务批次 public List<List<TaskNode>> getParallelBatches() { // 拓扑排序,把没有未完成依赖的任务分到同一批 } }

拓扑排序输出的结果,是一批包含多个可并行任务的列表。ExecutorAgent 可以拿线程池去并发消费同一批任务,完成后继续下一批。这种编排方式能把“汇总报告”这类任务的总耗时从原来的串行一长串压缩到几个批次。

5.2 消息驱动的 Agent 间通信:简单而可靠

多 Agent 之间怎么传消息?这是编排层设计最容易出问题的地方。JavaManus 的做法是事件总线 + 消息队列的混合体,实际实现并不复杂:一个内存中的 LinkedBlockingQueue,加上一个 Dispatcher 线程,把特定主题的消息投递给订阅了该主题的 Agent。

  • 消息类型枚举:TaskAssigned、TaskCompleted、TaskFailed、FinalAnswer。
  • 每个 Agent 在启动时声明自己订阅哪些消息类型。
  • Dispatcher 根据消息类型路由,不关心业务内容。

为什么不直接用现成的消息队列,比如 RabbitMQ、Kafka?因为多智能体编排是进程内的高频协作,走网络消息队列会引入大量序列化和网络延迟。进程内队列足够,而且还保留了线程亲和性——同一个任务链路上的消息尽量由同一个线程处理,减少上下文切换开销。

这里有一个重要的经验:Agent 间消息体一定要带 traceId。最初我在做编排器时没有 traceId,结果排查问题的时候,日志像一团乱麻,根本看不出来某个工具结果是哪个子任务产生的。后来给 AgentRequest 和 AgentReply 都加上了 traceId 字段,再由 MDC 自动透传到日志里,排查效率直接上一个台阶。

5.3 编排器的容错机制:子任务失败不应该让全盘崩溃

多智能体系统比单 Agent 系统更脆弱——任何一个 ExecutorAgent 调工具失败了,如果处理不当,整个任务就卡死了。JavaManus 的编排器实现了三层容错:

  • 重试:对可重试的任务(比如网络请求超时、HTTP 5xx),默认重试 2 次,退避策略采用指数退避,初始 500ms,翻倍递进。
  • 降级:如果某个子任务重试后仍然失败,编排器会生成一个“降级摘要”,把失败原因发给 PlannerAgent,由规划者判断是否可以调整方案,还是直接告知用户。
  • 超时熔断:每个子任务分配一个 ExecutorAgent 时都带着过期时间。如果执行超过 30 秒(可配置),直接中断该任务,不让它无限阻塞后续任务。

这三层容错叠加起来的效果,是让框架在真实场景里表现得特别皮实。我做过一次故障演练,把一个搜索工具的后端服务直接停掉,整套系统没有挂,最终还能给用户一个“部分子任务失败,但已完成主体内容”的答复。这个体验,比起直接抛异常让用户重来,好太多了。

6. 实操记录:从最小可运行版本到生产级配置

6.1 最小可运行版本的 5 个步骤

我不喜欢纸上谈兵。下面给出一套可以直接跑起来的流程,按照这个步骤,大约 2-3 小时就能在本地把 JavaManus 的最小版本跑通。我把这个过程浓缩成五个步骤。

第一步:初始化项目结构。用 Maven 创建多模块项目,JDK 17+,spring-boot-starter-web 作为基础依赖(如果不需要 web,可以只保留 spring-boot-starter)。JavaManus 的 manus-core 模块不需要 Spring,尽量保持纯净;manus-springboot-starter 才引入 Spring 上下文,方便自动装配。

第二步:实现 ModelClient。先不做多厂商适配,只实现一个 OpenAiModelClient。用 JDK HttpClient 发 POST 请求到 /chat/completions,请求体是 ChatRequest:model、messages、tools、temperature。用 Jackson 解析响应,提取 content 与 tool_calls。

public class ChatResponse { private String content; private List<ToolCall> toolCalls; private int promptTokens; private int completionTokens; }

这里建议先不接流式输出,等整条链路跑通了再升级到 SSE 流式。我当时就是先做非流式,跑通了再改流式,改动并不大,但调试难度低了很多。

第三步:定义并注册一个工具。找一个最简单的工具开始,比如“获取当前时间的工具”。这个工具没有外部依赖,参数也不需要,只要模型有办法获取时间数据就行。注册之后,用“现在北京时间几点”来测试,看模型会不会调用工具。

第四步:实现 ReAct 循环。按前面 4.1 节的状态机逻辑跑一遍,验证“模型调工具 → 拿到返回值 → 生成最终回复”的完整链路。这个环节 90% 的新手会遇到一个问题:模型返回的 tool_calls 参数被 JSON 解析成了 key 是字符串、value 是乱序的类型,传参时类型对不上。解决办法很简单——对 tool_calls 里的 arguments 字段,再单独用 ObjectMapper 解析一次,不做字段名大小写转换,strict 模式关闭,让 Jackson 容忍多余字段。

第五步:加入会话记忆与摘要机制。把 memory 模块接入,多轮对话测试。比如先让 Agent 记住“我叫张三”,过五轮后再问“我叫什么”,看记忆是否能命中。通过了,最小可运行版本就完成了。

6.2 生产级配置要点:并发、超时与模型参数

最小版本跑通之后,离“能上线”还有一段路。生产级使用,必须处理并发与超时。我的配置建议如下:

  • 并发控制:JavaManus 的 Agent 实例默认设计为无状态,可以被线程池并发调用。我为每个用户会话创建一个 AgentSession,内部持有独立的 Memory 实例,Session 之间不共享状态。底层 LLM 调用使用 semaphore 限制并发数,避免突发流量把 API 打崩。
  • 超时设置:LLM HTTP 调用的连接超时 5 秒、读超时 60 秒,工具调用的整体超时 30 秒。这些超时统一封装在 ModelClient 与 ToolExecutor 里,不暴露给上层。
  • 模型参数:temperature 默认 0.2,Agent 场景一般不需要太高的随机性;max_tokens 默认 2048,但总结任务需要更大;top_p 保持默认或调成 0.9。最重要的是加一个 stop 条件:当模型输出达到特定词元时强制截断。ReAct 循环里不加 stop 条件的后果,是模型可能顺着思路一直胡诌。

关于成本控制,还有一个建议:一定要记录 token 消耗。JavaManus 的 AgentReply 里带上了 promptTokens 与 completionTokens,配合日志,能实时观察每次任务的成本结构。我见过不少团队 Agent 上线后账单暴增,追查的时候才发现是某条链路忘记压缩记忆,导致每次请求的上下文越来越大。有了 token 记录,这类问题第一时间就能暴露。

6.3 从 JavaManus 到业务集成:Spring Boot Starter 的实际使用

JavaManus 最后做成了 spring-boot-starter,就是为了让业务方接入成本降到最低。下面是实际使用方式:

@RestController public class ChatController { private final ManusAgentEngine engine; public ChatController(ManusAgentEngine engine) { this.engine = engine; } @PostMapping("/chat") public AgentReply chat(@RequestBody ChatRequestDTO request) { return engine.chat(request.getSessionId(), request.getUserInput()); } }

调用方只传 sessionId 和 userInput,engine 内部负责找到对应的 AgentSession,如果 sessionId 不存在,就自动初始化一个新 Session,把默认工具和默认模型配置装配进去。业务方不需要知道里面是 ReAct 还是多智能体编排,也不需要知道用的哪家模型。

不过这里要提醒一句:Starter 的定位是“开箱即用”,但不要把默认配置当成永远不用变的配置。JavaManus 内置的工具集合(网络搜索、时间查询、计算器)都是通用型工具,真实的业务场景一定需要你注册自己的业务工具。比如做电商客服的团队,需要注册订单查询工具、优惠券计算工具、物流状态查询工具——这些工具通过 Tool 接口实现一次,注册进去就能用,不需要改框架代码。

7. 常见问题与避坑实录:我在开发 JavaManus 时踩过的 12 个坑

7.1 模型调用层的坑:tool_calls 解析、空参数、并发安全

把踩坑记录整理成清单,这里重点讲高频的几类。

tool_calls 的 arguments 解析失败。这是 Agent 开发里出现频率最高的错误。模型返回的 tool_calls 里,arguments 是一个 JSON 字符串,而不是 JSON 对象。我刚实现时直接拿 JsonNode.get("arguments") 去取字段,结果拿到的是字符串,导致解析报错或者参数值全成了字符串。正确做法是先取字符串,再解析一次。后来我在 ToolExecutor 的入口统一做了这个处理。

工具返回空参数时模型仍然调用工具。有些工具并不需要参数,但模型在调用时仍然会给一个空对象“{}”,这没问题。但有些模型会给一个非空对象,里面塞了无关字段,如果不加处理,工具执行时会报参数不存在。JavaManus 的解决方式是:工具执行前,把 JSON Schema 里没声明的字段全部过滤掉,只保留声明过的参数,并用默认值补全缺失参数。这一步相当于给工具调用加了一个“清洗层”。

ObjectMapper 线程安全问题。Jackson 的 ObjectMapper 是线程安全的,但如果你在代码里把 JsonNode 拿去 mutate 后复用,就会有隐患。JavaManus 里所有 JsonNode 都是不可变的,需要修改时用 ObjectMapper.createObjectNode() 重建,从不直接改原节点。这个习惯让我避免了非常多的偶发 bug。

7.2 记忆与上下文的坑:“记忆丢失”和“记忆混淆”

多会话上下文串了。早期 Memory 实现用了静态变量保存对话历史,结果两个用户同时使用时,A 的对话历史被 B 的内容污染。后来把所有记忆状态全部挪到 AgentSession 实例内部,并禁止跨 Session 共享,这个问题才彻底解决。多线程环境里,“可变状态不能放在共享单例里”是铁律。

上下文被工具结果撑爆。一个搜索工具可能返回 2 万字的文档摘要,直接把上下文窗口塞满。我的处理方案是第一层截断:只保留前 N 个字符;第二层提取:用正则或者简单文本解析,把关键信息(例如标题、摘要、URL)单独拎出来。第二层的效果比第一层好很多,因为模型看到的是高价值信息,不是被截断的半句话。

摘要机制导致早期对话信息丢失。这是所有 Agent 框架的通病,会话摘要压缩后,用户早期说过的一些细节可能永久丢失。JavaManus 的临时解法是两条路并行:压缩后的摘要进主上下文,同时完整历史按时间分片保存到 long-term store,一旦主模型判断信息不足,可以自动调一个“查历史”工具去检索。这个设计虽然多了一层工具维度,但确实有效解决了“记忆永久丢失”的焦虑。

7.3 多智能体编排的坑:死锁、任务丢失、结果错位

子任务并发存在死锁风险。我自定义了一个线程池专门跑 ExecutorAgent,大小默认 8。有一个阶段 PlannerAgent 会同时下发 8 个子任务,每个子任务内部又要调用一次模型、调一次工具,本来应该没问题。但有一次测试时发现所有线程全部卡住不动,排查发现是一个子任务内部又递归调用了另一个 Agent,而那个 Agent 的执行也需要从同一个线程池拿线程,结果线程池耗尽,互相等待。解决办法是:Agent 内部严禁使用同一个线程池,外层线程池只做编排调度,Agent 内部的工具调用单独用 worker 线程。这条规则写进了框架文档,列为红线。

子任务完成的顺序与分配顺序不一致。多个 ExecutorAgent 并行执行后,结果返回的顺序是乱序的。如果不做归位处理,后面的结果汇总就会张冠李戴。JavaManus 的解决办法是每个子任务带一个 taskId,TaskCompleted 消息里包含 taskId 与 AgentReply,汇总器拿到消息后先按 taskId 归位,再组装最终答案。这一步极力推荐。

用户取消任务后,后台 Agent 仍继续跑。这是一个很隐蔽的坑。用户发起一个长任务,比如“调研 20 个网站的 SEO 信息”,中途用户关闭了页面。JavaManus 在早期版本里没有处理取消逻辑,结果后台的线程池和 API 调用还在一直跑,白白消耗资源。后来我在会话上下文中加了一个 CancellationToken,每次循环开始前检查取消标记,一旦取消就立即停止。这个设计初看不起眼,但在真实生产环境里能省下不少成本。

7.4 一个额外的收获:从“写框架”到“理解大模型”

开发 JavaManus 的过程,让我对 AI Agent 的认知发生了很大变化。以前用 LangChain 的时候,觉得 Agent 是黑盒,跑通了能用就行;自己实现一遍之后,才真正理解了大模型的边界:模型本身没有“执行力”,它的输出只是概率性的文本;Agent 框架存在的价值,是把文本输出转化为确定性的行动。你给模型一把螺丝刀,它不会自己想起去拧螺丝,你得给它一个能把“螺丝刀”这个词转化成真正动作的传动机构。

JavaManus 的这个传动机构,就是工具注册表加上 ReAct 循环。当你把工具描述写得清楚、参数 Schema 定义得严谨、循环判断做得完善时,模型的“幻觉”会被大幅压缩——因为它每次决策都很容易被验证,错了马上能收到工具的报错反馈,然后重新规划下一步。

这个项目也让我意识到,Java 在 AI 领域并没有落后,只是缺少一本“Java 人能看懂的 Agent 实践手册”。Python 生态固然丰富,但 Java 技术栈的稳定性、可观测性、工程化能力,在落地企业级 AI 应用时反而是实打实的优势。写这篇文章的时候,JavaManus 的代码还在迭代中,但我已经用它接入了内部的几个业务模块,从工单分类到报表生成,跑得都比较顺。下一步我打算把 RAG 能力整合进来,让 Agent 的工具集里多一个“私有知识库检索”的能力,到时候再写一篇实战分享出来。

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

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

立即咨询