2026/10/12 20:52:25

从零构建Java Agent框架:JavaManus主循环、工具调用与虚拟线程工程实践

从零构建Java Agent框架:JavaManus主循环、工具调用与虚拟线程工程实践 最近这段时间AI Agent 几乎成了 Python 生态的代名词。我所在的小组恰好负责把大模型能力接进公司内部一套基于 Java 的自动化平台最初照惯例去调社区里几个主流方案结果发现要么太重、要么跟特定全家桶深度绑定排错成本高到让人头大。折腾了两周之后我们决定换一条路用 Java 从零写一个轻量 Agent 执行框架代号就叫 JavaManus。这个项目从立项到跑通第一个端到端任务花了大概三周中间经历了工具调用的类型地狱、SSE 流式解析的边界问题、Token 塞满上下文的尴尬以及并发调优的重构。这篇文章不是推荐你去用一个现成框架而是把 JavaManus 的骨架完整拆开主循环怎么设计、工具怎么注册、模型接入层怎么抽象、记忆和 Token 怎么管理、虚拟线程怎么把并行工具调用速度拉起来。适合那些对 Agent 底层机制感兴趣、又必须待在 Java 生态里的开发者。先说结论真正让 Agent 变复杂的从来不是模型本身而是模型外围这一圈工程逻辑。Java 虽然不如 Python 在 AI 研究侧吃得开但强类型、虚拟线程、成熟的可观测体系反而让 Agent 在生产环境里更稳。下面按设计顺序逐层展开。1. 为什么偏要用 Java 写 Agent生态现状与 JavaManus 的设计边界1.1 现实中的三种嵌入姿势我为什么选了第三条内部系统要让大模型干活通常有三种做法。第一种是在旁边挂一个 Python 写的 Agent 服务Java 通过 HTTP 调它。这种方法开发最快但后续维护很痛苦两套技术栈的部署、监控、日志体系完全割裂Agent 内部思考过程对 Java 侧是个黑盒出了问题要跨服务查链路联调时来回传 JSON 简直是一场灾难。第二种是直接引入 Java 社区里已有的 Agent 框架。这类框架确实能跑但抽象层次偏高很多设计是照着 Python 生态的接口习惯搬过来的内部状态流转相对封闭。我们有一个场景要求工具执行过程里人工随时能暂停审核这类框架需要绕很大的弯才能实现。所以最终选了第三种自己写一个最小内核。JavaManus 的定位不是包罗万象的 SDK而是一个专注解决Agent 在 Java 进程里稳定跑起来的执行引擎。它做四件事调度模型、执行工具、维护记忆、暴露观测数据。其余像权限、审计、任务编排都是复用的公司现有系统Agent 只是其中一个执行单元。1.2 三个设计原则贯穿了整个项目JavaManus 从第一天起定了三条原则后面所有踩坑和重构都跟它们有关。原则一最小依赖。运行时只依赖一个 JSON 库我们在 Gson 和 Jackson 之间选择了前者理由是 Agent 的消息体结构相对简单Gson 反射式序列化代码量更少。不引入 Spring、不引入任何 Agent 相关依赖保持内核干净。这样做的好处是接入方不需要被框架绑架可以把这个内核嵌进任何 Java 项目。原则二工具即方法。Agent 要调用外部能力在 Java 里最自然的表达就是一个普通方法。只要在方法上打一个注解方法就能变成 Agent 可感知、可调用的工具。这个设计让业务方接入成本降到最低不需要理解复杂接口。原则三可观测性优先。框架里每个关键节点都必须有日志埋点。我们实现了基于 MDC 的 traceId 贯穿机制一次 Agent 任务的每一次模型调用、每一个工具执行、每一轮记忆读写全部能按 traceId 串联起来。这个原则在后来调优时救了命。技术基座选型也比较直接JDK 21主要目的是用虚拟线程处理 IO 密集型调用HTTP 客户端用 JDK 内置的 HttpClient省掉一个依赖本地化存储用 H2 内嵌数据库不额外部署中间件。组件选型理由JDK21虚拟线程、现代 HTTP 客户端JSONGson反射式处理在动态工具调用场景更省事HTTPJDK HttpClient官方维护支持异步和流式存储H2 内嵌模式无部署成本适合单机任务状态持久化2. Agent 主循环从裸工具调用到可暂停的状态机2.1 ReAct 循环的最小形态Agent 的底层逻辑说白了就是 ReAct 模式让模型先推理Reasoning再决定调用什么工具Acting拿到工具结果后继续推理直到模型自己认为任务完成。最初版本我用一个 while 循环直接实现看起来非常简洁while (iteration maxIterations) { // 1. 把历史消息发给模型拿到响应 AssistantMessage response llmProvider.chat(memorySnapshot); // 2. 如果模型没有工具调用请求结束 OptionalToolCall toolCall response.getToolCall(); if (toolCall.isEmpty()) { return buildFinalResult(response); } // 3. 执行工具 ToolResult result toolRegistry.execute(toolCall.get()); // 4. 把工具结果作为观察消息回填 memorySnapshot memoryStore.append(response, result); iteration; }这段逻辑单独看没有任何问题但一旦放到真实环境就开始暴露短板。最典型的场景模型连续好几轮选择调用同一个查询类工具把相同的输入反复查了四五遍每次都返回同样的数据就是不收敛。这不是模型本身笨而是循环结构里没有任何机制去干预这种无效重复。还有一个更现实的问题——循环一旦开始人就插不进手了。某个工具执行需要财务侧人工审批纯循环结构只能等它自己跑完或超时这对企业场景是不可接受的。2.2 从循环到状态机把过程变成可管理对象把这些痛点汇总后我把主循环重写成了一个有限状态机。Agent 任务被拆成五种状态THINKING等待模型输出、TOOL_PENDING模型已请求调用工具等待裁决、TOOL_EXECUTING工具正在执行、OBSERVING等待工具结果回填、FINISHED成功结束以及ERROR需要人工恢复。核心数据结构是AgentState它把 Agent 的一次任务从一段循环代码变成一个可以被持久化、可暂停、可恢复的对象public class AgentState { private final String traceId; private AgentPhase phase; private int iteration; private String taskPrompt; private ListAgentTurn turns new ArrayList(); private ToolCall pendingToolCall; private ToolResult lastToolResult; }AgentTurn记录一轮完整的模型输出 工具调用 工具结果这样整个任务可以随时序列化落到数据库进程重启之后还能接着跑。状态机主循环是这样工作的每次迭代先检查当前状态如果发现已经连续N轮出现相同的工具请求且结果没变化就强制终止当前工具调用并往消息序列里注入一条系统提示要求模型换一种策略。状态迁移里还有一条关键路径TOOL_PENDING - TOOL_EXECUTING之前会先触发一个ToolApprovalListener。默认实现是自动放行如果业务方注册了人工审核处理器状态机会停留在TOOL_PENDING等待审核侧调用state.continueFromPending()或者state.rejectPending()。这个设计最终落地成了内部人工介入 Agent 任务的核心机制靠的是一个简单的阻塞取消化队列审核人员点了通过队列里的许可令牌被消费状态机才继续往下走。这种改造带来的收益是明显的Agent 从发出去就失控的异步任务变成了每一步都可审计、可暂停、可恢复的工作流对象。对于企业内部系统来说这个区别比任何炫酷算法都重要。3. 工具定义与调用注解、反射和类型擦除到底有多痛3.1 AgentTool 注解让业务方法十秒变工具Agent 的能力上限取决于它能调用多少工具而工具的易用性直接决定整个框架能不能被业务方接受。JavaManus 里定义工具的方式简单到有点原始在任意类的公开方法上打一个AgentTool方法就会自动变成 Agent 可感知的工具。public class OrderQueryTools { AgentTool(name query_order_status, description 根据订单号查询订单当前状态返回状态文本) public String queryOrderStatus( AgentParam(description 订单号例如 ORD-2024-0001) String orderId) { return orderService.getStatusByOrderId(orderId); } }这个工具注册到 Agent 后模型就会在需要时生成一个类似{name: query_order_status, arguments: {\orderId\: \ORD-2024-0001\}}的调用请求。业务方不需要理解底层任何机制只需要在方法上加注解。框架初始化时通过反射扫描指定包路径下的类把方法元信息收集起来。3.2 生成工具描述让模型知道有这个工具模型要正确使用工具光有代码不行还必须在每次请求时携带工具的 JSON Schema 描述。我们扫描注解后动态生成 OpenAI 兼容协议里的functions列表{ type: function, function: { name: query_order_status, description: 根据订单号查询订单当前状态返回状态文本, parameters: { type: object, properties: { orderId: { type: string, description: 订单号例如 ORD-2024-0001 } }, required: [orderId] } } }这块实现的核心工具是 Java 反射。方法签名里每个参数的类型、注解描述、是否必填都可以通过Parameter对象拿到。我写了一个ParameterSchemaGenerator专门处理String、Integer、Long、Boolean、List?、MapString,Object以及自定义 POJO 这几种类型的映射。3.3 类型擦除的连环坑从 Object 到正确参数类型工具注册容易真正让人抓狂的是调用阶段。反射拿到的是Method执行时method.invoke(instance, args)要求传入一个Object[]但模型传来的是 JSON 字符串比如{orderId: ORD-2024-0001}。所以必须先反序列化成 Java 参数对象再填充到数组中。这个环节踩的坑最多坑一空字符串与 null 的语义混淆。模型偶尔会生成{orderId: }这种调用。如果参数类型是Long用 Gson 直接把空字符串转Long会直接抛异常。我们后来在参数适配层单独加了一个规则空字符串统一转换成 null然后交给业务参数校验去处理而不是在框架层抛出诡异异常。坑二Boolean 的反序列化陷阱。有一段时间我们一个工具的isActive参数总是表现异常最后发现 Gson 处理{isActive: false}时字符串false被当成 true 的非空字符串处理了。解决方案是显式为每个参数注册自定义反序列化器不允许默认行为。坑三List 泛型的对象类型丢失。方法签名声明ListAttachment但反射拿到的泛型参是List运行时擦除导致直接转 List 后拿到的全是 LinkedTreeMap。必须在ParameterizedType层面解析实际类型参数手动构造成ListAttachment再反序列化。这个问题的排查会让人怀疑人生因为它在某些调用路径上偶尔能跑通、偶尔抛ClassCastException。3.4 参数校验与错误还原工具调用出错是常态模型也会瞎猜参数。JavaManus 在工具执行外层包了一层异常兜底任何业务异常都会被捕获并格式化为工具结果文本回传给模型让模型基于错误信息自行修正。但需要注意不能把所有异常都一股脑丢给模型像数据库连不上这种系统级错误应该直接标记为ERROR状态触发人工介入否则模型会拿着错误信息反复重试徒增 Token 消耗和延迟。此外工具注册中心用的是ConcurrentHashMap支持动态注册和启停。Agent 任务开始前先加载该任务域允许的工具列表避免模型跨权限调用敏感能力。这个按任务域挂载工具的设计也是后来安全评审里加分很多的一点。4. 大模型接入层统一 OpenAI 兼容协议背后的四个坑4.1 抽象接口整个框架不关心你用的是哪家模型JavaManus 对模型的接入抽象只有一个接口核心方法就两个一个处理非流式的完整响应一个处理流式响应。public interface LLMProvider { ChatResponse chat(ChatRequest request); void stream(ChatRequest request, StreamHandler handler); ChatResponse chatWithTools(ChatRequest request, ListToolSpec tools); void streamWithTools(ChatRequest request, ListToolSpec tools, StreamHandler handler); }刚开始我们只对着官方协议实现了一版客户端后来发现市面上几乎所有主流大模型都提供 OpenAI 兼容的 Chat Completions 接口只是字段细节和风格略有差异。于是我们把协议层做得尽量温顺请求体只包含消息、工具、温度、流式这四个核心字段响应解析则做了两层适配第一层解析标准结构第二层兼容各家在非标准字段上的细微差别。因为不同厂商对错误码的定义并不一致统一抽象层最好只在 HTTP 状态码层面做分支判断业务错误一律抛给上层根据实际错误信息处理。4.2 SSE 流式解析Java HttpClient 的逐行不一定是逐条流式输出是最常用的模式因为用户会实时看到思考过程token 被一个字一个字打出来。Java 实现的难点在于 SSEServer-Sent Events协议解析。JDK 内置的HttpClient没法直接处理 SSE但可以用BodyHandlers.ofLines()逐行读取响应体HttpClient client HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(10)) .build(); HttpRequest request HttpRequest.newBuilder(uri) .header(Content-Type, application/json) .header(Accept, text/event-stream) .POST(BodyPublishers.ofString(jsonBody)) .build(); client.sendAsync(request, BodyHandlers.ofLines()) .thenAccept(response - { response.body().forEach(line - { if (line.startsWith(data:)) { String payload line.substring(5).trim(); if ([DONE].equals(payload)) { handler.onDone(); return; } // JSON解析每个增量块 StreamChunk chunk gson.fromJson(payload, StreamChunk.class); handler.onDelta(chunk.getDeltaContent()); } }); });这里最容易出问题的不是 forEach 本身而是网络超时和连接中断。尤其是长任务或者模型推理时间较长时连接可能空闲超时触发HttpTimeoutException。实际调优时我们把connectTimeout和整体最大时长分开设置连接超时短一点10 秒读超时用异步的任务级别超时总任务时长上限 10 分钟而不是依赖 HTTP 客户端的默认行为。另外BodyHandlers.ofLines()实际是流式读取如果上层处理速度跟不上需要背压机制。我在StreamHandler里引入了一个容量有限的阻塞队列虚拟线程从队列里消费 delta 数据再喂给上层输出渲染器。这个设计既保证了不丢数据又天然限制了内存占用。否则如果模型输出的 token 太多而上层 UI 渲染跟不上内存会直接被打满。4.3 结构化输出让模型别乱说话Agent 内部很多场景并不需要自然语言创作而是希望模型输出可解析的 JSON。比如让模型根据工具调用结果做最终总结时我们要求它输出结论 置信度 使用到的工具名列表这个结果要直接落库不能被自由文本污染。实现上我们用了两层保障。第一层是请求协议里带上 JSON 输出模式让模型尽量返回合法 JSON第二层是后处理兜底把返回文本里的第一对花括号截出来尝试解析解析失败才请求模型重新生成。这里有一个很实际的建议永远不要信任模型百分百遵守输出格式约束一定要有后处理兜底否则生产环境里你会被各种诡异输出折磨到怀疑人生。4.4 重试、限流与超时模型 API 并不像看起来那么可靠。高峰期 429 限流、瞬时 5xx、网络抖动都是常事。我在LLMProvider的默认实现里内置了指数退避重试第一次重试等 1 秒第二次等 2 秒第三次 4 秒最多三次。这里要特别小心工具调用链路里包含模型调用所以重试不能无限叠加否则极端情况下整个 Agent 任务会膨胀成一个巨大的重试风暴。最终实现是每个 Agent 任务的重试总次数做全局上限且重试只对网络层错误生效业务性错误比如参数校验失败不重试。这个模块产出一个核心的教训模型接入层看似只是发个 HTTP 请求实际是 Agent 框架里最需要工程化打磨的地方因为它承担了所有外部不确定性。5. 记忆与上下文压缩Token 消耗是 Agent 最大的隐性成本5.1 每轮全量塞给模型迟早爆掉初版 JavaManus 把任务里所有历史消息原封不动塞给模型。跑第一个真实场景时任务进行到第 8 轮工具调用请求体长度翻了一倍第 12 轮时模型直接开始忘记最初的系统指令回答变得越来越飘。原因很简单上下文窗口有限而 Agent 任务每执行一个工具都会新增一轮模型输出 工具结果这些内容大量是中间状态对最终决策并没有太多增量信息价值。Token 不仅仅是钱的问题它还是 Agent 决策质量的天花板。上下文越长模型注意力越分散关键指令被淹没的概率就越高。5.2 三明治式记忆固定底座 最近全量 关键检索我最终采用的记忆策略可以概括成三明治最顶上固定放系统提示词和全局指令中间放最近 N 轮对话全量数据保证模型对当前情况的感知能力下面再接一段检索出来的历史关键记忆把那些可能影响当前决策的早期信息拉回来。这三层组合成每次请求的实际 messages。这里有个很重要的调参经验最近 N 轮的 N 不能拍脑袋定。我们做了多组对比直接把 N 从 3 调到 6、再调到 10观察任务成功率和耗时的变化。最终选了 N8 的折中方案原因是在我们的任务类型里8 轮以内基本覆盖了上一个工具结果影响下一步决策的窗口再往前基本就是冗余。5.3 Token 估算没有计量就没有控制做压缩的前提是知道每段内容到底占多少 Token。我们尝试过按字符数估算比如中文 1.5 字符约等于 1 Token但误差极大控制效果很差。最终引入了一个开源的 BPE Tokenizer 移植库来做精确估算每次写入记忆时同步记录消息的 Token 数。记忆管理器维护一个预算比如maxContextTokens 12000每次组装请求消息前做一次预算检查。超过预算时优先压缩早期对话内容最近对话尽量保留。这个机制跟保险丝一样保证任何情况下请求体都不会超过模型上下文限制。5.4 本地简易向量检索给记忆算出相似度如果要进一步复用历史经验就得做语义检索。比如 Agent 之前处理过一个高度相似的问题当时的决策过程对当前任务很有参考价值。我在 JavaManus 里写了一个轻量实现先用嵌入模型把每条历史消息转成向量存进本地内存表然后在事件触发时用余弦相似度计算当前问题与历史的接近程度。public double cosineSimilarity(float[] a, float[] b) { if (a.length ! b.length) return 0.0; double dot 0.0, normA 0.0, normB 0.0; for (int i 0; i a.length; i) { dot a[i] * b[i]; normA a[i] * a[i]; normB b[i] * b[i]; } return dot / (Math.sqrt(normA) * Math.sqrt(normB) 1e-8); }这个方案不追求学术意义上的准确性但对我们的任务类型已经够用。考虑到嵌入向量生成也有网络开销检索结果会被缓存避免同一任务内重复调用模型接口。5.5 记忆落库H2 内嵌数据库的取舍记忆如果不落盘Agent 进程一重启所有学习到的经验就烟消云散。我们用 H2 做了内嵌存储保存三种数据任务状态的序列化快照、完整消息历史、向量索引的持久化副本。选择 H2 是因为它部署零成本、单机性能足够、支持 SQL 查询方便排查这个 Agent 上一轮到底干了什么。如果未来要做跨机共享记忆H2 这种内嵌库肯定不行得换外部向量库或者分布式存储但至少在单机原型阶段它的性价比是碾压级的。6. 并行工具调用的实测调优虚拟线程把耗时砍掉大半6.1 先看数据一次任务的时间到底花在哪给 Agent 接线完成功跑通后团队开始做性能收尾。我抓了一次典型的 8 轮工具调用任务耗时分布如下环节耗时占比模型调用累计 9 次8.6s68%工具执行累计 8 次3.1s24%序列化/反序列化0.6s5%记忆管理与组装请求0.4s3%模型调用耗时是主导项这部分没法从算法层面压缩只能靠减少调用次数和流式体验优化。真正能下手的是工具执行这一块——那 8 次工具调用里有相当一部分互相之间没有数据依赖完全可以并行执行。6.2 虚拟线程给每个工具调用一个独立的轻量线程传统做法是每个工具调用开一个Thread但线程本身很贵8 个并发就要 8 个系统线程线程池管理也麻烦。JDK 21 的虚拟线程完美匹配这种 IO 密集场景虚拟线程的创建和切换成本极低可以肆无忌惮地以量取胜。流程改造后Agent 在一个推理回合里发现模型同时请求了 3 个工具这把 3 个工具调用提交到一个由虚拟线程驱动的并行执行器try (var executor Executors.newVirtualThreadPerTaskExecutor()) { ListFutureToolResult futures toolCalls.stream() .map(call - executor.submit(() - toolRegistry.execute(call))) .toList(); for (FutureToolResult future : futures) { ToolResult result future.get(30, TimeUnit.SECONDS); memoryStore.appendObservation(result); } }注意这里我用newVirtualThreadPerTaskExecutor它每个任务创建一条虚拟线程执行完自动回收完全不需要手写线程池参数。实测对比非常明显同样一轮 3 个并行工具调用原方案串行耗时约 5.9 秒虚拟线程并行后降到 1.8 秒。整个任务从 12.7 秒压到了 8.2 秒对用户体感来说是从明显等待变成还能接受。6.3 序列化和内存小体的幸与不幸Agent 请求/响应体的单条消息都不大但总量不小。最初用 Gson 默认反射机制在频繁序列化时 CPU 开销尚可但内存中临时对象太多GC 压力偏大。后来做了两个优化一是为固定结构消息类开启 Gson 的setObjectToNumberStrategy和自定义注册适配器降低反射解析对象树的开销二是把记忆中重复出现的系统提示词、工具描述这类永不变化的大字符串做强引用缓存避免反复创建。6.4 可观测性一次任务全链路耗时画像调优最怕没有数据。JavaManus 在关键节点埋了统一的监控事件模型调用开始/结束、工具执行开始/结束、记忆组装耗时、Token 消耗量、本轮状态迁移原因。每个事件都带上 traceId通过日志的 MDC 机制自动打印到一行结构化日志里。有一次生产环境报Agent 任务卡了 40 秒没反应我们靠日志里的 trace 数据一分钟定位到问题一个工具在某个特定参数下会触发内部接口长轮询导致超时。如果框架没有这种细粒度的可观测钩子这种问题排查起来就像大海捞针。建议任何人在构建 Agent 框架时都把可观测性放在跟功能实现同等重要的位置。6.5 这个框架继续长下去的方向JavaManus 目前只是单 Agent 任务执行内核。如果要往更复杂的场景扩展我认为第一个值得做的是多 Agent 协作把状态机从单任务升级成多角色会话每个 Agent 有自己的记忆和工作台通过消息总线通信。第二个方向是把任务级记忆升级成跨任务长期记忆让 Agent 从历史经验中累积工作技能。第三个方向是在线的工具配置热更新不停机就能给运行中的 Agent 挂载新工具。我个人的体会是Agent 框架的难点从来不是某个单点技术而是怎么把模型、工具、记忆、状态这些零件拧成一个可靠的整体。JavaManus 这个项目最大的收获不在代码量本身而在于让我看清了模型只是放大器工程才是底盘这句话的分量。如果你也准备在 Java 生态里做 Agent先把主循环和工具调用这两层地基打扎实剩下的事都会顺很多。