
最近把一个内部工单系统从规则引擎迁移到 Spring AI 的 Agent 方案上第一版效果惨不忍睹Agent 把不该发的邮件发出去了把不该创建的任务建了用户直接吐槽“这 AI 是不是太自作主张了”。后来我重新设计了交互链路核心就一句话——让 AI 先问清楚再行动。这篇文章就聊聊在 Spring AI 里实现 Agent 人机交互确认机制这件事从底层原理到可落地的代码再到生产环境里踩过的坑一次性讲透。这其实不是一个简单的“加一个确认弹窗”的问题。Agent 的价值在于自主决策但自主决策带来的不可控风险恰恰是生产环境最头疼的事。我在 Spring AI 项目里把“人在回路”这一环做进去之后任务成功率反而提升了用户信任度也上来了。如果你正在做 Agent 开发或者正准备把 Spring AI 接到业务系统里这篇文章应该能帮你省掉不少试错时间。1. Agent“先问清楚再行动”这件事为什么值得单独写一篇1.1 自主执行带来的风险和问题Agent 和普通接口调用的最大区别是它具备“决策权”。传统程序里代码会严格按照预设顺序执行Agent 不一样它拿到用户一句话会自己分析意图、拆解步骤、选择工具、生成参数然后直接调用工具。听起来很美好但问题恰恰出在这里。大模型本质上是概率模型它的“决策”存在天然的幻觉风险。我曾经遇到过 Agent 把订单金额算错后直接走退款流程的情况也见过它把用户随口说的“这个功能要是能自动处理就好了”理解成一次真实的分批执行指令。没有确认机制的时候这些错误操作会直接落到真实业务系统里造成不可逆的影响。所以“先问清楚再行动”不是产品经理拍脑袋想出来的交互设计而是 Agent 落地生产环境必须具备的安全阀。凡涉及写库、发消息、支付、删除、创建资源这类敏感操作都应该在工具真正执行前给用户一次确认的机会。1.2 人在回路从“替用户做”到“陪用户做”人机交互这里有一个核心设计思想叫做 human-in-the-loop中文圈子一般叫“人在回路”。它的核心理念很简单机器可以负责分析、决策、执行但关键节点必须有人参与确认。这不是对 AI 能力的不信任而是对复杂业务场景的必要兜底。打个比方自动驾驶 L2 和 L4 的区别就在这里。L4 全程不需要人管但 L2 要求驾驶员随时准备接管。当前大模型的可靠性还远没到可以放开 L4 的程度尤其涉及金融、医疗、企业数据操作这些领域Agent 必须设计成“随时可以被人类接管”的形态。我在 Spring AI 里做确认机制时就把 Agent 的整个流程拆成了“决策”和“执行”两段。决策可以完全交给模型但执行必须经过人的确认。这套设计的好处是Agent 还是那个聪明的 Agent但系统整体变得可控了。1.3 与 LangChain、pi-agent 等框架的思路对比做 Agent 开发的人应该都听说过 LangChain、pi-agent 这类框架。LangChain 的 Agent 走的是 ReAct 模式给模型一堆工具的描述模型自己决定调用哪个工具框架自动执行然后把结果反馈给模型形成闭环。在这个闭环里工具调用是自动的、连续的模型可以在一次思考过程中连续调用好几个工具。pi-agent 这类偏桌面端的 Agent 产品走的也是类似的自动执行路线部分场景会弹确认框但大多是“是否允许访问某个资源”而不是“你是否确认这个操作”。Spring AI 给我的感觉是更克制一点。它把 ChatClient、ChatModel、Tool 这些组件拆得很干净没有强行规定 Agent 必须全自动。你可以很容易地在模型返回工具调用请求和真正执行工具之间插入自己的业务逻辑比如一次人工确认。这个自由度是 Spring AI 在 Agent 开发上一个很友好的地方。1.4 何时该问何时不该问确认策略的设计“先问清楚再行动”不等于每一步都问那样会把用户烦死。实际操作中需要定义清楚什么样的操作必须确认什么样的操作可以自动执行。我的经验是分类处理。只读类操作比如查询订单状态、搜索资料、查天气这些没有副作用可以自动执行。有副作用但可逆且低风险的操作比如保存草稿、生成临时文件可以自动执行并在事后通知用户。有副作用且不可逆或者高风险的操作比如删除数据、发送消息、扣款、修改线上配置必须经过确认。这套策略需要固化在 System Prompt 或者业务代码里不能让模型自己临场判断“这个要不要问”。模型对风险的感知和业务系统对风险的定义未必一致还是用代码规则强制约束更靠谱。2. 拆解 Spring AI 的 Agent 底层链路2.1 Spring AI 的核心组件和最小认知Spring AI 是 Spring 官方推出的 AI 应用开发框架目标很直接让 Java 开发者可以用熟悉的 Spring 编程模型接入大模型能力。它最基本的能力就是 ChatModel封装了各家大模型的调用接口往上封装了 ChatClient提供流式 API让代码写起来更舒服。再往上一层就是这次的主角 Tool。Tool 可以理解为给模型准备的“外部能力接口”比如查数据库、调 REST API、发邮件。在 Spring AI 里实现一个 Tool 很简单用 Tool 注解标记方法即可框架会自动把方法名和方法描述生成给模型看。这里顺便说一个容易混淆的概念Skill 和 Agent 的区别。Spring AI 社区里Skill 通常指预先定义好的、能力固定的“技能包”比如“查询天气技能”可能封装了好几个 API 调用Agent 则是在运行时根据用户需求动态编排技能和工具的智能体。Skill 是静态的Agent 是动态的。确认机制更关注 Agent 这一层因为动态编排意味着不可预测性而不可预测性必须靠交互来兜底。2.2 Tool Calling 的执行过程要理解确认机制首先得理解 Tool Calling 的执行过程。模型本身没有能力直接调用你的 Java 方法它只能“请求”调用某个工具。整个链路大致是这样用户输入一句话比如“帮我查一下订单 12345 的状态”系统把这句话连同所有可用工具的描述一起发给模型模型判断需要调用某个工具于是返回一个 tool_calls 请求里面包含工具名和参数框架拦截到这个请求去执行对应的 Java 方法执行结果作为一个新的消息回传给模型模型拿到结果后生成最终回复给用户关键点在第 4 步。大多数框架默认“收到 tool_calls 就直接执行”但默认并不代表必须这样。在 Spring AI 里你有机会在第 3 步和第 4 步之间插入自己的业务代码这正好是确认机制的黄金位置。2.3 拦截 tool_calls确认机制的黄金位置为什么说这是黄金位置因为模型返回 tool_calls 的时候已经把所有关键决策信息都给你了它想调用哪个工具、参数是什么、为什么调用可以从上下文中推断出来。这时候你完全有机会在真正执行之前把这些信息暴露给用户。我一直认为 Agent 的确认机制不该依赖“模型在对话里先问一句”因为大模型并不是每次都听话。更稳妥的做法是在代码层面强制拦截模型输出 tool_calls 之后系统先暂停执行把包含工具名、参数、操作说明的确认信息推给用户用户点了确认按钮、回复“确认”或“执行”系统才真正调用工具。这样设计的另一个好处是可控。你可以在确认环节做参数校验、风险等级判断、甚至做数据脱敏让用户在不知情的情况下也能被保护。2.4 依赖与版本spring ai maven 坐标怎么引开始写代码之前得先把依赖弄对。这一步看起来简单实际踩坑的人特别多尤其是 Maven 坐标和版本问题。以 Spring AI 1.0.x 为例Maven 坐标用 Spring Boot 的 BOM 统一管理版本即可dependencyManagement dependencies dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-bom/artifactId version1.0.0/version typepom/type scopeimport/scope /dependency /dependencies /dependencyManagement引入核心依赖dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-model-openai/artifactId /dependency如果用智谱 AI需要的是spring-ai-starter-model-zhipuai版本可以查 Maven Central 上的 Spring AI BOM 信息。用本地部署的 DeepSeek 这类开源模型一般通过 Ollama 或者兼容 OpenAI 协议的方式接入依赖坐标可以用spring-ai-starter-model-ollama。如果你是阿里云百炼或者通义千问的用户更推荐直接用 Spring AI Alibaba 的 starter它对国内模型做了很多适配后续我会单独讲。注意不同大版本之间的 API 差异非常大。比如 Spring AI 0.8.x 时代还没有稳定的 ChatClient Fluent API1.0 之后才全面转向面向 ChatClient 的编程模型。网上不少教程是基于 0.8.x 写的如果你用的是 1.0照着抄一定会报错。3. 实操用 Spring AI 实现“先问清楚再行动”3.1 基础版本检测 tool_calls 并二次确认先看最核心的代码逻辑。实现确认机制需要绕过 ChatClient 的高层封装直接用 ChatModel 做细粒度的交互循环。import org.springframework.ai.chat.messages.*; import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.chat.model.ToolContext; import org.springframework.ai.chat.prompt.Prompt; import org.springframework.ai.chat.prompt.ChatOptions; import org.springframework.ai.model.tool.ToolCallingManager; import org.springframework.ai.tool.ToolCallback; import org.springframework.ai.chat.model.Generation; import org.springframework.stereotype.Service; import java.util.ArrayList; import java.util.List; Service public class ConfirmableAgent { private final ChatModel chatModel; private final ToolCallingManager toolCallingManager; private final ListToolCallback toolCallbacks; public ConfirmableAgent(ChatModel chatModel, ToolCallingManager toolCallingManager, ListToolCallback toolCallbacks) { this.chatModel chatModel; this.toolCallingManager toolCallingManager; this.toolCallbacks toolCallbacks; } public String chatWithConfirm(String userInput, UserConfirmationHandler confirmationHandler) { ListMessage messages new ArrayList(); messages.add(new UserMessage(userInput)); while (true) { ChatResponse response chatModel.call(new Prompt( messages, ChatOptions.builder() .internalToolExecutionEnabled(false) // 关闭自动执行 .build() )); AssistantMessage assistantMessage response.getResult().getOutput(); messages.add(assistantMessage); // 模型没有要求调用工具说明它已经生成了最终答复 if (assistantMessage.getToolCalls() null || assistantMessage.getToolCalls().isEmpty()) { return assistantMessage.getText(); } // 走到这里说明模型想调用工具先停下来问用户 ListToolResponseMessage toolResponses new ArrayList(); for (ToolCall toolCall : assistantMessage.getToolCalls()) { ConfirmDecision decision confirmationHandler.confirm(toolCall); if (decision.isApproved()) { // 用户确认了才真正执行工具 ToolCallback callback findToolCallback(toolCall.name()); Object result callback.call(toolCall.arguments(), new ToolContext(List.of())); toolResponses.add(new ToolResponseMessage(toolCall.id(), result.toString())); } else { // 用户拒绝了把这个结果告诉模型让它调整方案 String cancelMessage 用户拒绝了调用 toolCall.name() 原因 decision.reason(); toolResponses.add(new ToolResponseMessage(toolCall.id(), cancelMessage)); } } messages.addAll(toolResponses); } } private ToolCallback findToolCallback(String name) { return toolCallbacks.stream() .filter(c - c.getToolDefinition().name().equals(name)) .findFirst() .orElseThrow(() - new IllegalArgumentException(未找到工具: name)); } }这里最关键的就是internalToolExecutionEnabled(false)这个配置。Spring AI 默认情况下检测到 tool_calls 会自动执行工具把执行权关闭之后框架就只负责返回模型的“意图”执行权交到你手里。这就是“先问清楚再行动”的技术基础。确认处理器的接口可以这样定义FunctionalInterface public interface UserConfirmationHandler { ConfirmDecision confirm(ToolCall toolCall); }实际对接 Web 端时你可以在 confirm 方法里把工具名、参数、操作说明转成 JSON 返回给前端前端渲染成确认卡片用户点击“确认”或“取消”之后后端再继续执行循环。整个流程既保持了 Agent 的自主性又加入了人的判断。3.2 确认消息怎么生成才友好直接把模型返回的 tool_calls 原文展示给用户是很糟糕的体验。模型生成的参数是 JSON 结构用户能看明白才怪。比如模型想要调用cancelOrder工具原始参数可能是这样{ orderId: 20250115001, reason: 用户申请取消这个订单 }这种内容放在确认框里用户还得去对比订单号体验很差。更好的做法是再用一次模型把工具调用意图转成人类可读的自然语言描述。public ConfirmDecision confirm(ToolCall toolCall) { String readableMessage explainToolCall(toolCall); // readableMessage: // 用户您好您请求执行【取消订单】操作。订单号20250115001 // 原因用户申请取消这个订单 // 该操作执行后不可恢复是否确认执行 return uiService.showConfirmDialog(readableMessage, toolCall); } private String explainToolCall(ToolCall toolCall) { String prompt 请帮我用自然语言解释下面的工具调用请求要求 1. 说明工具名称 2. 逐项解释关键参数 3. 指出该操作可能产生的后果 4. 用第二人称直接向用户汇报 工具名%s 参数%s .formatted(toolCall.name(), toolCall.arguments()); return chatModel.call(new Prompt(prompt)).getResult().getOutput().getText(); }注意解释工具调用这步也要花钱花时间别每次都调用。可以做一个简单的缓存把“工具名参数哈希”作为 key短时间内重复出现相同的工具调用直接复用解释结果。3.3 支持确认 / 修改 / 取消三种意图只支持“确认”和“取消”两个按钮还是太粗糙。实际业务需求里“确认之前想改参数”是非常常见的场景。用户看了确认信息后可能会说“等一下订单号不对不是 20250115001是 20250115006。”这种情况下如果只能确认或取消用户就只能取消整个操作再重新描述一遍诉求体验很差。我的做法是在确认环节增加“修改参数”的选项用户可以直接下发修改指令系统用模型解析修改后的完整参数再进入二次确认。public ConfirmDecision parseUserDecision(String userReply, ToolCall originalToolCall) { // 用一个专门的 System Prompt 约束模型输出 JSON String decidePrompt 用户看到了一次工具调用确认提示现在用户给出了回复。 请你判断用户的意图 1. APPROVE - 用户同意原计划执行 2. CANCEL - 用户取消了操作 3. MODIFY - 用户提供了新的参数需要重新生成工具调用 工具名%s 原参数%s 用户回复%s 请严格输出 JSON 格式{decision: APPROVE|CANCEL|MODIFY, newArguments: null或新的JSON参数, reason: 说明} .formatted(originalToolCall.name(), originalToolCall.arguments(), userReply); String json chatModel.call(new Prompt(decidePrompt)).getResult().getOutput().getText(); // 解析 JSON返回 ConfirmDecision }用户说“改成某某订单”模型就会输出 MODIFY 和新参数程序再去执行修改后的工具调用。这样 Agent 的人机交互就不再是二元的“执行/不执行”而是真正做到了“先问清楚”再做决定。3.4 对接通义千问、智谱、DeepSeek 等模型不同模型厂商的对接方式Spring AI 分成了几个不同的 starter。如果你的业务主要面向国内模型我推荐用 Spring AI Alibaba它是一套基于 Spring AI 的扩展框架对通义千问的接入做了大量本地化适配配置也简单。dependency groupIdcom.alibaba.cloud.ai/groupId artifactIdspring-ai-alibaba-starter/artifactId version1.0.0-M6.1/version /dependency配置如下spring.ai.dashscope.api-key你的百炼API-KEY spring.ai.dashscope.chat.options.modelqwen-plus如果要用智谱 AI引入spring-ai-starter-model-zhipuai之后配置spring.ai.zhipuai.api-key和spring.ai.zhipuai.chat.options.modelglm-4-plus使用方式基本一致。如果要在本地环境快速验证逻辑我建议用 Ollama 跑一个开源模型比如 qwen2.5 或者 deepseek-r1 的蒸馏版。本地跑的好处是免费、不涉及敏感数据外传、调试方便缺点是模型能力弱一些长链路规划容易翻车。生产环境还是用云端大模型更稳。spring.ai.ollama.base-urlhttp://localhost:11434 spring.ai.ollama.chat.options.modelqwen2.5:7b不管接哪家模型上面那段确认循环的代码都不用改。因为确认机制作用在 tool_calls 这一层而 tool_calls 是各家模型都遵循的通用行为协议。4. 生产环境还要考虑的事4.1 超时、会话恢复与自动取消确认机制引入了一个新问题用户一直不点确认怎么办Agent 的整个执行链路会一直挂起占用后端线程资源这在生产环境是不能接受的。我当时的做法是给确认环节加一个超时时间。从把确认信息推送给用户开始计时比如 5 分钟内用户没有任何操作系统自动判定为“取消”并把超时结果作为工具响应回传给模型让模型知道用户没有确认可以自行生成后续回复。public ConfirmDecision confirmWithTimeout(ToolCall toolCall, Duration timeout) { try { CompletableFutureConfirmDecision future CompletableFuture.supplyAsync(() - confirm(toolCall)); return future.get(timeout.toMillis(), TimeUnit.MILLISECONDS); } catch (TimeoutException e) { return ConfirmDecision.cancel(用户确认超时自动取消); } }同时还要考虑会话恢复的问题。用户可能点了确认框之后切走了隔了十分钟才回来这时候后端请求早就超时了。可以把待确认的 tool_calls 持久化到 Redis用户在任意设备上都能看到待办确认列表点一下再继续执行。这就从“同步阻塞式确认”变成了“异步工单式确认”体验完全不同。4.2 Agent 记忆让“问清楚”更省事确认机制虽然安全但频繁确认会让用户产生疲劳。解决这个问题的最好方式是引入 Agent 记忆机制让系统记住用户的偏好和已确认的历史操作。Spring AI 提供了 ChatMemory 和 ChatClient 的 Advisor 机制可以把对话历史持久化到 Redis 或数据库。实现确认记忆的思路是在用户确认某个操作时记录当前工具调用和用户的确认行为后续如果再遇到相同领域且参数相似的操作直接参考用户历史偏好决定是否还需要确认。比如用户前三次删订单时都点了“确认执行”并且明确说过“删订单不用再问我了”那 Agent 在以后的会话中针对删订单操作就可以走自动执行只在删除后通知用户。反之如果用户对某个操作从来没有确认过那每次都必须弹确认框。这里的判断逻辑不要做得太激进。我的原则是涉及不可逆操作的即使有记忆默认也要确认除非用户显式授权过“同类操作免确认”。4.3 从单 Agent 到多 Agent 协同单 Agent 的确认机制搞清楚之后多 Agent 的场景会更复杂。比如一个主 Agent 负责理解用户诉求拆解任务后分发给子 Agent子 Agent 再去调用工具。这时候确认信息可能散落在多个子 Agent 里用户的体验是碎片化的。我的建议是设计一个“确认中心”。所有子 Agent 在真正执行有风险的工具调用前都把确认请求抛到一个统一的确认中心由确认中心负责汇总、排序、展示给用户。用户看到一个集中的待确认列表可以批量确认或者逐条处理。确认结果再通过消息队列回传给各个子 Agent。这种架构能让整个系统的交互体验保持一致同时各个 Agent 的自主性也不会被破坏。Spring AI 里实现起来也不难把确认处理器封装成一个公共组件注入各个 Agent 即可。5. 常见问题与排查技巧实录5.1 “工具调用了但用户根本没确认”怎么排查如果你发现 Agent 直接执行了工具没有走确认流程最可能的原因是internalToolExecutionEnabled没有设置成false。Spring AI 的高层封装里很多快捷方法默认会开启自动执行这时候工具调用发生在框架内部你的确认代码根本没机会介入。排查方法很直接在工具方法里加一个日志看调用栈是从哪个入口进去的。如果调用栈里没有经过你的确认方法说明确认逻辑被跳过了去检查配置。5.2 模型不返回 tool_calls 怎么办有些模型在上下文不完整的时候可能直接把要求的工具“描述”出来而不是走 tool_calls 通道。比如它回复“我可以帮你查订单请问你需要查哪个订单”这种回退到普通对话的情况本质上是因为模型不确定参数选择了向用户追问。这其实也不算坏事它自己已经在“先问清楚”了。但如果你发现模型明明知道参数却一直不调用工具问题多半出在工具描述上。Spring AI 会自动把方法的 Javadoc 或 Tool 注解的描述生成给模型你必须把工具用途、参数格式、示例都写清楚给模型看。工具描述不清晰模型宁愿不调用也不敢猜。5.3 确认时上下文丢失问题一个很隐蔽的坑是在确认循环里调用模型生成确认解释时用了新的 Prompt导致原本的对话上下文丢失。进入确认循环后模型可能忘了用户最初的需求生成一些莫名其妙的回复。解决方法是把原始 messages 列表透传给确认环节生成确认解释时也要带上原始上下文而不是只塞一段工具名和参数。我踩过这个坑之后把所有跟模型交互的入口都收敛到一个方法里统一管理上下文这个问题就再没出现过。5.4 人机交互效率度量团队内部在评审这套确认机制时有人提了一个很实在的问题频繁确认会不会拖慢 Agent 的效率后来我引入了一个简单的度量指标确认响应间隔。测量方式就是统计用户从看到确认提示到做出选择之间的按键间隔时间如果这个时间长期在 10 秒以上说明确认信息不够清晰用户需要反复阅读理解如果普遍在 1-2 秒说明确认信息设计得足够好用户一眼就能做出判断。这个数据能从产品层面反向推动确认文案和交互设计优化。这其实揭示了 Agent 人机交互的一个核心矛盾安全和效率的平衡。确认流程设计得越友好用户决策成本就越低这个平衡点自然就越好。上面这一套“先问清楚再行动”的确认机制是我在 Spring AI 实战项目里反复调出来的方案。第一版上线时用户对 Agent 意见很大后来加了确认机制信任度明显上来了。我个人体会是Agent 产品落地的时候与其追求“全自动”不如先把“什么时候该问、怎么问清楚”这件事做扎实。如果你也在做类似的 Agent 交互设计建议先把确认链路跑通再逐步放开自动执行的场景。那种“既能自主干活、又能在关键处跟你确认”的 Agent才是真正能在生产环境里持续创造价值的形态。