
ChatClient、ChatMemory、ToolCallback这些类名在 Spring AI 1.0.x / 1.1.x 里是稳定的2.0Spring AI Alibaba 体系里包名调整过但我下文用的是保守写法升级版本时留意一下就行。Spring AI 支持 Agent 人机交互——让 AI 先问清楚再行动最近在公司搞了一个内部小工具基于 Spring AI 做了一个带 Agent 能力的交互式应用。起初目标很简单让用户用自然语言去查数据、调接口、做简单分析AI 直接完成整套动作。但做着做着发现一个问题——AI 太莽了。用户随口说一句把上个月的数据分析一下它可能直接跑一个耗时五分钟的报表任务结果用户其实只想要一张走势图用户说帮我约个会议室它可能默认订了最大的那间。不是模型不行而是缺少一个关键环节先问清楚再行动。这个项目说白了就是把澄清与确认这个交互环节做进 Agent 的执行链路里让 AI 在动手干活之前先把不明确的点问出来、把关键假设确认掉。今天这篇就完整拆一下我是怎么设计的从思路、代码到踩坑一次性讲透。如果你也在用 Spring AI 开发 Agent或者正准备做类似的人机协作功能这篇文章应该能帮你省不少试错时间。1. 整体设计思路Agent 先问清楚再干活1.1 为什么先问清楚是刚需而不是加分项先聊一个现象。很多 Agent 项目跑在 demo 阶段很好看一上真实业务就翻车翻车的原因通常不是模型答错了而是任务边界没对齐。大模型本身有很强的意图补全能力用户只说半句话它就会自动脑补另一半。这种能力在聊天场景是优点在任务执行场景就是隐患——它会把一个模糊的请求直接翻译成一个具体的、可能错误的执行计划。举个例子。你在 Agent 里接了一个数据库查询工具用户说看看这个月销售情况。模型可能直接执行一个全量统计聚合十几张表跑出来一堆数据。但用户的真实意图可能是看一下华东区销售是否达标还可能想按周看趋势。如果模型动手前先问一句您想看哪个区域按周还是按月汇总后面的一切都会准确得多。所以这个项目的核心理念就一句话把澄清意图、确认关键参数作为一个独立的、有优先级的前置环节。Agent 不是拿到请求立刻执行而是先进入一个意图确认状态把缺失的信息补完再进入动作执行状态。1.2 三条设计原则我梳理这个方案时给自己定了三条硬性原则。第一澄清不是聊天而是必要的执行前置。澄清阶段必须与普通多轮对话分开它有自己的状态流转不能被用户随便一句话带偏。用户说算了不查了也好办——直接终止本轮任务用户说就按你说的来就继续往下走。第二AI 只问有实际价值的问题。不能每次拿到模糊请求都在那问七八个问题用户会烦。要利用模型对意图的推断能力只把不确定的关键参数提取出来问。能猜的、有默认值的、可以从上下文推断的尽量不要问。第三用户确认之后要记住了。同一个用户下次再说类似请求时这些参数应该自动复用。这需要会话记忆的配合不能每次都是全新开始。1.3 整体架构澄清 执行两个阶段一条链路整体架构其实不复杂左边是用户输入中间是一个决策模块我用的是大模型 结构化输出根据当前状态决定走澄清还是执行右边是工具调用层和系统交互层。如果把整套流程画到代码层面大致是这样一个链路用户输入 ↓ ChatMemory会话记忆存储 ↓ 决策模块ReAct 循环之前先做一次意图完整性判断 ↓ 判断结果信息不足 → 进入澄清节点AskClarification ↓ 判断结果信息完整 → 进入执行节点调工具、跑任务、生成答复我最终是把澄清节点做成了一个特殊的 Agent 步骤不在模型外部硬编码规则而是让模型在 ReAct 循环中自主决定是否需要澄清。这样设计的好处是澄清能力是可扩展的不同类型的任务都能复用这套逻辑。1.4 技术选型为什么选 Spring AI 而不是 LangChain4j 或其他框架说实话Java 生态里做 AI Agent 的框架选择不算多。我主要对比了 Spring AI、LangChain4j 和自研封装三个方案。Spring AI 的优势很明显它是 Spring 官方生态的一部分与 Spring Boot、Spring Cloud 的集成是天然的。依赖注入、配置管理、监控体系都能直接用上对已有 Spring 技术栈的团队来说学习成本很低。而且 Spring AI 1.0 之后抽象做得相当不错ChatClient 的流式接口、ChatMemory 的会话管理、ToolCallback 的工具注册都能比较自然地组合起来。LangChain4j 功能也很全但在 Spring 生态的契合度上还是差一点。自研封装的话灵活性最高但工作量大而且对话管理、记忆管理这些基础设施自己重写一遍太费劲了。综合下来我选了 Spring AI而且后来发现 Spring AI 提供了 ToolCalling 的支持Agent 的基础循环写起来比我预想的顺利。我这边的版本是 Spring AI 1.0.0 GA 之后的版本如果你用的是 0.8.x 的老版本API 差异会比较大建议直接升级。2. Agent 核心机制从 ChatClient 到模型调用与工具注册2.1 先分清几个概念Agent、模型、工具很多人一开始会把 Agent 和模型搞混。模型只是一个大脑Agent 是围绕模型搭建的具备行动能力的系统。打个比方模型是人的思维Agent 是人的身体——有思维没有身体只能想象不能行动有了身体才能动手干活。在 Spring AI 里Agent 的核心机制是模型调用 工具注册。模型负责理解意图、规划步骤工具负责实际执行。Spring AI 提供了一个统一的 ToolCallback 抽象把模型想调用工具这件事变成了框架帮你调用工具并把结果传回给模型。打个比方方便理解模型就像公司里的项目负责人工具就像各职能部门的执行人员。项目负责人不会自己写代码、查数据库它只需要发出指令执行人员把结果反馈回来再由项目负责人决定下一步怎么安排。这就是 ReActReasoning Acting循环。2.2 ChatClient 统一入口同步、流式、结构化输出Spring AI 1.0 之后最常用的就是 ChatClient。它把模型调用封装得非常简洁而且支持流式输出、结构化输出、工具调用。我在项目里用到的核心代码大概是这样的Service public class AgentService { private final ChatClient chatClient; private final ChatMemory chatMemory; public AgentService(ChatClient.Builder builder, ChatMemory chatMemory) { this.chatClient builder .defaultSystem(TaskPrompt.SYSTEM_PROMPT) .build(); this.chatMemory chatMemory; } public AgentResponse execute(String userId, String userMessage) { // 1. 拼接记忆上下文 ListMessage messages chatMemory.get(userId, 10); UserMessage currentMessage new UserMessage(userMessage); messages.add(currentMessage); // 2. 调用模型带工具 String modelResponse chatClient.prompt() .messages(messages) .options(ChatOptions.builder() .model(qwen-plus) .temperature(0.2) .build()) .tools(new MyDatabaseTool(), new MeetingRoomTool()) .call() .content(); // 3. 保存记忆 chatMemory.add(userId, currentMessage); chatMemory.add(userId, new AssistantMessage(modelResponse)); return parseResponse(modelResponse); } }核心就几个点。一是 messages 要带上历史ChatMemory 帮忙管理二是 temperature 在 Agent 场景我调得比较低0.2 左右因为 Agent 执行任务需要确定性不需要太多发散三是 tools 方法传入工具实例框架会自动把它们转成模型能识别的 function calling 格式。这里要特别说一下 temperature。很多初学者会忽略它。Agent 任务和写诗聊天不一样Agent 需要的是稳定、可控的执行模型输出越保守越好。我在实盘里把 temperature 从默认值往下调发现工具调用的准确率明显提升——因为模型乱猜工具参数的情况少了很多。2.3 工具注册的三种方式Spring AI 里注册工具有几种常见姿势我这里列一下测评结论。第一种是Tool注解方式。直接在 Service 方法上加Tool注解Spring AI 会自动扫描并注册Service public class OrderDataTool { Tool(description 查询指定时间范围内的订单数据返回订单总金额和订单数) public String queryOrderStats(String startDate, String endDate, String region) { // 实际查询逻辑 return orderMapper.summaryByRegion(startDate, endDate, region); } }第二种是ToolCallback方式。更底层一点可以手动控制 tool 的定义、输入输出 Schema适合复杂工具场景。第三种是直接在.tools()方法里内联定义 Function。适合一次性使用的场景。我最终是混着用的数据查询类工具用Tool注解因为要写的方法多注解方式最省事需要精细控制 schema 的用ToolCallback一次性的小工具就内联。2.4 模型不可靠怎么兜底工具调用依赖模型的结构化输出能力。模型再强也有抽风的时候——参数格式错、工具名拼错、甚至直接编一个不存在的工具名。Spring AI 框架会做一定程度的解析容错但不够我自己加了一层兜底逻辑if (modelResponse null || modelResponse.isBlank()) { // 模型没有给出任何内容重试一次 return retryOnce(userId, userMessage); } // 检查是否含有工具调用标记 if (!modelResponse.contains(toolCall) !modelResponse.contains(需要查询)) { // 模型可能没有正确触发工具调用需要提示模型重新思考 }这层兜底不能完全依赖框架一定要自己加。真实场景里模型偶尔会跳过工具直接编答案特别是被问了边界问题又没有足够指令约束的时候。3. 先问清楚的落地实现意图确认节点3.1 让模型自己判断信息够不够要不要问这是整个项目的核心模块。我在设计意图澄清Clarification时没有用传统规则引擎 意图分类的方式而是完全交给大模型判断——但是通过一个结构化输出的强约束让模型在执行工具和发起澄清之间明确二选一。说实话一开始我试过用if (message.contains(?))这类规则去判断效果惨不忍睹。用户不是程序员不会规规矩矩地提问题。比如用户说帮我订个会议室这句话本身没有问号但缺少时间、地点、人数三个关键参数显然应该进入澄清流程。用规则没法处理这种隐含的不确定性只有让模型判断才行。我在系统提示词里加了一段约束这句提示词是这套机制的灵魂你是企业办公助手你的职责是帮助用户完成各类操作任务。 在执行具体操作之前如果用户请求中缺少完成操作所必需的关键参数如时间、地点、对象、具体范围等 你必须先向用户提问澄清不能擅自假设参数值。 如果你认为信息已经足够完整则直接执行操作。然后我在运行时做两件事用结构化输出把模型的决策强制压成一个固定的 JSON 结构根据 JSON 里的action字段决定下一步走澄清分支还是执行分支。3.2 结构化输出定义澄清请求的统一协议定义一个 recordpublic record TaskDecision( String action, // clarify 或 execute String taskType, // 任务类型描述: query_order, book_meeting ListString missingParams, // 缺失的关键参数列表 ListString questions, // 要向用户提问的问题列表 String taskSummary // 对用户请求的最终理解 ) {}然后让模型返回。这段代码是核心String decisionJson chatClient.prompt() .messages(messages) .options(ChatOptions.builder() .model(qwen-plus) .temperature(0.1) .responseFormat(json_object) .build()) .call() .content(); TaskDecision decision objectMapper.readValue(decisionJson, TaskDecision.class); if (clarify.equals(decision.action())) { return new AgentResponse() .type(ResponseType.CLARIFICATION) .questions(decision.questions()) .taskSummary(decision.taskSummary()); } else { // 进入执行逻辑 return executeTask(decision, userId, userMessage); }这里有一个细节结构化输出要确保模型百分百返回合法 JSON否则解析会挂。我在 Spring AI 的ChatOptions里配置了responseFormat(json_object)这在国内几个主流模型上都支持不同模型可能参数名略有不同但思路是一样的。3.3 合理设计提问清单别让用户觉得 AI 是个复读机澄清节点最容易被做成反人类体验。如果每次一问就是五六个问题用户会骂娘。我在设计时强行加入了一条约束一次提问不超过 3 个问题并且优先问最关键的那个。这个约束也用提示词实现如果你决定需要向用户澄清请遵循以下要求 1. 最多提出 3 个最关键的问题 2. 优先提出缺了它任务完全无法执行的参数 3. 对于可有可无的参数自行设定默认值并在回复中说明 4. 如果上一个澄清回合用户已经回答过某些问题不要再重复提问直接采用已获得的信息。这条约束的效果非常明显。用户第一次说帮我订个会议室模型会问请问需要约哪几天大概几个人用户回答了明天下午十人左右模型就不会再问地点而是用默认的公司三层会议室继续确认。这样交互负担小很多。3.4 把澄清结果写回记忆同一个用户不需要问两遍这里靠 ChatMemory 实现。每一次澄清对话都会写入会话记忆下一个回合模型在读历史上下文时能看到用户在前面的回答。这样即使落到执行阶段模型也能从上下文里拿到答案。// 澄清追问后把用户的回答写入记忆 chatMemory.add(userId, new UserMessage(userAnswer)); // 引导模型基于新信息重新做决策 String finalDecision makeDecision(userId, originalRequest \n用户补充信息: userAnswer);这个用户补充信息的拼接方式是我实测比较有效的一种也可以直接把整段对话丢给模型让它自己抽。两种方式各有取舍前者更快更可控后者更自然但偶尔会漏。3.5 澄清链路完整代码演示把整套逻辑串起来其实就是下面这个循环。我把它封装成一个状态机每次用户输入都会进入这个方法public AgentResponse handleUserInput(String userId, String userMessage) { // 1. 获取当前会话历史 ListMessage messages chatMemory.get(userId, 10); // 2. 构造决策分类请求 String decisionPrompt 根据用户请求和对话历史判断是否需要对用户进行澄清。 当前用户请求: %s 请输出 JSON{action:clarify 或 execute, ...} .formatted(userMessage); // 3. 调用模型获取决策 TaskDecision decision getDecision(messages, decisionPrompt); // 4. 根据决策分支处理 if (clarify.equals(decision.action())) { // 进入追问模式 return AgentResponse.askQuestions(decision.questions()); } // 5. 执行模式拼装工具调用 return AgentResponse.execute() .taskType(decision.taskType()) .taskSummary(decision.taskSummary()) .result(invokeAgentLoop(userId, userMessage)); }4. Agent 执行模块工具调用循环与异常处理4.1 ReAct 循环的 Spring AI 写法决策完成后进入执行模块。执行模块标准做法是 ReAct 循环让模型决定调用哪个工具获得工具结果后再次让模型总结和决定下一步。Spring AI 的.tools()实际上已经帮我们封装了 ReAct 循环的大部分逻辑。当你把多个工具传给chatClient.prompt().tools(...)后框架会自动把模型返回的工具调用信息解析出来执行对应工具把结果拼回对话上下文再让模型继续——循环往复直到模型不再请求调用工具而是给出最终回答。这段逻辑如果自己写大概要几十行甚至上百行循环控制代码而且容易写漏。Spring AI 框架帮我们省了这一步。我这边要做的主要是两件事注册工具、捕获工具执行异常。4.2 工具实现的最佳实践入参收敛与结果可读化工具方法的设计直接影响模型调用工具的准确率。我踩了很久的坑后总结出三条经验。一是入参尽量收敛。工具参数越少模型判断越准。请把五个参数的方法拆成两个两参数的小工具按需调用。Tool(description 查询指定日期范围内某区域的订单总额) public String queryOrderByRegion(String startDate, String endDate, String region) { // 只做3个参数的查询简单可靠 }二是方法描述要人话化。模型的工具选择靠 description 判断description 写得不够清楚模型会选错工具。查询订单和查询订单并汇总统计在模型眼里可能是同一个工具描述里要把边界写清楚。三是返回结果尽量精简并预格式化。工具返回内容会重新交给模型做总结。如果你的工具返回 100 行 JSON模型既容易总结错也浪费 token。我会在工具里直接做好聚合返回一段短小精悍的文本。return String.format(2025年1月华东区订单共 %d 笔总金额 %.2f 元环比增长 %.1f%%, orderCount, totalAmount, growthRate);4.3 执行过程中的异常与重试机制工具调用可能失败——数据库连不上、参数校验不过、外部 API 超时。失败时不能让整个链路崩溃要设计错误反馈回路。Spring AI 工具执行抛异常后框架会把异常信息传给模型让模型决定如何应对。这其实是好事——模型可以根据异常信息调整参数重新调用或者向用户说明失败原因。我配合做了三件事第一在工具方法内部 catch 业务异常必要时把它包装成正常返回。比如查不到数据时返回暂无数据比抛异常给模型处理更自然。第二对时间类参数做兜底转换。用户经常说下周二月底这种模糊描述数据库可听不懂。我在工具内部内置一个自然语言时间解析器解析失败时返回错误说明模型会引导用户重新提供。第三设置超时控制。每个外部调用最多等待 5 秒防止 Agent 卡死。Spring AI 的默认超时配置结合 RestClient / WebClient 的全局超时一起设置。spring: ai: openai: chat: options: timeout: 30s国内模型的底层走的是类似 OpenAI 的兼容接口超时配置基本同理。这个 30 秒是整体生成超时不是单次工具调用工具调用本身的超时我在代码里单独控制。4.4 流式输出比一次性返回体感好太多如果 Agent 执行耗时长最好用流式输出。用户等待时最怕看到无响应。我最终把 Agent 的回复改成流式用户能看到 AI 正在打字、正在思考、正在操作。public FluxString streamHandle(String userId, String userMessage) { return chatClient.prompt() .messages(buildMessages(userId, userMessage)) .tools(...) .stream() .content(); }流的每个 chunk 都直接推给前端前端用 SSE 或 WebSocket 接收。体感上用户感觉 AI 在实时干活而不是转圈等半天。5. 这项目踩过的坑Spring AI 与 Agent 的五个大坑5.1 坑一模型交互协议理解不到位导致工具调用失败这个坑我印象最深。Spring AI 工具调用的底层交互是框架把工具定义转成模型需要的 JSON Schema 格式发给模型模型返回一个结构化的 tool call 指令框架解析指令、执行工具、把结果传回模型。有一次我自定义了一个很复杂的工具参数参数里带嵌套对象结果模型调用时参数怎么都对不上。排查半天发现不是模型的问题是我的工具参数 schema 写得太复杂模型根本不知道该怎么填。后来我把嵌套对象拆掉改成平铺的字符串参数一瞬间就通了。经验教训工具参数越简单越好尤其嵌套层级别超过一层。实在需要结构化参数就传 JSON 字符串让工具内部解析。5.2 坑二会话记忆的 token 膨胀Agent 场景比单纯聊天更容易触发 token 膨胀。因为 ReAct 循环里多轮工具调用结果都会写进上下文很快就把上下文窗口塞满了。我一开始没管这个跑了二十多轮之后发现模型开始忘记用户的最初意图。后来上了 ChatMemory 的滚动窗口机制只保留最近 10 条消息。对于已经完成的工具调用结果在下一个任务开始时主动清理。实现方式chatMemory.clear(userId); // 每个新任务开始时清空上一轮的工具调用记录 chatMemory.add(userId, new UserMessage(开始新任务 newTask));5.3 坑三模型乱编工具参数当模型没有把握时它会根据猜的数值填参。比如用户说查下上周的数据模型直接填了个2025-02-10 到 2025-02-16这其实是不确定的范围。应对方式是费曼式校验在工具里加参数合法性检查非法参数直接返回提示。例如在时间范围工具里加if (startDate null || endDate null) { return 日期参数缺失请提醒用户提供具体的开始和结束日期; } if (endDate.isBefore(startDate)) { return 结束日期不能早于开始日期请确认日期范围; }工具返回的信息会引导模型反思并修正自己的行为比自己写一堆正则去拦更有效。5.4 坑四流式输出与工具调用的冲突前面说流式输出体验好。但当你使用工具调用时流式输出会有个坑——模型的第一个chunk可能是一个工具调用指令而不是用户可读的文本。如果前端直接把所有 chunk 渲染出来用户会看到一坨 JSON非常难看。解决方案是在前端做分流起始阶段判断 chunk 内容如果是工具调用标记就抛弃或渲染成AI 正在查询数据...的提示只有最终文本内容才展示给用户。这个处理不只前端要做后端也要先过滤。我的做法是把工具调用的流式输出拆成两个阶段第一阶段无声调用工具第二阶段再开头把工具结果和模型总结一起流式返回。5.5 坑五国内大模型接口差异我实际对接过程中发现Spring AI 对国内模型的适配往往走的是 OpenAI 兼容接口但各家在tools、response_format、temperature上的细节各有不同。最好先用官方 demo 跑通再切换模型。不要在原模型调通的情况下直接切代码里的 model name 就以为能跑。我在不同模型间切换时会用一个小测试类专门验证Test void testToolCallingCompatibility() { String result chatClient.prompt() .user(帮我查一下今天的日期) .tools(new DateTool()) .call() .content(); assertNotNull(result); System.out.println(result); }这个测试代码可以当成工具调用兼容性探针切换任何模型前先跑一遍省得在集成调试时浪费时间。6. 优化与思考Agent 的未来是把澄清做成基础设施这个项目实际跑起来之后我的一个体会是人机协作的最高境界不是 AI 替人做事而是 AI 在不确定时主动问人、在确定时果断执行形成一个人定边界、AI 提效的闭环。澄清机制本质上是在打造一个信任边界。用户信任 AI是因为 AI 在动手前会把不确定的地方说出来。哪怕它最后没能完成整个任务用户至少知道 AI 理解到了哪一步、卡在了哪里。从产品角度看下一步我会做这几件事第一把澄清环节做成可配置的策略包。不同任务类型默认问不同的问题业务方可以直接在配置中心调整不需要改代码。第二把澄清纳入安全审计链路。每一次AI 查询了什么、用户确认了什么、AI 最终干了什么都留痕。这在权限敏感的场景比如查询工资数据、调用支付接口尤其重要。第三探索澄清 推理时扩展的结合。当用户给出一个复杂任务时AI 不仅要问清楚边界还要展示自己的拆解步骤用户可以直接在拆解结果上修修改改而不是一条条回答提问。比如用户说帮我分析用户流失原因AI 先给出分析框架——数据维度、时间维度、流失定义——让用户直接在这上面调整比问三个问题高效得多。这个方向如果再深挖就是现在 Agent 领域很热的 Planning 与 Human-in-the-Loop 的结合。Spring AI 已经把最底层的模型调用、工具注册、记忆管理都封装好了我们团队真正要花心思的地方反而在交互链条如何设计上。技术永远只是底座真正决定一个 Agent 好不好用的是你把人机互动的那条线画得够不够自然。