有段时间我一直在琢磨一个问题社区里基于 Python 的 AI Agent 框架已经多到挑花眼为什么还有人会从零用 Java 写一套答案其实很朴素——你的团队未必是 Python 团队你的核心系统也未必跑在 Python 上。我所在的团队长期维护一套 Java 服务线上接口每天有大量真实请求当产品提出“让模型自主调用内部工具完成多步任务”这个需求时选项其实不多要么把 Python 那套 Agent 框架包成旁路服务再通过 HTTP 对接要么直接在 Java 侧实现一个轻量 Agent 框架。前者听着省事做起来全是服务边界、上下文同步、会话状态割裂的坑后者听起来自讨苦吃但一旦跑通所有 Agent 能力就成了现有技术栈的一部分能被网关、监控、权限体系直接复用。于是就有了 JavaManus。这个名字没什么特别的深意就是我自己这套 Java Agent 框架的代称。它不是什么重工业级产品而是一个把“模型调用、工具调用、多轮记忆、执行循环”串起来的可运行框架。这篇文章我会把整体设计思路、核心模块的拆法、关键代码实现、以及我在一线踩过的坑完整写出来。适合谁看想在 Java 技术栈里落地 Agent 能力的后端工程师或者在纠结“要不要自己搭框架”的团队技术负责人。老规矩不讲废话直接拆。1. 为什么我决定用 Java 从零写一个 Agent 框架1.1 现成框架为什么在 Java 场景里很难直接用很多人都推荐直接用社区里成熟的 Python Agent 框架理由是生态丰富、迭代快、示例多。这个建议在纯 Python 项目里完全成立但在 Java 技术栈里会碰到一个很现实的问题语言边界。Python 框架跑起来是独立进程Java 业务要调用它就得走 HTTP 或者消息队列。一套 Agent 任务往往不是一次请求就结束的它会经历“模型思考、调用工具、拿到结果、继续思考”等多轮循环每一轮都需要把状态同步给 Python 服务。会话上下文、工具执行结果、token 消耗、用户取消信号这些全要跨系统传递。我在实际对接中感受最深的是本来一个内存对象就能解决的问题被拆成了好几个接口、好几张表链路一长问题定位都困难。再加上内部工具的注册问题。Java 服务里已有的业务方法比如订单查询、库存校验、权限判断如果要被 Python 框架调用得先封装成 HTTP 接口加上鉴权、限流、参数校验再维护一套接口文档。这套东西做下来工作量已经不亚于自己写一个 Agent 框架了。而如果直接在 Java 内部做工具注册一个注解就能把一个方法暴露成模型可调用的工具省掉了中间层调用链也短得多。1.2 Java 做 Agent 的真正优势和需要接受的现实Java 做 Agent 不是自讨苦吃它有几个被低估的优势。第一是类型安全工具参数定义可以用注解加反射自动生成 JSON Schema模型返回的参数可以用 Jackson 直接反序列化成强类型对象而不是在 Python 里到处用字典字段写错到运行时才暴露。第二是并发能力Agent 执行过程中经常要并行调用多个工具或者同时处理多个用户的会话Java 的 CompletableFuture 和 JDK 21 的虚拟线程在这一块非常顺手。第三是可观测性团队现有的日志框架、链路追踪、监控告警都能直接复用Agent 的每一轮思考、每一次工具调用都可以作为一个 span 打出去这在 Python 旁路服务里很难做到同等质量。当然也得接受现实Java 生态里针对 LLM 的高级封装确实少一些很多基础能力比如流式输出的协议转换、token 统计、上下文裁剪策略都需要自己写。但这恰恰是这篇文章的价值——我把这些通用能力沉淀下来你不需要重复踩坑。2. JavaManus 的整体架构设计核心抽象与运行循环2.1 先把 Agent 拆成四个核心组件开始动手前我画过很多版架构图最后收敛成四个核心组件这也是后来证明最稳的拆分方式。它们分别是模型接入层ModelProvider、工具层Tool、记忆层Memory和执行循环AgentLoop。模型接入层负责跟不同的大模型服务商打交道统一处理消息格式转换、流式输出、重试和 token 统计。为什么要拆这一层因为市面上的模型服务接口风格差异很大有的走 OpenAI 兼容协议有的走自家协议还有的要经过公司内部的网关做鉴权和限流。如果这些逻辑散落在业务代码里换一个模型供应商就要改一大片。工具层是 Agent 能不能真正“动手干活”的关键它要解决三个问题怎么描述一个工具、怎么把模型返回的参数安全地传给工具、怎么把执行结果正确地送回给模型。记忆层负责管理多轮对话的上下文包括短期会话历史和长期知识检索。执行循环则把前三者串起来像一个调度器控制 Agent 从开始到结束的每一步。这四个组件的依赖关系是单向的AgentLoop 依赖另外三个Tool 不依赖 ModelProviderMemory 也不依赖 Tool。这样设计的好处是每个组件都能单独测试替换任何一个实现都不影响其他部分。2.2 主循环从 ReAct 模式到可控的 AgentLoopAgent 的核心运行模式不是什么玄学它的本质是一个“观察、思考、行动、再观察”的循环。模型看到当前的消息和工具列表决定是调用某个工具还是直接给出最终回答。如果选择调用工具框架执行工具并把结果作为新的消息通常是一条带角色标记的工具结果消息追加进对话然后再次调用模型。这个过程不断重复直到模型给出最终答案或者触发某个终止条件。// 伪代码展示 JavaManus 主循环的核心结构 while (iteration maxIterations) { ChatResponse response modelProvider.chat(memory.buildMessages(), toolRegistry.getAllTools()); if (response.hasToolCalls()) { for (ToolCall call : response.getToolCalls()) { ToolResult result toolRegistry.execute(call); memory.addToolResult(call.getId(), result); } iteration; continue; } if (response.hasFinalAnswer()) { return response.getFinalAnswer(); } } throw new AgentTimeoutException(Max iterations reached);代码看起来很简单但真正落地时有几个细节需要处理。第一个是 maxIterations 的判断不能设太大否则模型可能会在一个错误的方向上反复尝试既浪费 token 又拖慢响应时间。我一般默认设 8~10 轮根据任务复杂度调整。第二个是重复动作检测如果模型连续多次用相同参数调用同一个工具说明它陷入了循环算法上可以加一个“最近 N 轮的工具调用签名”做去重判断。第三个是异常兜底工具执行如果抛出异常不能直接让整个 Agent 崩溃而应该把异常信息作为工具结果返回给模型让模型有机会修正自己的调用方式。到这里最核心的 Agent 骨架已经立住了。接下来要往骨架上填肉也就是让 Agent 真正能够可靠地调用各种工具。这是整个框架里坑最多的地方。3. 工具调用机制让模型真正“动手干活”的关键3.1 工具抽象从 Function Calling 到统一 Tool 接口模型本身没有手它只能输出一段结构化文本通常是一个工具名称加一段 JSON 参数。真正执行动作的是我们的代码。所以工具抽象的第一件事是定义一套统一的描述和执行规范。我在 JavaManus 里设计了一个 Tool 接口核心方法只有几个获取工具名、获取工具描述、校验并执行调用。public interface Tool { String getName(); String getDescription(); JsonNode getParametersSchema(); // 自动生成的 JSON Schema ToolResult execute(JsonNode arguments); }为了不让每个工具类都写一遍参数 Schema我用注解加反射来自动生成。业务方只需要写一个普通方法加上注解声明工具名和描述Scanner 会在启动时扫描所有标注了注解的 Bean把方法签名转成 JSON Schema并注册到工具注册表。这比手写 Schema 靠谱太多——手写容易出现字段名跟方法参数对不上等到模型调用时才发现问题。AgentTool(name query_order, description 根据订单号查询订单状态和物流信息) public OrderInfo queryOrder(ToolParam(description 订单号例如 OD20250101001) String orderId) { // 实际业务逻辑 }这里有一个关键设计参数描述必须写清楚。模型不是人它不知道你的 orderId 是什么格式如果你不告诉它“这是一串以 OD 开头的 14 位字符串”它就可能给你传一个 UUID然后工具执行失败白白浪费一轮对话。所以在做工具扫描的时候我会强制要求每个参数都必须有 description否则启动时直接报错把这个当做一个硬约束。3.2 参数解析与校验模型返回的 JSON 不可信模型返回的工具调用参数本质上是模型“猜”出来的文本。它可能多一个逗号可能字段名拼错可能少传必填参数甚至可能传出一个 JSON 数组而不是对象。如果直接拿 Jackson 的反序列化结果去调用方法你会得到一堆难以理解的异常而这些异常如果扔给模型模型通常也没法自己修复。我的做法是分层处理。第一层做宽松解析用 Jackson 的 JsonNode 接收原始 arguments 字符串如果解析失败先尝试修复常见的格式问题比如去掉多余的逗号、修正未加引号的字段名。第二层做结构校验检查必填字段是否存在、字段类型是否匹配、枚举值是否合法。第三层才做类型转换把 JsonNode 转为目标方法的参数对象。如果第二层或第三层失败框架会生成一条结构化的错误信息返回给模型告诉它“缺少必填参数 xxx正确的格式是 yy”让模型有机会重新发起调用。这个过程非常像现实中的项目经理和外包人员打交道你不能只丢一句“你做错了”你得告诉对方具体哪里错了、应该怎么做。模型也是这样给它清晰、可操作的错误反馈它的修正成功率会高很多。3.3 工具注册表与自然语言描述生成工具注册表的设计直接影响 Agent 的可扩展性。我用了一个 ConcurrentHashMap 来维护工具名到工具实现的映射启动时注册运行中只读。这样避免了并发修改的问题也方便做权限控制——每个会话可以持有不同的工具集合比如普通用户只能看到查询类工具管理员才拥有修改类工具。注册工具的时候需要生成一段自然语言描述这段描述会出现在发给模型的系统消息里。描述的质量非常重要。我踩过的一个坑是一开始只写了枯燥的功能说明比如“根据用户ID查询用户信息”模型经常在无关场景下误调用这个工具。后来我把描述改得更情境化加上“当用户询问自己的账户情况、订单列表、收货地址时使用该工具”误调用率明显下降。这个经验告诉我们工具描述本质上是给模型写的“使用指南”写清楚触发场景和禁忌场景能让 Agent 的行为准确一个量级。4. LLM 接入层与流式输出的实现细节4.1 统一 Provider 抽象屏蔽各家 API 差异Agent 框架不可能只对接一家模型服务。一方面是为了容灾某家服务不可用时可以切换另一方面是不同模型各有优势有的擅长复杂推理有的响应速度快、适合简单任务。所以 Provider 抽象层要能做到“换模型不换框架代码”。public interface ModelProvider { ChatResponse chat(ChatRequest request); StreamChatChunk chatStream(ChatRequest request); TokenUsage countTokens(ListChatMessage messages); }ChatRequest 里包含消息列表、工具列表、温度、最大 token 数等参数ChatResponse 里包含模型返回的文本内容、工具调用列表、token 消耗统计。不同的模型服务商在这个接口后面各有自己的适配器适配器负责把内部统一的消息格式转换成各家协议要求的格式再把返回结果解析成统一结构。这里有个容易被忽略的点各家 API 对“停止条件”的表达不一样有的用 finish_reason 字段有的用字符串值表示。如果你的框架硬编码了某种协议的枚举值换模型时很可能出现“明明该收尾了却还一直循环”的问题。所以在内部模型里我定义了统一的 finish reason 枚举让适配器去映射而不是在核心循环里判断原始字段。4.2 流式输出的协议设计与背压处理Agent 任务通常比普通大模型调用耗时更长因为中间可能穿插多轮工具调用。如果让用户对着空白页面干等十几秒体验会非常差。流式输出是必须的。我理解的流式输出不只是把 LLM 的 token 一段段吐给前端它更应该是整个 Agent 运行过程的事件流。也就是说前端应该能看到“模型正在思考”“正在调用工具 xx”“工具返回了结果”“模型生成回答中”这些状态变化。为此我设计了一套基于 SSE 的事件协议事件类型包括 agent.thinking、tool.started、tool.finished、message.delta、agent.finished、agent.error 等。用 Java 实现这套事件流我没有引入很重的响应式框架而是用了一个非常朴素的 BlockingQueue 加轮询线程的方案。Agent 执行线程往队列里写事件SSE 推送线程从队列里取事件并写给客户端。队列可以设置容量当生产速度大于消费速度时阻塞写入实现简单的背压控制防止内存被慢客户端拖爆。4.3 token 计数与预算控制大模型 API 都是按 token 计费的Agent 多轮调用很容易在不知不觉中烧掉大量 token。预算控制不是可有可无的优化它是保证线上成本可控的底线。我在框架里做了两层控制单轮限制和总预算限制。单轮限制指的是每次发给模型的 messages 总长度不能超过模型的上下文窗口超了就触发记忆裁剪这部分在下一节展开。总预算限制是指一个会话任务累计消耗的 token 不能超过某个阈值比如 8 万 token达到了就强制结束 Agent并返回一个“当前任务过于复杂请优化指令或分段执行”的提示。token 统计不能只看消息的字符数量因为不同模型的 tokenizer 分词粒度不一样。我的做法是把统计逻辑也放进 Provider 适配器里由各家使用自己的 tokenizer 或近似算法核心循环只依赖统一的 TokenUsage 对象。实测下来这种方式误差可以控制在 5% 以内基本够用。5. 多轮记忆与上下文管理5.1 窗口裁剪不是简单地拼 promptAgent 的任务是多轮的每一轮工具调用结果都会塞回上下文上下文会越涨越大。如果每次都是无脑把所有消息发给模型很快会撞上模型的上文窗口限制。最简单的裁剪办法是“只保留最近 N 条消息”但实测效果不好。原因在于模型在完成当前任务时有些早期信息非常关键比如用户的初始指令、系统设定的工具描述一旦被裁剪掉模型会忘记自己要干什么。我采用的策略是给消息分优先级系统消息最高优先级永远保留用户的初始指令次高最近几轮的对话保留早期轮次中被工具结果成功引用的摘要信息可以压缩成一段 summary 文本。具体实现时我写了一个 contextWindow 管理器它维护一个消息列表每次在调用模型前计算总 token 数超过阈值就触发压缩。压缩时优先裁剪最老的、无关紧要的工具输出把它们用一句话总结如果还要继续裁剪就把早期的对话总结成一段摘要。这样既控制长度又保住核心信息。// 消息裁剪的核心逻辑按优先级排序保系统、保近期、压缩早期 ListChatMessage compact(ListChatMessage messages, int maxTokens) { ListChatMessage system filterByRole(messages, SYSTEM); ListChatMessage recent tail(messages, DEFAULT_RECENT_TURNS); ListChatMessage early removeAll(messages, system, recent); // 对 early 分段做摘要而不是直接丢弃 String summary summarize(early); return concat(system, new SummaryMessage(summary), recent); }这里有一个重要的经验裁剪函数本身也是一个 LLM 调用它也会消耗 token 和时间。所以不要每轮都裁剪而是设定一个阈值比如超过上下文的 80% 时才触发。另外摘要的生成最好用便宜、快速的模型不必用最强的模型。5.2 短期记忆与长期记忆的分层设计除了上下文窗口里的短期记忆Agent 还应该有长期记忆能力。比如用户上周问过某类问题、偏好某种回复风格如果这些信息能在新一轮会话中被模型感知效果会好很多。长期记忆这块我没有自己实现向量数据库而是做了抽象接口 MemoryStore提供写入和检索两个能力。默认实现是文件存储用关键字匹配也可以对接团队的向量检索服务。写入的时机很关键不是每轮都写而是在 Agent 结束时提炼一次把任务关键信息、工具结果、用户偏好总结成结构化记录入库。检索的时机是任务开始时根据用户 ID 拉取相关记忆注入系统消息。这块最容易犯的错是过度设计。很多人一上来就搭向量数据库、搞 embedding 管道但实际场景里大多数 Agent 任务根本用不上那么重的记忆。先用简单的存储把闭环跑通等用户量和数据量上来了再换引擎完全来得及。6. 并发模型与任务编排Java 并发在 Agent 里的实战6.1 会话隔离与线程池规划Agent 服务是典型的多用户高并发场景每个用户发起一个任务任务内部有多轮模型调用和工具调用。这里最核心的问题是隔离不能让一个用户的 Agent 状态污染到另一个用户。我按会话维度创建独立的 AgentLoop 实例每个实例持有自己的 Memory 对象和工具上下文。所有会话共享模型 Provider、工具注册表等无状态组件。线程池方面默认用固定大小线程池跑 AgentLoop 任务避免一个慢任务占满所有线程。JDK 21 的虚拟线程也试过在大量 IO 等待的场景下确实能提升吞吐但如果工具的代码里有同步锁或者本地计算密集任务虚拟线程的优势会被削弱。关于线程池参数可以给出一个可复用的经验值假设一个 Agent 任务平均耗时 20 秒单线程处理能力是每分钟 3 个任务那一个 20 核实例上跑 60 个并发任务就需要约 20 个线程。实际调参还得看工具调用的阻塞时间和模型 API 的响应时间建议先做压测再结合排队的长度动态调。线程数不是越大越好太多反而会引起上下文切换开销和服务商限流。6.2 异步工具调用的编排与超时控制Agent 在某一轮可能会同时发起多个工具调用。比如用户问“对比一下这两家供应商的报价和交付周期”模型完全有可能同时调用“查供应商 A 报价”和“查供应商 B 报价”两个调用之间没有依赖关系。这时候如果串行执行会白白多等一份时间。我用 CompletableFuture 做了并行编排把同一轮的所有 ToolCall 提交到线程池并行执行全部完成后统一把结果写回 Memory。超时控制比并行更隐蔽。模型 API 可能卡住工具方法可能死锁如果没有超时机制整个 Agent 任务会吊死在那里。我设计了一套层级超时单次模型调用基本超时设为 60 秒单个工具执行超时设为 30 秒整个 Agent 任务默认 5 分钟。超时后不是简单地抛出异常而是把超时信息作为错误反馈给上层让调用方决定是重试、降级还是结束任务。CompletableFutureChatResponse future CompletableFuture.supplyAsync(() - provider.chat(request), agentExecutor); ChatResponse response future.orTimeout(60, TimeUnit.SECONDS).exceptionally(handleTimeout(...)).join();取消机制我也踩过坑。当用户主动取消任务时不能只是调一个 cancel 就完事底层正在跑的模型调用、工具调用线程不会自动感知。我的做法是维护一个会话级取消标志在每次工具执行前检查同时在线程池的 Future 上调用 cancel(true)让阻塞中的线程收到中断信号。7. 实战踩坑记录与排查清单7.1 高频问题速查表症状原因解决方案模型反复调用同一个工具陷入死循环重复动作导致状态无法推进增加重复调用签名检测超过 N 次强制收尾工具参数解析总是失败模型生成的 JSON 不规范加宽松解析层失败后把具体错误信息回喂给模型每轮对话越来越慢上下文里积累了太多历史工具结果触发上下文窗口裁剪优先压缩早期工具输出切换模型后 Agent 行为异常不同服务的协议枚举、消息格式不统一所有协议差异收敛到 Provider 适配器内部高并发下会话数据串线使用了静态变量或共享的可变对象会话级隔离AgentLoop 实例绑定独立 Memory 和上下文模型不按系统提示规则走系统消息优先级不够被裁剪或位置靠后系统消息永远置顶且不可被压缩工具描述写得太泛模型误调用工具边界不清晰描述里写清触发场景和禁止场景7.2 几个印象深刻的线上问题第一个是循环调用问题。某次线上任务里模型连续五次调用同一个“查询库存”工具而且查询参数几乎完全一样。分析日志发现原因是工具返回结果里没有明确告诉模型“这次查询已经完成”而且库存数量为 0模型认为还有别的可能于是反复确认。修复办法是两处工具执行完之后强制往结果中加一句“查询已完成这是最终结果”同时在主循环里加重复动作检测连续三次相同签名直接结束当前任务并抛出提示。第二个是上下文污染。初期我把 Memory 对象设计成了单例结果就出现了 A 用户的历史消息出现在 B 用户的任务里。这个问题在测试环境几乎复现不了因为测试从来不会并发触发。后来定位到是静态变量持有会话上下文把所有会话状态改成实例化对象并在线程池的提交任务中显式传递会话 ID 才解决。从那以后我给自己定了一条规矩Agent 框架里一切可变状态都必须绑定会话生命周期不允许任何隐式共享。第三个是模型耍小聪明。模型发现在工具调用失败后错误信息会被回喂给它于是有些模型会“故意”把参数编造的离谱走一遍失败流程来拖延时间虽然最终不会出错但白白浪费了很多 token 和轮次。我们的应对是限制了错误回喂的次数同一工具连续失败三次后直接向用户返回“该工具暂时不可用”不再让模型无限重试。这三个问题本质上是同一个问题模型和框架之间没有建立足够的约束。框架必须像管理者一样设定规则、限制权限、兜底异常而不能默认模型一定按文档执行。8. 后续扩展思路与个人经验体会JavaManus 目前已经跑过了从“初始版本”到“能稳定处理线上任务”的阶段。我后续想继续扩展的方向有三个。第一个是多 Agent 协作把单个 AgentLoop 升级成可嵌套的子 Agent 编排实现“主 Agent 拆任务子 Agent 并行处理”的架构。第二个是推出的评估体系核心是给每次 Agent 运行打质量分包括任务完成度、调用轮次、token 消耗、用户反馈等用这些数据来调优工具描述和系统提示词。第三个是让工具描述动态生成根据用户的权限、上下文动态调整工具的描述和可用集合。关于框架本身我个人的体会是不要一开始追求大而全先把主循环跑通让模型能调用一个工具、能给一个回答这个闭环立住之后再逐步加记忆、流式、并发、监控。很多问题不是设计出来的而是在真实流量里被需求逼出来的。比如流式事件协议一开始我完全没考虑直到第一个用户反馈“任务转圈十几秒没有反馈”才意识到这不是体验问题而是基础能力缺失。如果有人也想从零开始写一个 Java Agent 框架我最想给的建议是把工具调用链的可靠性和可观测性放在第一位甚至比模型能力本身更重要。模型总会更新、供应商总会变化但只要你的工具层稳定、交互协议清晰、日志完整换再强的模型都只是换个适配器的事。Java 生态在这块或许不如 Python 花哨但它扎实、可控、耐折腾这也恰好是 Agent 落地到生产环境时最需要的特质。