老实说接到“用 Java 从零搞一个 AI Agent 框架”这个需求时我第一反应是抗拒的。团队里所有 Agent 原型都是 Python 写的几行代码就能跑通一个 ReAct 循环模型调用、工具注册、上下文拼接都有现成的库。可一旦要把 Agent 塞进真正的后台服务里事情就变了任务调度在 Java 侧配置中心在 Java 侧灰度发布、日志追踪、权限控制全在 Java 侧。Python 跑得再快跟这套基础设施对接也要花掉一大半精力。所以我干脆把 JavaManus 这个框架从头写了一遍。它能做什么呢简单说就是三件事把模型推理抽象成一个可替换的组件把工具调用变成一套自动发现、自动参数映射、自动执行的机制再给 Agent 一个可控的循环骨架让它能稳定地在“思考-行动-观察”之间转圈直到任务完成。这个框架适合谁参考如果你正在 Java 生态里做 Agent 相关的事情或者你被类似问题缠住——模型输出乱、工具参数对不上、Agent 跑着跑着死循环、上下文越塞越长——这篇文章里应该有你需要的答案。这篇内容不打算讲大道理我直接按自己设计 JavaManus 时的思路把整体架构、核心实现、实操过程、踩坑记录和可观测性建设都拆开过一遍。你看到的所有代码逻辑都来自项目里真实运行过的版本可能不豪华但该有的边界都摸过了。1. 框架为什么这么拆JavaManus 的整体架构与设计取舍1.1 为什么不用 Python 那套方案硬搬到 JavaPython 生态里做 Agent 之所以顺手是因为所有东西都自带“动态”属性。函数签名、参数类型、调用关系在运行时随便变模型返回一个工具名和参数直接反射调一下就能把能力接上。Java 是强类型语言这套玩法天然水土不服但反过来想这也是一种优势类型系统可以在“工具被调用之前”就把参数错误挡在门外而不是等模型瞎传一个字符串进来才报错。我在 JavaManus 里最核心的一个决定就是把“模型”“工具”“记忆”“循环”这四件事拆成独立模块各自定义极小的接口谁也不要管谁内部怎么实现。模型层只负责“给一段上下文返回一段结果”工具层只负责“给我参数返回一个字符串”记忆层只负责“存和取”循环层只负责“调度”。这样做的直接好处是换模型不需要动业务代码加工具不需要改循环逻辑排查问题的时候也能顺着模块边界快速定位。1.2 整体架构与关键模块划分JavaManus 分四层从上到下分别是接口层、调度层、能力层和基础设施层。接口层暴露给业务方的是AgentRuntime你只需要给它一个任务字符串它返回一个结果对象。调度层是核心循环也就是AgentExecutor它负责按照某种策略调用模型、解析输出、执行工具、追加观察结果。能力层包括三个可插拔组件ChatModel抽象、ToolRegistry工具注册中心、MemoryStore记忆存储。基础设施层则是日志、追踪、配置这些通用能力不参与业务决策。模块之间没有隐式依赖全部通过接口引用。举个例子AgentExecutor只知道ToolRegistry有execute(String name, MapString, 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(ListChatMessage messages, ListToolDefinition 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(MapString, 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-coreAgent 运行时、接口定义、循环调度javaManus-tools预置常用工具比如时间工具、HTTP 工具、Mock 业务工具javaManus-spring-boot-starter给基于 Spring Boot 的项目提供自动配置和扫描如果你只想快速试一遍不需要 Spring BootjavaManus-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 的强类型和工程化能力反而会变成它的最大优势。