1. 为什么我要用 Java 从头造一个 AI Agent 框架
1.1 一个真实的需求场景
去年下半年,我所在的技术团队接到一个内部需求:把日常运维中大量重复的“查日志、比对配置、生成巡检报告”流程自动化。市面上的 AI Agent 框架不少,但绝大多数是 Python 生态的,而我们的核心业务系统、内部工具链、权限体系全部是 Java 写的。如果为了跑一个 Agent 单独维护一套 Python 服务,运维成本、人员技能栈、发布流程都要翻倍。
于是我做了一个决定:用 Java 从零构建一个轻量级的 AI Agent 框架,内部代号 JavaManus。它的目标很明确——让 Java 开发者能用自己熟悉的语言和工程习惯,快速搭建出具备“思考-行动-观察-再思考”循环能力的智能体,而不是被 Python 生态绑定。
这篇文章不是一篇“框架使用手册”,而是我在设计和实现过程中,对架构选型、核心模块、踩坑经验的完整复盘。如果你也是 Java 后端,想在自己的系统里嵌入 Agent 能力,或者单纯好奇“Java 到底能不能做好这件事”,那这篇内容应该能给你一些直接可用的参考。
1.2 JavaManus 到底解决了什么问题
先说清楚定位。JavaManus 不是一个“大而全”的平台,它更像是一个可嵌入的 Agent 运行时内核。你可以把它理解成一个“决策循环引擎”:给它一个目标,它会自动拆解任务、调用你注册的工具、根据工具返回结果决定下一步,直到任务完成或达到终止条件。
它解决的核心痛点有三个:
- 语言鸿沟:Java 团队不需要引入 Python 运行时,直接用 Maven 依赖就能接入。
- 工具集成成本:企业内部大量 Java 方法、HTTP 接口、数据库查询,可以通过注解或配置快速注册为 Agent 可调用的工具。
- 可控性:每一步决策、每一次工具调用都有完整的日志和状态快照,方便排查“它为什么这么做”。
适合谁来参考?我认为是三类人:一是 Java 后端工程师,想在现有系统里加一点“智能决策”能力;二是架构师,在评估自研 Agent 框架的可行性;三是对 Agent 原理感兴趣、但不想被 Python 框架细节淹没的学习者。
2. 整体架构设计与核心思路拆解
2.1 为什么选择“循环+工具”而不是“大模型直出”
很多人第一次接触 Agent,会觉得“不就是让大模型多轮对话吗”。但真正落地时会发现,纯对话式的大模型有两个致命问题:一是它无法主动获取实时信息,二是它无法执行有副作用的操作(比如重启服务、写数据库)。
JavaManus 的核心设计思想是ReAct 模式的 Java 化实现:Reason(推理)+ Act(行动)。每一轮循环里,Agent 先根据当前上下文和可用工具列表,生成一个“思考”和一个“行动指令”;行动指令被解析后,由框架调用对应的 Java 工具;工具返回的“观察结果”再被拼回上下文,进入下一轮。
这个循环看起来简单,但工程上有大量细节:如何解析大模型输出的行动指令?如何保证工具调用的参数类型安全?如何防止无限循环?这些才是框架真正的价值所在。
2.2 分层架构:把“智能”和“工程”分开
我把 JavaManus 分成四层,每层职责单一,方便替换和测试:
| 层级 | 职责 | 关键类/接口 |
|---|---|---|
| 接入层 | 接收用户目标,管理会话生命周期 | AgentSession、AgentRequest |
| 决策层 | 调用大模型,解析思考与行动 | Planner、ActionParser |
| 执行层 | 工具注册、参数绑定、调用与结果封装 | ToolRegistry、ToolInvoker |
| 观测层 | 日志、状态快照、循环控制 | AgentContext、LoopGuard |
这样分层的最大好处是:决策层可以换不同的大模型供应商,执行层可以换不同的工具类型,互不影响。我在实际项目中就遇到过“上午用 A 模型,下午因为限流换成 B 模型”的情况,只改了一个配置类,其他代码零改动。
2.3 核心数据结构:AgentContext 的设计取舍
AgentContext 是整个框架的“记忆体”,它需要保存:用户原始目标、每一轮的思考、行动、观察结果、当前轮次、已调用工具历史。
我最初的设计是用一个 List 存所有消息,简单直接。但很快发现两个问题:一是上下文会无限增长,最终超出模型窗口;二是排查问题时很难快速定位“第几轮发生了什么”。
后来改成分轮次的结构化存储:每一轮是一个 RoundRecord,包含 thought、action、observation 三个字段。这样既方便做窗口截断(只保留最近 N 轮),也方便在日志里按轮次打印。这个改动看起来小,但对调试体验的提升是巨大的。
提示:上下文管理是 Agent 框架最容易忽视的部分。不要等到 token 超限才想起来做截断,最好在设计初期就把“轮次”作为一等公民。
3. 核心模块的详细实现与实操要点
3.1 工具注册机制:注解驱动的设计
工具是 Agent 的“手脚”。在 Java 里,最自然的注册方式就是注解。我定义了一个@AgentTool注解,可以标注在方法上:
@AgentTool(name = "queryLog", description = "根据服务名和时间范围查询日志") public String queryLog( @ToolParam("serviceName") String serviceName, @ToolParam("startTime") String startTime) { // 实际查询逻辑 return logService.query(serviceName, startTime); }框架启动时扫描所有带@AgentTool的方法,把方法名、描述、参数信息注册到 ToolRegistry。描述信息非常关键——它会被拼进给大模型的提示词里,模型靠这个描述来决定“什么时候该调用哪个工具”。
这里有个实操心得:工具描述要写得像给新人看的 API 文档。我试过把描述写成“查询日志”,模型经常在不需要的时候乱调;改成“根据服务名和起止时间查询指定服务的运行日志,返回最近 100 条”,调用准确率明显提升。
3.2 行动指令解析:如何让模型输出可执行的 Java 调用
这是整个框架最“脆”的环节。大模型输出的是自然语言,我需要从中提取出结构化的工具名和参数。我的做法是:在提示词里强制约定输出格式,用类似 JSON 的结构:
{ "thought": "我需要先查询订单服务的日志", "action": "queryLog", "actionInput": { "serviceName": "order-service", "startTime": "2024-01-01 00:00:00" } }然后在 Java 侧用 Jackson 解析。但模型并不总是听话,有时候会多输出一段解释,有时候 JSON 格式有细微错误。我的应对策略是三层防护:
- 提示词约束:明确说“只输出 JSON,不要有其他内容”。
- 容错解析:先用正则提取第一个
{到最后一个}之间的内容,再尝试解析。 - 失败重试:如果解析失败,把错误信息作为“观察结果”喂回模型,让它重新生成。
实测下来,加了第三层之后,解析失败导致的循环中断减少了 90% 以上。
3.3 参数类型绑定:从 JSON 到 Java 方法参数
解析出 actionInput 后,需要把它绑定到具体 Java 方法的参数上。这里我用的是反射 + 类型转换。难点在于 JSON 里的数字可能是 Integer,但方法参数可能是 Long 或 Double。
我的处理方式是:读取方法参数的@ToolParam注解拿到参数名,从 JSON 里取值,然后根据参数声明的类型做转换。对于复杂对象,直接用 Jackson 的convertValue方法。这个过程必须做严格的异常捕获,因为一旦类型不匹配,整个循环就会崩。
注意:不要让工具方法抛出未捕获的异常。框架会把异常信息作为观察结果返回给模型,这其实是好事——模型看到“参数类型错误”后,下一轮往往会自己修正。
3.4 循环控制:防止 Agent “鬼打墙”
Agent 最常见的失控场景是:反复调用同一个工具,得到同样的结果,却一直不结束。我在 LoopGuard 里设了三道闸:
- 最大轮次限制:默认 10 轮,超过就强制终止并返回当前结果。
- 重复行动检测:如果连续两轮的 action 和 actionInput 完全一致,直接判定为死循环,终止。
- 无进展检测:如果连续三轮的 observation 内容高度相似(用简单的编辑距离判断),也终止。
这三道闸不是拍脑袋定的,是我在实际跑任务时一次次“救火”总结出来的。尤其是第二道,能拦住大部分因为提示词歧义导致的重复调用。
4. 完整实操流程:从零跑通一个巡检 Agent
4.1 环境准备与依赖引入
假设你有一个标准的 Maven 项目,引入 JavaManus 的核心依赖(这里用虚构的 groupId 示意):
<dependency> <groupId>com.example.agent</groupId> <artifactId>javamanus-core</artifactId> <version>1.0.0</version> </dependency>然后需要一个能调用大模型的客户端。JavaManus 把模型调用抽象成LlmClient接口,你只需要实现一个chat(List<Message>)方法。我用的是 HTTP 方式调用兼容 OpenAI 格式的接口,核心代码大概三十行,用 Java 11 的 HttpClient 就够了。
4.2 定义你的第一个工具集
假设我们要做一个“服务巡检 Agent”,先定义两个工具:
@Component public class InspectionTools { @AgentTool(name = "getServiceStatus", description = "获取指定服务的当前运行状态,返回 UP 或 DOWN") public String getServiceStatus(@ToolParam("serviceName") String serviceName) { return monitorClient.getStatus(serviceName); } @AgentTool(name = "getErrorCount", description = "获取指定服务最近一小时的错误日志数量") public int getErrorCount(@ToolParam("serviceName") String serviceName) { return logClient.countErrors(serviceName, 1); } }这两个工具都很简单,但组合起来就能让 Agent 完成“检查服务是否正常,如果不正常再看错误数”这样的逻辑。
4.3 启动 Agent 并观察执行过程
启动代码非常简洁:
AgentSession session = AgentSession.builder() .llmClient(new HttpLlmClient(apiKey, endpoint)) .toolRegistry(toolRegistry) .maxRounds(8) .build(); AgentResult result = session.run("请检查 order-service 和 user-service 的运行状态,如果有服务异常,报告其错误日志数量"); System.out.println(result.getFinalAnswer());跑起来之后,控制台会按轮次打印日志。我截取一段真实的执行记录(服务名已脱敏):
[Round 1] Thought: 需要先检查两个服务的状态 Action: getServiceStatus(order-service) Observation: UP [Round 2] Thought: order-service 正常,继续检查 user-service Action: getServiceStatus(user-service) Observation: DOWN [Round 3] Thought: user-service 异常,需要查询错误数 Action: getErrorCount(user-service) Observation: 47 [Round 4] Thought: 已获得所有信息,可以总结 Final Answer: order-service 运行正常;user-service 状态为 DOWN,最近一小时错误日志 47 条,建议立即排查。整个过程 4 轮结束,没有多余调用。这个例子虽然简单,但完整展示了 Agent 的核心价值:根据中间结果动态决定下一步,而不是执行固定的脚本。
4.4 参数计算与阈值设定
在更复杂的场景里,工具可能需要数值参数。比如“查询最近 N 分钟的错误日志”,N 应该由模型决定还是框架固定?我的经验是:能固定就固定,必须动态才交给模型。因为模型对数值的推理并不稳定,让它算“最近两小时等于多少分钟”经常出错。
如果确实需要动态计算,我会在工具内部做转换,比如参数传“2h”“30m”这样的字符串,由 Java 代码解析成分钟数。这样模型只需要做它擅长的语义理解,数值计算交给确定性代码。
5. 常见问题与排查技巧实录
5.1 模型不按格式输出怎么办
这是最高频的问题。我的排查顺序是:
- 先看提示词里有没有明确说“只输出 JSON”。
- 再看模型返回的原始内容,是不是被 Markdown 代码块包裹了(比如
json ...)。如果是,解析前先剥掉代码块标记。 - 如果还是不行,降低 temperature 参数,通常 0.1 以下格式稳定性会好很多。
我整理了一个速查表:
| 现象 | 可能原因 | 解决方式 |
|---|---|---|
| 输出多余解释文字 | 提示词约束不够强 | 在提示词末尾加“不要输出任何解释” |
| JSON 被代码块包裹 | 模型习惯性行为 | 解析前用正则剥离 ``` 标记 |
| 参数名拼写错误 | 模型幻觉 | 在提示词里列出所有工具的参数名 |
| 反复调用同一工具 | 观察结果没被正确拼回上下文 | 检查上下文拼接逻辑,确保 observation 非空 |
5.2 工具调用超时或异常如何处理
工具方法可能因为网络、数据库等原因超时。我的做法是给每个工具调用包一层超时控制,默认 10 秒。超时后不抛异常,而是返回一个字符串“工具调用超时,请稍后重试或换一种方式”。这样模型有机会调整策略,而不是整个 Agent 崩溃。
注意:不要让工具异常直接冒泡到框架顶层。Agent 的健壮性很大程度上取决于“它能不能从失败中恢复”。
5.3 上下文太长导致模型“失忆”
当轮次多了之后,早期的重要信息可能被挤出窗口。我的解决方案是关键信息摘要:在每一轮结束时,如果 observation 超过一定长度,就截断并保留前 200 字加“...”。同时在上下文最前面始终保留用户原始目标,确保模型不跑偏。
另一个技巧是:把工具列表放在系统提示词里,而不是每轮都拼进去。这样能省下不少 token。
5.4 独家避坑:不要在工具里做“智能”判断
我踩过最大的坑,是在工具方法里写了一些“如果参数为空就默认查所有服务”的逻辑。结果模型发现传空参数也能工作,就再也不传具体服务名了,导致每次都是全量查询,性能极差。
教训是:工具应该严格、笨拙、确定性。智能判断留给模型,工具只做精确执行。参数缺失就报错,让模型自己学会传对参数。
6. 框架扩展与后续演进方向
6.1 支持多 Agent 协作
单 Agent 适合线性任务,但复杂场景需要多个 Agent 分工。我在 JavaManus 上做了一个简单扩展:允许把一个 Agent 注册为另一个 Agent 的工具。这样“巡检 Agent”可以调用“日志分析 Agent”,形成层级结构。
实现上并不复杂,只需要把 AgentSession 包装成一个带@AgentTool的方法。但要注意控制递归深度,否则可能出现 A 调用 B、B 又调用 A 的死循环。
6.2 加入短期记忆与长期记忆
目前 AgentContext 只保存单次会话的记忆。如果要跨会话记住“上次巡检发现 user-service 有问题”,就需要引入持久化。我的做法是用一个简单的 KV 存储,把每次任务的最终结论按服务名索引存下来,下次任务开始时作为背景信息注入。
这个改动让 Agent 从“一次性工具”变成了“有经验的助手”。实测在重复巡检场景下,模型能更快定位到历史问题服务。
6.3 可观测性:把黑盒变成白盒
Agent 最大的信任障碍是“不知道它为什么这么做”。我在框架里加了一个简单的 Trace 机制:每一轮的 thought、action、observation、耗时都记录到结构化日志里,可以导出成 JSON 供前端展示。
这个功能在内部推广时帮了大忙。当运维同学看到 Agent 的完整思考链后,对它的信任度明显提升,也更愿意反馈“哪一步判断错了”。
7. 一些个人体会
用 Java 做 AI Agent 框架,最大的挑战不是语言本身,而是工程思维和模型思维的切换。Java 开发者习惯确定性、强类型、可预测;而大模型是概率性的、模糊的、需要容错的。JavaManus 的设计过程,本质上是在这两者之间找平衡点。
我个人的经验是:把模型当成一个“聪明但不可靠的实习生”。给它清晰的指令、明确的工具、严格的边界,同时准备好它犯错时的兜底方案。框架的价值不在于让模型变得完美,而在于让模型的不完美变得可管理。
最后分享一个小技巧:在调试阶段,把每一轮的完整提示词和模型原始返回都打印出来。虽然日志会很长,但当你看到模型“为什么突然跑偏”时,答案往往就藏在某一句提示词的措辞里。这个习惯帮我省下了大量猜测时间。