☰
Spring AI ReactAgent实战:打造商品发布智能审核系统
2026/10/7 6:24:28 网站建设 项目流程

如果要给“降SpringAI”系列选一个最能体现Agent魅力的章节,我会毫不犹豫地选“或跃在渊”——对应到技术实现,就是Spring AI下的ReactAgent模式。前八掌我们讲了提示词、结构化输出、RAG、工具调用,但真正让系统“主动思考”的,是从这一掌开始的:模型不再是一问一答的接口,而是一个能自己决定“我要先查什么、再调什么工具、拿到结果后下一步干什么”的智能体。我用Spring AI Alibaba搭过一套商品发布文本的智能审核系统,核心就是ReactAgent。这篇文章把整个落地过程拆开讲,包含系统提示词怎么配置、工具怎么设计、循环调用里的坑怎么填,适合已经在用SpringAI做业务、想把固定流程升级成自主决策Agent的开发者。

1. ReactAgent这一掌,为什么值得从工具调用里单拆出来

先亮个观点:Spring AI里的ReactAgent并不是一个神秘的新框架,它就是“ChatClient + 工具注解 + 提示词 + 多轮推理循环”的组合。但组合方式不同,效果天差地别。这一节我们先把它跟普通的工具调用做对比,顺便说说“或跃在渊”到底对应在哪儿。

1.1 “或跃在渊”和ReAct的底层逻辑

降龙十八掌里,“或跃在渊”是龙从深渊中跃起、蓄势待发的一式。ReAct这个词是Reason和Act拼出来的:先推理(Reason),再行动(Act),观察结果后继续推理。这和一次性工具调用最大的区别在于,Agent可以“走一步看一步”。

拿审核场景举例。普通工具调用流程是:用户输入文本 -> 模型判断要不要调工具 -> 调用一次工具 -> 根据返回结果给出答案。如果这个判断需要“先检查联系方式,再根据联系方式结果决定要不要检查竞品词”,普通模式下模型可能只会调用一次工具就草率收尾。ReactAgent则不同:每次工具返回后,模型会把观察结果放回对话上下文,重新推理下一步。这个“返回上下文继续推理”的动作,就是龙跃起之后还要回到渊里蓄力——直到模型认为自己已经拿够了证据,才跳出循环,给出最终结论。

我在Spring AI Alibaba里真正跑通这个逻辑时,有种豁然开朗的感觉:原来之前觉得工具调用“不够聪明”,不是模型问题,而是我只给了它“一次出手”的机会。

1.2 ChatClient直接调工具和Agent循环的差别

用一张表格说清楚差别,这也是我经常跟团队解释的版本:

维度一次性工具调用ReactAgent循环
工具调用次数通常一次可能多轮,直到模型认为信息足够
推理依据只有用户输入和系统提示词每轮工具返回都会作为新观察加入上下文
决策能力弱,适合单步判断强,适合多步校验、分支判断
失败恢复工具异常则整体失败模型可根据错误信息换策略或换工具
调试难度低中等,需要看工具调用链路
适用场景分类、抽取、翻译审核、规划、检索后综合分析

在Spring AI的实现里,当你给ChatClient传入带@Tool的方法时,模型返回一个toolCall请求,框架自动把工具执行结果作为tool消息追加回去,再次请求模型。这个循环对业务代码是透明的,你只看到一次chatClient.prompt().call(),实际底层可能已经来回走了三四轮。我第一次通过日志看到这种自动循环时,就知道Agent这个方向是对的。

1.3 Spring AI Alibaba对ReactAgent的定位

说回“阿里”这部分。我用的Spring AI Alibaba是阿里开源的Spring AI适配项目,它没有把ReactAgent包成一个黑盒,而是遵循Spring AI标准API:底层接的是DashScope、通义系列模型,上层暴露的依然是ChatClient、ChatModel、@Tool这些标准化接口。所以你在Spring AI原版上学到的工具调用、提示词技巧,在Spring AI Alibaba里完全通用,切换模型供应商的成本也被压得很低。

我选择Spring AI Alibaba的另一个理由是它对国内模型生态的适配比较顺,公司内部不少模型服务走的是兼容OpenAI协议的网关,用这个starter接起来非常省事。项目里只需要保证一个原则:业务代码不直接依赖具体模型SDK,全走Spring AI抽象层,这样后续换模型、加模型都是配置级改动。

1.4 适合用ReactAgent的场景,以及别硬上的场景

不是所有业务都需要ReactAgent。我自己踩过这个坑:有一阵子把关键词过滤也交给Agent做,结果模型偶尔会“理解”过滤规则,漏掉纯规则就能命中的词,性能还差。后来我把业务拆成两层:能用正则、词表快速确定的,绝不上Agent;需要语义判断、需要跨多个证据综合决策的,才交给ReactAgent。

适合的场景有几个共性:判断步骤多、依赖外部数据源(词库、数据库、规则引擎)、结果需要附带证据链、失败时希望模型能自我修正。典型例子就是智能审核:一条文本可能同时涉及联系方式、广告违禁词、竞品提及、夸大宣传,单个工具无法覆盖,必须要Agent按顺序取证、综合打分。这正是“或跃在渊”的含义——宁可多回几次深渊,也要把证据拿全再升空。

2. 最小可运行骨架:先把工具调通,再谈Agent

这一节直接给一个能跑起来的骨架。你别一上来就上完整审核案例,先把“模型调工具-工具返回-模型继续推理”这个闭环打通,后面加业务逻辑会轻松很多。

2.1 依赖怎么加

我用的是Spring Boot 3.x项目,Maven里加上Spring AI Alibaba的starter。版本号我不写死,因为官方release更新比较快,建议直接用当前最新稳定版:

<dependency> <groupId>com.alibaba.cloud.ai</groupId> <artifactId>spring-ai-alibaba-starter</artifactId> <version>使用官方最新release版本</version> </dependency>

如果你是纯Spring AI项目,不接阿里云系列模型,也可以只用spring-ai-starter-model,配合兼容OpenAI协议的本地模型网关。核心逻辑完全一样,只是配置项改一下。

2.2 配置文件示例

在application.yml里做最小配置。注意Spring AI Alibaba的api-key我习惯用环境变量注入,避免把密钥提交到仓库:

spring: ai: dashscope: api-key: ${DASHSCOPE_API_KEY} chat: options: model: qwen-plus temperature: 0.1

temperature我调得很低,审核场景不需要模型发挥想象力,低温度能让工具选择和结论稳定不少。等你要做创意内容时再调高也不迟。

2.3 第一个带工具调用的Agent代码

我写了一个最简版本:定义两个工具,一个返回当前时间,一个做文本长度统计。然后让模型自己决定“先调哪个、调几次”。

@Service public class MinimalReactAgent { private final ChatClient chatClient; public MinimalReactAgent(ChatClient.Builder builder) { this.chatClient = builder .defaultSystem("你是一个会使用工具的助手。请根据问题自行决定调用哪个工具,并把最终答案用自然语言输出。") .build(); } public String run(String userInput) { return chatClient.prompt() .user(userInput) .tools(new DemoUtils()) .call() .content(); } }
@Component public class DemoUtils { @Tool(name = "getCurrentTime", description = "获取当前系统时间,返回格式为yyyy-MM-dd HH:mm:ss") public String getCurrentTime() { return LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); } @Tool(name = "countTextLength", description = "统计输入文本的字符长度,返回整数") public int countTextLength(String text) { return text.length(); } }

这段代码背后发生了什么?我强烈建议你打开日志观察一次完整链路,它大概长这样:模型收到用户问题 -> 返回一个toolCall,声明要调getCurrentTime-> 框架执行工具 -> 把结果作为新消息追加 -> 模型看到时间后继续推理,可能再次调用countTextLength-> 拿到所有数据后模型组织最终答案。

我第一次在自己项目里看到这条链路时,特意打印了每一条message的角色和内容,那种“模型真的在思考”的实感,是普通接口调用给不了的。

2.4 为什么这个骨架能循环起来

很多人第一次看这段代码会有疑惑:代码里只调用了一次call(),多轮循环是谁在驱动?答案是Spring AI的ChatModel内部实现了tool calling的循环处理。每次模型返回工具调用请求,框架会自动执行对应方法,再把结果封装成tool消息发回模型。循环结束的条件是:模型不再要求调用工具,返回普通文本内容。

知道这点很重要。因为一旦工具方法太多、描述太笼统,模型可能会陷入“反复调同一个工具”的死循环。我在后面专门有一节讲这个问题,这里先记住一个结论:循环虽然由框架驱动,但“循环的边界”需要你在提示词和工具设计上主动控制。

3. 智能审核案例:让Agent同时碰三个审核工具

骨架跑通后,我直接上真实案例。这个案例我从一个商品发布审核项目中提炼出来,核心任务是:用户输入一段商品描述文本,Agent自动检查联系方式、广告违禁词、竞品名称,最后给出是否允许发布、风险等级、证据列表。

3.1 审核场景和工具定义

审核不是把全部文本丢给模型读一遍就完事,而是要让模型借助确定性工具取证。我设计了三个工具:

工具名作用返回内容
contactInfoExtract从文本中提取手机号、微信号、QQ、邮箱等联系方式命中类型和原文片段列表
adWordCheck检查广告法违禁词、绝对化用语、夸大宣传词命中词列表
competitorMentionCheck检查是否提到竞品品牌或对竞品的贬低表述竞品词列表和原文片段

工具实现不复杂,核心是正则和本地词表。这里我给出一个工具类的骨架:

@Component public class AuditTools { @Tool(name = "contactInfoExtract", description = "从商品描述文本中提取联系方式。支持手机号、座机、微信号、QQ号、邮箱。返回ContactItem列表,没有命中时返回空列表。") public List<ContactItem> contactInfoExtract(String text) { List<ContactItem> hits = new ArrayList<>(); // 手机号 Pattern mobile = Pattern.compile("1[3-9]\\d{9}"); Matcher m = mobile.matcher(text); int count = 0; while (m.find() && count < 5) { hits.add(new ContactItem("MOBILE", m.group())); count++; } // 微信:常见关键词后面的连续英文数字 // 竞品词表同理 return hits; } @Tool(name = "adWordCheck", description = "检查文本中是否出现广告违禁词,如绝对化用语、夸大宣传词。返回命中词列表,没有命中时返回空列表。") public List<String> adWordCheck(String text) { List<String> adWords = List.of("国家级", "世界级", "最高级", "最佳", "第一", "全网最低", "史无前例"); return adWords.stream() .filter(text::contains) .collect(Collectors.toList()); } @Tool(name = "competitorMentionCheck", description = "检查文本中是否提到指定竞品品牌或对竞品的贬低表述。返回竞品词列表。") public List<String> competitorMentionCheck(String text) { List<String> competitors = List.of("某竞品A", "某竞品B", "某竞品C"); return competitors.stream() .filter(text::contains) .collect(Collectors.toList()); } }

注意每个@Tool的description都要写清楚“什么时候用、返回什么、没命中时返回什么”。模型选择工具的依据就是这段描述,描述含糊,模型就会乱选工具。这一点我在下一节还会展开。

3.2 系统提示词这样配,Agent才知道先干什么后干什么

系统提示词是ReactAgent的“行动纲领”。我给审核Agent配的系统提示词经过了好几轮迭代,结构基本固定成四段:角色定义、工具使用顺序、证据要求、输出格式。

你是商品发布内容审核助手。你的任务是对用户输入的商品描述文本进行合规审核。 工具使用顺序: 1. 先调用 contactInfoExtract 检查联系方式; 2. 再调用 adWordCheck 检查广告违禁词; 3. 再调用 competitorMentionCheck 检查竞品提及; 4. 最后基于所有工具结果综合判断。 要求: - 每一步都必须真实调用工具,不能凭记忆猜测。 - 工具返回为空时,在证据中注明“未命中”,不要把未命中写成命中。 - 最终结论必须引用原文片段作为证据。 输出格式为JSON: { "passed": true或false, "level": "LOW/MEDIUM/HIGH", "reason": "结论理由", "evidence": ["工具类型: 原文片段"] }

这里最关键的其实是“工具使用顺序”这一段。我遇到过模型跳过第二个工具、只根据第一个工具就下结论的情况,把顺序写进提示词后,正确率明显提升。原因也不难理解:模型在没有明确流程约束时,倾向于“差不多就回答”;有了显式顺序,它就知道每一步都要交作业。

3.3 完整调用代码和结果解析

工具类和提示词都准备好后,Agent主体代码非常简单:

@Service public class AuditAgent { private final ChatClient chatClient; private final AuditTools auditTools; public AuditAgent(ChatClient.Builder builder, AuditTools auditTools) { this.auditTools = auditTools; this.chatClient = builder .defaultSystem(PromptTemplateLoader.load("audit-system-prompt.txt")) .build(); } public AuditResult audit(String productText) { String content = chatClient.prompt() .user(productText) .tools(auditTools) .call() .content(); return JsonParser.fromJson(content, AuditResult.class); } }

PromptTemplateLoader是我写的一个工具类,用来从resources/prompts/目录加载系统提示词模板。这样提示词可以独立于代码维护,业务人员也能直接改文本。

我用一个典型输入测试一下:"正品包包,全网最低价,加微信abc123详聊,比某竞品B质量好太多"。Agent的输出结果大概是:

{ "passed": false, "level": "HIGH", "reason": "文本包含联系方式、广告违禁词、竞品贬低表述,属于高风险发布内容", "evidence": [ "contactInfoExtract: 微信 abc123", "adWordCheck: 全网最低", "competitorMentionCheck: 某竞品B" ] }

我实际跑出来的结果跟上面基本一致,而且Agent是真的依次调用了三个工具,不是一口气编出来的结论。这一步让我确信,ReactAgent在审核场景里不是玩具,是真能接生产任务的。

3.4 为什么不让模型直接判断,而要强制走工具

有人可能会问:既然模型已经知道“全网最低”是违禁词,直接让它判断不就行了,为什么还要绕一圈调工具?我的回答是:确定性业务不能交给概率。模型的知识有边界,它可能不知道你们公司最新定义的竞品名单,也可能把“不是第一”这种否定表述误判成违禁词。工具在这里的作用是把“业务规则”变成“确定性的、可更新的数据源”,模型只负责“调度工具和综合推理”,不负责“背诵规则”。

这也是我把工具放在Agent里的核心原因:规则变了,只改工具和词表,不用改提示词。审核结果的一致性、可审计性都上了一个台阶。

4. 系统提示词这样配,Agent才不会乱飞

“SpringAI系统提示词怎么配置”是我这段时间被问最多的问题。这里我把在审核Agent上验证过的配置方法完整讲一遍,并且说清楚每一段为什么存在。

4.1 一份可复用的系统提示词模板

我拆解成六段,每段解决一个具体问题:

提示词段落核心内容解决的问题
角色定义你是谁、负责什么任务让模型切换专业状态,避免通用聊天口吻
工具清单与调用顺序有哪些工具、先调哪个后调哪个避免工具选择混乱、跳过关键步骤
证据要求结论必须引用原文片段防止模型编造证据、凭记忆回答
负向约束禁止做的事防止模型在工具未命中时强行输出命中
输出格式JSON结构和每个字段的说明保证下游能稳定解析结果
边界兜底异常情况怎么处理防止工具出错时模型直接崩溃

4.2 工具描述怎么写,模型才不乱选

工具描述里最容易犯的错是写得太空。比如"检查联系方式"和"检查广告词",模型看不出这两个工具的具体边界,就可能把“微信号”既当成联系方式又当成广告词。我现在的写法是:触发条件、支持的输入类型、返回结构、未命中行为四要素齐全。

@Tool(name = "adWordCheck", description = "检查文本中是否出现广告法违禁词。只负责违禁词,不负责联系方式或竞品。返回List<String>格式的命中词列表,没有命中时返回空列表。")

加一句“不负责联系方式或竞品”听起来多余,实际上是在帮模型做工具选择的排除法。模型对工具边界的理解越清晰,工具调用准确率越高。

4.3 输出格式约束的常见坑

我在配置输出格式时栽过一个跟头:只写了“输出JSON”,没有给字段说明,结果模型偶尔会多返回一个details字段,或者把passed写成字符串"false"。后来我在提示词里加了这样的说明:

输出字段说明: - passed:boolean类型,只有true和false两种值; - level:只能是LOW、MEDIUM、HIGH三个值之一; - reason:不超过50个字,说清楚判断依据; - evidence:string数组,每一项格式为“工具类型: 原文片段”。

这招很有效。模型也是“给点阳光就灿烂”的,你不把边界画死,它就会自由发挥。

4.4 让模型先复述任务,再调工具

另一个提升稳定性的小技巧:在系统提示词里要求模型在开始工具调用前,先用一句话复述用户输入的审核要点。不要小看这一步,它等于强制模型先“进入状态”,减少对输入文本的漏看。我见过不少漏检案例,原因就是模型拿到长文本后直接跳到结论,漏掉了藏在中间的联系方式。加了“先复述再执行”之后,漏检率下降得很明显。

当然,复述这句话不会出现在最终输出里,因为它属于模型内部推理的一部分。你只需要在提示词里写清楚“你的思考过程不用呈现给用户,最终输出只包含JSON”即可。

5. 循环调用里的三个坑:死循环、假结果、上下文爆炸

ReactAgent跑起来容易,跑好难。我这一节全是实际踩过的坑,有些问题排查了我整整一个下午。

5.1 死循环:模型反复调用同一个工具

有一次测试,模型一直在调用contactInfoExtract,连续调了五六次都不肯结束。我看日志才发现,工具返回的ContactItem里有一个字段叫type,模型可能是想通过反复调用拿到更多联系方式。后来我用两个手段解决:一是在提示词里明确“每类联系方式最多提取一条,禁止重复调用同一工具”;二是在业务侧加了一个外层校验,如果工具调用次数超过5次,直接终止本轮并返回“审核失败,请人工处理”。

Spring AI的底层循环机制很强大,但它没有帮你限制“循环次数”的义务,这部分必须在业务层自己兜住。我建议你在封装Agent时,把工具调用次数纳入监控指标。

// 伪代码:外层兜底控制循环次数 AtomicInteger toolCallCount = new AtomicInteger(0); String content = chatClient.prompt() .user(input) .tools(tools) .call() .content(); // 如果日志发现toolCallCount持续增长,说明有死循环风险

严格来说,Spring AI内部的循环次数据说也有相关参数,但不同版本API变化较快,我不会把一个未来可能变更的参数名写死在这里。最稳妥的做法就是自己监控、自己兜底。

5.2 上下文爆炸:每轮工具调用都会累积历史

Agent每次工具调用后,框架都会把“工具请求”和“工具结果”追加到对话上下文。如果工具返回的是一个很大的List,几轮下来上下文就可能膨胀到上万token。审核工具还好,我遇到更严重的是在做RAG检索Agent时,每轮检索返回几篇文档,上下文很快爆掉。

解决思路是让工具的返回结果“够用但不要过多”。比如contactInfoExtract最多返回5条命中,每条只返回类型和原文片段,不返回其他元数据。你要在工具方法里做截断,不要把整个词库或整段分析结果倒给模型。

5.3 假结果:模型在工具没返回时强行编造

这是让我最警惕的坑。有一次工具实现有Bug,adWordCheck返回了空列表,但模型在最终结论里写了“命中广告违禁词:全网最低”。我查日志发现工具结果明明是空,模型为什么能“看到”违禁词?因为它凭自己的知识猜了一个。

从那以后,我在系统提示词里加了一条硬约束:证据列表中的每一项必须来自工具返回结果;若工具返回为空,必须写“未命中”,禁止自行补充。并且在代码里加了校验:最终输出的evidence字段如果出现了工具返回列表里不存在的文本,就判定为异常输出,转人工复核。

这条硬约束在审核场景里极其重要。审核系统的价值在于可追溯,模型编一条证据,整个日志链就废了。

5.4 工具抛异常:别让整个对话中断

工具方法执行过程中可能遇到词表加载失败、正则出错等情况。默认情况下,异常会直接中断对话,模型拿不到任何反馈,用户看到的就是一个报错。我后来把所有工具方法统一改成“返回错误信息而不是抛异常”:

@Tool(name = "adWordCheck", description = "...") public List<String> adWordCheck(String text) { try { // 业务逻辑 } catch (Exception e) { return List.of("ERROR: 词表加载失败,请稍后重试"); } }

这样模型收到错误信息后,至少有机会在最终结论里说明“本次审核工具异常,结果不可靠”,而不是让整个调用链直接断掉。

6. 生产级优化:日志、兜底和提示词版本管理

骨架跑通、案例验证、坑也踩过一轮之后,最后说说我把这套ReactAgent推到生产环境前做的几件事。

6.1 把工具调用全链路打出来

Agent跟普通接口最大的区别在于“过程不可见”。普通接口你只能看到输入输出,Agent中间做了三次工具调用、每次返回是什么,你不打日志根本不知道。我在生产环境把关键节点都打印出来:用户输入、模型发起的工具请求、工具返回结果、最终输出。定位问题的时候,这些日志比任何监控都管用。

log.info("ReactAgent user input: {}", input); // 框架自动执行工具调用,日志会记录toolCall log.info("ReactAgent final output: {}", content);

如果你用的是Spring AI Alibaba的自动装配,很多链路信息可以通过观测体系收集。项目里没有接入复杂链路追踪的话,最简单的log.info就是保命手段。

6.2 三层兜底:低风险直放、中风险提示、高风险人工

我把审核结果分成三个处理路径,而不是让模型一个人拍板:

风险等级处理方式
LOW自动通过
MEDIUM自动提示修改建议,同时记录日志
HIGH转人工审核,Agent结果仅作为参考

这个兜底设计很关键。Agent再聪明,也只是辅助决策,不是最终决策者。尤其审核场景涉及处罚和投诉,一旦模型判断失误,人工介入能兜住最坏情况。

6.3 提示词模板独立维护,纳入版本管理

这也是“系统提示词怎么配置”的终极答案:不要写在Java字符串里,不要散落在各个Service。我建议把系统提示词放到resources/prompts/目录,用独立的文本文件管理。配置文件一多,你可能会想引入配置中心,但初期一个目录就够了。

每次修改提示词,我都要求团队在提交记录里写清楚“改了哪一段、解决什么问题、副作用是什么”。这样Agent行为变了,你能快速回溯到是哪次提示词变更引起的,而不是对着代码猜半天。

6.4 我对ReactAgent生产落地的一点体会

这套审核Agent上线跑了两个月,最大的体会是:ReactAgent的价值不在“灵光一现”的智能感,而在“可编排的确定性”。你把工具做成业务规则的稳定底座,把提示词做成行为约束的缰绳,模型反而能在这套框架里展示出真正的价值。那些一上来就想让模型“自由发挥”的开发,往往会被不可控输出折磨到放弃。

如果你正要开始在SpringAI项目里做智能审核或类似的Agent场景,我的建议是按这个顺序来:先把工具函数写得让模型一眼看懂,再花半天调系统提示词,最后盯着日志看三轮工具调用链路。等这三步都稳了,再谈“或跃在渊”,龙才能真的跃起来。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询