news 2026/10/26 14:32:41

纯Java实现AI Agent框架:ReAct循环与工具调用实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
纯Java实现AI Agent框架:ReAct循环与工具调用实战

1. 为什么我要自己造一个 Java AI Agent 框架

1.1 从一个真实需求说起

去年下半年开始,我陆续接到几个企业内部的智能化改造需求,核心诉求都差不多:把已有的 Java 业务系统接上大模型,让系统能自己判断、自己调工具、自己完成任务。听起来像是"加个接口调一下 GPT"这么简单,但真动手做的时候才发现,事情远没有那么轻松。

最开始的方案很朴素,就是在 Spring Boot 里写一个 Service,收到用户问题后拼一段 Prompt 发给大模型,拿到回复直接返回。这个方案能跑通 Demo,但一上真实场景就崩了。比如用户问"帮我查一下上个月华东区销售额最高的三个客户,然后给他们各发一封回访邮件",这里面包含了数据查询、排序筛选、邮件发送三个动作,单轮对话根本搞不定。你需要让模型自己决定先调哪个工具、拿到结果后再决定下一步做什么,这就是典型的AI Agent场景。

市面上的 Python 方案确实成熟,LangChain、AutoGPT 那一套玩得很溜,但我们团队全是 Java 技术栈,线上系统是 Spring Boot + MyBatis-Plus,运维体系、监控体系、发布流程都是围绕 Java 建的。为了一个 Agent 功能再养一套 Python 服务,成本太高,而且跨语言调用的稳定性、链路追踪、事务一致性都是坑。于是我决定自己动手,用 Java 写一个轻量的 Agent 框架,这就是JavaManus的由来。

1.2 JavaManus 到底解决什么问题

一句话概括:JavaManus 是一个用纯 Java 实现的 AI Agent 运行时框架,核心是 ReAct 循环 + 工具调用 + 记忆管理,让 Java 后端能直接拥有自主决策和任务执行能力。

它要解决的核心痛点有三个:

  • 语言鸿沟:Java 团队不想为了 Agent 引入 Python 技术栈,希望复用现有的 Spring 生态、依赖注入、AOP、监控。
  • 工程化缺失:很多 Agent Demo 是脚本级别的,没有超时控制、没有重试、没有并发隔离、没有可观测性,上不了生产。
  • 可控性差:Agent 自己决定调什么工具,如果不管控,很容易出现死循环、乱调接口、Token 爆炸等问题,必须有刹车机制。

适合谁来参考?我认为有三类人:一是 Java 后端工程师,想在自己的系统里加 Agent 能力;二是架构师,正在评估 Agent 框架的选型;三是对 ReAct、Function Calling 这些概念有了解,但没动手实现过的开发者。哪怕你之前没接触过 Agent,只要会写 Spring Boot,跟着思路走也能理解整套设计。

1.3 整体设计思路与技术选型

在动手之前,我先把架构想清楚了。一个 Agent 框架,剥开外壳,本质就是四件事:想(Reason)、做(Act)、记(Memory)、控(Control)。

  • 想:调用大模型,让它根据当前上下文决定下一步。
  • 做:执行模型指定的工具,把结果回填。
  • 记:维护对话历史和中间步骤,让模型有上下文。
  • 控:限制循环次数、超时、Token 消耗,防止失控。

技术选型上,我做了几个关键决策:

维度选型理由
语言Java 17团队主力版本,支持 record、sealed class,写起来舒服
框架Spring Boot 3.x生态成熟,依赖注入、配置管理开箱即用
模型接入Spring AI官方抽象层,屏蔽不同厂商差异,切换模型成本低
工具注册注解 + 反射类似 MyBatis-Plus 的思路,写个注解就能把方法暴露成工具
记忆存储内存 + 可插拔默认内存实现,接口预留 Redis、数据库扩展
并发模型虚拟线程Java 21 的虚拟线程让 IO 密集的模型调用更省资源

这里重点说一下为什么选Spring AI而不是自己封装 HTTP 调用。自己封装当然更灵活,但你要处理流式响应、Function Calling 协议、不同厂商的字段差异,工作量巨大。Spring AI 已经把这些抽象好了,ChatClient一行代码就能发起对话,@Tool注解直接支持工具注册,省下来的时间可以专注在 Agent 循环逻辑上。

至于ReAct,这是目前最主流的 Agent 推理范式,全称是 Reasoning + Acting。它的核心思想是让模型交替进行"思考"和"行动":先输出一段推理(Thought),再决定调用哪个工具(Action),拿到工具结果(Observation)后继续推理,直到得出最终答案。相比一次性让模型输出完整计划,ReAct 的好处是每一步都能根据实际结果调整,容错性更强。

2. 核心模块拆解与关键实现细节

2.1 ReAct 循环:Agent 的心脏

ReAct 循环是整个框架最核心的部分,我把它设计成一个while循环,每轮做四件事:

  1. 把当前的消息历史(系统提示 + 用户输入 + 历史步骤)发给模型。
  2. 解析模型返回,判断是"要调工具"还是"给最终答案"。
  3. 如果要调工具,执行工具并把结果追加到历史。
  4. 如果给了最终答案,跳出循环返回。

伪代码大概长这样:

public AgentResult run(String userInput) { List<Message> messages = new ArrayList<>(); messages.add(systemPrompt()); messages.add(new UserMessage(userInput)); for (int step = 0; step < maxSteps; step++) { ChatResponse response = chatClient.call(messages); AssistantMessage assistant = response.getResult().getOutput(); messages.add(assistant); if (hasToolCalls(assistant)) { for (ToolCall call : assistant.getToolCalls()) { String result = toolExecutor.execute(call); messages.add(new ToolResponseMessage(call.id(), result)); } } else { return AgentResult.success(assistant.getText(), messages); } } return AgentResult.failure("超过最大步数限制"); }

看起来简单,但魔鬼在细节里。第一个坑是模型返回格式的解析。不同模型对 Function Calling 的支持程度不一样,有的返回结构化的tool_calls字段,有的需要你在 Prompt 里约定 JSON 格式然后自己解析。我的做法是优先用原生 Function Calling,如果模型不支持,就降级到 Prompt 约定 + JSON 解析,两条路都留着。

第二个坑是循环终止条件。只靠maxSteps不够,因为模型可能在第 3 步就陷入"调工具 → 结果不对 → 再调同一个工具"的死循环。我加了两个额外判断:一是检测连续重复的工具调用,如果同一个工具用相同参数调了两次,直接中断;二是设置总超时时间,比如 60 秒,超时强制返回。

实操心得:maxSteps不要设太大,我实测下来 8 到 12 步能覆盖绝大多数场景。设成 20 以上,一旦模型抽风,Token 消耗会非常吓人,而且用户等半天没响应体验极差。

2.2 工具注册:让 Java 方法变成 Agent 的手脚

Agent 的能力边界,取决于你能给它多少工具。工具注册这块,我借鉴了 MyBatis-Plus 的思路——用注解标记,启动时扫描注册,开发者几乎零成本接入。

定义一个工具长这样:

@Component public class OrderTools { @AgentTool(name = "queryOrderStatus", description = "根据订单号查询订单状态,参数为订单号字符串") public String queryOrderStatus(String orderId) { Order order = orderService.getById(orderId); return order == null ? "订单不存在" : order.getStatus(); } }

框架启动时,通过 Spring 的ApplicationContext拿到所有 Bean,扫描带@AgentTool的方法,把方法名、描述、参数类型注册到一个ToolRegistry里。调用时根据工具名找到对应的方法,用反射执行。

这里有几个关键设计点值得展开:

第一,工具描述的质量直接决定 Agent 的智商。模型是靠description来判断该不该调这个工具的。描述写得太笼统,比如"查询订单",模型可能在不该调的时候也调;写得太细,又浪费 Token。我的经验是描述里要包含三要素:做什么、参数是什么、什么时候用。比如"根据订单号查询订单状态,当用户询问订单进度时使用"。

第二,参数类型要简单。模型生成参数时容易出错,尽量用 String、Integer 这种基础类型,避免复杂的嵌套对象。如果确实需要多个参数,用 JSON 字符串传进来,在方法内部再解析。

第三,工具执行必须隔离。工具方法可能抛异常、可能超时、可能返回超大结果。我在ToolExecutor里做了统一包装:异常捕获后返回错误信息给模型(而不是直接崩掉),超时用Future.get(timeout)控制,返回结果超过一定长度就截断,避免撑爆上下文。

2.3 记忆管理:让 Agent 记得住上下文

记忆分两种:短期记忆和长期记忆。

短期记忆就是当前任务的对话历史,包括用户输入、模型的思考、工具调用和结果。这部分必须完整保留,否则模型会"失忆",重复调工具或者逻辑断裂。但也不能无限增长,因为模型的上下文窗口有限。我的策略是:保留系统提示 + 最近 N 轮完整历史 + 更早历史的摘要。摘要用模型自己生成,把前面十几步压缩成一段话。

长期记忆是跨会话的,比如用户偏好、历史任务结果。这部分我设计成可插拔接口:

public interface MemoryStore { void save(String sessionId, List<Message> messages); List<Message> load(String sessionId); void clear(String sessionId); }

默认提供InMemoryMemoryStore,用ConcurrentHashMap存,适合单机 Demo。生产环境可以换成 Redis 实现,或者落库。这里要注意的是序列化问题,Message 对象里可能包含工具调用的复杂结构,存 Redis 时要选好序列化方式,我推荐 JSON,可读性好,排查问题方便。

踩过的坑:一开始我用 Java 原生序列化存 Redis,结果模型升级后 Message 类结构变了,反序列化直接报错,历史数据全废。后来改成 JSON,加了版本字段,兼容性好很多。

2.4 控制层:给 Agent 装上刹车

Agent 最让人不放心的就是"自主性"。它自己决定调什么工具、调多少次,如果不加约束,轻则浪费 Token,重则误操作生产数据。控制层我做了四道防线:

  • 步数限制:maxSteps硬上限,超过直接终止。
  • 超时控制:整个任务的总超时,以及单次模型调用、单次工具执行的超时。
  • Token 预算:累计消耗超过阈值就停止,防止账单爆炸。
  • 工具白名单:不同场景可以配置不同的工具集,敏感工具默认不暴露。

这四道防线里,我觉得Token 预算最容易被忽视但最重要。我见过有团队做 Agent,测试环境跑得好好的,上线后一个用户触发了死循环,一晚上烧掉几百块。加上预算控制后,最多就是任务失败,不会失控。

3. 从零搭建:完整实操流程

3.1 环境准备与依赖配置

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

<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-openai-spring-boot-starter</artifactId> <version>1.0.0-M4</version> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> </dependency> </dependencies>

JDK 版本建议 17 以上,如果想用虚拟线程就上 21。配置文件里填模型相关的参数:

spring: ai: openai: api-key: ${AI_API_KEY} base-url: ${AI_BASE_URL} chat: options: model: gpt-4o-mini temperature: 0.2

temperature我设成 0.2,因为 Agent 场景需要稳定、可预测的输出,太高的随机性会让工具调用变得不可靠。model选 mini 版本是因为 Agent 会频繁调用模型,成本敏感,复杂任务再切大模型。

3.2 定义核心数据结构

先把几个核心类定下来,这是整个框架的地基。

public record AgentRequest( String sessionId, String userInput, Map<String, Object> context ) {} public record AgentResult( boolean success, String answer, List<AgentStep> steps, int totalTokens, long costMillis ) {} public record AgentStep( int index, String thought, String toolName, String toolInput, String observation ) {}

用 record 是因为这些对象本质是数据载体,不可变、简洁。AgentStep记录每一步的细节,方便排查问题和做可视化展示。

3.3 实现工具注册与执行器

工具注册器负责扫描和存储工具元信息:

@Component public class ToolRegistry { private final Map<String, ToolDefinition> tools = new ConcurrentHashMap<>(); public void register(String name, Object bean, Method method, AgentTool anno) { ToolDefinition def = new ToolDefinition( name, anno.description(), bean, method ); tools.put(name, def); } public ToolDefinition get(String name) { return tools.get(name); } public Collection<ToolDefinition> all() { return tools.values(); } }

扫描逻辑放在一个BeanPostProcessor里,Spring 启动时自动完成。执行器负责反射调用和异常处理:

public String execute(ToolCall call) { ToolDefinition def = registry.get(call.name()); if (def == null) { return "错误:工具 " + call.name() + " 不存在"; } try { Object[] args = parseArgs(def, call.arguments()); Object result = def.method().invoke(def.bean(), args); return truncate(String.valueOf(result), MAX_RESULT_LENGTH); } catch (Exception e) { return "工具执行失败:" + e.getMessage(); } }

注意这里异常不往外抛,而是转成字符串返回给模型。这样模型能感知到工具失败了,可以换个方式重试或者告诉用户,而不是整个 Agent 崩掉。

3.4 组装 Agent 主循环

把前面几个模块串起来,就是 Agent 的核心:

@Service public class JavaManusAgent { public AgentResult run(AgentRequest request) { long start = System.currentTimeMillis(); List<Message> messages = buildInitialMessages(request); List<AgentStep> steps = new ArrayList<>(); int tokens = 0; for (int i = 0; i < maxSteps; i++) { if (System.currentTimeMillis() - start > totalTimeout) { return AgentResult.failure("任务超时"); } ChatResponse resp = chatClient.call(messages); tokens += resp.getMetadata().getUsage().getTotalTokens(); if (tokens > tokenBudget) { return AgentResult.failure("Token 预算超限"); } AssistantMessage assistant = resp.getResult().getOutput(); messages.add(assistant); if (assistant.getToolCalls().isEmpty()) { return AgentResult.success(assistant.getText(), steps, tokens, System.currentTimeMillis() - start); } for (ToolCall call : assistant.getToolCalls()) { String obs = toolExecutor.execute(call); messages.add(new ToolResponseMessage(call.id(), obs)); steps.add(new AgentStep(i, assistant.getText(), call.name(), call.arguments(), obs)); } } return AgentResult.failure("超过最大步数"); } }

这段代码是整个框架的骨架,每一行都有讲究。超时检查放在循环开头,Token 检查放在模型调用之后,工具调用结果统一追加到消息历史。看起来简单,但把这几块拼起来,一个能用的 Agent 就跑起来了。

3.5 写一个真实场景验证

光有框架不够,得跑个真实场景。我拿"订单查询 + 邮件发送"这个组合来测:

@RestController public class AgentController { @PostMapping("/agent/chat") public AgentResult chat(@RequestBody AgentRequest req) { return agent.run(req); } }

配上两个工具:queryOrderStatus和sendEmail。发一句"帮我查一下订单 A12345 的状态,如果已发货就发邮件通知客户",观察日志能看到 Agent 的完整推理过程:

  1. Thought:需要先查订单状态。
  2. Action:queryOrderStatus("A12345")。
  3. Observation:已发货。
  4. Thought:状态是已发货,需要发邮件。
  5. Action:sendEmail(...)。
  6. Observation:发送成功。
  7. Final Answer:订单已发货,邮件已通知客户。

整个过程 5 到 7 秒,Token 消耗大概 1500 左右。这个链路跑通,说明框架的核心能力没问题。

4. 常见问题排查与避坑实录

4.1 模型不调工具怎么办

这是新手最常遇到的问题:明明注册了工具,模型却直接给文字回答,不调工具。原因通常有三个:

  • 工具描述太模糊:模型不知道这个工具能干什么。解决方法是把描述写具体,包含使用场景。
  • 系统提示没引导:系统提示里要明确告诉模型"你有以下工具可用,需要时请调用"。
  • 模型本身能力不足:小模型对 Function Calling 支持差,换个强一点的模型试试。

我一般会先看模型返回的原始内容,如果它说"我无法查询订单",那就是描述问题;如果它直接编了个答案,那就是系统提示没约束好。

4.2 工具调用参数解析失败

模型生成的参数格式经常不标准,比如该传字符串传了数字,该传 JSON 传了自然语言。我的处理策略是宽容解析:先尝试标准解析,失败后尝试从文本里提取关键信息,再失败就返回错误让模型重试。

private Object[] parseArgs(ToolDefinition def, String raw) { try { return objectMapper.readValue(raw, def.paramTypes()); } catch (Exception e) { // 降级:尝试从文本中提取 return fallbackParse(def, raw); } }

避坑技巧:工具参数尽量设计成单个 String,让模型自由发挥,你在方法内部做解析。这样比让模型直接生成结构化参数的成功率高很多。

4.3 死循环与 Token 爆炸

前面提过,死循环是 Agent 的头号杀手。除了步数和 Token 限制,我还加了一个重复检测:记录最近几次工具调用的name + arguments哈希,如果连续两次完全相同,直接中断并返回"检测到重复操作"。

另外,工具返回结果一定要截断。我见过有工具返回了几万字的日志,直接塞进上下文,下一轮模型调用 Token 直接翻十倍。截断长度我设成 2000 字符,超过就保留头尾,中间用省略号。

4.4 并发场景下的会话隔离

多个用户同时用,会话必须隔离。我用sessionId作为 key,每个会话独立的消息历史。这里要注意线程安全,如果多个请求共享同一个 sessionId,消息历史会串。我的做法是给每个 session 加锁,或者用ConcurrentHashMap+ 每会话独立的List。

问题现象可能原因排查方向解决方案
模型不调工具描述模糊/提示缺失看原始返回优化描述和系统提示
参数解析失败格式不标准打印原始参数宽容解析 + 降级
死循环无终止条件看步骤日志步数+重复+Token 三重限制
响应慢模型调用多统计每步耗时换小模型/减少步数
会话串数据并发未隔离检查 sessionId会话级锁或独立存储

4.5 生产环境部署的几个建议

最后分享几个上线经验。第一,模型调用一定要加熔断和重试,网络抖动很常见,用 Resilience4j 包一层,失败重试两次,还失败就降级返回。第二,日志要打全,每一步的 Thought、Action、Observation 都记下来,出问题能复盘。第三,监控 Token 消耗,按用户、按接口维度统计,发现异常及时告警。第四,工具权限分级,查询类工具可以放开,写操作类工具要加审批或二次确认。

我在实际项目里跑下来,JavaManus 这套框架支撑日均几千次 Agent 调用没什么压力,单次任务平均耗时 6 秒左右,Token 成本可控。它不是什么银弹,但确实让 Java 团队能低成本地拥有 Agent 能力,不用为了一个功能去折腾另一套技术栈。后续我打算把工具编排、多 Agent 协作这些能力补上,让框架能处理更复杂的任务链路。

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

MiniMind大模型训练实战:从数据到部署全流程解析

1. 项目概述&#xff1a;MiniMind大模型训练实战指南在2023年这个AI技术爆发的关键节点&#xff0c;能够独立训练定制化大模型已经成为算法工程师的核心竞争力。MiniMind作为一个轻量级大模型训练框架&#xff0c;因其模块化设计和PyTorch生态兼容性&#xff0c;成为许多团队入…

作者头像 李华
网站建设 2026/10/26 14:30:33

AI音乐生成工具选型三维评估指南:粒度、约束与部署

1. 这份2023年AI音乐生成综述&#xff0c;为什么值得花两小时精读&#xff1f; 我第一次打开这份标题为《A Survey of AI Music Generation Tools and Models (2023.08)》的PDF时&#xff0c;心里是带着点怀疑的。当时手头正卡在一个跨平台音频交互Demo里——需要实时生成符合情…

作者头像 李华
网站建设 2026/10/26 14:30:32

前端协议驱动开发:用MCP与Playwright构建可验证UI契约

1. 这不是“又一个前端学习清单”&#xff0c;而是一套可落地的工程化能力操作系统“在职前端 Skill 和 MCP 分享”——看到这个标题&#xff0c;我第一反应不是点开看技术栈罗列&#xff0c;而是立刻在脑子里拉出一张表&#xff1a;左边是每天被需求、排期、线上 Bug 和跨端兼…

作者头像 李华
网站建设 2026/10/26 14:30:17

知识图谱技术在科技创新中的应用与实践

1. 项目概述&#xff1a;当知识图谱遇见科技创新 在实验室泡了十年&#xff0c;我见过太多科研成果躺在论文库里"睡大觉"。去年参与某高校技术转移中心项目时&#xff0c;发现他们用Excel表格管理2000多项专利&#xff0c;技术供需匹配全靠人工记忆。直到我们引入知识…

作者头像 李华
网站建设 2026/10/26 14:30:12

Windows 11硬件限制绕过方法全解析

1. 项目概述 Windows 11作为微软最新的操作系统&#xff0c;在硬件要求上比前代产品严格得多&#xff0c;特别是强制要求TPM 2.0芯片和支持的CPU列表。这个限制让很多老设备用户感到困扰&#xff0c;也催生了各种绕过检查的方法。作为一名长期关注Windows系统的技术爱好者&…

作者头像 李华
网站建设 2026/10/26 14:29:03

域控制器落地实战:从8.5kg线束减重到Zone Controller三档选型

简介&#xff1a;本资源为安波福&#xff08;Aptiv&#xff09;官方发布的《域控制器白皮书2021》PDF文档&#xff0c;面向汽车电子工程师、整车架构设计师及智能网联领域研究人员&#xff0c;聚焦解决当前车辆ECU数量激增、线束复杂度上升、传感器I/O爆炸式增长及电气化演进带…

作者头像 李华