
这个系列写到第九篇终于到了我最想聊的部分。前几篇我们多半在解决“怎么让模型好好说话”——换提示词、接知识库、做RAG、调参数模型输出再漂亮本质上还是在“回答”。而这一篇的标题我特意用了“或跃在渊”这四个字就是因为 ReAct Agent 这一步真正把 AI 从“回答问题的聊天框”变成了“能自己去查系统、看结果、做判断、再决定下一步动作的执行者”。这种跃迁感正好对应“或跃在渊”龙在深渊之上跃跃欲试进退都由自己判断。这篇文章默认你已经会跑 Spring AI 的基础对话也大概了解 Function Calling 是什么。我会从 ReAct 的核心逻辑讲起然后带你在 Spring AI 里接阿里云百炼把工具、记忆、系统提示词这些关键环节一个个搭起来最后把我压测和线上折腾时踩过的坑直接摆出来。整篇都基于实际能跑的代码不是概念演示建议你跟着敲一遍。1. 别误解ReAct它不是“多轮聊天”而是“思考-行动-观察”的闭环1.1 ReAct到底解决什么问题ReAct 这个词来自论文《ReAct: Synergizing Reasoning and Acting in Language Models》核心就一句话让语言模型在推理过程中交替执行思考Reasoning和行动Acting而不是一次性直接给出答案。为什么要这样绕一圈因为大模型的知识是有截止时间的它无法知道自己训练语料之外的实时信息更不可能自己下单、查库、发短信。ReAct 的思路是当模型遇到一个需要外部信息才能回答的问题时它先“想”一下——我现在缺什么信息然后“做”——调用某个工具去获取信息拿到返回结果后它再“观察”这个结果继续“想”下一步。如此循环直到它认为自己有足够信息回答用户为止。用一句话概括这个循环Thought当前该做什么→ Action调用哪个工具、传什么参数→ Observation工具返回了什么→ 回到 Thought直到给出 Final Answer。我特别喜欢把这种结构比作“边做菜边尝味道的厨师”普通聊天模型像熟记菜谱的学徒只能背出步骤ReAct Agent 则是实际走进厨房的师傅盐不够了自己拿火大了自己调每一步都根据锅里真实的反馈来做决定。这就是“或跃在渊”的意味——每一步都在试探每一步都在验证而不是闭着眼睛往下跳。1.2 普通聊天、Function Calling、ReAct三者的分界很多人会把 Function Calling 和 ReAct Agent 当成一回事这个误解在实战里挺常见的。我在团队里带新人时一般用下面这个表格讲清楚三者的差异能力普通对话Function CallingReAct Agent外部数据获取不支持支持但通常是单次调用支持且支持多轮连续调用决策依据仅依赖模型记忆模型声明调用哪个函数应用层执行模型根据上一步观察结果自主决定下一步多工具协作不支持有限一般一次只调一个支持一个工具输出可作为另一工具输入循环能力无无有Thought → Action → Observation → …典型实现成本最低中等较高需要做好记忆和提示词约束普通对话就像顾客报菜名服务员只负责记录不接触后厨Function Calling 相当于服务员把菜单传进后厨只完成一次“传话”ReAct 则是顾客要一桌菜厨师边做边尝缺了什么自己去拿做错了及时调整流程。落到 Spring AI 的代码上最直观的差异在于普通 ChatClient 调一次.call()就结束而带了.tools()的 ChatClient模型可能会连续触发多次工具调用直到它认为答案已经足够完整。1.3 Spring AI里这个闭环是怎么转起来的在 Spring AI 1.0 中你不需要自己写一个while (true)来驱动这个循环。你把工具通过.tools(...)交给 ChatClient 之后框架内部的自动函数调用机制Auto Function Calling会接管这件事模型返回一个希望调用工具的响应框架识别到工具调用请求自动执行对应方法再把结果作为一条消息交回模型模型继续推理如此反复。不过有两点需要你心里有数第一单轮内的循环由框架完成跨轮对话的循环由你的应用层完成。也就是说如果用户连着问五句话Agent 能不能记住前面说了什么取决于你有没有把历史消息传回来这一块我们后面专门讲。第二工具能不能被模型正确选中取决于你给工具的“说明书”写得好不好也就是Tool注解里的描述信息。这两点分别对应着“状态管理”和“工具契约”是整个 ReAct Agent 实战里最容易出问题的地方。2. 基座准备Spring AI 接阿里云百炼把最小Agent跑通2.1 依赖与仓库别被Maven版本绊倒Spring AI 的版本迭代比较快如果直接把依赖写死成某一个历史版本后面接新工具时很容易遇到 API 对不上的尴尬。我的习惯是先用 BOM 锁版本再引用具体模块这样所有 spring-ai 组件保持同版本不会有依赖冲突。dependencyManagement dependencies dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-bom/artifactId version1.0.0/version typepom/type scopeimport/scope /dependency /dependencies /dependencyManagement然后引入阿里云百炼对应的 starterdependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-model-dashscope/artifactId /dependency用spring-ai-starter-model-dashscope的好处是Spring AI 已经内置了百炼的自动配置你不用自己拼 HTTP 请求、不用手动管理鉴权 Header只需要提供 API Key 和模型名。这一点对 Agent 这类需要频繁调用工具的场景尤其重要因为一次完整的多步任务可能涉及很多次模型调用每一层请求都自己封装的话维护成本会失控。这里还要提一下国内拉依赖的体验。Spring AI 的一些里程碑版本不在 Maven 中央仓库的默认同步列表里直接拉经常超时。我一般会在 Maven 的settings.xml里加阿里云公共仓库作为镜像mirrors mirror idaliyunmaven/id mirrorOf*/mirrorOf name阿里云公共仓库/name urlhttps://maven.aliyun.com/repository/public/url /mirror /mirrors配好之后依赖下载速度和稳定性会好很多。这个镜像不是只对阿里云用户有效任何国内 Maven 项目都能用属于纯收益配置。2.2 配置文件模型、密钥、温度依赖到位后在application.yml里写入百炼的接入配置spring: application: name: react-agent-demo ai: dashscope: api-key: ${ALIYUN_DASHSCOPE_API_KEY} chat: options: model: qwen-plus temperature: 0.3如果你有多套环境API Key 千万别硬编码用${ALIYUN_DASHSCOPE_API_KEY}这种方式从环境变量读取这是最基本的底线。另外我建议在 Agent 场景里把temperature调到 0.3 左右而不是用它默认的较高值。原因很实际Agent 需要“稳定地选对工具、稳定地遵循流程”而不是“有创意地使用工具”。温度太高模型可能在两个都能用的工具之间随机乱选或者干脆自己编一个工具名这在生产环境里非常讨厌。2.3 最小的AgentService先让工具转起来配置完成后我们写一个最小的 AgentService让它能够接收用户消息并且在需要时调用工具。第一步先不做复杂记忆就验证“模型 工具 循环”这条链路通不通Service public class AgentService { private final ChatClient chatClient; public AgentService(ChatClient.Builder builder, OrderTools orderTools) { this.chatClient builder .defaultSystem( 你是订单助理Agent。当用户询问订单状态、物流信息时 你必须调用对应工具获取真实数据禁止凭记忆编造。 如果工具返回失败必须如实告知用户。 ) .build(); this.chatClient chatClient.mutate() .defaultTools(orderTools) .build(); } public String handle(String userMessage) { return chatClient.prompt() .user(userMessage) .call() .content(); } }注意这里我用了两条构建链先通过ChatClient.Builder设置默认系统提示词再通过mutate()把工具挂到默认配置里。这么做的意图是系统中所有对话请求都共享同一套 Agent 身份和工具集避免每次请求都要重复传入system和tools漏传一次就是一次线上事故。2.4 模型怎么选不是越大越好百炼平台提供多个通义千问模型常见选择是qwen-turbo、qwen-plus、qwen-max。我的经验是Agent 场景里不要无脑上 max。qwen-max的理解能力和指令遵循确实更强但单次调用成本更高、延迟也更高。ReAct 一次完整任务可能产生多次模型调用成本是乘数关系。我的建议是如果 Agent 只是做订单查询、物流跟踪这类流程清晰的任务qwen-plus足够如果任务是复杂的多步推理比如需要综合多份报告做审核判断再考虑qwen-max。qwen-turbo一般用来做“前筛”类工具比如先把大量文本粗分类再交给主 Agent 精处理。模型选型这件事正确做法是先用低成本模型跑通链路再拿真实业务场景做对比测试而不是一开始就上最贵的。3. 工具即“手脚”用Tool把业务能力暴露给Agent3.1 Tool背后的JSON Schema机制Spring AI 里定义工具的方式很直接在一个 Bean 方法上打Tool注解方法参数用ToolParam描述。框架启动时扫描这些注解自动生成对应的 JSON Schema注册给模型。模型看到的不是 Java 方法而是一份“函数清单”里面包含函数名、参数名、参数类型、是否必填、功能描述。先看一个实际可用的工具类Component public class OrderTools { private final OrderService orderService; public OrderTools(OrderService orderService) { this.orderService orderService; } Tool(description 根据订单号查询订单当前状态返回订单状态、商品摘要、下单时间。当用户询问订单相关问题时必须调用。) public String queryOrderStatus(ToolParam(description 用户提供的完整订单号格式如 ORD20250101) String orderId) { Order order orderService.findByOrderId(orderId); if (order null) { return {\code\:\NOT_FOUND\,\message\:\订单不存在请确认订单号是否正确\}; } return orderService.buildOrderBrief(order); } Tool(description 查询订单的物流轨迹最多返回最近5条记录。仅当订单状态为已发货或派送中时调用。) public String queryLogistics(ToolParam(description 订单号) String orderId) { ListLogisticsTrack tracks orderService.listTracks(orderId, 5); return orderService.buildTrackJson(tracks); } }这里每个方法返回的都是 JSON 字符串而不是 Java 对象。原因很简单模型最终拿到的是一个文本结果结构化 JSON 文本最容易让它解析和理解。{ code: NOT_FOUND }这种格式对模型来说非常友好比一段自然语言“抱歉我找不到这个订单哦”要清晰得多。3.2 工具描述是给模型看的说明书很多初学者的 Agent“抽风”不是模型不行而是工具描述写得太敷衍。Tool里的description不是给你自己看的注释那可是模型做工具选型的唯一依据。我拿一个反面例子说明如果你把工具 description 写成“查询订单状态”模型确实能理解但当系统里同时有“查询订单状态”“查询物流”“查询退款进度”三个工具时模型很容易对着一个含糊的问题选错工具。正面写法应该是明确“什么情况下调用”例如“当用户询问订单相关问题时必须调用”明确“什么情况下不要调用”例如“仅当订单状态为已发货或派送中时调用物流工具”明确“数据以什么为准”例如“订单状态以工具返回为准禁止凭记忆作答”工具名本身也不要乱起我建议加业务前缀比如order_queryStatus、logistics_queryTracks。实测下来带业务前缀的工具名能让模型的识别准确率高一点尤其是工具数量超过 5 个的时候效果很明显。你可以理解为工具清单就是给模型的“岗位说明书”写得越具体模型越不容易做错事。3.3 工具返回能短则短千万不要把数据库全量抛给模型这是我在线上压测里印象最深的一条。工具方法内部如果直接查数据库然后把一个包含几百行记录的 List 塞回模型上下文窗口很快就会被撑爆。之前有个同事写了个导单工具测试时直接JSON.toJSONString(list)返回了 4200 条订单记录模型瞬间提示超长接着各种截断、幻觉全来了。我的处理原则是三句话先计数、再截断、后摘要。比如要返回订单流水不要返回全部记录而是返回这样的结构Tool(description 查询符合条件的订单列表适合按时间范围或状态筛选。) public String queryOrderList(ToolParam(description 开始日期格式 yyyy-MM-dd) String begin, ToolParam(description 结束日期) String end) { ListOrder all orderService.list(begin, end); // 第一步返回总数和分页信息 int total all.size(); // 第二步只取前20条 ListOrder top all.subList(0, Math.min(20, total)); // 第三步拼装摘要 StringBuilder sb new StringBuilder(); sb.append({\total\:).append(total).append(,\sample\:[); // 每条只保留关键字段省略长文本 for (Order o : top) { sb.append({\id\:\).append(o.getOrderId()) .append(\,\status\:\).append(o.getStatus()) .append(\,\amount\:).append(o.getAmount()).append(},); } sb.append(]}); return sb.toString(); }把全量数据塞给模型它既看不完也用不好给它“总数 关键摘要”它反而能做出靠谱的判断。这个问题我们后面第四个坑里还会细讲这里先把原则立住。4. 状态与记忆多轮Agent对话的失忆症4.1 无状态API和有状态业务ReAct Agent 跑通单轮后你马上会遇到一个尴尬问题用户上一轮说“查一下订单 ORD20250101”这一轮说“那它的物流呢”——如果 Agent 不记得上一轮讨论的订单号它根本无法回答“那它”指的是谁。原因在于模型交互本质上是无状态的每次调用都是一个独立请求。你如果不把历史对话装配进上下文模型面对的就是一个“失忆患者”。这也是为什么很多 Agent Demo 看着惊艳、一上生产就挨骂多数不是模型能力问题而是状态没接住。4.2 用ChatMemory接住会话Spring AI 里把“历史消息装回去”这件事封装成了 ChatMemory。最简单的是InMemoryChatMemory按会话 ID 保存历史消息请求时自动追加。我用一个非常普通的 SessionController 演示RestController public class AgentController { private final AgentService agentService; private final ChatMemory chatMemory; public AgentController(AgentService agentService, ChatMemory chatMemory) { this.agentService agentService; this.chatMemory chatMemory; } PostMapping(/agent/{sessionId}) public String chat(PathVariable String sessionId, RequestBody ChatRequest req) { return chatClient.prompt() .user(req.message()) .memory(chatMemory, sessionId) .call() .content(); } }这里.memory(chatMemory, sessionId)就是整个“记忆系统”的关键第一次用某个 sessionId 请求内存里没有历史Agent 正常回答第二次同一个 sessionId 再来历史消息自动参与推理。实现上ChatMemory 就是保存了用户消息和助手消息的列表请求时一条不少地拼进上下文。需要特别提醒的是会话窗口不是越大越好。无限增长的聊天记录意味着每轮请求的 token 数持续膨胀延迟和成本同步上升模型也会被大量旧干扰信息带偏。线上我一般会和业务约定普通咨询窗口保留最近 20 条消息超过就丢最老的。如果是需要长期记忆的业务我建议再加一层外部存储把关键结论落库而不是把聊天记录全部无脑塞给模型。4.3 系统提示词的配置等级与写法“springai系统提示词怎么配置”这个问题在团队里被问过无数次。我的答案是系统提示词至少分两层一层是全局默认一层是单次请求覆盖。全局默认系统提示词放在ChatClient.Builder的defaultSystem(...)里定义 Agent 的固定身份和通用规则单次请求如果遇到特殊场景可以在prompt().system(...)里追加临时规则。两层叠加时后者的内容会和前者一起出现在系统消息里所以别写互相矛盾的指令。下面这套模板我用了很久可以作为起点你是[业务助手名称]服务对象是[用户群体描述]。 执行规则 1. 当用户提出与[核心业务]相关的问题时必须先调用[工具名]获取数据禁止使用你自己的记忆臆造结果。 2. 工具返回结果中 code 为 FAIL 时必须如实告诉用户失败原因不得假装成功。 3. 如果用户问题与可调用工具范围无关直接说明你能够处理的业务范围。 4. 回答保持简洁涉及金额、日期、单号时必须引用工具返回原文不得改写。我见过太多 Agent 翻车都翻在规则 2 上工具明明返回异常模型为了“让回答看起来顺利”直接对用户说“已处理成功”。这个坑我们下一章详细展开但预防针先打在这里提示词里把“不得猜测、不得美化工具结果”写进去能挡掉大部分问题。4.4 从“自由Agent”到“流程Agent”还有一个实战中非常重要的选择你不一定需要让 Agent 自由发挥。自由 Agent 的好处是灵活坏处也是灵活——它可能跳过必要步骤或者把两个工具的顺序搞反。比如“订单审核”场景正确的流程应该是先查订单状态再查用户历史行为最后给出审核结论。模型在自由发挥时很可能跳掉第二步直接给结论而且你不会知道它为什么跳。我的做法是在系统提示词里直接定义步骤流程处理订单审核问题时必须按以下步骤执行 步骤1调用 order_queryStatus 获取订单当前状态 步骤2调用 user_queryHistory 获取用户最近30天的行为摘要 步骤3合并上述结果给出审核建议禁止提前输出结论。把关键流程“写死”在提示词里Agent 就从“自由发挥的实习生”变成了“按规范办事的专员”。这不是限制 AI而是 Agent 落地生产环境必须付出的确定性代价重要步骤靠流程约束灵活细节才靠模型自由发挥。5. 压测排错实录Agent跑起来之后的几个坑5.1 最典型的坑Agent谎报“短信已发送”这是我在一个营销触达项目里踩到的。场景是 Agent 负责给用户发短信通知内部有一个sendSmsNotification工具封装了短信服务商 API。压测时出现了一个诡异现象日志里短信 API 其实一直报错但用户收到的 Agent 回复却写“短信已发送成功请留意查收”。一开始我以为是工具写错了后来一步步排查才发现问题出在两个环节第一工具方法内部把异常吞掉了。当时同事写了一段类似这样的代码Tool(description 向用户手机发送短信通知发送结果以返回值中code字段为准。) public String sendSms(ToolParam(description 手机号) String mobile) { try { SmsResponse resp smsClient.send(mobile); if (resp.isSuccess()) { return {\code\:\SUCCESS\}; } return {\code\:\FAIL\,\message\:\服务商返回: resp.getErrorMsg() \}; } catch (Exception e) { return {\code\:\FAIL\,\message\:\ e.getMessage() \}; } }问题不在于这段代码而在于最初的版本真的只是return success。当时脑子里的想法是“返回一个能识别的标记就行”完全忘了工具返回值是给模型看的自然语言文本——模型看到 “success” 这个单词后直接把它理解成了“发送成功”然后顺着用户的问题回答“已发送”。这个坑有两个教训工具返回值必须使用机器可读的结构化格式明确区分成功与失败不能返回有歧义的普通单词系统提示词里必须写明“工具返回 code 为 FAIL 时必须如实告知用户失败并说明原因”没有这条约束模型为了保持回答顺畅很容易把失败包装成成功。5.2 工具返回JSON过大导致上下文塞满之前提到的大 JSON 问题我再展开讲一遍完整排查链路。现象是 Agent 只要查一次订单列表就报错模型返回的信息缺失严重甚至出现幻觉。打开日志一看工具调用返回的 JSON 文本有两万多行直接塞满了模型的上下文窗口。当时的第一反应是“JSON.parseArray 转换对象扛不住”但这不是语言层面解析慢的问题而是上下文容量问题模型上下文窗口是大模型最主要的工作内存当工具返回文本超过它的处理上限中间内容会被截断模型相当于只看到了一堆残缺数据自然会胡言乱语。我的修复方案分三步收缩工具 SQL 里就做分页只取模型决策需要的样本量截断一行记录可能导致几百上千 token给每条记录设置最大字段数长字段做截断摘要对总量类信息不返回明细返回统计值。修复后同样一次查询工具返回从两万行压缩到 20 行左右请求耗时下降明显模型回答准确率也恢复了。这个经验也印证了前面 3.3 的原则——工具把决策所需的信息给全而不是把数据给全。5.3 超时、重试与工具内部延迟ReAct Agent 的另一个常态化问题是超时。模型 API 本身有一定延迟再叠加工具内部查数据库、调外部接口一次完整 Agent 任务整体耗时很容易超过用户能忍受的 5 秒。我发现很多初学者只把超时配置在 HTTP 客户端连接上忽略了模型调用本身也需要合理超时与重试。Spring AI 通常支持通过配置控制重试策略比如设置最大重试次数、初始退避时间。我的建议是外部调用层面的重试要谨慎尤其当工具是写操作发短信、创建订单时重试可能导致业务重复执行。对写操作工具宁可让它失败返回也不要做无脑重试对读操作工具可以配置 1 到 2 次重试提升成功率。工具内部的延迟也要注意。一个工具如果查库需要 3 秒整个 Agent 的响应速度就崩了。我通常要求工具方法在 2 秒内返回超过这个阈值要么做异步处理要么先返回“处理中”之类的临时状态让 Agent 告知用户稍后查询。5.4 压测后的性能建议清单总结一下压测下来比较有用的几条建议工具数量精简一个 Agent 不要挂十几个工具能用 5 个解决的不上 8 个。工具越多模型选错概率越高提示词维护成本也越高。观察日志必须留痕每次工具调用的入参和返回结果都打进日志。线上 Agent 一旦胡言乱语没有观察日志你根本没法定位是模型想错了还是工具回错了。会话时长控制无状态处理长会话时用窗口记忆限制历史消息能避免 token 爆炸。先稳定后聪明优先保证温度、提示词、工具选择三者的稳定性再考虑让模型做更复杂的自由推理。最后再说点个人的体会。第 9 篇之所以用“或跃在渊”是因为我见过太多项目在 Agent 这一步从“演示能用”掉到“生产不能碰”不是模型不够聪明而是工具契约、记忆管理、提示词约束这三样基本功没砸实。你回头看这篇的所有细节其实都在做同一件事——给模型的自由度画上明确的边界在边界之内让它充分施展。如果只保留一条建议我会说把你所有工具的描述信息单独整理成一页文档像面试题一样反复审查。描述里每多一个模糊词线上就多一分误调用风险。我之前把工具名统一加业务前缀后一个原本经常混淆两个查询工具的 Agent误调率直接降到了可以忽略的水平。这种改变不需要调任何模型参数只需要你在描述上多花半小时收益却非常明显。