Java 重造 Agent 框架:从 ReAct 到工具调用的工程实践

📅 发布时间:2026/10/19 0:30:44
Java 重造 Agent 框架:从 ReAct 到工具调用的工程实践
1. 为什么我要用 Java 重造一个 Agent 框架市面上讲 AI Agent 的文章十篇里有八篇是 Python 写的剩下两篇是 TypeScript。Java 程序员想找个能直接读源码、能塞进现有 Spring 项目里的 Agent 框架翻遍 GitHub 也就那么几个半成品。我在一个后端团队里做架构日常就是跟 JVM、线程池、连接池打交道业务系统全是 Java 写的。当老板说给我们的工单系统加个能自动查日志、调接口、生成报告的智能助手时我不可能为了这个把整个技术栈换成 Python。这就是 JavaManus 诞生的背景。名字里的 Manus 取自手的意思寓意这个框架是给大模型装上一双能操作外部世界的手——能调工具、能记上下文、能分步骤完成任务。它不是又一个聊天机器人封装而是一个任务编排引擎你给它一个目标它自己决定先干什么、后干什么、用哪个工具、失败了怎么重试。这篇文章适合两类人看。一类是 Java 后端想在自己的系统里嵌入 Agent 能力但不想引入 Python 运行时另一类是对 Agent 原理好奇想通过读一个完整实现来搞懂 ReAct、工具调用、记忆管理到底是怎么回事。我会把设计决策、核心代码结构、踩过的坑全部摊开讲代码量不小但每一段都有存在的理由。先说结论JavaManus 的核心只有四个模块——推理循环Reasoning Loop、工具注册中心Tool Registry、上下文记忆Memory、执行器Executor。听起来简单但把这四样东西拼成一个稳定运行的系统中间有大量细节要处理。下面我按实际开发顺序从架构设计讲到代码落地再讲测试和调优。2. JavaManus 的四层架构与核心抽象2.1 为什么是四层而不是三层很多 Agent 框架喜欢分三层模型层、工具层、编排层。我一开始也这么设计写到一半发现不对劲。问题出在记忆上。如果把记忆塞进编排层那编排逻辑会变得极其臃肿因为记忆的读写策略什么时候存、什么时候取、取多少和任务编排下一步做什么是两种完全不同的关注点。前者是数据管理后者是控制流。所以我把记忆单独抽成一层。最终的四层是模型接入层统一不同大模型厂商的 API 差异对外暴露一个ChatModel接口。记忆层管理对话历史、工具调用记录、中间结果支持滑动窗口和摘要压缩。工具层定义工具的标准接口负责注册、发现、参数校验和调用。编排层核心的推理循环决定每一步的动作。这四层之间是单向依赖编排层依赖工具层和记忆层工具层和记忆层依赖模型接入层。没有循环依赖每一层都可以单独测试。2.2 核心接口设计Agent、Tool、Memory先看最顶层的Agent接口。我刻意让它保持极简public interface Agent { AgentResult run(String goal); AgentResult run(String goal, AgentContext context); }AgentResult里包含最终答案、执行轨迹每一步的思考、动作、观察、消耗的 token 数和耗时。执行轨迹非常重要后面讲调试的时候会用到。Tool接口是框架的扩展点任何人都可以实现它来接入自己的业务能力public interface Tool { String name(); String description(); ParameterSchema parameters(); ToolResult execute(MapString, Object args); }这里有个设计取舍值得说。description()和parameters()不是给人看的是给大模型看的。大模型靠这两样东西判断这个工具是干什么的、我该传什么参数。所以 description 的写法直接决定工具调用的准确率。我踩过的坑是description 写得太技术化模型理解不了写得太模糊模型会乱调。后面第 4 节会专门讲怎么写好 description。Memory接口负责上下文的存取public interface Memory { void add(Message message); ListMessage getRecent(int count); ListMessage search(String query, int topK); void compress(); }compress()方法是关键。Agent 跑长任务时对话历史会迅速膨胀超过模型上下文窗口。压缩策略是把早期的详细记录总结成一段摘要保留最近几轮完整记录。这个策略不是拍脑袋定的是基于近期信息对当前决策影响更大这个假设。2.3 推理循环的状态机模型编排层的核心是一个状态机。我用枚举定义状态public enum AgentState { THINKING, // 模型正在推理 ACTING, // 正在执行工具 OBSERVING, // 处理工具返回结果 FINISHED, // 任务完成 FAILED // 任务失败 }循环的逻辑是THINKING → 解析模型输出 → 如果是工具调用则进入 ACTING → 执行完进入 OBSERVING → 把观察结果写回记忆 → 回到 THINKING。如果模型输出的是最终答案直接进入 FINISHED。这个状态机的好处是每一步都可中断、可恢复、可记录。我在每个状态转换点都埋了钩子Hook方便做日志和监控。比如你想知道 Agent 卡在哪一步看状态转换日志就一目了然。注意状态机一定要设最大循环次数。我最初没设结果模型陷入调用工具→结果不满意→再调用同一个工具的死循环烧掉了几十万 token。现在默认上限是 15 轮超过就强制返回当前最佳结果。3. 工具调用从注册到执行的完整链路3.1 工具注册中心的实现细节工具注册中心本质上是一个 Map但直接暴露 Map 会有并发问题。我用ConcurrentHashMap存工具实例key 是工具名public class ToolRegistry { private final MapString, Tool tools new ConcurrentHashMap(); public void register(Tool tool) { if (tools.containsKey(tool.name())) { throw new IllegalStateException(工具名重复: tool.name()); } tools.put(tool.name(), tool); } public Tool get(String name) { return tools.get(name); } public ListTool all() { return new ArrayList(tools.values()); } }工具名重复必须抛异常不能覆盖。因为模型是根据名字调工具的如果两个工具同名行为不可预测。这个检查在启动时做比运行时出错好得多。注册中心还有一个隐藏职责生成工具描述清单。每次调用模型前要把所有工具的 name、description、parameters 拼成一段文本塞进 prompt。这段文本的格式很讲究我用的是类 JSON Schema 的结构因为模型对 JSON 格式的理解最稳定。3.2 参数校验别让模型传错类型模型返回的工具参数是 JSON但类型经常出错。比如它会把数字5传成字符串5把数组传成逗号分隔的字符串。如果不校验直接执行轻则报错重则数据错乱。我的做法是在ParameterSchema里定义每个参数的类型和是否必填执行前做一次转换和校验public class ParameterSchema { private final MapString, ParamDef params; public MapString, Object validateAndCoerce(MapString, Object raw) { MapString, Object result new HashMap(); for (Map.EntryString, ParamDef entry : params.entrySet()) { String key entry.getKey(); ParamDef def entry.getValue(); Object value raw.get(key); if (value null) { if (def.required()) { throw new ToolParamException(缺少必填参数: key); } continue; } result.put(key, coerce(value, def.type())); } return result; } }coerce方法负责类型转换。字符串转数字、单值转数组、字符串转布尔值这些都要处理。实测下来加了这一层之后工具调用的失败率从 15% 降到了 3% 左右。3.3 工具执行的超时与隔离工具执行可能很慢比如调一个外部 HTTP 接口或者跑一个复杂查询。如果工具卡住整个 Agent 就卡住了。所以每个工具执行都要有超时控制。我用ExecutorService加Future.get(timeout)实现public ToolResult executeWithTimeout(Tool tool, MapString, Object args, long timeoutMs) { FutureToolResult future executor.submit(() - tool.execute(args)); try { return future.get(timeoutMs, TimeUnit.MILLISECONDS); } catch (TimeoutException e) { future.cancel(true); return ToolResult.error(工具执行超时: tool.name()); } catch (Exception e) { return ToolResult.error(工具执行异常: e.getMessage()); } }这里有个细节future.cancel(true)只是尝试中断线程如果工具内部不响应中断比如在跑一个死循环线程还是会继续跑。所以工具实现里要自觉检查中断标志。我在框架文档里明确要求任何耗时超过 1 秒的工具内部必须定期检查Thread.currentThread().isInterrupted()。提示工具执行失败不要直接让 Agent 失败而是把错误信息作为观察结果返回给模型。模型看到错误后往往会换个方式重试这比直接终止任务聪明得多。4. 提示词工程让模型稳定输出结构化动作4.1 ReAct 格式的 Java 实现ReActReasoning Acting是 Agent 的经典范式核心是让模型交替输出思考和动作。我用的是简化版格式要求模型每次输出必须是以下两种之一Thought: 你的推理 Action: 工具名 Action Input: JSON 参数或者Thought: 你的推理 Final Answer: 最终答案解析这段文本是编排层最脆弱的地方。模型有时候会多输出一段解释有时候 JSON 格式不对有时候工具名拼错。我的解析器做了三层容错先用正则提取Action:和Action Input:之间的内容。尝试用 JSON 解析器解析参数失败则尝试修复常见错误比如单引号转双引号、补全缺失的括号。如果工具名不存在返回一个错误观察结果让模型重新选。public AgentAction parse(String modelOutput) { String action extractBetween(modelOutput, Action:, Action Input:); String input extractAfter(modelOutput, Action Input:); if (action null) { String answer extractAfter(modelOutput, Final Answer:); if (answer ! null) { return AgentAction.finish(answer); } return AgentAction.error(无法解析模型输出); } try { MapString, Object args jsonMapper.readValue(repairJson(input), Map.class); return AgentAction.call(action.trim(), args); } catch (Exception e) { return AgentAction.error(参数解析失败: input); } }4.2 工具描述怎么写才能提高调用准确率这是我最想强调的一点。工具描述不是文档是给模型的使用说明书。我总结了三条经验第一描述里要包含什么时候用和什么时候不用。比如一个查询订单的工具描述不能只写查询订单信息而要写当用户询问订单状态、物流进度、订单金额时使用此工具。当用户询问商品详情时不要使用应使用商品查询工具。第二参数描述要给出示例值。模型对示例的敏感度远高于对类型的敏感度。写orderId: 订单编号例如 ORD20240101001比写orderId: string 类型订单编号效果好得多。第三工具数量控制在 10 个以内。我做过对比测试当工具数量从 5 个增加到 20 个时模型选错工具的概率从 8% 飙升到 35%。如果业务确实需要很多工具就做分组先让模型选组再在组内选工具。4.3 少样本示例的取舍在 prompt 里放几个思考-动作的示例能显著提升模型输出格式的稳定性。但示例不是越多越好。我试过放 5 个示例结果模型开始机械模仿示例里的工具调用即使当前任务并不需要那个工具。最后的方案是放 2 个示例一个展示工具调用一个展示直接回答。并且示例里的工具名用占位符避免模型产生错误联想。这个平衡点是我反复调了十几版 prompt 才找到的。5. 记忆管理上下文窗口的攻防战5.1 滑动窗口的边界问题最简单的记忆策略是滑动窗口只保留最近 N 条消息。但直接砍掉早期消息会丢失关键信息。比如任务开始时用户说我要查上个月的订单跑到第 10 轮时这条信息被挤掉了模型就忘了要查哪个月。我的改进方案是关键信息锚定在添加消息时用一个轻量级分类器其实就是关键词匹配加规则判断这条消息是否包含任务约束时间范围、数量限制、格式要求等。如果是就打上pinned标记滑动窗口淘汰时跳过这些消息。public void add(Message message) { if (isConstraint(message.content())) { message.setPinned(true); } messages.add(message); if (messages.size() maxSize) { evictOldestUnpinned(); } }这个策略实现简单但效果立竿见影。在一个查订单并生成报告的测试任务里加了锚定之后任务成功率从 62% 提升到了 89%。5.2 摘要压缩的触发时机与实现当消息数量超过阈值光靠滑动窗口不够了需要压缩。压缩的触发时机很关键太早压缩会丢失细节太晚压缩会超出上下文窗口。我的策略是双阈值当消息数超过maxSize * 0.8时开始准备压缩超过maxSize时强制执行。压缩时把最老的 50% 消息交给模型让它生成一段摘要然后用摘要替换这些消息。public void compress() { int compressCount messages.size() / 2; ListMessage toCompress messages.subList(0, compressCount); String summary model.summarize(toCompress); messages.subList(0, compressCount).clear(); messages.add(0, Message.system(历史摘要: summary)); }摘要的 prompt 我改了很多版最后固定为请用不超过 200 字总结以下对话的关键信息包括用户的目标、已完成的步骤、重要的中间结果、尚未解决的问题。不要包含任何工具调用的原始参数。5.3 工具调用记录的单独存储工具调用的原始返回结果往往很长比如一个 API 返回了几 KB 的 JSON全部塞进上下文会迅速耗尽窗口。我的做法是上下文里只保留工具返回的摘要完整结果存在单独的存储里需要时通过工具重新获取。具体实现是给每个工具结果生成一个 ID上下文里记录工具 X 返回结果 ID: abc123摘要: 查询到 5 条订单。如果模型需要详细信息它可以调用一个内置的retrieve_result工具传入 ID 拿完整数据。这样上下文始终保持在可控范围内。6. 实测中的性能瓶颈与调优记录6.1 模型调用是最大瓶颈跑了一个压测让 Agent 完成一个需要 8 轮交互的任务统计各阶段耗时。结果模型调用占了总耗时的 87%工具执行占 9%框架自身开销占 4%。这意味着优化框架代码的收益很小真正的瓶颈在模型响应速度。能做的优化有三点。一是并行化无依赖的工具调用。如果模型一次返回多个工具调用有些模型支持且这些工具之间没有数据依赖就并行执行。我用CompletableFuture.allOf实现实测能省 30% 左右的时间。二是缓存模型响应。对于相同的 prompt比如系统提示词部分可以缓存模型的 KV Cache但这需要模型服务端支持。三是流式解析。不等模型输出完整响应就开始解析一旦检测到完整的 Action 块就立即执行不用等后面的内容。6.2 线程池配置的坑工具执行用的线程池我一开始用了Executors.newCachedThreadPool()结果在压测时创建了上千个线程直接把系统拖垮。后来改成固定大小加有界队列new ThreadPoolExecutor( 8, // 核心线程数 16, // 最大线程数 60, TimeUnit.SECONDS, new ArrayBlockingQueue(100), new ThreadPoolExecutor.CallerRunsPolicy() );核心线程数设为 8 是基于工具执行大多是 IO 密集型的判断。CallerRunsPolicy是让提交任务的线程自己执行起到背压作用防止任务无限堆积。6.3 日志与可观测性Agent 的行为是概率性的出了问题很难复现。所以日志必须详细。我记录了每个状态的进入和退出时间、每次模型调用的完整 prompt 和响应、每次工具调用的参数和结果。这些日志量很大我用了异步 appender 加滚动策略单文件不超过 100MB保留 7 天。排查问题时我写了一个简单的轨迹回放工具读取日志按时间顺序打印出思考→动作→观察的完整链路。这个工具帮我定位了好几个诡异的 bug比如模型在某一步突然改变了任务目标回放一看是因为上一步的工具返回里包含了一句有歧义的提示。7. 从 Demo 到生产还需要补哪些课7.1 安全边界工具权限控制Demo 阶段所有工具对所有 Agent 开放生产环境不能这样。我加了一层权限控制每个 Agent 实例绑定一个角色每个工具声明自己需要的权限执行前做检查。public class SecureToolRegistry { public ToolResult execute(String agentRole, String toolName, MapString, Object args) { Tool tool tools.get(toolName); if (!tool.allowedRoles().contains(agentRole)) { return ToolResult.error(当前角色无权调用工具: toolName); } return tool.execute(args); } }这个设计还有一个好处可以给不同业务线分配不同的工具集避免互相干扰。7.2 成本控制token 预算与熔断大模型调用是按 token 计费的Agent 跑飞了会烧很多钱。我加了一个 token 预算机制每个任务开始时设定预算上限每次模型调用后扣减超预算就强制终止并返回当前结果。public class TokenBudget { private final AtomicLong remaining; public boolean tryConsume(long tokens) { long left remaining.addAndGet(-tokens); return left 0; } }配合熔断机制如果连续 3 次工具调用失败或者模型连续 3 次输出无法解析就终止任务。这两个机制上线后异常任务的成本降低了 70% 以上。7.3 测试策略怎么测一个概率性系统Agent 的输出是不确定的传统的断言测试不适用。我的测试策略分三层单元测试测确定性部分参数校验、JSON 解析、记忆淘汰逻辑。这些用普通 JUnit 就行。集成测试用 mock 模型写一个MockChatModel按预设脚本返回响应验证编排逻辑是否正确。这样可以测试各种异常分支比如模型返回格式错误、工具超时等。评估测试用真实模型准备一组标准任务跑 N 次统计成功率、平均轮数、平均 token 消耗。这个不追求 100% 通过而是看指标是否在可接受范围内。我一般跑 20 次取平均波动超过 15% 就说明 prompt 或工具描述需要调整。8. 一些让我少走弯路的实现细节先说一个关于 JSON 解析的。模型返回的 JSON 经常带 markdown 代码块标记比如json ...。我一开始用正则去剥后来发现直接找第一个{和最后一个}更可靠。但如果参数里本身包含花括号比如一个 JSON 字符串参数这个方法又会出错。最终的方案是用一个宽容的 JSON 解析器它能自动跳过非 JSON 前缀。再说一个关于工具返回值的。工具返回给模型的内容最好统一成字符串。我试过直接返回对象让框架序列化结果不同工具的序列化格式不一致模型理解起来很费劲。现在所有工具都返回ToolResult里面有一个toObservation()方法负责把结果转成模型友好的文本格式。数字加单位、列表加序号、错误加前缀这些细节都在这一个方法里统一处理。还有一个关于重试的。工具调用失败后不要立即重试同一个工具。我观察到的模式是模型看到失败信息后如果直接重试大概率还是失败。更好的做法是在观察结果里加一句提示比如该工具调用失败请检查参数是否正确或尝试其他工具。这句话能引导模型改变策略而不是盲目重试。最后说一个关于并发安全的。Agent 实例不要设计成有状态的单例。我最初把 Memory 直接放在 Agent 里结果多个请求共用一个 Agent 时上下文串了。现在的设计是 Agent 无状态每次run方法创建一个新的AgentContext里面包含独立的 Memory 和执行轨迹。这样同一个 Agent 实例可以安全地被多个线程使用。这套框架目前在我们内部跑了几个月接入了工单查询、日志分析、报表生成三个场景日均处理几千个任务。最深的体会是Agent 框架的难点不在让它跑起来而在让它稳定地跑、可控地跑、便宜地跑。模型能力在快速进步但工程侧的这些脏活累活短期内还是得自己扛。