news 2026/10/26 6:12:44

纯Java从零实现AI Agent框架:ReAct循环与工程实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
纯Java从零实现AI Agent框架:ReAct循环与工程实践

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 Calling

Agent 的推理范式有好几种,主流的有 ReAct(Reasoning + 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) { List<Message> 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 对应 string,int 对应 integer,复杂对象递归展开。这样开发者不用手写 schema,减少出错。

工具执行要隔离和限流。工具方法可能抛异常、可能超时、可能被恶意参数攻击。我在ToolExecutor里做了几层保护:每个工具调用有独立超时(默认 30 秒),异常被捕获后转成错误信息返回给模型而不是直接崩掉整个循环,参数在反序列化时做类型校验。另外我用信号量对并发工具调用做了限流,防止某个工具被疯狂调用打垮下游。

工具注册支持动态增删。有些场景下工具是运行时才确定的,比如根据用户权限决定能用哪些工具。所以ToolRegistry设计成线程安全的,支持运行时注册和注销。

3.3 记忆管理:短期上下文与长期记忆的分层策略

记忆这块我分了两个层次,因为它们的生命周期和访问模式完全不同。

短期记忆就是当前对话的消息列表,生命周期是一次 Agent 执行。它需要频繁读写、需要控制长度。我的实现是一个ConversationMemory,内部用ArrayDeque存消息,每次添加后检查 token 数。超过阈值时,从最老的消息开始压缩:把连续的“助手思考 + 工具观察”对合并成一条摘要消息。摘要用模型生成,prompt 是“用一句话概括以下交互的关键信息”。

长期记忆是跨会话的,比如用户的偏好、历史事实。我用 Caffeine 做本地缓存,key 是用户 ID,value 是提取出的记忆条目。写入时机是在 Agent 执行结束后,用一个单独的“记忆提取”调用,让模型从这轮对话里抽取值得记住的信息。读取时机是在构建 system prompt 时,把相关记忆注入进去。

这里有个权衡:记忆提取会增加额外的模型调用,成本和延迟都上去了。我的做法是做成可配置的,简单场景直接关掉,复杂场景才开。另外提取出来的记忆要定期清理,不然会越积越多,反而干扰模型判断。我设了 30 天过期,并且限制每个用户最多 50 条。

还有个细节是记忆的相关性检索。早期我是把所有记忆都塞进 prompt,结果 token 浪费严重还稀释了重点。后来改成用简单的关键词匹配做初筛,只注入最相关的几条。如果以后要上向量检索,接口已经预留好了,换实现即可。

3.4 流式输出与可观测性:让 Agent 的思考过程可见

Agent 最让人不放心的地方就是“黑盒”。用户发一句话,等半天,出来一个结果,中间发生了什么完全不知道。所以流式输出和可观测性是我重点做的部分。

流式输出用 Reactor 的Flux实现,把 ReAct 循环的每一步都推出去:思考内容、工具调用、工具结果、最终答案。前端通过 SSE 接收,可以实时展示“Agent 正在思考……正在查询天气……查询完成……正在总结”。这个体验上的提升非常明显,用户等待时的焦虑感大幅降低。

实现上,AgentExecutor返回的不是一个结果对象,而是一个Flux<AgentEvent>。每个事件有类型(THOUGHT、ACTION、OBSERVATION、ANSWER)和内容。这样调用方可以自由决定怎么消费。

可观测性方面,我用 Micrometer 埋了几个关键指标:单次执行的步数分布、每步耗时、工具调用成功率、token 消耗。这些指标接到 Prometheus 后,能很清楚地看到 Agent 的健康状况。有一次线上发现某类问题的平均步数突然从 3 涨到 8,查下来是某个工具的描述被改坏了,模型反复试错。没有这些指标,这种问题很难发现。

4. 完整实操:从零跑通一个 Agent

4.1 环境准备与依赖配置

先把工程搭起来。我用 Maven 管理依赖,核心的几个:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-core</artifactId> <version>1.0.0-M4</version> </dependency> <dependency> <groupId>com.github.ben-manes.caffeine</groupId> <artifactId>caffeine</artifactId> </dependency> <dependency> <groupId>io.projectreactor</groupId> <artifactId>reactor-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.3

temperature 我设成 0.3,因为 Agent 场景需要稳定的格式输出,太高容易乱。这个值可以根据任务调,创意类任务可以到 0.7。

4.2 定义第一个工具并注册

写一个最简单的计算器工具,验证整条链路:

@Component public class CalculatorTool { @AgentTool( name = "calculator", description = "执行基础数学运算,支持加减乘除。当用户需要计算数值时调用" ) public String calculate( @ToolParam(description = "运算表达式,如 3+5*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*37+500"} 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 能不能上生产的关键。模型能力再强,工程上不稳,一样白搭。所以如果你也在做类似的东西,别急着堆功能,先把这几个基础环节打磨扎实。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/26 6:12:21

基于CNN的轻量化手势方向识别系统设计与实现

1. 项目概述&#xff1a;手势方向识别的现实意义与技术选型手势识别技术正在从实验室走向日常生活&#xff0c;成为人机交互的重要桥梁。这个基于Python和CNN的毕业设计项目&#xff0c;瞄准了一个非常实用的细分领域——手势方向识别。想象一下&#xff0c;在智能家居场景中&a…

作者头像 李华
网站建设 2026/10/26 6:11:45

Cloudflare开源决策模型Clef:Apache-2.0、64K、Jev兼容

看到这个标题的第一反应&#xff0c;我觉得 Cloudflare 这次做了一件很对味的事&#xff1a;把决策模型独立开源&#xff0c;还给了 Apache 2.0 权重和 64K 上下文&#xff0c;并且直接兼容 Jev API。如果你平时在折腾 Agent、工具调用、或者各种 codex / opencode / Claude Co…

作者头像 李华
网站建设 2026/10/26 6:11:02

AI CRM技术解析:智能获客与决策链优化

1. 2026 AI CRM行业格局与核心挑战2026年的CRM市场已经进入AI深度整合阶段&#xff0c;传统客户关系管理系统正在经历智能化重构。根据第三方调研数据显示&#xff0c;采用AI技术的CRM系统客户留存率提升37%&#xff0c;销售转化周期缩短42%。在这个背景下&#xff0c;"获…

作者头像 李华
网站建设 2026/10/26 6:10:50

企业AI培训系统架构与核心技术解析

1. 项目概述&#xff1a;企业AI培训的技术架构与价值定位九尾狐AI作为企业级AI培训解决方案的典型案例&#xff0c;其技术架构设计直击当前企业数字化转型中的两大核心诉求&#xff1a;降低AI技术应用门槛与控制运营成本。这套系统本质上是一个融合了知识工程、自适应学习算法和…

作者头像 李华
网站建设 2026/10/26 6:08:59

AI Agent技术演进:从规则引擎到自主学习的实践路径

1. AI Agent进化史&#xff1a;从条件反射到自主思考的跃迁第一次接触AI Agent这个概念时&#xff0c;我正为一个电商推荐系统焦头烂额。当时系统只能基于用户点击历史做简单推荐&#xff0c;就像个只会条件反射的机器人。直到研究明白Agent的进化路径&#xff0c;才发现原来智…

作者头像 李华
网站建设 2026/10/26 6:08:39

Agentic OS安全设计:如何防止AI Agent泄露用户代码与数据

1. 从一个真实事故说起&#xff1a;为什么“能跑就行”的Agent正在变成定时炸弹去年年底&#xff0c;我帮一个做跨境电商的朋友排查线上事故。他们的运维同事给一个自动化Agent开放了服务器Shell权限&#xff0c;用来做日志清理和临时扩容。结果某天凌晨&#xff0c;这个Agent在…

作者头像 李华