纯Java手写AI Agent框架:ReAct循环与工具调用实战 1. 为什么我要用 Java 从头造一个 AI Agent 框架1.1 一个被忽视的事实Agent 框架几乎全是 Python 的天下过去一年多我一直在做企业级后端系统的开发主力语言是 Java。当 AI Agent 这个概念火起来之后我下意识地去搜了一圈开源实现结果发现一个很尴尬的局面叫得上名字的 Agent 框架十有八九是 Python 写的。LangChain、AutoGPT、MetaGPT 这些清一色 Python 生态。Java 这边呢要么是调个大模型 API 的薄封装要么是把 Spring AI 当胶水用真正意义上具备“规划—执行—反思”完整闭环的 Agent 框架少得可怜。这不是说 Java 不行而是这个领域的先行者恰好都在 Python 社区。但问题在于大量企业的核心业务系统是 Java 写的。你让一个跑了五年的订单系统、风控系统去接一个 Python Agent 服务中间要跨进程、跨语言、跨运维体系光是序列化和部署这一套就够喝一壶的。所以我很早就有一个念头能不能用纯 Java 把 Agent 的核心骨架搭出来让它天然融入现有的 Java 技术栈这个念头落地之后就有了 JavaManus 这个项目。名字里的 Manus 取自“手”的意思寓意它是大模型伸向真实世界的那只手——模型负责思考框架负责动手。整个项目不依赖任何 Python 运行时纯 JVM 上跑JDK 17 起步核心模块用 Maven 管理能直接嵌进 Spring Boot 应用里。1.2 JavaManus 到底解决了什么问题说人话JavaManus 要解决的是这样一件事让一个大语言模型不只是“聊天”而是能自己拆解任务、调用工具、观察结果、修正下一步动作直到把一件事办完。举个具体场景。你告诉它“帮我查一下这个季度华东区的销售数据生成一份汇总表然后发到我的邮箱”。一个普通的对话模型只能给你一段文字建议告诉你“你可以这样做”。而一个 Agent 框架要做的是先规划出“查数据库 → 聚合计算 → 生成表格文件 → 调用邮件接口”这几步然后一步步执行中间哪一步失败了还要能重试或者换方案。JavaManus 就是干这个的。它提供了一套抽象Agent智能体、Tool工具、Memory记忆、Planner规划器、Executor执行器。开发者只需要把业务能力包装成 Tool把模型接进来剩下的编排逻辑框架帮你管。适合谁来参考三类人。第一类是有 Java 基础、想理解 Agent 底层原理的后端工程师第二类是想把 AI 能力接进现有 Java 系统的架构师第三类是做技术选型、在 Python 和 Java 方案之间纠结的团队负责人。哪怕你暂时不写代码光看设计思路也能对“Agent 框架到底是怎么回事”有个清醒的认识。1.3 我为什么不用现成的 Spring AI 直接改有人会问Spring AI 不是已经能调模型、能挂函数了吗为什么还要自己造轮子这个问题我认真想过。Spring AI 的定位是“把大模型能力接入 Spring 生态”它的强项是 ChatClient、Embedding、VectorStore 这些基础能力本质上还是一个“模型调用层”。而 Agent 框架的核心难点不在调模型在于循环控制和状态管理——模型输出一个动作框架执行把结果喂回去模型再输出下一个动作这个 ReAct 循环怎么设计、上下文怎么裁剪、工具调用失败怎么兜底、多轮之后记忆怎么压缩这些才是 Agent 框架的命门。Spring AI 没有替你解决这些它只是给了你零件。JavaManus 是在这些零件之上把“循环”和“状态”这两件事做扎实。当然JavaManus 并不排斥 Spring AI你完全可以把 Spring AI 的 ChatModel 当作底层模型客户端接进来两者是互补关系。我自己在实现时就留了适配层既能接 Spring AI也能接原生的 HTTP 客户端灵活度更高。2. 核心架构拆解一个 Agent 框架该有哪些零件2.1 整体分层从模型到工具的四层结构我在设计 JavaManus 的时候刻意把系统分成了四层每一层职责单一层与层之间通过接口通信。这样做的目的是让每一层都能独立替换不至于牵一发动全身。第一层是模型接入层Model Layer。这一层只干一件事把统一的请求发给不同的大模型服务商再把响应转成框架内部的统一格式。我定义了一个ChatModel接口核心方法就一个chat(ListMessage messages, ListToolSpec tools)。不管背后是哪个厂商的模型到了这一层都被抹平。为什么要抽象这一层因为模型迭代太快了今天用这个明天可能就换那个如果业务代码直接依赖某个厂商的 SDK迁移成本会非常高。第二层是智能体核心层Agent Core。这是整个框架的大脑包含 Planner、Executor、Memory 三个组件。Planner 负责把用户的目标拆成步骤Executor 负责按步骤调用工具Memory 负责记住对话历史和中间结果。这三者协同工作构成了 Agent 的“思考—行动—记忆”闭环。第三层是工具层Tool Layer。所有能被 Agent 调用的能力都在这里注册。一个 Tool 包含名称、描述、参数 schema 和执行逻辑。描述特别重要因为模型是靠描述来判断该不该调用这个工具的。我见过太多人工具写得没问题但描述写得含糊结果模型死活不调用最后怪框架不好用。第四层是运行时层Runtime Layer。这一层管的是线程池、超时控制、重试策略、日志追踪这些工程化的东西。Agent 执行过程中可能涉及多次模型调用和工具调用每一次都可能超时或失败运行时层要保证整个流程可控、可观测、可中断。2.2 ReAct 循环Agent 的心跳是怎么跳的Agent 最核心的机制就是 ReAct 循环全称是 Reasoning and Acting。说白了就是“想一步、做一步、看结果、再想下一步”。这个循环听起来简单但实现起来细节非常多。我用伪代码把核心逻辑说清楚while (!finished stepCount maxSteps) { // 1. 把当前记忆和可用工具发给模型 ModelResponse response chatModel.chat(memory.getContext(), toolRegistry.getSpecs()); // 2. 解析模型输出判断是要调工具还是给最终答案 if (response.hasToolCall()) { ToolCall call response.getToolCall(); // 3. 执行工具 ToolResult result executor.execute(call); // 4. 把工具结果写回记忆 memory.addToolResult(call, result); } else { // 模型给出了最终答案循环结束 finished true; return response.getContent(); } stepCount; }这段逻辑里有几个关键决策点。第一maxSteps必须设上限否则模型可能陷入死循环一直调工具停不下来。我默认设的是 15 步超过就强制终止并返回当前进展。第二工具执行结果要原样写回记忆不能只写“成功”或“失败”因为模型需要看到具体数据才能决定下一步。第三每一轮都要检查是否已经得到最终答案避免无谓的模型调用浪费 token。提示ReAct 循环最大的坑是“模型不按格式输出”。你要求它返回 JSON它可能给你一段自然语言加 JSON 混在一起。所以解析层一定要做容错我用了正则加 JSON 修复的双保险策略。2.3 记忆管理为什么不能把所有历史都塞给模型记忆管理是很多人容易忽略、但实际非常关键的一环。最朴素的做法是把所有对话历史都拼进 prompt 发给模型但这样做的后果是 token 消耗爆炸而且模型在超长上下文里反而容易“失忆”抓不住重点。JavaManus 的记忆分三层。第一层是短期记忆保存最近 N 轮的完整对话N 默认是 10。第二层是工作记忆保存当前任务的中间结果比如已经查到的数据、已经生成的中间文件路径这些不占对话轮次但必须保留。第三层是长期记忆通过向量检索的方式保存历史任务的摘要需要时才召回。裁剪策略上我用的是“滑动窗口 摘要压缩”。当对话轮次超过阈值时把最老的那几轮交给模型做一次摘要用一段简短的话代替原始对话。这样既保留了信息又控制了长度。实测下来一个跑了 20 多轮的任务经过压缩后上下文能控制在 4000 token 以内成本降了一大截。2.4 工具注册与调用让模型知道它能干什么工具是 Agent 的手脚没有工具Agent 就是个只会说话的嘴炮。JavaManus 的工具注册机制参考了函数调用的标准做法每个工具用注解或者 Builder 方式声明。Tool weatherTool Tool.builder() .name(get_weather) .description(查询指定城市的实时天气输入城市名称返回温度和天气状况) .parameter(city, string, 城市名称例如杭州) .handler(args - { String city args.get(city); return weatherService.query(city); }) .build(); toolRegistry.register(weatherTool);这里有个经验之谈工具描述要写得像给新员工交代任务一样具体。不要写“查询天气”要写“查询指定城市的实时天气输入城市名称返回温度和天气状况”。模型判断是否调用工具全靠这段描述。描述里把输入输出都讲清楚调用准确率能提升一大截。我做过对比测试描述详细的工具模型调用正确率比描述模糊的高出将近 40%。3. 关键模块的实操实现细节3.1 模型接入层如何优雅地适配多家模型模型接入层我踩过的坑最多。不同厂商的 API 格式差异很大有的用messages数组有的用prompt字符串有的工具调用返回结构化 JSON有的返回一段需要解析的文本。如果每个地方都写 if-else代码会烂成一团。我的做法是定义一个中间格式UnifiedMessage然后为每家写一个 Adapter。Adapter 的职责就两个把统一格式转成厂商格式把厂商响应转回统一格式。核心接口长这样public interface ChatModel { ChatResponse chat(ChatRequest request); } public class ChatRequest { private ListUnifiedMessage messages; private ListToolSpec tools; private Double temperature; private Integer maxTokens; }ChatResponse里最关键的是finishReason和toolCalls两个字段。finishReason告诉你模型是正常结束还是因为要调工具而暂停toolCalls里装着模型想调用的工具名和参数。这两个字段解析对了ReAct 循环才能正常转起来。注意有些模型在返回工具调用时参数是流式分片返回的需要自己拼接。我一开始没处理这个导致参数经常缺一半排查了大半天才发现是流式的问题。后来在 Adapter 里加了缓冲区等参数完整了再解析。3.2 规划器设计任务拆解到底该由谁来做规划器这块我纠结了很久最后选了“模型驱动 框架兜底”的混合方案。纯模型驱动的规划灵活但不可控纯规则驱动的规划可控但死板。具体做法是任务开始时先让模型输出一个初步的步骤列表格式是 JSON 数组每一步包含step和expectedTool。框架拿到这个列表后不是死板地照着执行而是在每一步执行前重新评估——如果上一步的结果和预期不符就触发重新规划。public class Planner { public ListPlanStep plan(String goal, ListToolSpec tools) { String prompt buildPlanPrompt(goal, tools); String raw chatModel.chat(prompt).getContent(); return parsePlan(raw); // 带容错的 JSON 解析 } public ListPlanStep replan(String goal, ListPlanStep done, String observation) { // 把已完成步骤和最新观察结果喂给模型让它调整后续计划 } }重新规划这个能力特别重要。真实任务里第一步查数据库可能返回空结果这时候死板执行第二步“生成报表”就没意义了。有了 replan模型会意识到“数据没查到应该先换个查询条件”整个 Agent 就显得聪明很多。3.3 执行器与超时控制别让一个卡死的工具拖垮全局执行器负责真正调用工具。这里最大的风险是工具执行时间不可控——可能是一个慢查询可能是一个卡住的 HTTP 请求。如果不做超时控制整个 Agent 就挂在那里了。我的方案是用CompletableFuture加超时public ToolResult execute(ToolCall call) { Tool tool registry.get(call.getName()); CompletableFutureToolResult future CompletableFuture.supplyAsync( () - tool.invoke(call.getArgs()), executorService ); try { return future.get(tool.getTimeoutSeconds(), TimeUnit.SECONDS); } catch (TimeoutException e) { future.cancel(true); return ToolResult.error(工具执行超时 tool.getName()); } catch (Exception e) { return ToolResult.error(工具执行异常 e.getMessage()); } }注意这里返回的是ToolResult.error而不是抛异常。为什么因为工具失败是 Agent 流程中的正常情况模型需要知道“这个工具失败了失败原因是超时”然后它可能会决定重试或者换个工具。如果直接抛异常中断整个流程Agent 就没有自我修复的机会了。每个工具的超时时间可以单独配置。查询类工具给 10 秒写操作类给 30 秒涉及外部系统的给 60 秒。这个值要根据实际业务调不能一刀切。3.4 上下文构建prompt 到底该怎么拼上下文构建是决定 Agent 表现的关键。同样一个模型prompt 拼得好不好效果天差地别。JavaManus 的上下文由四部分组成系统指令、工具说明、历史对话、当前任务。系统指令我写得比较克制核心就三句话你是一个能调用工具的智能助手你需要通过调用工具来完成任务当你得到足够信息时给出最终答案。写太多反而会让模型抓不住重点。工具说明部分我把每个工具的名称、描述、参数格式列成清单。这里有个技巧把最常用的工具放在最前面。模型对 prompt 开头和结尾的内容注意力更集中把高频工具前置能提升调用准确率。历史对话部分就是记忆模块提供的内容已经做过裁剪和压缩。当前任务部分要写得明确包含用户的原始目标和当前进展。整个 prompt 拼下来我一般控制在 3000 到 6000 token 之间。太短信息不够太长模型容易迷失。这个区间是实测下来比较平衡的。4. 实战踩坑与问题排查实录4.1 模型不调用工具怎么办这是新手最常遇到的问题明明注册了工具模型就是不调用直接给一段文字答案。排查下来通常有三个原因。第一个原因是工具描述太模糊。前面说过描述要具体到输入输出。如果描述是“处理数据”模型根本不知道什么时候该用它。改成“读取 CSV 文件并返回前 10 行数据输入文件路径”调用率立刻上来了。第二个原因是系统指令没有强调“必须用工具”。有些模型默认倾向于直接回答你需要在系统指令里明确写“如果任务需要外部数据或操作必须调用相应工具不要凭记忆回答”。第三个原因是模型本身的能力问题。小参数模型在工具调用上的表现确实不如大模型。如果预算允许工具调用密集的场景建议用能力更强的模型。我做过对比同一个工具集强模型的调用准确率能到 90% 以上弱模型可能只有 60%。4.2 工具参数解析失败的排查思路模型返回的工具参数经常有格式问题。常见的有JSON 里多了注释、字符串没加引号、数字被写成了字符串。这些都会导致解析失败。我的排查步骤是这样的。第一步把模型返回的原始内容打日志看清楚它到底返回了什么。很多时候问题一眼就能看出来。第二步检查是不是流式返回导致的参数分片如果是确认缓冲区逻辑是否正确。第三步在解析层加容错用 JSON 修复库先尝试修复再解析修复不了再报错。提示我强烈建议在开发阶段把每一次模型调用的原始请求和原始响应都完整落盘。Agent 的问题排查80% 靠的是看原始日志而不是看框架抛出的异常。4.3 死循环与步数失控的处理Agent 陷入死循环是很常见的。表现是它反复调用同一个工具或者在不同工具之间来回横跳就是不给最终答案。我的处理分三层。第一层是硬性步数上限超过就强制终止这个前面说过。第二层是重复检测如果连续三次调用的工具和参数完全相同就判定为循环强制中断并提示模型换思路。第三层是超时控制整个 Agent 任务设一个总超时比如 5 分钟到点就停。光靠框架拦截还不够prompt 里也要给模型提示。我会在系统指令里加一句“如果某个工具连续失败两次请尝试其他方法或直接告知用户无法完成”。这句话能显著减少无效循环。4.4 常见问题速查表问题现象可能原因排查方向解决手段模型不调用工具描述模糊/指令缺失/模型能力弱看原始响应改描述、加指令、换模型参数解析失败格式错误/流式分片看原始返回加容错、修缓冲逻辑死循环无步数上限/无重复检测看调用序列加步数限制、重复检测上下文超长记忆未裁剪看 token 数滑动窗口加摘要压缩工具超时卡死无超时控制看执行日志加 CompletableFuture 超时最终答案质量差上下文信息不足看拼装的 prompt补充关键观察结果这张表是我在实际开发和调试中一点点攒出来的基本上覆盖了 90% 的常见问题。遇到问题先对照这张表能省下大量瞎猜的时间。5. 工程化落地从能跑到好用还差什么5.1 可观测性Agent 执行过程必须能追溯Agent 最让人头疼的地方是“黑盒感”——它做了一堆操作但你不清楚它为什么这么做。所以可观测性不是锦上添花是必需品。JavaManus 在运行时层埋了完整的追踪点。每一次模型调用记录输入 prompt、输出内容、耗时、token 消耗。每一次工具调用记录工具名、参数、结果、耗时、是否成功。整个任务记录总步数、总耗时、最终状态。这些数据我建议至少保留最近 7 天方便出问题时回溯。如果接入监控系统还能做告警——比如某个工具失败率突然升高或者平均步数异常增长都可能是模型或工具出了问题的信号。5.2 并发与资源隔离多任务同时跑怎么不打架生产环境里Agent 任务往往是并发的。多个用户同时发起任务如果共用一个线程池一个慢任务可能拖垮所有任务。我的做法是给每个 Agent 任务分配独立的执行上下文工具调用走一个带隔离的线程池。线程池的核心线程数根据工具类型区分IO 密集型工具如 HTTP 调用给大一点的池子CPU 密集型工具给小一点的池子。同时给每个任务设资源配额比如最多占用 2 个并发工具调用防止单个任务吃光资源。另外模型调用本身也要限流。很多模型服务商有 QPS 限制如果不做客户端限流高并发时会被直接拒绝。我用的是令牌桶算法把模型调用的速率控制在服务商允许的范围内。5.3 成本控制token 就是钱得省着花Agent 的 token 消耗比普通对话高得多因为每一轮循环都要把上下文重新发一遍。一个 10 步的任务token 消耗可能是单次对话的十几倍。如果不做控制账单会很难看。我的省钱策略有这么几条。第一记忆压缩前面说过的滑动窗口加摘要能砍掉大量冗余历史。第二工具结果精简工具返回的数据如果很长先做一次截断或摘要再写回记忆不要把几万字的原始数据全塞进去。第三模型分级简单任务用便宜的小模型复杂任务才用大模型框架支持按任务类型路由到不同模型。第四缓存相同或相似的查询结果做缓存避免重复调用。实测下来这几条组合起来能把 token 成本压到原来的三分之一左右。对于高频使用的场景这个降幅相当可观。5.4 安全边界工具权限必须收得住Agent 能调用工具就意味着它能对真实系统产生影响。如果工具里有“删除数据”“发送邮件”“执行脚本”这类高危操作权限控制必须做扎实。JavaManus 的权限模型分两级。第一级是工具级白名单只有显式注册的工具才能被调用模型无法凭空捏造工具。第二级是参数级校验高危工具在执行前要过一遍参数校验器比如删除操作必须带明确的 ID且 ID 要在允许范围内。另外我建议对高危工具加人工确认环节。Agent 可以规划出“删除某条记录”这一步但真正执行前需要用户确认。这个确认机制在框架里做成可插拔的需要时开启不需要时关闭。6. 我对 Java Agent 框架未来的一些个人判断6.1 Java 做 Agent 的优势正在显现一开始我也担心 Java 在这个领域会不会太笨重。但做下来发现Java 的优势在工程化场景里反而更明显。强类型让工具定义和参数校验更可靠成熟的线程池和并发工具让运行时控制更稳Spring 生态让接入现有系统几乎零成本。Python 适合快速验证想法Java 适合把想法做成能上生产的东西。这两个定位不冲突各有各的战场。6.2 框架的边界在哪里我得说句实话Agent 框架不是万能的。它擅长的是“有明确工具可用、任务可以拆成步骤”的场景。如果任务本身模糊不清或者需要的工具根本不存在再好的框架也白搭。所以别指望搭个框架就能解决所有自动化问题工具的建设才是真正的重头戏。框架只是骨架工具才是血肉。6.3 后续可以扩展的方向如果继续往下做我会优先考虑三个方向。一是多 Agent 协作让多个专精不同领域的 Agent 互相配合比如一个负责查数据、一个负责写报告、一个负责审核。二是工具的动态发现与注册让 Agent 能在运行时按需加载工具而不是启动时全部注册。三是更细粒度的记忆管理引入知识图谱来组织长期记忆让召回更精准。最后分享一个我在调试 Agent 时的小技巧把maxSteps临时设成 3然后观察模型在前三步的表现。如果三步之内它都没走上正轨那多半是 prompt 或者工具描述有问题而不是模型不行。这个“三步测试法”帮我快速定位了无数次问题比盯着完整日志看效率高多了。