决定自己动手用 Java 写一个 AI Agent 框架之前我已经在内部项目里被各种半成品方案磨了快两个月。团队的服务大多数跑在 JVM 上对接的是自研的权限系统、工单平台和一堆老旧的内部接口Python 那边的 Agent 原型写得再快落到 Java 生产环境里还是得重新造轮子。所以我把这个自研的框架代号定为 JavaManus目标很直接给 Java 技术栈提供一个可控的 AI Agent 调度层让大模型能安全地调用我们的业务工具让一次对话能真正完成“拆解任务、按步执行、返回结果”的闭环。这篇文章我不打算写成一个面面俱到的框架说明书而是把 JavaManus 从零设计、实现、踩坑的过程拆给你看。核心会集中在六个问题上为什么非要自研框架Agent 主循环在 Java 里怎么写工具层如何抽象上下文和记忆怎么管流式输出和并发控制有哪些坑以及从单 Agent 走向多 Agent 编排时该做什么取舍。如果你正在 Java 项目里做类似的 Agent 集成或者打算自己封装一套可复用的 Agent 底座这篇文章应该能帮你少走不少弯路。1. 为什么我不直接调各家 SDK而要自己搭一层框架1.1 现成方案解决不了“内部系统接入”的问题市面上不是没有 Agent 框架但多数框架的抽象都建立在 Python 生态上底层假设是“模型输出 JSON、调外部 API、存向量库”这套逻辑在 Demo 里跑得很顺可一旦接到企业内部系统问题立刻冒出来内部接口的鉴权体系五花八门有的走 SSO 票据有的是签名认证还有的直接连内网数据库做查询。框架如果只提供“HTTP 工具”这一种形态你就得把内部能力全部包一层 HTTP 服务这既增加链路延迟也让安全边界变得模糊。JavaManus 从一开始就把工具定位成“JVM 内的方法调用”而不是“远程接口调用”。某个工具对应一个 Java Bean 方法参数由模型从对话里抽取出来框架负责校验、转换、调用然后拿返回值继续喂给模型。这样工具实现方不需要关心 HTTP 协议不需要额外起网关只要写一个普通方法再注册进来就行。1.2 Java 生态里缺的不是模型调用而是“Agent 状态的调度层”模型调用这件事各家 SDK 已经封装得很好了真正缺的是一个能在 Java 进程里管理多轮状态、工具执行、上下文压缩、流式回调的调度层。你自己写框架本质上是在写这个调度层。JavaManus 的设计边界很明确它不包含大模型本身不负责训练和微调它也不内置向量数据库记忆功能只做接口抽象它甚至不替你做 Prompt 的“万能模板”因为不同业务场景的 Prompt 策略差异太大。框架只负责两件事跑通 Agent 循环以及定义好工具、记忆、回调这些扩展点。1.3 技术选型少依赖只做调度不做全家桶模块选型理由JSON 处理Jackson工具参数解析和配置管理都是它稳定HTTP 客户端JDK HttpClient模型网关调用够用避免引入重型依赖流式推送Servlet SseEmitter适配 Spring Boot 场景也留了 WebFlux 适配口并发控制自研信号量 线程池工具并发执行需要精细控制通用框架难覆盖状态存储内存 可插拔接口单机跑通优先后续可换 Redis / 数据库选型的原则就一句话能用 JDK 自带能力解决的不引第三方包。Agent 框架的复杂度本来就在调度逻辑上依赖太多反而把问题搞混。2. Agent 核心循环感知、规划、行动在 Java 里怎么落地2.1 主循环不是简单的 while(true)而是有边界的决策循环Agent 的基本运行方式大家都清楚把用户问题和历史消息发给模型模型返回“下一步动作”如果动作是调用工具就拿结果回填继续问模型直到模型觉得任务完成。JavaManus 的主循环跑起来就是这个流程但工程实现上有几个容易被忽略的细节。第一个是循环必须设置硬边界。我见过不少 Agent 因为一次工具调用返回了重复内容导致模型反复执行同一个动作token 烧掉不说外部系统也可能被重复调用产生脏数据。JavaManus 默认单轮任务最大迭代次数是 12 次由配置项maxIterations控制同时每次工具调用的返回内容会做哈希如果连续两次哈希一致直接判定为“复读”终止循环并提示用户需要换一种方式处理。while (iteration maxIterations) { AgentResponse response model.chat(messages, buildToolSpecs()); if (response.isFinished()) { break; } if (response.hasToolCall()) { ToolResult result toolRegistry.execute(response.getToolCall()); messages.add(result.toChatMessage()); } iteration; }这段代码看起来简单但每个字段背后都有讲究buildToolSpecs()每次都要重新生成因为工具列表可能因为多 Agent 分工而变化ToolResult要区分“成功返回”“业务错误”“系统异常”三种状态模型后续的决策逻辑完全不同。2.2 模型输出不是规范的 Java 对象解析要稳主循环最考验人的是模型输出解析。模型返回的内容有几种形态纯文本、结构化 JSON、包含工具调用的特殊格式。JavaManus 的做法是定义统一的AgentMessage协议把模型返回拆成text、toolCalls、status三个字段再交给策略解析器处理。解析策略有一个原则不信任模型输出格式完全稳定。我处理过很多次模型在 JSON 里多加注释、漏掉双引号、或者把布尔值写成字符串的情况。所以解析器用的是 Jackson 的宽松模式先做容错修复再强校验。如果最终仍然解析失败不会直接报错而是把原始文本封装成“模型回复”继续走一轮循环让模型自己根据之前的上下文纠偏。public ParsedMessage parse(String rawOutput) { ObjectMapper mapper new ObjectMapper(); mapper.enable(JsonParser.Feature.ALLOW_COMMENTS); mapper.enable(JsonParser.Feature.ALLOW_UNQUOTED_FIELD_NAMES); try { JsonNode node mapper.readTree(rawOutput); return extractTextAndToolCalls(node); } catch (JsonProcessingException e) { return ParsedMessage.plainText(rawOutput); } }2.3 中间状态一定要有痕迹否则没法排查Agent 是一个多步骤决策系统复盘时必须能看到每一步模型想了什么、调用了什么工具、返回了什么结果。JavaManus 内部维护了一个TrajectoryRecorder把每一次循环的模型输入、输出、工具请求、工具结果全部记录成结构化日志。这个日志同时会落两份一份是普通日志文件用于排查一份是内存中的环形缓冲区用于前端实时展示中间思考过程。很多人做 Agent 只关注最终结果但真实生产里 70% 的问题都是在中间步骤暴露的工具参数传错了、模型对某个业务概念理解偏了、某个工具返回太慢导致超时。没有中间轨迹这些问题几乎没法定位。3. 工具抽象层让大模型学会“调用你的业务方法”3.1 Tool 接口的四个核心元素JavaManus 里一个工具就是一个类核心接口只有四个方法工具名称、工具描述、参数定义、执行逻辑。描述这个字段比大多数人想的更重要因为模型的工具选择完全依赖它。以前我写工具描述总是很随意比如“查询用户信息”后来发现模型经常在参数不完整的时候也去调用这个工具导致返回空数据。改成“查询用户基本信息调用前必须确保 userId 参数已从上下文中获取到若用户未明确提供编号则先调用搜索工具”工具选择的准确率明显上升。public interface AgentTool { String getName(); String getDescription(); MapString, JsonNode getParameterSchema(); ToolResult execute(MapString, Object args); }参数描述不是定死在代码里的而是通过注解自动生成 JSON Schema再在运行时装配到模型 API 的tools参数里。大致结构长这样{ name: queryUserInfo, description: 查询用户基本信息调用前必须确保 userId 已从上下文中获取到, parameters: { type: object, properties: { userId: { type: string, description: 用户唯一编号 } }, required: [userId] } }3.2 参数校验不靠模型自觉框架必须做二次防护即使模型输出已经给了 toolCall参数类型和取值也不一定合法。JavaManus 在toolRegistry.execute()里内置了一套校验器先比对 schema 里的必填字段是否齐全再检查类型最后交给工具自身的业务校验。比如queryUserInfo要求 userId 是非空字符串如果模型传了数组或者直接传null框架会在进入业务方法之前拦截掉而不是让业务代码去面对奇怪的输入。这里有我特别想强调的一个工程习惯工具执行的异常一定要“带回”给模型而不是直接抛出到外层把整个会话打断。比如某个接口超时了你应该把错误信息包装成工具结果告诉模型“调用已失败错误原因是超时建议稍后重试或换一个参数”模型往往能自动调整策略。JavaManus 里ToolResult有一个errorMessage字段专门干这个事。3.3 工具的注册与发现注解扫描 手动装配两套机制框架同时支持两种注册方式基于注解的自动扫描适合工具类较多、想快速接入的场景手动装配则适合希望精确控制工具权限的场景。自动扫描会把 Spring Bean 里的AgentTool方法自动解析成 Tool 实例并校验工具名称是否重复。手动装配则直接操作ToolRegistryToolRegistry registry new ToolRegistry(); registry.register(new QueryUserInfoTool(), 内部系统用户查询, true);第三个布尔参数代表“敏感操作”。标记为敏感的工具在调用前必须经过二次确认这个确认可以是人工审批也可以是外部策略服务校验。不加这个机制Agent 一旦把“删除订单”这类工具暴露给模型风险是不可控的。4. 记忆与上下文管理把 token 预算花在刀刃上4.1 对话消息不能无限膨胀压缩要分层Agent 多轮交互最直接的问题是上下文膨胀。一个工具返回 5000 字业务数据下一把模型就要把这 5000 字重新读一遍连续几个工具下来上下文就爆了。JavaManus 的上下文管理分三层原始消息层、压缩摘要层、长期记忆层。原始消息层就是正常的对话内容。压缩摘要层则通过配置触发当累计 token 数超过summarizeThreshold默认是 8000框架会把较早的消息交给模型生成一段中文摘要替换掉原始消息。长期记忆层则是把跨会话的重要信息比如用户偏好写入独立存储下次会话开始时可以通过检索加载回来。第一层最近消息完整保留用于模型决策 第二层中间消息按轮次做摘要 第三层早期消息只保留结构化要点4.2 工具返回结果必须做截断这是最容易忽略的坑模型并不需要工具返回的全部字段。JavaManus 给工具的执行结果加了一个ResultEnvelope里面包含brief、full、truncated三个部分。每次工具返回后框架会先检查结果体积默认超过 6000 字符就直接截断只把brief部分拼进上下文完整结果存到会话状态里模型需要用更多细节时再通过专用工具按 key 取。这个方法解决了 Agent 项目里非常常见的“数据洪水”问题。比如查询订单列表业务接口一口气返回 200 条订单模型根本不需要逐条读只需要知道总数和关键统计具体某一条再针对性查询就行。把截断策略交给工具实现方而不是框架能适应更多业务场景。4.3 长短期记忆的接口设计先跑通再谈向量化很多 Agent 框架一上来就上向量库其实早期根本用不上。JavaManus 的长期记忆接口一开始只定义了三个方法保存、按关键词检索、按会话检索。内部先用内存 Map 实现等业务量上来再替换成向量数据库实现。public interface MemoryStore { void save(String key, AgentMemory memory); ListAgentMemory searchByKeyword(String keyword, int limit); ListAgentMemory searchBySession(String sessionId); }我建议做 Java Agent 框架的开发者都按这个节奏来记忆的“存储结构”比“检索算法”重要。先把历史结论和用户偏好结构化存好后续不管接向量库还是图数据库都有迁移空间。一上来就折腾嵌入模型反而容易忽略业务语义。5. 流式输出与并发场景下的工程细节5.1 流式输出不是简单转发要区分“增量”和“事件”Agent 的响应过程很慢动辄十几秒如果不用流式输出用户早就走了。JavaManus 对外提供的是 SSE 接口整个 Agent 运行过程会持续推送事件事件类型text_delta模型回复增量 事件类型tool_start工具开始执行 事件类型tool_end工具执行完成 事件类型agent_finishAgent 全部任务完成这套事件模型的优点是让前端可以逐步渲染先看到模型的分析再看到“正在调用查询工具”执行完再看到结果汇总。相比直接整段返回用户在心理上会觉得系统真的在干活而不是卡死了。流式转发的实现上要注意线程模型。JavaManus 内部给每次会话分配一个独立的执行线程SSE 的SseEmitter只负责往下写数据。这里最坑的点是框架把“模型 token 增量”和“Agent 事件”混在一个通道里导致前端无法区分哪一段是工具触发的系统消息。我最终的做法是给每条 SSE 消息强制加event字段前端按事件类型分发。5.2 同一次任务里的工具调用该串行还是并发单 Agent 经常会同时返回多个 toolCall比如“查询用户信息同时查一下用户最近的订单”。JavaManus 支持把同一轮里的多个无依赖工具并发执行线程池大小默认是 2避免一次性拉起太多请求打爆内部系统。对存在依赖关系的工具则必须串行框架通过参数匹配判断后一个工具的入参是否引用了前一个工具的输出名。并发执行工具带来一个麻烦日志顺序会乱。JavaManus 给每次并发子任务分配 traceId最终结果汇总时再按原始 toolCall 的 index 排序这样模型拿到的是一个有序结果数组不会因为并发执行而搞错对应关系。5.3 取消任务比启动任务更复杂用户发一条新消息后觉得等太久直接关掉页面此时 Agent 线程还可能在执行外部工具调用。JavaManus 实现了CancellableContext每种工具在 execute 时会收到一个cancelSignal。框架在取消时先标记状态等当前工具调用返回后再终止循环而不是强行打断线程——强杀线程会导致内部资源泄露和状态不一致。取消后还要把“任务已被取消”回写到会话历史这样模型下一次响应时知道上次任务没有完成而不是产生幻觉继续沿用旧的状态。6. 从单 Agent 到多 Agent编排器的设计与取舍6.1 单 Agent 的上下文墙迫使你必须拆分角色单一 Agent 承担所有任务时上下文很快就会堆满又要理解用户复杂需求又要维护一堆工具的执行细节还要不断汇总中间结果。JavaManus 在解决这个问题的过程里演进出了多 Agent 编排器。编排器不直接干具体活它只做两件事拆任务、收结果。用户请求 - 规划者 Agent拆解任务、指定执行者 - 执行者 A只关注业务查询类工具 - 执行者 B只关注数据分析类工具 - 汇总者 Agent收集所有子结果生成最终回复6.2 多 Agent 之间不直接通信只共享状态设计多 Agent 很容易把系统做成“Agent 互相调 Agent”最后通信链路复杂到没法排查。JavaManus 的编排器里所有 Agent 共享一个SharedState对象每个 Agent 可以往里面写入自己的产出也可以读取前置 Agent 的产出。这个设计参考了流水线而不是蜘蛛网。A 做完写 stateB 从 state 里读 A 的结果再把自己的结果写回去。整个链路是单向的状态变更由编排器统一记录任何一个子任务出问题都能清楚定位到是哪一步。6.3 编排器的收敛判定不能各干各的最后没人收尾多 Agent 最怕的是规划者把任务拆的很细每个执行者也顺利跑完最后却没有 Agent 负责综合回答。JavaManus 强制要求一条任务链路必须有一个“收尾 Agent”它读取所有子任务的 state 产出生成面向用户的最终回复。如果某个子任务失败收尾 Agent 不会强行回答问题而是用模板生成“哪些部分成功了哪些部分失败了失败原因是什么”的汇总报告。我们在生产里发现这种结构化失败报告比 Agent 假装顺利完成有价值得多执行团队可以只凭汇总结果快速定位需要人工介入的地方。最后分享两个实践心得JavaManus 这个框架从设计到落地我自己最大的体会是不要迷信“Agent 万能”也不要把框架做成大杂烩。它就是一个调度器真正让 Agent 有效的永远是工具定义的质量、上下文管理的策略、以及每一个业务场景里对执行边界的约束。框架做的是把好习惯固化下来让团队不至于在没人管的情况下让模型一通乱调。还有一个小技巧强烈建议在做自研 Agent 框架时加上本地回放能力。JavaManus 在开发期会把每次会话的完整轨迹序列化成一个 JSON 文件包括模型输入输出、token 消耗、工具耗时、异常信息。出问题的时候直接拿这个文件回放调试不用重现用户现场排查效率能提升一倍。等你把几十个回放样本积累下来再回头优化 Prompt 和工具描述会比拍脑袋改配置靠谱得多。