☰
用Java从零实现AI Agent框架:JavaManus的设计与踩坑实录
2026/10/22 5:36:19 网站建设 项目流程

AI Agent 这个词这两年快被说烂了,但真正能自己动手把 Agent 从零写出来的人,还是少数。大部分教程默认你用 Python,可我一直做 Java 后端,就想着能不能用自己最熟悉的语言,从零实现一个 AI Agent 框架。JavaManus 就是这个思路下的产物:一个不依赖任何 Agent 编排框架、只用 Java 和标准大模型接口搭建的智能体执行框架。这篇文章不是讲概念,而是把我从设计、编码到踩坑的完整过程拆开给你看,包括模块划分、工具调用协议、上下文管理、循环终止条件这些真正影响 Agent 稳定性的细节。适合想深入理解 Agent 底层原理、又不想丢掉 Java 技术栈的开发者参考。

1. 设计思路:先想清楚 Agent 到底是什么

1.1 Agent 和普通程序差在哪

很多人对 Agent 的第一印象是“能聊天”,这其实是被 Chat 界面带偏了。一个真正意义上的 Agent,核心不是聊,而是“循环”:感知当前状态、做出决策、执行动作、观察结果、继续决策,直到达成目标。普通 Java 程序是线性流程,写好 main 方法从头跑到尾;Agent 是一个不确定的循环,循环的每一步都可能调用外部工具,也可能直接结束。

打个比方,普通程序就是一台自动售货机:你投币、按键、掉货,流程固定。Agent 更像一个外包员工:你给出一个目标,它自己判断先查资料还是先写代码,遇到问题会调头换方案。JavaManus 做的,就是把“外包员工”的这套工作方式用代码固定下来:一个可以反复调用的模型接口、一个能注册和执行工具的工具箱、一个能保存历史消息的上下文容器,以及一个控制循环何时结束的判断器。

想明白这一点之后,我就确定了第一版架构的核心:不做一个大而全的“低代码平台”,而是做一个精干的执行内核。外部 LLM 只负责输出决策,Java 代码负责真正干活,中间通过结构化 JSON 传递指令和结果。

1.2 框架边界:什么该做,什么不该做

最初动手时,我差点把项目做成一个“啥都管”的怪物:想自己集成向量库,想做多智能体协作平台,还打算封装一套可视化编排界面。做到一半发现,这些功能会严重分散精力,而且对理解 Agent 原理没有帮助。

后来我重新划清了边界。JavaManus 应该做这几件事:

  • 统一封装大模型 HTTP 接口,屏蔽不同服务提供方的差异;
  • 维护多轮对话消息列表,管理上下文长度;
  • 提供工具注册机制和标准工具调用协议,让业务方法像写 Spring Controller 一样暴露给模型;
  • 实现最核心的 Agent 执行循环,控制最大步数和退出条件。

JavaManus 不应该做的事:不训练模型、不实现推理内核、不强行绑定某个数据库或消息队列。模型推理是别人的专业,Agent 框架的专业是“编排”:把模型的能力和真实世界的工具连接起来,并且保证连接过程可控、可观测、可容错。

这样划界有一个非常大的好处:框架本身可以保持零依赖或极少依赖,无论是嵌到 Spring Boot 项目,还是做成独立的命令行小程序,都不会有厚重的框架冲突。我在实践中把 JavaManus 做成了 Maven 多模块工程,每个模块职责单一,测试起来也轻松。

1.3 整体架构与模块划分

我把 JavaManus 拆成四个 Maven 模块:

模块职责
javamanus-coreAgent 执行循环、消息模型、上下文管理、工具注册表
javamanus-client大模型 HTTP 调用、请求响应封装、流式响应解析(预留)
javamanus-tools内置常用工具,比如数学计算、时间获取、HTTP 请求
javamanus-example可运行示例和配置样例,方便快速跑通

模块划分遵循一个简单的依赖方向:core 不感知 client 的具体实现,client 只负责把请求发出去并把结果转成 core 能识别的消息对象,tools 依赖 core 的注解和接口,example 负责把前三者装配起来。这个方向的收益在换模型服务商时特别明显:我只要在 example 里换一个配置类,模型供应商切换完全不影响 Agent 执行逻辑。

从运行视角看,JavaManus 的内部链路是:

用户输入 -> 消息列表追加 -> 模型网关生成响应 -> 解析响应(可能包含工具调用) -> 执行工具 -> 工具结果作为新消息追加 -> 再次调用模型 -> 直到响应中不再包含工具调用 -> 返回最终文本。

这个链路可以用一句话概括:一个循环、两个协议、三个容器。循环是 Agent 执行循环,两个协议是模型输入输出协议和工具调用参数协议,三个容器是消息列表、工具注册表和上下文窗口。后面所有代码,都是围绕这个骨架展开的。

2. 从模型网关到工具调用:核心模块实现细节

2.1 模型网关:先屏蔽掉所有模型差异

Agent 的第一行代码,不是循环,而是定义一个能“对话”的接口。JavaManus 里我定义了一个极简接口:

public interface ChatModel { ChatResponse chat(List<Message> messages); }

Message 是 core 模块里的统一消息模型,ChatResponse 是模型返回结果的封装。具体走 HTTP 还是本地 mock,全部由实现类决定。这样做的好处有三个:第一,单元测试时我可以写一个随时返回固定 JSON 的 FakeChatModel,不需要真的拉起大模型;第二,未来接不同的模型服务,只需要新增一个实现类;第三,业务代码永远面向接口编程,不会被某个特定协议污染。

对应 HTTP 场景,我写了一个 HttpChatModel,负责把消息列表序列化成标准 JSON 请求体,再通过 Java 内置的 HttpClient 发送 POST 请求。为了不让 core 模块依赖 Jackson,我把 JsonSerializer 也抽象成了接口,core 模块只认字节数组和 Message 类型,具体 JSON 解析全部放在 client 模块里。

为什么这里要用 Java 的 HttpClient 而不是引入第三方 HTTP 库?Java 11 之后原生 HttpClient 支持 HTTP/2、异步发送和超时控制,性能足够,还能少一个依赖。从零做框架时,每少一个依赖,后期维护成本就会低一截。

提示:模型网关一定要把“超时”和“重试”从调用逻辑里拆出来单独设计。Agent 循环里的单次模型调用可能长达几十秒,如果 HttpClient 的 connectTimeout 和 requestTimeout 没设对,很容易出现线程堆积。我第一版在这里吃过亏,后面会细说。

2.2 消息封装与上下文管理

模型接口看起来只是输入一串文字,实际上输入的是“结构化历史”。JavaManus 的消息模型定义成 Java record,简洁且不可变:

public record Message( String role, // system / user / assistant / tool String content, Map<String, Object> toolCalls, // 模型要求的工具调用列表,可为空 String toolCallId, // 某次工具调用的唯一 ID String name // 工具结果消息里的工具名 ) {}

上下文管理最核心的难点是“怎么控制 token 长度”。大模型接口都有输入长度上限,而 Agent 每执行一步,消息列表就会增加至少两条内容:模型输出一条,工具结果一条。多跑几轮后,列表轻易就能超过窗口长度。

我的方案是给 JavaManus 加一个 ContextWindow 类,在每次调用模型前做一次检查。简单版本按字符数估算:默认认为英文一个字符约 0.25 token,中文一个字符约 0.6 token,留出 20% 的缓冲余量。超过限制时,采用“滑动窗口”策略:保留 system 消息和最近 N 条对话消息,把中间过程压缩成一段摘要文本。日志会打印裁剪前后的 token 估算值,方便定位问题。

不要迷信精确 token 计算。不同模型的分词器差很多,按字符估算虽然不完美,但在工程上够用,而且不需要引入额外依赖。如果项目里有对应的分词器库,再替换也不迟。

2.3 工具注册表与 JSON Schema 参数协议

Agent 能不能干活,取决于工具能不能被模型正确调用。我在 JavaManus 里设计了一个类似 Spring MVC 的注解:

@AgentTool(name = "calculator", description = "计算数学表达式,例如 1 + 2") public record CalculatorParam(String expression) {} public class CalculatorTool { @AgentToolMethod public String run(CalculatorParam param) { // 这里可以接入自定义表达式求值器 return String.valueOf(eval(param.expression())); } }

调用过程分成三层:第一层是“声明”,也就是注解和参数 record;第二层是“注册”,框架启动时扫描带有 @AgentTool 的类,把工具名、描述、参数 JSON Schema、执行函数存入 ConcurrentHashMap;第三层是“执行”,模型返回一段 JSON,框架解析出工具名和参数对象,然后反射调用对应方法。

为什么参数要用 record 而不是普通类?因为 record 天然适合 Jackson 反序列化,不可变性避免了工具执行过程中参数被意外修改,而且代码量最少。参数描述里我会显式给出 JSON Schema 示例,模型才能知道该传什么字段。下面的 JSON 就是 JavaManus 自动生成的工具定义之一:

{ "name": "calculator", "description": "计算数学表达式,例如 1 + 2", "parameters": { "type": "object", "properties": { "expression": { "type": "string", "description": "要计算的数学表达式" } }, "required": ["expression"] } }

生成工具定义的过程里有一个反直觉的坑:模型不擅长看长文档,但非常擅长按 JSON Schema 填参数。所以工具的 description 要写“什么时候用”,参数 description 要写“值得填什么”,而不是写长篇使用说明。description 越短,调用准确率越高。这个规律在多个模型上实测都成立。

2.4 Agent 执行循环:ReAct 思路的 Java 落地

工具准备好了,接下来就是主角循环。JavaManus 的执行循环参考了 ReAct 思想,但做了一些工程化简化。完整代码如下:

public class AgentExecutor { private final ChatModel chatModel; private final ToolRegistry toolRegistry; private final int maxIterations; private final ContextWindow contextWindow; public String execute(String userInput) { List<Message> messages = new ArrayList<>(); messages.add(systemPrompt()); messages.add(new Message("user", userInput, null, null, null)); for (int i = 0; i < maxIterations; i++) { contextWindow.trim(messages); ChatResponse response = chatModel.chat(messages); Message assistantMsg = response.message(); messages.add(assistantMsg); if (assistantMsg.toolCalls() == null || assistantMsg.toolCalls().isEmpty()) { return assistantMsg.content(); } for (ToolCall call : parseToolCalls(assistantMsg)) { String result = toolRegistry.execute(call.name(), call.arguments()); messages.add(new Message("tool", result, null, call.id(), call.name())); } } throw new IllegalStateException("Agent reached max iterations: " + maxIterations); } }

每一轮循环都做四件事:把模型回复追加到历史,检查是否包含工具调用,如果没有就直接返回文本;如果有,就逐个执行工具,并把执行结果作为 role=tool 的消息追加回历史;然后开始下一轮。maxIterations 是硬性保护,默认 10 步,避免模型陷入无限循环。

很多初学者会漏掉一点:模型生成的 assistant 消息本身也要进入历史,否则模型会“失忆”。假设模型第一次回复“我需要调用天气工具”,你把这句话丢掉,只把工具结果塞回去,第二次调用时模型根本不知道你已经调用过天气工具,它可能又发起同样的调用。JavaManus 里所有消息一旦生成就入历史,顺序严格保持,这一步是 Agent 稳定性的基石。

3. 实操过程:从工程骨架到最小可运行版本

3.1 工程骨架与依赖选型

JavaManus 的工程骨架是 Maven 多模块,JDK 版本 21。选 JDK 21 不是因为它是最新版,而是因为虚拟线程正式可用,后面并行执行多个工具时能省掉大量线程配置代码。核心模块的 pom 依赖干净到只有 Jackson 一个非 JDK 依赖:

模块依赖
javamanus-core无第三方依赖
javamanus-clientJackson Databind
javamanus-toolsjavamanus-core + 可选 HTTP 工具
javamanus-example上述全部 + JUnit

为什么要这么抠依赖?因为我希望 JavaManus 能放进任何存量项目。如果 core 模块引入了一堆框架注解,别人想集成还得跟着升级版本。用纯 Java 加少量工具库,反而是最不容易出错的选择。

工程创建后,第一步要定义清楚包结构。我按领域分包:message、model、tool、executor、context、client。项目刚起步时可能觉得包太多,但随着模块增加,领域分包比按技术分层(controller、service、dao)更能抵抗混乱。

3.2 用虚拟线程处理工具并发

Agent 工具大多属于 IO 密集型操作:调用远程 HTTP 接口、查数据库、读文件。Java 21 的虚拟线程在这种场景下几乎零成本,每个工具调用都可以丢进虚拟线程执行,不用担心平台线程耗尽。

我在 ToolRegistry 里增加了一个并行执行接口:

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { List<Future<String>> futures = toolCalls.stream() .map(call -> executor.submit(() -> toolRegistry.execute(call.name(), call.arguments()))) .toList(); for (Future<String> future : futures) { // 按原始顺序收集结果,避免乱序混淆上下文 messages.add(new Message("tool", future.get(), null, call.id(), call.name())); } }

这里有两个细节值得注意。第一,虚拟线程池要用 try-with-resources 包起来,确保执行完自动关闭。第二,收集结果时不要用 CompletableFuture 的无序流,工具结果必须按原始 toolCall 顺序放回消息列表。模型会按顺序理解“你先查了 A,再查了 B”,一旦乱序,后续推理就容易出错。

还有个性能常识:不是所有工具都应该并行。如果两个工具之间有依赖关系,比如先拿到用户 ID 才能查订单,那就老老实实串行执行。并行调用只有在工具结果互相独立时才安全。我在工具注解里加了一个parallelizable属性,默认 false,标记为 true 的工具才会被放进并行执行器。

3.3 最小可运行的配置与启动类

JavaManus 没有强绑定 Spring,但 example 模块为了方便演示,我用最朴素的 main 方法加 Properties 类完成启动。核心配置就三样:

javamanus.api.base-url=http://127.0.0.1:8089/v1 javamanus.api.api-key=${API_KEY} javamanus.api.model=agent-model javamanus.agent.max-iterations=10 javamanus.context.max-chars=12000

base-url 指向任意一个兼容大模型 HTTP 接口的服务地址,api-key 从环境变量读取,不落在配置文件里。启动类做的就是三件事:初始化 HttpChatModel,初始化 ToolRegistry 并扫描 @AgentTool 类,创建 AgentExecutor 开始对话。

我第一次跑通时犯了个低级错误:system prompt 写得太具体,把工具描述一股脑塞进去了。结果模型经常忽略后面真正的用户问题。后来我把 system prompt 收敛成几句话,只在开头声明“你是 JavaManus 智能助手,可以调用工具完成复杂任务”,后续把所有技能信息放到工具定义里。效果立刻改善。

最小配置跑通后,我建议再补一条“干跑”用例:用一个 FakeChatModel 返回写死的 JSON,验证工具执行和消息回填逻辑。这比直接接真实模型调试快得多,也是 JavaManus 设计接口隔离的初衷。

3.4 一个真实场景的完整执行链路

为了验证框架不是玩具,我用 JavaManus 跑了一个“调研发布流程”的小场景:用户问“帮我查一下明天的演示环境状态,然后根据状态更新任务清单”。

系统里注册了两个工具:get_environment_status和update_task_list。执行链路如下:

第一轮,模型收到用户问题,看到工具定义,决定先调用get_environment_status获取当前状态。JavaManus 解析出工具名和参数,页面显示“正在调用:获取环境状态”,随后把工具结果{"status":"ready","version":"v2.3.1"}作为 tool 消息追加。

第二轮,模型看到状态是 ready,判断不需要更新任务清单,直接生成最终回答:“演示环境状态正常,版本为 v2.3.1,无需更新任务清单。”整个循环只跑了两步就结束,没有多余动作。

这个过程看起来很平凡,但能平稳跑完需要同时满足四个条件:工具定义被模型正确理解、参数解析没有报错、上下文没有超限、终止条件正确触发。任何一环出问题,Agent 就会表现得“像个傻子”。这也说明,先确保最小链路稳定,再叠加复杂工具和规划能力,是更务实的路线。

4. 常见问题与排查技巧实录

4.1 模型输出不是合法 JSON

工具调用依赖模型输出结构化 JSON,但模型偶尔会耍小性子,在 JSON 前后加解释文字,或者生成截断的 JSON。JavaManus 的解析器必须做容错,而不是直接抛异常。

我采用的方案是“提取 + 重试”两步。先尝试把响应中第一对花括号到最后一对花括号之间的内容截出来,再交给 Jackson 解析;如果提取失败,就把“你刚才的输出格式不符合要求,请只输出 JSON”作为一条 system 提示追加到消息列表,让模型重新生成一次。重试次数最多两次,超过就终止本轮并报错。

注意:不要对模型输出抱有任何“一定合法”的幻想。把所有格式校验都当成运行时异常来处理,Agent 稳定性会提高一个量级。

4.2 工具参数解析与类型转换

模型返回的参数是字符串,而工具方法签名是 record。Jackson 在处理 record 时通常很顺利,但有一个高频问题:JSON 字段是snake_case,Java 字段是camelCase,反序列化直接失败。

JavaManus 里我在工具定义生成时就把参数名强制统一为 Java 字段名,并同步写入 JSON Schema。也就是说,生成给模型看的属性和 Java record 的字段名保持完全一致,不玩命名转换花活。模型填什么,框架就传什么,少一层转换就少一个出错点。

如果遇到工具执行抛 NullPointerException,我第一反应不是去猜业务逻辑,而是先打印解析后的参数对象。很多 NPE 是因为模型没传某个必填字段,Jackson 却给了 null。工具方法入口加一个显式校验会舒服很多。

4.3 上下文爆掉导致请求超时或报错

随着轮次增加,消息列表越来越长,最直观的副作用是模型响应变慢。JavaManus 的 ContextWindow 会在每次请求前估算字符数,触发滑动窗口裁剪。

有一个坑需要注意:滑动窗口虽然删除中间消息,但是在工具结果特别长的时候,窗口还是会很快被填满。解决办法是给工具结果设置“最大返回长度”,比如默认工具结果超过 2000 字符就截断,并且追加一行“(结果过长已截断)”。工具结果本来就是给模型看的关键信息,留 2000 字符足够应付绝大多数场景。

我见过一些项目把截断做成“从前往后砍”,这其实很伤:工具结果的关键信息往往出现在最前面,砍前面等于把答案扔了。JavaManus 默认保留前面,因为工具方法通常在结果开头给出“成功/失败”状态。

4.4 Agent 陷入重复调用工具的循环

模型连续三轮调用同一个工具,参数也一模一样,这是 Agent 开发里最让人头秃的问题。JavaManus 的默认三轮重试保护会强制终止循环,并抛出异常。但在生产环境,这不够。

我后来加入了“重复调用检测”:在上下文里记录最近三次工具调用的 name 和参数哈希。如果连续两次完全相同,就在下一轮系统提示里追加“你刚才的调用没有得到有效进展,请改变策略,不要再用相同参数调用同一工具”。实测表明,大部分模型在收到这条提示后会主动更换方案。

更深层的原因是工具结果表述不够友好。比如一个查询工具返回了空数组,如果工具结果只有[],模型可能认为失败了,于是反复重试。JavaManus 的工具执行器会在结果是空集合时自动追加一句“查询结果为空,换一个条件重试”的友好提示,效果立竿见影。

4.5 常见问题排查速查表

现象可能原因排查手段
工具没有被调用工具定义没注册、模型没理解触发条件打印注册表,检查工具 name 是否一致
解析 JSON 报错模型输出混有解释文字开启原始输出日志,启用提取与重试
工具结果没起效消息顺序错误、工具结果截断打印完整 messages,检查 tool 消息位置
请求耗时越来越长上下文过长查看 ContextWindow 裁剪日志
循环不终止maxIterations 太小或模型重复调用先调大上限定位,再优化工具结果
工具参数为空JSON Schema 缺 required 字段打印自动生成的工具定义 JSON

这张表基本覆盖了我从零搭建 JavaManus 过程中遇到的高频问题。遇到 Agent 行为诡异,先不要怀疑模型“太笨”,优先怀疑自己的框架在某个环节丢失了信息。

5. 几个值得坚持的工程习惯

如果让我再做一次 JavaManus,我会从一开始就坚持几个习惯,而不是踩完坑才补上。

第一,所有外部调用必须有日志追踪。Agent 本身是不确定的,没有日志根本没法复盘。JavaManus 在每次模型调用、工具执行、上下文裁剪时都会打印一行结构化日志,包含请求 ID 和迭代序号。排查问题时,顺着日志走一遍就能定位是哪一步出了问题。

第二,工具方法必须快速失败。一个工具执行了 5 分钟还没返回,Agent 的体验基本就废了。我在 @AgentTool 注解里加了一个timeoutSeconds属性,执行器调用时统一包一层超时控制。工具超时后返回一句“工具执行超时”,让模型决定是重试还是换方案,而不是卡死整个循环。

第三,不要羞于切换底层模型。JavaManus 的模型网关建立好了之后,某个模型表现不好,直接换另一个模型跑同一组测试用例就行。我自己在测试时会把一个场景的完整消息录制下来,作为回归模板。每次更换模型或调整提示词,都跑一遍回归用例,能迅速发现“这个模型不吃这套工具描述”之类的问题。

最后想说的是,Agent 框架的技术难度不在单个组件,而在把它们组织成一个稳定循环。JavaManus 现在依然是个小体量项目,但核心闭环已经完整:模型做决策,Java 做执行,工具做能力补充,上下文做记忆,日志做复盘。正如我开头说的,用 Java 写 Agent 不是赶时髦,而是让现有 Java 生态里的工程能力无缝接入智能体应用。如果你也正考虑从零搭一套自己的框架,希望这篇文章能给你一份足够扎实的路线图。

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

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

立即咨询