☰
Java AI Agent框架开发实战:架构设计、工具调用与可观测性
2026/10/22 5:48:50 网站建设 项目流程

老实说,接到“用 Java 从零搞一个 AI Agent 框架”这个需求时,我第一反应是抗拒的。团队里所有 Agent 原型都是 Python 写的,几行代码就能跑通一个 ReAct 循环,模型调用、工具注册、上下文拼接都有现成的库。可一旦要把 Agent 塞进真正的后台服务里,事情就变了:任务调度在 Java 侧,配置中心在 Java 侧,灰度发布、日志追踪、权限控制全在 Java 侧。Python 跑得再快,跟这套基础设施对接也要花掉一大半精力。

所以我干脆把 JavaManus 这个框架从头写了一遍。它能做什么呢?简单说就是三件事:把模型推理抽象成一个可替换的组件,把工具调用变成一套自动发现、自动参数映射、自动执行的机制,再给 Agent 一个可控的循环骨架,让它能稳定地在“思考-行动-观察”之间转圈直到任务完成。这个框架适合谁参考?如果你正在 Java 生态里做 Agent 相关的事情,或者你被类似问题缠住——模型输出乱、工具参数对不上、Agent 跑着跑着死循环、上下文越塞越长——这篇文章里应该有你需要的答案。

这篇内容不打算讲大道理,我直接按自己设计 JavaManus 时的思路,把整体架构、核心实现、实操过程、踩坑记录和可观测性建设都拆开过一遍。你看到的所有代码逻辑,都来自项目里真实运行过的版本,可能不豪华,但该有的边界都摸过了。

1. 框架为什么这么拆:JavaManus 的整体架构与设计取舍

1.1 为什么不用 Python 那套方案硬搬到 Java

Python 生态里做 Agent 之所以顺手,是因为所有东西都自带“动态”属性。函数签名、参数类型、调用关系在运行时随便变,模型返回一个工具名和参数,直接反射调一下就能把能力接上。Java 是强类型语言,这套玩法天然水土不服,但反过来想,这也是一种优势:类型系统可以在“工具被调用之前”就把参数错误挡在门外,而不是等模型瞎传一个字符串进来才报错。

我在 JavaManus 里最核心的一个决定,就是把“模型”“工具”“记忆”“循环”这四件事拆成独立模块,各自定义极小的接口,谁也不要管谁内部怎么实现。模型层只负责“给一段上下文,返回一段结果”,工具层只负责“给我参数,返回一个字符串”,记忆层只负责“存和取”,循环层只负责“调度”。这样做的直接好处是,换模型不需要动业务代码,加工具不需要改循环逻辑,排查问题的时候也能顺着模块边界快速定位。

1.2 整体架构与关键模块划分

JavaManus 分四层,从上到下分别是接口层、调度层、能力层和基础设施层。接口层暴露给业务方的是AgentRuntime,你只需要给它一个任务字符串,它返回一个结果对象。调度层是核心循环,也就是AgentExecutor,它负责按照某种策略调用模型、解析输出、执行工具、追加观察结果。能力层包括三个可插拔组件:ChatModel抽象、ToolRegistry工具注册中心、MemoryStore记忆存储。基础设施层则是日志、追踪、配置这些通用能力,不参与业务决策。

模块之间没有隐式依赖,全部通过接口引用。举个例子,AgentExecutor只知道ToolRegistry有execute(String name, Map<String, Object> args)这个方法,它不关心这个工具是查数据库、调 HTTP 接口还是算数学表达式。这种设计让我在后期接各种公司的内部系统时省了很多事,工具怎么实现、怎么改,完全不污染主循环。

1.3 几个重要的设计取舍

第一个取舍是默认循环选了 ReAct 而不是 Plan-and-Execute。ReAct 的好处是每一步都有明确输入输出,容易追踪和设置边界;Plan-and-Execute 看起来更高效,但“先规划再执行”的中间状态很难校验,一旦计划错了,后面全白搭。Java 不像 Python 那么好修修补补,所以我宁愿让 Agent 走细粒度一点的循环,换来可控性。

第二个取舍是工具注册用注解扫描而不是手写 if-else。在 Java 里手写分发逻辑确实简单直接,但每加一个工具就要动一遍调度代码,维护成本随工具数量线性上涨。JavaManus 用@AgentTool注解标记工具类,启动时扫包注册,新工具几乎零成本接入。

第三个取舍是模型抽象只定义了三个方法:chat、chatStream、structuredChat。很多框架恨不得把模型的所有能力都暴露出来,结果就是适配层写了一大堆,业务侧根本用不上。JavaManus 只保留最必要的能力,商业化模型和开源模型都只需要实现这三个方法就能接入。

2. 核心模块怎么落地:Agent 循环、模型接入、工具注册、记忆控制的实现细节

2.1 Agent 主循环:从思考到行动的闭环

主循环是整个框架的心脏。我把 Agent 的状态压缩成四个阶段:THINK(调用模型生成下一步计划)、ACT(解析模型输出并执行工具)、OBSERVE(把工具结果追加到上下文)、FINISH(输出最终答案)。从状态机的角度看,它就是一个有限状态自动机,每个状态都有明确的出口和异常分支。

实际代码里,核心循环的骨架长这样:

public AgentRunResult run(String task) { int step = 0; memory.appendUserMessage(task); while (step < maxSteps) { step++; LLMOutput output = chatModel.complete(memory.messages(), tools()); if (output.isFinal()) { return AgentRunResult.success(output.finalAnswer()); } if (output.isToolCall()) { String toolResult = toolRegistry.execute( output.toolName(), output.toolArguments() ); memory.appendToolResult(output.toolName(), toolResult); } } return AgentRunResult.fail("reach max steps: " + maxSteps); }

注意这里有两个关键的边界控制:maxSteps和一个隐藏的loopDetector。maxSteps是硬上限,防止 Agent 因为某一步没收敛就无限跑下去;loopDetector则记录最近几轮的“工具名 + 参数摘要”,如果发现完全相同的调用连续出现三次,直接判定为循环并终止。这俩是我在跑真实业务时被坑出来的,缺一个,生产环境就会给你颜色看。

2.2 模型接入层与结构化输出的坑

模型接入层是每个 Agent 框架里最繁琐的部分。JavaManus 定义了一个ChatModel接口,核心方法就一个:

public interface ChatModel { LLMOutput complete(List<ChatMessage> messages, List<ToolDefinition> tools); }

实现这个接口时,第一件事就是处理“对话消息”和“工具定义”这两个对象的模型兼容问题。很多模型服务商要求工具定义走独立字段,走标准 OpenAI 兼容接口可能字段名略有不同。我当时踩过最大的坑是:模型返回的“结构化输出”并不是真正的 JSON,而是把 JSON 嵌在json ...这种 Markdown 代码块里,直接JsonUtils.parse必然抛异常。

我现在的处理方式是写一个三层解析器:先去 Markdown 代码块标记,再去掉外层包装文本,最后用 Jackson 解析。如果前两层都失败,就尝试正则抽取最外层花括号。这个方法不优雅,但实测能救回大约 20% 的脏输出。

2.3 工具注册与执行机制

工具注册我用的是“注解 + ClassPath 扫描”的组合。每个工具类上加@AgentTool注解,声明工具名、描述、参数定义:

@AgentTool( name = "order_query", description = "根据订单号查询订单状态", params = {"orderId:string:订单号"} ) public class OrderQueryTool implements ToolExecutor { @Override public String execute(Map<String, Object> args) { String orderId = (String) args.get("orderId"); return mockOrderService.query(orderId); } }

启动时,框架扫描指定包下的ToolExecutor实现,解析注解并把工具名注册到ToolRegistry。执行时,ToolRegistry负责把模型传来的参数做类型转换。这里有几个容易翻车的地方:模型可能传"123"字符串给int类型参数,可能把orderId传成null,还可能在参数名上多一个空格。我在参数转换层统一做了宽松处理:按名字匹配,忽略大小写和首尾空格;类型转换失败时不直接抛异常,而是尝试String.valueOf,再失败才报错。

工具执行的超时和隔离也值得提一下。每个工具调用都跑在线程池里,超时时间按工具属性单独配置,默认 30 秒。这样做是防止某个外部接口卡死导致整个 Agent 悬挂。线程池隔离则是避免一个慢工具占满所有 worker,影响其他任务的并发执行。

2.4 记忆管理与上下文长度控制

Agent 的记忆不是简单的“把所有对话都塞进 prompt”。我在 JavaManus 里把记忆分成两层:短期记忆是当前任务的多轮消息列表,长期记忆做 KV 存储,用来跨任务保存关键信息,比如用户偏好、订单状态等。短期记忆默认用滑动窗口,保留最近 20 条消息,超出部分走摘要。

上下文长度是 Agent 框架绕不开的硬约束。模型输入窗口有限,而每轮工具观察结果都往里面塞,三轮下来 token 可能就翻了倍。我自己的策略是:设置一个“上下文预算”,比如 16K token。消息列表先全部占位,估算每条消息的 token 数,超过预算后,把较早的“用户-助手-观察”三条消息合并成一条摘要消息,替换掉原文。这个策略线上跑了很久,既保住了核心信息,又控制了成本。

3. 从空项目到跑通第一个 Agent:实操步骤与关键代码

3.1 项目骨架与依赖选型

JavaManus 本身是 Maven 多模块工程,核心模块分三个:

  • javaManus-core:Agent 运行时、接口定义、循环调度
  • javaManus-tools:预置常用工具,比如时间工具、HTTP 工具、Mock 业务工具
  • javaManus-spring-boot-starter:给基于 Spring Boot 的项目提供自动配置和扫描

如果你只想快速试一遍,不需要 Spring Boot,javaManus-core加一个简单的 main 方法就够。核心模块的依赖很少:Jackson 做 JSON 序列化,SLF4J 做日志,其他全用 JDK 自带能力。我特意控制依赖数量,因为 Agent 框架寄生在业务系统里,依赖越多冲突概率越大。

3.2 最小可运行版本的实现

先看最基础的用法。假设我们要写一个 Agent,让它根据用户的提问调用两个工具:一个查时间,一个查订单。第一步是注册工具,第二步是初始化运行时,第三步是提交任务。

初始化运行时最关键的一步是装配模型。我写了一个简单的适配器,指向一个兼容大部分模型服务的 HTTP 接口:

ChatModel model = new HttpChatModel.Builder() .endpoint(System.getenv("LLM_ENDPOINT")) .apiKey(System.getenv("LLM_API_KEY")) .modelName("custom-chat-model") .temperature(0.2) .build(); ToolRegistry registry = new ToolRegistry(); registry.register(new CurrentTimeTool()); registry.register(new OrderQueryTool()); AgentRuntime runtime = new AgentRuntimeBuilder() .model(model) .toolRegistry(registry) .maxSteps(8) .build(); AgentRunResult result = runtime.run("帮我查一下订单A10086的物流状态"); System.out.println(result.getAnswer());

这段代码跑起来之后,日志会输出每一步的思考、行动和观察结果。第一次跑通时,我在日志里看到的完整轨迹大概是这样的:

思维过程: 用户想知道订单状态,我需要先查订单信息。 行动计划: 调用 order_query 工具,传入订单号 A10086 执行工具: order_query({"orderId":"A10086"}) 观察结果: {"status":"SHIPPED","carrier":"SF","tracking_no":"SF123456"} 思维过程: 订单已发货,可以整理物流信息回复用户。 最终回答: 您的订单A10086已发货,物流公司为顺丰,运单号是SF123456。

这个轨迹看着简单,但它完整走完了 Agent 的闭环。我在做这一步时最大的体会是:不要急着加复杂能力,先把一个工具的闭环跑通,后面所有设计都是在这个闭环上做延长。

3.3 一个真实业务场景:让 Agent 完成带状态变更的操作

工具不是只能“查”,还能“改”。我在内部接了一个模拟订单服务的场景:Agent 先查询订单状态,如果订单处于“待支付”状态,就调用支付接口;支付成功后调用出库接口;出库完成再回复用户结果。这个场景牵扯到多个工具的串联,而且中间决定依赖前一步的观察结果,非常适合检验框架的序贯决策能力。

我把三个工具(order_query、order_pay、order_deliver)注册进同一个ToolRegistry,然后给 Agent 一个任务:“订单A10086已经确认支付,帮我完成出库操作。”JavaManus 会按模型逐步调用工具,而不是一次性执行完所有工具。之所以不设计成“一次性批量调用”,是因为现实中每个步骤的结果都可能影响下一步的方向,依赖前一步结果才能做出正确决策。

这个场景跑通之后,我发现一个隐藏需求:工具执行结果里如果包含错误信息,Agent 需要能自动判断“是否重试”“是否换一个工具”“是否直接回复用户失败原因”。于是我又在工具结果里加了一个status字段,SUCCESS和FAILURE两态,循环层对FAILURE结果不追加到正常上下文,而是加入一条系统消息提示 Agent 反思。这个小改动让 Agent 的行为稳定了一大截。

3.4 参数调优:温度、最大步数和超时

JavaManus 里可调的参数不算多,我常用的几个都列在框架的配置项里。刚开始做的时候我把温度设成 0.7,结果模型在调用工具时经常“发挥创意”,比如给不存在的参数编值,或者自作主张跳过某些步骤。后来在 Agent 工具调用场景里,我把温度默认改为 0.2,效果立刻稳了很多。

参数默认值建议区间作用
temperature0.20.1 - 0.4降低输出随机性,保证工具调用准确
maxSteps85 - 15限制推理轮数,防止死循环
toolTimeout30s5s - 60s单次工具调用的最大等待时间
contextBudget16K8K - 32K控制输入模型的上下文 token 上限

参数调优的过程实际上是个取舍过程:温度越高,模型越“聪明”但也越“飘”;最大步数越多,Agent 能完成更复杂的长链路,但成本和延迟也上去了。我给每条规则都设置了上限,让运行时在超限之后主动终止而不是静默继续,这样至少不会失控。

4. 跑起来之后会踩哪些坑:常见故障与排查实录

4.1 Agent 陷入死循环,日志里全是重复工具调用

这是 Agent 框架绕不开的第一个坑。症状很典型:日志里同一个工具名和参数反复出现,直到触发 maxSteps 才停下。一开始我以为是模型能力问题,后来发现根源往往在“反馈信号太弱”:工具返回的结果没有给模型足够的信息,让它意识到“这个动作已经做过了”。

我做了两个修复。第一个是循环检测器,记录最近五次调用的工具名和参数签名,如果重复次数超过三次,直接中断循环并返回失败原因;第二个是改进工具返回文案——如果一个工具返回的数据和上一轮完全相同,观察结果里会追加一句“该结果与上一次查询一致,请尝试其他方法或直接回复用户”。这两个修复配合起来,彻底杜绝了无限空转。

4.2 模型输出不合法 JSON,工具参数解析失败

有一个阶段,工具调用成功率长期停留在 90% 左右,剩下 10% 全是 JSON 解析异常。我详细翻日志,发现模型返回的“工具调用”五花八门:有时输出带转义符的 JSON,有时参数值里混入了额外文本,有时干脆把思考过程一起塞进参数里。

我的三层解析器只是兜底方案,更有效的办法是在发给模型的 prompt 里把格式约束写到极致。我会明确列出所有工具的参数结构,包括每个参数的类型、是否必填、示例值。对于复杂嵌套参数,直接给一个“标准 JSON 示例”作为单样本示例。同时,在解析失败时不要硬抛异常,而是返回一条错误消息给模型,让它自己修正。这个“错误反馈循环”让最终成功率稳定到了 99% 以上。

4.3 工具参数的类型转换错误

模型传参时,字符串和数字之间经常转换不精确。比如订单金额是"1,299.00",模型传给工具的参数却是1299,如果工具层严格按String接收就没事,但一旦工具参数声明为BigDecimal,解析就可能出错。

我在ToolRegistry里加了一个参数转换器,按目标类型做宽松转换:整数类型会先去逗号、去空格,再解析;布尔类型接受 true/false/1/0;时间类型默认用 ISO 格式解析,失败时尝试时间戳。转换失败时,错误信息里会带上“期望类型”和“实际值”,方便模型看到提示后自行纠正。这里一定要把错误信息写得具体,笼统的“参数不合法”对模型没有任何帮助。

4.4 上下文过长导致 token 成本飙升

跑得越多,越发现 token 消耗是个隐形炸弹。特别是任务复杂时,每轮观察结果都完整塞进上下文,一轮任务下来可能消耗几万 token。我在 2.4 里提到的“上下文预算 + 滑动窗口”方案,是线上一段时间压测后总结出来的。

实际执行时,我还会对工具结果做“截断存储”:超过 500 字符的工具返回,只保留前 500 字符,同时附上“结果过长已截断”的标记。对于 JSON 类型的工具返回,我做了字段级裁剪,只保留关键字段。这一步对成本控制的效果立竿见影,相同任务下 token 消耗大约下降了 40%。

4.5 并发调用时模型客户端不是线程安全的

Java 场景下这是很容易踩的雷。很多模型 SDK 底层复用一个 HTTP client,如果并发场景下共用同一个 connection pool 且没有正确做连接管理,轻则连接池耗尽,重则数据错乱。我的处理是:在HttpChatModel内部用独立的HttpClient实例,并且所有请求参数在进入调用前先同步复制一份,避免并发修改。

另外,工具执行线程池也做了隔离。业务系统里常有耗时任务和其他后台任务共用线程池,如果一个 Agent 工具调用卡住,可能拖垮整个应用。JavaManus 里每个AgentRuntime默认创建一个独立线程池,核心线程数和最大线程数都可配,避免跨任务相互影响。

4.6 问题速查表

症状可能原因解决方法
反复调用同一工具直到超限反馈信号不足,上下文无变化开启循环检测器,补充“结果与上轮一致”提示
工具参数解析失败模型输出格式不规范三层 JSON 解析器 + 错误反馈循环
类型转换异常字符串带格式符号宽松转换器,报错带上期望类型和实际值
token 消耗过高上下文无限膨胀滑动窗口 + 摘要 + 工具结果截断
并发下请求超时或响应错乱模型客户端线程不安全每个运行时独立 HTTP 客户端,参数复制副本

5. 把 Agent 管起来:可观测性建设与后续演进方向

5.1 给 Agent 加上完整的可观测能力

Agent 和普通接口最大的区别在于:它不是一个“请求-响应”的确定性过程,而是一连串连续决策。真出问题时,你很难回答“它为什么这么做”。JavaManus 在可观测性上做了三件事:结构化日志、链路追踪、决策轨迹回放。

结构化日志里,每次模型调用、工具执行、上下文变更都记录为一个带有 traceId 的事件,事件包含入参、出参、耗时和异常信息。链路追踪则把一次 Agent 运行的全过程串成一条完整 trace,中间任何一次模型调用或工具执行都有一个 span。决策轨迹回放是我个人觉得最有用的功能:把每一步的思考文本和工具调用结果存下来,事后可以像回看对话一样把整个决策过程再过一遍。线上问题大多靠这个功能定位,比直接看代码顺手多了。

5.2 灰度与降级:Agent 不稳定的应对策略

Agent 再完善,本质上也带有概率性。同样的输入,今天能完成,明天可能就换了一条路。我的经验是,把 Agent 当作一个“不可完全确定性”的组件来对待,业务侧必须做兜底。

JavaManus 的降级策略有两个维度。一是模型维度:如果一个高能力模型调用连续失败,可以自动降级到备用模型;二是工具维度:如果关键工具连续超时,Agent 应主动放弃相关操作并告知用户部分任务未完成,而不是反复重试导致调用风暴。框架里我提供了一个RecoveryPolicy接口,业务方可以自定义失败后的处理策略,默认策略是“重试一次后放弃并返回失败原因”。

5.3 多 Agent 协作和自适应编排是下一步

框架跑稳之后,自然就会往“多 Agent”方向走。JavaManus 目前支持多个AgentRuntime实例并行,但协作编排还没完全沉淀成通用能力。我的计划是在调度层之上加一个 Coordinator 组件,负责把一个大任务拆成多个子任务分发给不同 Agent,再汇总结果。这个设计还在演进中,但基础模块已经满足了拆分条件:每个 Agent 独立运行,通过消息队列或共享存储交换结果。

我自己的体会是,做多 Agent 之前,最好先确保单 Agent 的每一步都可观测、可回放、可插拔。如果单 Agent 都还在抽风,把它拆成十个只会获得十倍的抽风。框架层面不要急着堆功能,先把“每一步都能被理解”这件事做扎实,复杂编排才有意义。

最后分享一个我实际操作中的小技巧:在开发阶段,尽量把temperature调低、maxSteps调小,先把工具链路跑通。等链路稳定了,再慢慢加大模型自由度,观察它能不能自己走通更长的路径。这个过程有点像先教孩子按固定路线走,再让他自己去探索地图。JavaManus 这套东西跑了几个月之后,我最深的感受就是:Java 做 Agent 最大的障碍从来不是生态或工具链,而是我们对“不确定的运行过程”的容忍度太低。一旦接受 Agent 是一个需要持续观测、调参、兜底的长期项目,Java 的强类型和工程化能力反而会变成它的最大优势。

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

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

立即咨询