1. 为什么我要用 Java 从零写一个 AI Agent 框架先说结论JavaManus 是一个用纯 Java 实现的 AI Agent 运行时框架核心解决的是“让 Java 后端工程师能用自己熟悉的语言和工程体系快速搭建具备推理与工具调用能力的智能体应用”这个问题。它不是又一个 Python LangChain 的翻译版而是针对 Java 生态的工程习惯、并发模型、依赖注入方式重新设计的。我在一线做 Java 后端差不多十一年中间有三年多在做智能应用相关的平台建设。这两年 AI Agent 的概念很火但落到 Java 团队里情况往往很尴尬算法团队用 Python 跑通了 Demo工程团队要把它接进 Spring Boot 的服务里结果发现要么是跨语言调用要么是把 Python 那套东西硬塞进 JVM调试链路长得离谱。更现实的问题是大部分 Java 工程师并不想为了做一个 Agent 去重新学一遍 Python 的异步生态。JavaManus 就是在这个背景下动手写的。它的目标很明确用 Java 工程师已经掌握的技能栈Spring、线程池、注解、反射、Jackson实现一个可扩展、可观测、可部署的 Agent 框架。核心关键词包括 Java、AI Agent、ReAct、Spring AI但我不依赖 Spring AI 的 Agent 部分只借用它的模型抽象层其余全部自研。这篇文章我会把整个设计和实现过程拆开讲包括为什么选 ReAct 而不是别的范式、工具调用怎么在 JVM 里安全地做、记忆和上下文怎么管理、并发怎么处理、以及我踩过的那些坑。适合有 Java 基础、想自己动手做一个 Agent 框架或者想理解 Agent 内部运行机制的人。如果你只是想调个 API 做个聊天机器人那这篇可能偏重了但如果你想搞清楚“Agent 到底是怎么跑起来的”那接下来的内容应该对你有用。2. 整体架构设计与技术选型思路2.1 为什么是 ReAct而不是 Plan-and-Execute 或纯 Function CallingAgent 的推理范式有好几种主流的有 ReActReasoning Acting、Plan-and-Execute、Reflexion以及纯粹依赖模型 Function Calling 的方式。我在选型时把这几条路都推演了一遍。纯 Function Calling 的方式最省事把工具定义成 JSON Schema 丢给模型模型返回要调用的函数名和参数框架执行后把结果塞回去。但这种方式有个硬伤——它把“思考”这一步完全交给了模型的黑盒中间没有显式的推理过程出了问题很难定位是模型理解错了还是工具描述写得不好。而且不同模型对 Function Calling 的支持程度参差不齐换模型时行为差异很大。Plan-and-Execute 是先让模型出一个完整计划再逐步执行。听起来很美但实际用下来计划往往在第一步执行完就失效了因为真实环境的信息是逐步暴露的一开始就定死计划反而僵化。ReAct 的思路是“想一步、做一步、看一步”模型先输出一段思考Thought然后决定调用哪个工具Action框架执行后把观察结果Observation返回模型再基于新信息继续思考。这个循环天然适配“信息逐步暴露”的场景而且每一步的思考过程都是可见的调试和可观测性都好很多。我最终选 ReAct 作为核心范式但在实现上做了增强支持并行工具调用、支持思考过程的流式输出、支持最大步数限制和提前终止。这样既保留了 ReAct 的透明性又补上了它在效率上的短板。2.2 分层架构把“模型”“工具”“记忆”“编排”彻底解耦整个框架我分成了五层每层职责单一层与层之间通过接口通信层级职责核心接口模型层封装不同大模型的调用差异ChatModel工具层定义、注册、执行工具Tool、ToolRegistry记忆层管理对话历史和长期记忆Memory编排层驱动 ReAct 循环AgentExecutor接入层对外暴露 REST/SSE 接口AgentController这么分的原因很直接模型会换、工具会加、记忆策略会调但编排逻辑相对稳定。把易变的部分隔离在接口后面改一处不影响全局。比如我一开始用的是某云厂商的模型后来要换成另一个只需要实现一个新的ChatModel编排层一行代码都不用动。模型层我没有自己造轮子而是复用了 Spring AI 的ChatModel抽象。原因是我评估下来Spring AI 在模型接入这块已经做得比较成熟支持流式、支持多模态、支持多种模型提供商自己再写一遍性价比不高。但 Spring AI 的 Agent 相关能力当时还比较早期所以我只取它的模型层编排和工具全部自研。2.3 技术栈清单与选型理由JDK 17用到了 record、sealed interface、switch 模式匹配写起来比 JDK 8 舒服太多。record 特别适合定义工具的参数和返回值不可变、自带 equals/hashCode。Spring Boot 3.x依赖注入、配置管理、Web 层全都现成省掉大量样板代码。Spring AI只用它的ChatModel和ChatClient模型接入不用自己写。Jackson工具参数的 JSON 序列化/反序列化配合JsonProperty做参数映射。Caffeine本地记忆缓存带过期策略比手写 ConcurrentHashMap 省心。Reactor流式输出用SSE 推送思考过程。Micrometer埋点统计每次 Agent 调用的耗时、token 消耗、工具调用次数。这里有个选型上的取舍值得说我没有用虚拟线程。虽然 JDK 21 的虚拟线程很香但工具调用大部分是 IO 密集HTTP 请求、数据库查询用平台线程池配合合理的超时控制已经够用而且虚拟线程在 pinning 场景下还有坑生产环境我倾向于保守。等生态更成熟了再迁移不迟。3. 核心模块的细节拆解与实现要点3.1 ReAct 循环的骨架一个 while 循环里藏着多少细节ReAct 的核心就是一个循环但要把这个循环写稳细节非常多。先看骨架public AgentResult execute(String userInput) { ListMessage messages new ArrayList(); messages.add(systemPrompt()); messages.add(userMessage(userInput)); int step 0; while (step maxSteps) { ChatResponse response chatModel.call(messages); String output response.getContent(); ParsedAction action parser.parse(output); if (action.isFinalAnswer()) { return AgentResult.success(action.getAnswer(), messages); } ToolResult toolResult toolExecutor.execute(action); messages.add(assistantMessage(output)); messages.add(observationMessage(toolResult)); step; } return AgentResult.maxStepsExceeded(messages); }看起来简单但每一行都有讲究。第一system prompt 的设计决定了整个 Agent 的行为边界。我在 prompt 里明确规定了输出格式思考用Thought:开头动作必须写成Action: tool_name加Action Input: {json}最终答案用Final Answer:开头。格式约束越明确解析越稳定。我试过用更宽松的自然语言描述结果模型经常自由发挥解析器直接崩。第二解析器必须容错。模型不是每次都严格按格式输出有时候会多写一段解释有时候 JSON 里带注释。我的做法是先用正则提取Action:和Action Input:之间的内容再用 Jackson 的宽松模式解析 JSON。解析失败时不是直接抛异常而是把错误信息作为 Observation 返回给模型让它自己纠正。这个“自我修复”机制在实际使用中救了很多次场。第三最大步数限制是必须的。我见过模型陷入死循环反复调用同一个工具。默认我设成 10 步超过就返回当前已收集的信息并标记为未完成。这个值可以按场景调简单问答 3 步够了复杂任务可以到 20 步。第四消息列表的管理要控制长度。每轮循环都会往 messages 里加两条消息步数一多上下文就爆了。我在每轮结束后会检查 token 数超过阈值就触发压缩策略——把早期的 Observation 截断或者摘要化。这个后面记忆层会细讲。3.2 工具的定义与注册让 Java 方法变成 Agent 能调用的工具工具是 Agent 的手脚。在 Java 里定义工具我设计了一套基于注解的方式尽量贴近 Spring 的使用习惯Component public class WeatherTool { AgentTool( name get_weather, description 查询指定城市的当前天气参数为城市名称 ) public WeatherResult getWeather( ToolParam(description 城市名称如北京) String city) { // 实际调用天气 API return weatherService.query(city); } }框架启动时扫描所有带AgentTool的方法生成工具描述name description 参数 schema注册到ToolRegistry。模型看到的工具描述就是从这里来的。这里有几个关键设计点工具描述的质量直接决定模型会不会正确调用。我踩过的坑是description 写得太简略模型根本不知道什么时候该用这个工具。后来我定了个规矩description 必须说清楚“这个工具做什么、什么时候用、参数是什么含义”。比如“查询天气”就不如“查询指定城市的实时天气状况适用于用户询问某地天气时调用”。参数 schema 用反射自动生成。遍历方法的参数读取ToolParam的 description结合参数类型推断 JSON Schema 类型。String 对应 stringint 对应 integer复杂对象递归展开。这样开发者不用手写 schema减少出错。工具执行要隔离和限流。工具方法可能抛异常、可能超时、可能被恶意参数攻击。我在ToolExecutor里做了几层保护每个工具调用有独立超时默认 30 秒异常被捕获后转成错误信息返回给模型而不是直接崩掉整个循环参数在反序列化时做类型校验。另外我用信号量对并发工具调用做了限流防止某个工具被疯狂调用打垮下游。工具注册支持动态增删。有些场景下工具是运行时才确定的比如根据用户权限决定能用哪些工具。所以ToolRegistry设计成线程安全的支持运行时注册和注销。3.3 记忆管理短期上下文与长期记忆的分层策略记忆这块我分了两个层次因为它们的生命周期和访问模式完全不同。短期记忆就是当前对话的消息列表生命周期是一次 Agent 执行。它需要频繁读写、需要控制长度。我的实现是一个ConversationMemory内部用ArrayDeque存消息每次添加后检查 token 数。超过阈值时从最老的消息开始压缩把连续的“助手思考 工具观察”对合并成一条摘要消息。摘要用模型生成prompt 是“用一句话概括以下交互的关键信息”。长期记忆是跨会话的比如用户的偏好、历史事实。我用 Caffeine 做本地缓存key 是用户 IDvalue 是提取出的记忆条目。写入时机是在 Agent 执行结束后用一个单独的“记忆提取”调用让模型从这轮对话里抽取值得记住的信息。读取时机是在构建 system prompt 时把相关记忆注入进去。这里有个权衡记忆提取会增加额外的模型调用成本和延迟都上去了。我的做法是做成可配置的简单场景直接关掉复杂场景才开。另外提取出来的记忆要定期清理不然会越积越多反而干扰模型判断。我设了 30 天过期并且限制每个用户最多 50 条。还有个细节是记忆的相关性检索。早期我是把所有记忆都塞进 prompt结果 token 浪费严重还稀释了重点。后来改成用简单的关键词匹配做初筛只注入最相关的几条。如果以后要上向量检索接口已经预留好了换实现即可。3.4 流式输出与可观测性让 Agent 的思考过程可见Agent 最让人不放心的地方就是“黑盒”。用户发一句话等半天出来一个结果中间发生了什么完全不知道。所以流式输出和可观测性是我重点做的部分。流式输出用 Reactor 的Flux实现把 ReAct 循环的每一步都推出去思考内容、工具调用、工具结果、最终答案。前端通过 SSE 接收可以实时展示“Agent 正在思考……正在查询天气……查询完成……正在总结”。这个体验上的提升非常明显用户等待时的焦虑感大幅降低。实现上AgentExecutor返回的不是一个结果对象而是一个FluxAgentEvent。每个事件有类型THOUGHT、ACTION、OBSERVATION、ANSWER和内容。这样调用方可以自由决定怎么消费。可观测性方面我用 Micrometer 埋了几个关键指标单次执行的步数分布、每步耗时、工具调用成功率、token 消耗。这些指标接到 Prometheus 后能很清楚地看到 Agent 的健康状况。有一次线上发现某类问题的平均步数突然从 3 涨到 8查下来是某个工具的描述被改坏了模型反复试错。没有这些指标这种问题很难发现。4. 完整实操从零跑通一个 Agent4.1 环境准备与依赖配置先把工程搭起来。我用 Maven 管理依赖核心的几个dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-core/artifactId version1.0.0-M4/version /dependency dependency groupIdcom.github.ben-manes.caffeine/groupId artifactIdcaffeine/artifactId /dependency dependency groupIdio.projectreactor/groupId artifactIdreactor-core/artifactId /dependency配置文件里配好模型接入信息我用的是 OpenAI 兼容的接口换成别的模型提供商只需要改 base-url 和 model 名spring: ai: openai: api-key: ${AI_API_KEY} base-url: ${AI_BASE_URL} chat: options: model: gpt-4o-mini temperature: 0.3temperature 我设成 0.3因为 Agent 场景需要稳定的格式输出太高容易乱。这个值可以根据任务调创意类任务可以到 0.7。4.2 定义第一个工具并注册写一个最简单的计算器工具验证整条链路Component public class CalculatorTool { AgentTool( name calculator, description 执行基础数学运算支持加减乘除。当用户需要计算数值时调用 ) public String calculate( ToolParam(description 运算表达式如 35*2) String expression) { // 用 ScriptEngine 或自己写解析器 return String.valueOf(eval(expression)); } }工具注册靠 Spring 的组件扫描ToolRegistry在启动时收集所有AgentTool方法。这里要注意工具方法所在的类必须是 Spring Bean否则扫描不到。我一开始把工具写成了普通类调试了半天才发现没注册上。4.3 组装 Agent 并跑通第一个 ReAct 循环把模型、工具、记忆、编排组装起来Configuration public class AgentConfig { Bean public AgentExecutor agentExecutor( ChatModel chatModel, ToolRegistry toolRegistry, ConversationMemory memory) { return AgentExecutor.builder() .chatModel(chatModel) .toolRegistry(toolRegistry) .memory(memory) .maxSteps(10) .systemPrompt(defaultSystemPrompt()) .build(); } }然后写个测试问一个需要计算的问题“帮我算一下 128 乘以 37 再加 500 等于多少”。预期 Agent 会先思考需要计算调用 calculator 工具拿到结果后给出最终答案。跑起来后日志里能看到完整的 ReAct 轨迹Thought: 用户需要计算我应该使用 calculator 工具 Action: calculator Action Input: {expression: 128*37500} Observation: 5236 Thought: 计算完成结果是 5236 Final Answer: 128 乘以 37 再加 500 等于 5236看到这个轨迹跑通的那一刻整个框架的骨架就算立住了。4.4 参数计算与关键配置的取值依据几个关键参数我是这么定的都有依据maxSteps 10统计了常见任务的步数分布90% 的任务在 5 步内完成10 步能覆盖 99% 的场景再多的基本是模型出问题了。工具超时 30 秒大部分工具是 HTTP 调用正常响应在 3 秒内30 秒是给慢接口留的余量。超过这个时间基本可以判定下游有问题没必要继续等。上下文 token 阈值 模型上限的 70%留 30% 给输出和工具结果。比如模型上限 8K我在 5.6K 时触发压缩。记忆过期 30 天太短记不住太长会积累噪音。这个值可以根据业务调。5. 常见问题与排查技巧实录5.1 模型不按格式输出怎么办这是最高频的问题。表现是解析器报错或者 Agent 直接卡住。排查思路先看原始输出。把模型的原始返回打出来看它到底写了什么。常见情况有三种一是格式对但多了前后缀比如“好的我来帮你。Thought: ...”这种用正则提取就行二是 JSON 格式错误比如用了单引号或者带注释用 Jackson 的宽松模式能解决大部分三是完全没按格式来直接给了答案这种要在 system prompt 里加强约束或者用 few-shot 示例。我的经验是在 system prompt 里放一两个完整的示例比写十句规则都管用。模型是模仿学习给它看一遍正确格式它照着做的概率高很多。5.2 工具调用陷入死循环表现是同一个工具被反复调用参数还差不多。原因通常是工具返回的结果模型不满意或者工具报错后模型不知道怎么处理。解决办法有几个一是在 Observation 里明确告诉模型“这个工具已经调用过了结果是 X请基于此继续”避免它重复调用二是设置单工具最大调用次数超过就强制终止三是在 system prompt 里加一句“如果工具返回错误尝试换一种方式或直接告知用户”。我遇到过一次特别隐蔽的工具返回的 JSON 里有个字段名和模型预期的不一样模型以为没拿到数据就一直重试。后来统一了工具返回格式问题消失。工具返回结构的一致性比想象中重要。5.3 上下文超长导致模型报错步数一多消息列表就爆了。排查时先看 token 计数确认是不是真的超了。如果是检查压缩策略有没有生效。我的压缩策略是分级的轻度超限时截断最老的 Observation中度超限时把老的工具交互对摘要化重度超限时只保留 system prompt、最近三轮对话和最终答案。每级都有日志方便定位。有个坑是压缩后模型可能“失忆”忘了之前的关键信息。所以摘要化的时候要保留关键实体和结论不能简单截断。我用的 prompt 是“保留所有涉及的数字、名称和结论其余可以省略”。5.4 常见问题速查表问题现象可能原因排查方向解决手段解析器报错模型输出格式不符打印原始输出加强 prompt 约束加 few-shot工具不执行未注册或参数错误检查 ToolRegistry确认 Bean 扫描校验参数死循环工具结果不满足模型看调用轨迹限制调用次数优化返回格式上下文超长消息累积过多看 token 计数分级压缩控制步数响应慢工具超时或模型慢看各步耗时设超时换更快的模型结果不准prompt 或工具描述差看思考过程优化描述加示例5.5 几个我踩过的坑坑一工具方法用了事务注解。有个工具方法加了Transactional结果在 Agent 循环里调用时事务上下文和主流程冲突导致连接池耗尽。后来规定工具方法不能有事务需要事务的自己在内部开。坑二流式输出和工具调用冲突。早期我想让思考过程流式输出但工具调用需要完整的 JSON 才能解析。解决方案是分段流式思考部分流式推遇到 Action 就缓冲解析完再推。坑三模型返回的 JSON 里有中文引号。这个特别隐蔽Jackson 默认解析不了中文引号。后来在解析前做了一次字符替换把中文引号转成英文引号。坑四并发调用时记忆串了。早期记忆是单例的多个用户同时用会互相污染。改成按 sessionId 隔离后解决。记忆必须和会话绑定这是铁律。6. 后续可以怎么扩展框架跑通后扩展方向其实挺多的。我自己接下来打算做的是把工具调用做成 MCP 协议兼容的这样能直接复用社区里现成的工具生态不用每个都自己写。另外就是加上向量检索做长期记忆现在关键词匹配还是太粗糙。还有一个方向是多 Agent 协作。单个 Agent 能力有限复杂任务可以拆给多个专职 Agent一个负责规划、一个负责执行、一个负责校验。这个在架构上已经预留了接口AgentExecutor本身就可以作为一个工具被另一个 Agent 调用。最后分享一个我在实际使用中的体会Agent 框架的难点从来不在模型调用而在工程细节。格式解析、错误恢复、上下文管理、并发隔离这些看起来不起眼的地方才是决定一个 Agent 能不能上生产的关键。模型能力再强工程上不稳一样白搭。所以如果你也在做类似的东西别急着堆功能先把这几个基础环节打磨扎实。
阅读完成 · 觉得有帮助?