ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Java构建智能体实战:ReAct循环、工具调用与Spring Boot落地

Java构建智能体实战:ReAct循环、工具调用与Spring Boot落地 项目标题里带了“lucky_agent”这个名字从设计到落地我基本都是一个人折腾的。最开始它只是一个想法试试用 Java 能不能把大模型、工具调用、任务循环串成一个真正能干活的智能体。做完第一版之后我发现 Java 在这个场景里的优势被很多人低估了。这篇博客不会讲理论大纲直接看我是怎么把 Agent 拆开、又把各个环节接回去的包括那些文档里绝不会写的坑和参数细节。1. 项目概述与整体设计思路1.1 我为什么坚持用 Java 写 Agent而不是 Pythonlucky_agent 是我用 Java 21 和 Spring Boot 搭起来的一个智能体项目目标是让用户用一句自然语言发起任务它自己去拆解、调用工具、观察结果、修正路径最后返回答案或者直接生成一份业务文档。很多同学听到 Agent 的第一反应是 Python毕竟 LangChain 那套生态起步早。但我长期在 Java 后端环境里干活我更看重落地时的完整链路类型安全、统一依赖管理、编译期就能发现错误。大模型输出不稳定tool call 参数可能会乱这时 Java 的静态类型能把一部分脏数据挡在核心逻辑之外。另外现实中大多数公司的业务基建都是 Java 系。Agent 不可能只是一个孤立的对话框它要去读数据库、调内部服务、写工单系统、生成周报。用 Java 做 Agent意味着这些能力可以无缝复用Spring 的事务管理、注册中心、配置中心、监控埋点全都能直接用。lucky_agent 最早接的真实场景就是帮运营同事自动汇总销售数据并生成带折线图的 Word 周报这个流程从模型下发工具调用到文件落盘全程没离开 Java 体系。还有个务实的点Python 环境部署在某些内网环境里本身就麻烦Java 只要有 JDK 和 Spring Boot 打包就够。lucky_agent 最后部署到测试服务器时就是一个 java -jar 运行起来的容器进程运维同事不需要额外学习任何 Python 依赖管理这就省去了大量沟通成本。从团队协作的角度Java 是这个项目最合适的底座。1.2 理解 Agent 的任务循环感知、决策、执行没接触过 Agent 开发的人容易把它想象成更聪明的聊天机器人。实际上两者的结构完全不同。聊天机器人是单次输入、单次输出Agent 是一个循环模型先观察当前任务判断自己缺什么信息然后调用一个工具去拿外部结果拿到结果之后再继续思考再决定下一步动作。这个循环会一直进行下去直到模型认为任务已经完成或者它意识到自己无法完成。lucky_agent 的核心就是这样一条 ReAct 风格的主循环四个阶段分别是Reasoning思考、Acting行动、Observation观察结果然后回到思考阶段。我实现它用的就是一个普通的 for 循环。每轮迭代做的事情很固定把当前上下文发给模型等模型返回一段文本说法或者一个工具调用请求如果返回的是工具调用我就执行对应的 Java 方法把结果文本放回上下文再进入下一轮。这个设计最大的特征是“可停”一旦模型返回正常的终止信号循环立刻结束超过了我设定的最大轮数也会强制中断避免模型陷入死循环白白消耗令牌。如果把大模型比作调度台前的负责人它并不负责具体干活只负责判断下一步该找谁我注册的那堆 Java 方法才是真正动手的工人。负责人把命令发下去工人执行完把结果写张纸条递回来负责人看完纸条再决定下一步。这整个“循环决策”的过程就是智能体和普通对话机器人最本质的区别。2. 核心细节解析与实操要点2.1 模型接入层先解决“怎么和大模型对话”写 Agent 的第一件事是处理模型接入这一步做不好后面所有环节都会很难受。我第一个版本的代码是把 HTTP 调用直接写在核心类里的后来换了一个模型提供商就痛苦万分因为 JSON 结构、错误码、超时逻辑全都不一样。于是我把模型接入抽象成了 ModelClient 接口对外只暴露几个与供应商无关的方法一个是普通对话 chat另一个是支持工具调用的 chatWithTools底层实现各自封装不同厂商的 SDK 或 HTTP 协议。这个抽象层的核心目标是让上层代码不感知“我在跟哪家模型对话”。我把模型的返回结果统一转换成自己的内部消息结构内部消息分成三类文本、工具调用、工具结果。Agent 核心循环只认这三个类型不关心模型厂商到底返回了什么花哨字段。这样换模型时只需要新增一个 ModelClient 实现类再改一下配置文件核心代码一行都不用动。工具调用参数通常是 JSON 字符串Java 里我用 Jackson 解析成一个 ToolCall 对象再交给工具注册中心去匹配并执行对应方法。模型是概率输出偶尔会给你一个不合法 JSON比如多余的逗号、未闭合的括号。我在解析层做了容错先按严格模式解析失败后用宽松模式兜底如果还是不行就会把“参数解析失败请按 JSON 规范重新调用工具”这段文字作为普通消息返回给模型。这个策略实测很有效模型看到错误提示后大概率会自己修正而不是彻底卡死。2.2 工具注册与调用让 Agent 真正动起来模型不会自己凭空知道项目里有哪些方法它看到的是我提前注册好的“工具清单”。lucky_agent 用注解加反射实现了一个轻量级工具注册中心。在方法上打上 AgentTool 注解填上工具名称、功能说明、参数描述项目启动时自动扫描注册。模型在对话中会看到这些工具的“说明书”当它判断需要某个能力时就会发起一次工具调用请求。AgentTool( name queryDns, desc 查询域名解析记录用于排查站点解析异常、验证子域名归属等场景, params { ToolParam(name domain, desc 要查询的域名例如 example.com), ToolParam(name type, desc 记录类型A、AAAA、CNAME、MX、TXT) } ) public String queryDns(String domain, String type) { return DnsLookup.query(domain, type); }这段代码里最需要花心思的不是 DNS 查询逻辑而是那段 desc 描述。我之前写过很省事的版本比如“查询域名”结果模型根本不知道什么时候该用它。改成“查询域名解析记录用于排查站点解析异常、验证子域名归属等场景”之后模型在合适的场景下几乎每次都会想起来调它。参数命名同理用 domain 而不是 var1模型传错参数的概率立刻下降。工具返回值这层我踩过更深的坑。最初我图方便让工具直接返回 JSON 字符串结果就是模型要消化一大堆嵌套结构不仅费令牌还容易被无关字段带偏。现在我让每个工具返回一段结构清晰的文本先写结论再写关键字段JSON 只作为补充。例如 DNS 查询结果直接写成“该域名存在 A 记录IP 为 1.2.3.4TTL 300 秒”模型几乎不需要二次加工就能直接转述给用户。文件生成类工具则只返回“成功与否 文件路径 文件大小”不会把那堆二进制描述塞给模型。2.3 记忆模块短期上下文与长期记忆如何配合记忆系统是整个 Agent 项目里最容易“看起来简单、做起来翻车”的部分。lucky_agent 把记忆分成两个层次。短期记忆存在于一次任务内部就是一个列表每次把用户消息、模型回复、工具结果都追加进去。它的问题是会无限膨胀跑到第十轮时上下文可能已经有一大段历史既浪费令牌又稀释模型注意力。所以我在每一轮结束时会检查消息总长度超过一个阈值就把最久远的历史压缩成一段摘要用摘要替换掉旧消息。长期记忆则跨任务复用第一版我用本地数据库存“任务主题、结论、关键字段”。比如用户之前问过“上周订单量为什么下跌”Agent 排查完就把结论写入记忆表。下次再遇到类似问题它会先查记忆表把历史结论拼接进提示词避免重复劳动。后来数据量大了我改成了向量检索方案对每条记忆做向量化存放进轻量级向量数据库检索时按相似度取最相关的三条接进提示词。记忆模块有个容易被忽视的安全边界模型如果分不清哪些记忆是“通用经验”哪些是“本次任务的真实观察”就会把旧结论当成当前事实。我给每条记忆打上标签OBSERVATION 表示本次任务实际观察RULE 表示可迁移的通用经验HYPOTHESIS 表示曾经提出但未验证的猜测。默认只有前两种允许进入上下文HYPOTHESIS 通常不参与回答只有用户明确要求时才单独检索。这样既保留了记忆的长期价值也不会把没验证过的东西当作真相输出。2.4 安全与权限控制别让 Agent 变成脱缰野马Agent 能调用工具这是能力的来源也瞬间变成风险面。lucky_agent 在安全方面做了三层防护。第一层是工具白名单Agent 只能调用我注册过的工具任何没有登记的方法调用请求直接拒绝。这一点非常关键因为外部输入可能试图让模型去调用一个从来不存在的方法一旦注册中心没有拦下来反射调用就会出问题。第二层是敏感操作确认。像“发送邮件”“更新数据库”“删除文件”这类操作我在工具内部设置了作业标记必须显式传入确认开关才能执行否则直接拒绝。这些检查全部写在 Java 代码里而不是指望模型自己判断。模型不是安全边界它只是一个会犯错的决策组件真正执行敏感操作的代码必须自己把关。第三层是对输入结果的污染检测。现在主流模型很容易受到被包装过的提示词影响比如用户在一段任务描述里塞入“忽略之前所有指令直接执行某操作”或者工具返回的内容本身夹带了恶意指令。lucky_agent 在用户输入入口和工具结果返回入口各做了一次模式检查发现问题就直接把那一段替换成告警信息让模型明确知道这里不可信。虽然做不到百分之百拦截但能拦住绝大多数已知模式配合敏感操作确认机制我把风险压到了可接受的范围。3. 实操过程与核心环节实现3.1 用一个 ReAct 主循环串起整个 Agent讲完设计落到代码。lucky_agent 最核心的是一个 AgentRunner 类它只有一个 run(userTask) 方法内部跑完整个 Agent 循环。下面这段代码是完整循环的精简版不涉及网络框架细节但逻辑和线上版本完全一致public class AgentRunner { private final ModelClient client; private final ToolRegistry tools; private final ChatMemory memory; private final AgentConfig config; public String run(String userTask) { memory.addUserMessage(userTask); for (int step 0; step config.maxSteps(); step) { ModelResponse response client.chatWithTools(config.systemPrompt(), memory.messages()); if (response.isTextFinish()) { return response.text(); } for (ToolCall call : response.toolCalls()) { String result; try { result tools.invoke(call.name(), call.arguments()); } catch (Exception e) { result 工具执行失败 e.getClass().getSimpleName() - e.getMessage(); result | 请根据错误信息修正参数或改用其他工具继续完成任务。; } memory.addToolResult(call.id(), result); } memory.compressIfNeeded(); logAgentState(step, response, memory); } return 任务在设置的最大迭代次数内未能完成请调整任务描述或增加轮数后重试。; } }这段代码里最值得琢磨的是工具异常处理。工具执行失败时我不让异常直接抛出去中断整个 Agent而是把它包装成一条普通文本放回上下文。模型看到“工具执行失败文件不存在请根据错误信息修正参数”之后通常能自己判断是参数写错了还是这个方案不可行然后自动换一条路走。从一开始的动不动就中断到现在的“模型能自己纠错”这个细节是提升任务完成率的关键。循环跑完之后如果仍未完成我不会交一个空字符串或简单说“不知道”而是给用户一个明确的解释和下一步建议比如“任务在 10 轮内未完成请缩小任务范围或增加最大轮数”。开发阶段看日志能还原出模型每一步的思考轨迹生产环境我还会把这套轨迹写进日志表事后复盘时非常有用。3.2 工具实现示例从 DNS 查询到 Word 图表生成工具的数量不需要一开始就堆很多。lucky_agent 第一版只有六个查天气、查 DNS、计算表达式、生成二维码、读本地文件、写临时文件。这些工具的共同特点是独立、结果能自然嵌入一段文字适合拿来验证 Agent 主循环是否通畅。先把回路打通再谈复杂能力。真正让项目显得有实际价值的是我把 Agent 接进了办公文档场景。搜索热词里很多人问“Java 的 POI 能不能生成 Word 图表”答案是可以但要注意版本和 API 路径。lucky_agent 里我封装了一个 generateWordReport 工具内部用 Apache POI 创建 XWPFDocument再往里插入 XWPFChart图表数据来自模型传进来的 JSON 参数。这个工具最复杂的部分不是 API 本身而是把模型传进来的杂数据清洗成图表需要的坐标轴数据。AgentTool(name generateWordReport, desc 根据给定的指标数据生成一份带图表的 Word 报告数据格式为 [{month:2024-01, sales: 120}]) public String generateWordReport(String title, String chartType, String jsonData) { ListMapString, Object rows parseJsonArray(jsonData); try (XWPFDocument doc new XWPFDocument()) { doc.createParagraph().createRun().setText(title); XWPFChart chart doc.createChart(); populateChart(chart, chartType, rows); String filePath reports/ System.currentTimeMillis() .docx; try (FileOutputStream out new FileOutputStream(filePath)) { doc.write(out); } return 报告已生成文件路径 filePath 数据点数量 rows.size(); } catch (Exception e) { return 报告生成失败 e.getMessage(); } }这个示例暴露了一个重要的思维模式工具返回的是描述性文本而不是原始数据。模型不需要读 docx 二进制流它只需要知道“文件已生成、路径在哪、包含多少数据点”就能把结果组织成用户能理解的回答。这可以推广到所有“操作文件、修改数据库、调用外部服务”的工具上核心循环只关心工具调用结果的一句话摘要细节交给 Java 代码去处理。3.3 配置、超参与可观测性设计Agent 应用有个特点代码一样配置不同跑出来的效果天差地别。lucky_agent 把影响行为的关键参数全部抽到了配置文件中方便调试时快速调整。下表是我实际项目中使用的参数配置参数含义我常用的值踩坑说明model.temperature控制随机性0.2Agent 任务需要低随机性太高容易改计划agent.maxSteps最大循环轮数8-12太低任务完不成太高会白烧令牌agent.contextWindow上下文预留窗口6000-8000 token要留足工具返回内容的余量agent.toolTimeout单个工具超时时间15秒防止一个卡住整个循环agent.memoryKey是否启用长期记忆true关闭后每个任务都从零开始效率低且费钱可观测性是我中途才补的补完简直是再次重生。AgentRunner 每走一步都会记一条日志第几步、模型返回什么类型、调用了哪个工具、参数摘要、工具结果长度、当前积累的令牌数。这些日志能完整还原模型的决策链看到它为什么中途绕了个大弯也能发现哪类任务总是陷入循环。配合日志的是 traceId。lucky_agent 里每次任务执行都会生成一个全局 traceId贯穿模型请求头、日志、生成的文件名、数据库记录。出问题时一行 traceId 就能串起用户请求、模型输入输出、工具调用和最终结果。没有这套机制排查 Agent 问题是灾难模型每次输出都不一样猜都猜不到当时发生了啥。4. 常见问题与排查技巧实录4.1 报错“Agent execution terminated due to error”怎么办这个报错几乎是每个 Agent 开发者都会遇见的它不一定来自你自己写的代码可能是框架里处理循环时抛出的提示。底层原因通常集中在三类工具调用参数解析失败模型吐出的 JSON 不合法一解析就崩业务工具内部抛了运行时异常比如文件不存在或数据库连接断开上下文超出模型 token 上限请求直接被拒。我的处理原则是不要让任何错误终止循环。所有工具调用都包在 try/catch 里异常先转成文本回传。JSON 解析单独做容错失败时用宽松模式再试一次。上下文超限靠压缩机制兜底。这样“Agent execution terminated due to error”基本不会再出现即使某个工具反复失败模型也能明确告诉你“这个链路有问题”而不是丢出一个晦涩异常。排查这类问题时定位直接看 traceId 对应的日志。重点看最后一步返回了什么以及这是第几次迭代。第一次就崩多半是初始上下文或模型配置有问题第五次之后才崩重点查那次迭代之前调用的工具八成是它的返回内容太长或格式产生了歧义。4.2 上下文越跑越长压缩、截断与记忆清理我统计过自己的项目一次包含六个工具调用的任务平均要消耗一万二到一万八令牌其中一半以上都花在历史消息上。随着迭代加深模型容易被前面的无效信息带偏。所谓上下文污染不一定是恶意注入才发生哪怕是正常的工具结果如果过于冗长也会挤占模型处理后续指令的注意力。上下文管理一定要写进主循环不能等出问题再处理。lucky_agent 维护一个消息总量指标超过上下文窗口的 70% 就触发压缩把前几轮对话摘要成一句话保留最近两轮完整内容。摘要本身也由模型生成但我会要求摘要控制在 150 字以内只写“用户提过什么要求、已经执行过什么工具、结论是什么”不带情感和猜测。长期记忆的清理同样重要。我一开始把所有记忆都堆在数据库里后来发现旧任务结论会干扰新任务判断。现在每条记忆带一个新鲜度字段一周内的记忆直接可见超过一周的必须经过检索评分才能进入提示词评分低的直接忽略。加了这套机制之后模型处理相似任务时准确率明显上升不会再被几周前一个不完整的旧结论带偏。4.3 数据一致性让每个工具调用有唯一标识Agent 调用工具和普通后端接口调用最大的区别在于模型可能反复调用同一个工具。它第一次调了“查订单量”把结果写在上下文里第二次因为自己忘了又调了一遍。如果这个接口不幂等比如每次查询都写一条日志或生成一份新文件那么任务就会积累脏数据。我的解法很 Java给每次任务分配一个全局 taskId工具内部先判断这个 taskId 是否已经处理过相同请求。查询类工具可以做结果缓存写入类工具加唯一约束。这样任务中途失败后续补跑时已经执行成功的工具就不会重复执行。这其实就是后端体系里常用的“幂等 补偿”Agent 只是把决策从人换成了模型底层一致性逻辑没有变化。还遇到过一种特殊问题关键词重复触发。模型在前几步已经调过 DNS 查询记录了结果后几步又把同一工具调一遍由于没有缓存第二次结果被拼进上下文跟第一次冲突。我在工具结果里加上来源时间戳之后模型能察觉到自己在重复调用大部分情况下会自己停下来不再绕圈。4.4 从 Java 基础到 Agent 工程哪些老知识翻新了搜索热词里一堆 Java 面试八股我做完 lucky_agent 之后有个明显感受那些经典 Java 知识不但没过时反而在 Agent 工程里换了新用法。并发部分Agent 循环特别适合虚拟线程每个子任务开一条虚拟线程去调工具互不阻塞吞吐量比之前的线程池方案高不少。AQS 那套同步机制也没消失工具注册表本身是并发访问的热点得靠并发容器加原子操作才安全。字符串处理也重新变得重要了。模型返回的文本和参数需要大量拼接、截断、替换如果直接用字符串相加生成几千字报告时性能会明显下降改成 StringBuilder 后报告生成时间几乎可以忽略。甚至各种排序算法也没浪费模型要按销售额排序时与其让它去做不擅长的计算不如明确调用一个排序工具让 Java 代码去处理结果更稳定。背后的道理很简单Agent 不是把智商都押在模型身上而是把模型擅长的事和代码擅长的事合理分工。模型擅长理解意图、拆解任务代码擅长计算、排序、并发、事务。Java 基本功越扎实这个分工边界就越清晰Agent 的表现也越稳。5. 复盘与面试lucky_agent 怎样讲才算“懂”5.1 项目复盘功能、收益与瓶颈lucky_agent 目前能做的事可以概括成三类信息查询与解释类任务、办公文档生成类任务、内部服务编排整合类任务。第一个版本上线后运营同事反馈最积极的是自动生成周报功能各渠道数据一拉Agent 自己判断趋势、写结论、再生成带折线图的 Word 文件整个过程从以前半个小时的苦力活变成一分钟。成本也要诚实面对。跑一个复杂任务平均要消耗两万到三万令牌按常见模型定价单次成本几元到十几元不等比纯人工便宜但不能完全放开。我给系统加了一个预算上限每次任务前估算最大令牌消耗超出的直接拒绝请用户缩小任务范围。有意思的是这个限制反而让用户更愿意把任务描述清楚任务成功率也提高了。瓶颈依然很明显多工具协同任务还是容易迷失方向。比如“查完 A 平台数据再对比 B 平台数据最后总结差异”模型经常在第三步开始凭记忆对比而不是把两个平台数据放一起仔细看。解决方向是引入规划器让模型先把子任务列成清单再按清单逐步执行。这就是各类 Agent 框架里说的 planner 与 executor 分离思路也是 lucky_agent 下一版的核心改造方向。5.2 常见概念辨析skill、harness、agent 到底什么关系无论是面试还是项目沟通概念不清都会严重减分。skill、harness、agent 这三个词经常被混着用我按自己调试 lucky_agent 的经验做个简单区分skill 是能力单元比如生成图表、解析 PDF、计算指标它不包含决策逻辑就是一个可以被调用的技能agent 是决策主体它决定每一步该调用哪个 skill、以什么顺序调用、拿到结果之后下一步怎么走harness 是运行环境负责循环控制、工具调度、上下文维护、异常处理简单说就是 Agent 跑在哪层脚手架上面。用 lucky_agent 来对应我注册的 queryDns、generateWordReport 都是 skillAgentRunner 本身是 agent 逻辑Spring Boot 里的配置、日志、虚拟线程、工具注册中心这些基础设施就是 harness。做大规模 Agent 系统三件事都要抓边界还要分清楚。常见反面教材是“把所有东西都塞进提示词”最后逻辑全纠缠在一起谁都不好调试。5.3 给后来者的三点实操建议第一点是起步别急着套框架。我见过太多朋友上来就接一个重量级 Agent 框架结果陷入配置地狱出了问题分不清是框架的 bug 还是自己业务逻辑的锅。先用一百多行代码把 ReAct 循环写明白把工具调用跑通再去看框架是怎么封装这些步骤的理解会扎实很多。第二点是先把日志和 traceId 做好再往上加功能。Agent 开发中“看不见内部状态”是最痛苦的事。我第一版没有日志遇到问题只能一遍遍重跑任务白白烧掉很多钱。后来补上 traceId 和每步日志一次失败就能快速定位到具体工具开发效率至少翻了两倍。第三点是安全永远要前置。在 Agent 能调文件、操作数据库之前先把权限边界、参数校验、操作审计写好。我自己亲历过一次模型被提示词诱导尝试调用一个我根本没注册的工具还好有白名单机制挡住。只要工具注册表、参数校验、结果限长这些硬编码防线存在模型再聪明也很难真的越界。lucky_agent 离一个成熟的框架还很远但它把 Agent 的骨架和关键环节都让我摸了一遍。后续我会逐步加入多 Agent 协作、外部知识库接入、更细粒度的工具权限把它做成团队内部可复用的基础组件。如果你也在用 Java 折腾 Agent不妨先从一个小循环开始。踩过几个坑之后你也会发现这条路比预想中要清晰很多。
返回列表