以企业智能客服为例手把手搭建有记忆、能调工具、输出可靠的 AI 服务环境说明Spring AI 1.1.x2025.11 GA· Spring Boot 3.3 · Java 17 · 模型私有部署 Qwen3.5vLLM OpenAI 兼容接口前言三行代码的 Demo 上了生产就崩你可能见过 Spring AI 最简单的 Hello WorldString answer chatClient.prompt() .user(你好) .call() .content();三行优雅跑通了。然后你兴冲冲搬到生产环境——让模型返回 JSON用String接收后解析上线第三天崩了。原因模型心情好的时候返回json {...}带了 Markdown 代码块直接JsonParseException。注册了一个查询订单的工具测试时好使生产偶发工具调用静默失败模型直接编了个订单状态出来。对话十分钟用户说帮我改一下上面那个订单的地址模型问什么订单这三个问题对应三件套的三个价值Structured Output 解决格式可靠性Tool Calling 解决能力扩展Memory 解决状态延续。它们不是三个孤立功能是一个可信 Agent 的三条腿缺一条就跛。本文以企业智能客服为主线场景从 v1仅 Structured Output→ v2加入 Tool Calling→ v3加入 Memory生产可用一步步组合落地。文章所有代码对接私有部署的 Qwen3.5走 vLLM OpenAI 兼容接口关于 vLLM 部署可参考我之前的文章[vLLM 0.18 生产部署最佳实践]。版本迁移速查1.0 → 1.1 Breaking Changes很多读者照着旧文章写报错了不知道为什么——因为 Spring AI 1.1 改了大量 API。先收藏这张表功能1.0 旧写法1.1 新写法备注工具注册FunctionCallbackTool注解旧方式仍兼容但已标记废弃Structured OutputBeanOutputConverter手动解析.entity(MyRecord.class)1.1.1 新增原生支持MemoryChatMemory直接注入Advisors API架构彻底重构工具调用拦截无ToolCallAdvisorhook1.1.1 新增对话 ID无标准化方式CONVERSATION_ID_KEY参数多会话隔离关键一、依赖配置1.1 pom.xml使用 BOM 统一管理版本避免依赖冲突这是 Spring AI 初学者最常踩的第一个坑dependencyManagement dependencies dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-bom/artifactId version1.1.1/version typepom/type scopeimport/scope /dependency /dependencies /dependencyManagement dependencies dependency groupIdorg.springframework.ai/groupId !-- 注意私有部署 Qwen 走 OpenAI 兼容接口用这个 starter 即可 -- artifactIdspring-ai-starter-model-openai/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId !-- Memory 持久化用 Redis生产环境必须不能用 InMemory -- /dependency /dependencies1.2 application.ymlspring: ai: openai: # 指向你私有部署的 vLLM 服务 base-url: http://your-vllm-host:8000/v1 api-key: EMPTY # vLLM 默认不校验 key随便填 chat: options: model: Qwen3.5-7B-Instruct # 与你 vLLM 启动时的 --served-model-name 一致 temperature: 0.7 max-tokens: 2048 data: redis: host: localhost port: 6379⚠️踩坑记录base-url必须包含/v1很多人写到 host:port 就停了会报 404。二、Structured Output让 LLM 返回你能用的数据2.1 为什么字符串响应不够用先看问题代码// ❌ 脆弱的写法 String raw chatClient.prompt() .system(返回 JSON 格式的订单状态) .user(查询订单 ORD-20240401) .call() .content(); // 这行有一定概率崩溃 OrderStatus status objectMapper.readValue(raw, OrderStatus.class);崩溃原因不同模型、不同版本对返回 JSON的理解不同。Qwen 有时会返回好的以下是订单状态 json {orderId: ORD-20240401, status: SHIPPED}这带了自然语言前缀和 Markdown 代码块objectMapper.readValue 直接抛异常。你可以写正则来处理但这是跟模型行为打补丁不是根治。 ### 2.2 三种 Structured Output 方式对比 | 方式 | 写法复杂度 | 稳定性 | 适用场景 | |------|-----------|--------|---------| | .entity(Class) | ⭐ 最简 | ⭐⭐⭐⭐ | 1.1.1 推荐日常使用 | | BeanOutputConverter | ⭐⭐⭐ | ⭐⭐⭐ | 需要自定义 Schema 描述时 | | StructuredOutputValidationAdvisor | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | 高可靠场景校验失败自动重试 | ### 2.3 方式一.entity() 原生结构化推荐 首先定义响应结构用 Java Record java // 订单状态响应 public record OrderStatusResponse( String orderId, String status, // PENDING / SHIPPED / DELIVERED / CANCELLED String estimatedArrival, // 预计到达时间如 2024-04-05 ListString trackingEvents // 物流轨迹列表 ) {}然后在 Service 中直接使用Service public class OrderQueryService { private final ChatClient chatClient; public OrderQueryService(ChatClient.Builder builder) { this.chatClient builder .defaultSystem( 你是一个订单查询助手。 请严格按照用户要求的格式返回信息不要添加多余的解释。 ) .build(); } public OrderStatusResponse queryOrderStatus(String userMessage) { return chatClient.prompt() .user(userMessage) .call() .entity(OrderStatusResponse.class); // ✅ 类型安全告别手动解析 } }Spring AI 内部会自动将OrderStatusResponse的结构生成 JSON Schema注入到 System Prompt并在拿到响应后自动反序列化。2.4 方式二StructuredOutputValidationAdvisor高可靠场景对于关键业务场景比如财务、合规不能接受任何格式错误可以加校验重试Bean public ChatClient reliableChatClient(ChatClient.Builder builder) { return builder .defaultAdvisors( // 校验失败时最多重试 3 次重试时将校验错误信息反馈给模型 StructuredOutputValidationAdvisor.builder() .maxRetries(3) .build() ) .build(); }⚠️踩坑记录加了StructuredOutputValidationAdvisor后每次重试都是一次完整 LLM 调用Token 消耗最多增加 3 倍。建议只在关键路径上使用不要全局加。2.5 嵌套结构与 Optional 字段处理// 支持嵌套 Record public record CustomerServiceResponse( String answer, // 回答内容 ListString calledTools, // 调用了哪些工具 Nullable String recommendation, // 可能为 null 的建议 ConfidenceLevel confidence // 枚举 ) {} public enum ConfidenceLevel { HIGH, MEDIUM, LOW }⚠️踩坑记录枚举字段在某些模型上会被返回为小写如highSpring AI 默认大小写不敏感但如果你自定义了ObjectMapper注意加MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS。三、Tool Calling给模型装上手和脚3.1 Tool Calling 的完整生命周期很多文章只讲怎么注册工具但不讲工具调用的完整循环——这是理解 Agent 的关键用户输入 ↓ LLM 分析需要调用工具选哪个工具参数是什么 ↓ 返回 tool_calls不是最终答案是调用指令 ↓ Spring AI 拦截执行工具方法Java 代码 ↓ 工具执行结果作为新消息返回 LLM ↓ LLM 结合工具结果生成最终自然语言回答 ↓ 用户看到答案这个循环可能执行多次比如查了订单发现需要再查一次物流详情。Spring AI 1.1 的ToolCallAdvisor负责管理这个循环。3.2 注册工具Tool 注解1.1 推荐方式Component public class CustomerServiceTools { private final OrderRepository orderRepository; private final PointsRepository pointsRepository; // ✅ description 是工具调用的灵魂——模型靠这段文字决定要不要调用这个工具 Tool(description 查询指定订单的当前状态和物流信息。 当用户询问订单到哪了发货了吗什么时候到时调用。 参数 orderId 必须是完整的订单号格式ORD-开头的字符串 如果用户只说我的订单请先让用户提供订单号。 ) public OrderStatusResponse queryOrderStatus(String orderId) { return orderRepository.findById(orderId) .map(this::convertToResponse) .orElseThrow(() - new ToolExecutionException( 订单不存在 orderId 请确认订单号是否正确 )); } Tool(description 修改订单的收货地址。 仅当用户明确要求修改地址时调用不要在未经确认的情况下修改。 注意只有状态为 PENDING待发货的订单可以修改地址 已发货SHIPPED或已完成DELIVERED的订单无法修改。 需要完整地址省/市/区/街道/门牌号。 ) public String updateDeliveryAddress(String orderId, String newAddress) { Order order orderRepository.findById(orderId) .orElseThrow(() - new ToolExecutionException(订单不存在 orderId)); if (!PENDING.equals(order.getStatus())) { // ✅ 可预期的业务失败返回字符串让 LLM 转述而不是抛异常 return String.format( 无法修改订单 %s 当前状态为「%s」只有待发货的订单可以修改地址, orderId, order.getStatus() ); } order.setDeliveryAddress(newAddress); orderRepository.save(order); return 地址修改成功新地址 newAddress; } Tool(description 查询用户的积分余额和最近积分记录。 当用户询问我有多少积分积分够吗积分怎么用时调用。 userId 从系统上下文获取无需用户提供。 ) public PointsInfo queryPoints(String userId) { return pointsRepository.findByUserId(userId); } private OrderStatusResponse convertToResponse(Order order) { return new OrderStatusResponse( order.getOrderId(), order.getStatus(), order.getEstimatedArrival(), order.getTrackingEvents() ); } }3.3 工具描述好坏对比这是最影响工具调用准确率的因素却是大多数文章忽略的细节。// ❌ 坏的 description模型不知道什么时候该调用参数怎么传 Tool(description 查询订单) public OrderStatusResponse query(String id) { ... } // ✅ 好的 description触发时机 参数来源 边界条件都说清楚 Tool(description 查询指定订单的当前状态和物流信息。 触发时机用户询问订单进度、发货状态、预计到达时间时调用。 参数说明orderId 是ORD-开头的订单号从对话上下文或用户输入中提取。 如果用户没有提供订单号先向用户索要不要猜测。 ) public OrderStatusResponse queryOrderStatus(String orderId) { ... }实测对比在 Qwen3.5-7B 上好的 description 比坏的工具调用成功率高约 25-30%10次测试中坏的约 7 次正确好的约 9-10 次正确。3.4 ToolCallAdvisor拦截监控工具调用Spring AI 1.1.1 新增了ToolCallAdvisor的 hook 机制可以在工具调用前后插入自定义逻辑Configuration public class ToolCallConfig { Bean public ToolCallAdvisor toolCallAdvisor(MeterRegistry meterRegistry) { return ToolCallAdvisor.builder() .beforeToolCall((toolDef, args) - { // 记录调用日志 log.info([Tool调用开始] tool{}, args{}, toolDef.name(), args); // 记录 Prometheus 指标 meterRegistry.counter(tool.call.total, tool, toolDef.name()).increment(); // 可以在这里做限流、鉴权等前置检查 }) .afterToolCall((toolDef, result) - { log.info([Tool调用完成] tool{}, resultLen{}, toolDef.name(), result.toString().length()); // 工具返回内容太长会撑爆上下文在这里截断 // 下文踩坑部分会详细说 }) .build(); } }3.5 踩坑记录坑一编译参数缺失导致工具参数名丢失Spring AI 通过反射获取工具方法的参数名。如果没有加-parameters编译参数参数名会被优化掉变成arg0、arg1模型完全不知道怎么传参。在pom.xml中加plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-compiler-plugin/artifactId configuration compilerArgs arg-parameters/arg /compilerArgs /configuration /plugin坑二工具返回内容过长导致上下文溢出如果工具返回了一个 10KB 的 JSON比如订单详情包含所有历史操作会把上下文撑爆。解决方案Tool(description ...) public String queryOrderDetail(String orderId) { OrderDetail detail orderRepository.findDetailById(orderId); // ✅ 主动精简只返回模型需要的字段 return String.format( 订单号:%s, 状态:%s, 金额:%.2f元, 预计到达:%s, detail.getOrderId(), detail.getStatus(), detail.getAmount(), detail.getEstimatedArrival() ); // ❌ 不要直接 return objectMapper.writeValueAsString(detail); }坑三多个工具同时被调用时的并发问题当用户问我的订单状态和积分余额分别是多少模型可能同时触发两个工具调用。Spring AI 1.1 默认串行执行如果需要并行需要配置Bean public ToolCallingManager toolCallingManager() { return ToolCallingManager.builder() .parallelToolCalls(true) // 开启并行工具调用 .build(); }注意并行工具调用要求工具方法本身是线程安全的。四、Memory让模型记住你是谁4.1 Memory 的本质上下文注入不是魔法先消除一个常见误解LLM 本身是无状态的每次调用都是全新的。Spring AI 的 Memory 做的事情是在每次请求时把历史对话消息塞回 Prompt。第3轮对话时实际发给 LLM 的 Prompt 是 [System] 你是客服助手... [User] 我的订单 ORD-001 到哪了 ← 第1轮从Memory取出 [Assistant] 您的订单已发货预计明天到达 ← 第1轮 [User] 那能改地址吗 ← 第2轮从Memory取出 [Assistant] 已为您修改为新地址 ← 第2轮 [User] 谢谢帮我再查一下积分余额 ← 第3轮当前输入理解了这个后面的 Token 消耗、窗口管理问题就自然明白了。4.2 三种 Memory Advisor 对比Advisor历史如何注入优点缺点推荐场景MessageChatMemoryAdvisor以 Message 列表追加模型感知好支持角色区分Token 消耗略高大多数场景推荐PromptChatMemoryAdvisor注入 System Prompt 文本Token 消耗低部分模型对长 System Prompt 响应差简单问答场景VectorStoreChatMemoryAdvisor向量检索相关历史支持超长会话配置复杂有检索延迟客服历史数超长4.3 Memory 存储生产环境不能用 InMemory// ❌ 只能在开发时用服务重启数据全丢多实例部署数据不共享 ChatMemory memory new InMemoryChatMemory(); // ✅ 生产用 RedisSpring AI 1.1 官方支持 Bean public ChatMemory chatMemory(RedisTemplateString, Object redisTemplate) { return RedisChatMemory.builder() .redisTemplate(redisTemplate) .defaultTtl(Duration.ofHours(24)) // 对话24小时后自动清理 .build(); }4.4 Memory 窗口管理上下文不能无限增长随着对话轮次增加每次注入的历史越来越长Token 消耗线性增长。必须设置窗口// 方案一按消息条数截断简单但可能截断一半的多轮对话 MessageChatMemoryAdvisor.builder(chatMemory) .windowSize(20) // 最多保留最近20条消息 .build() // 方案二按 Token 数截断推荐更精确 // 需要配合 TokenCountingChatMemory Bean public ChatMemory tokenAwareChatMemory(RedisTemplateString, Object redis, TiktokenTokenCountEstimator tokenCounter) { return TokenCountingChatMemory.builder() .delegate(RedisChatMemory.builder().redisTemplate(redis).build()) .maxTokens(4096) // 保留最近 4096 Token 的历史 .tokenCounter(tokenCounter) .build(); }4.5 多会话隔离最高频 Bug这是生产中最常见的 Memory 问题必须单独强调// ❌ 错误写法conversationId 写死在 Bean 里 Bean public ChatClient chatClient(ChatClient.Builder builder, ChatMemory chatMemory) { return builder .defaultAdvisors( MessageChatMemoryAdvisor.builder(chatMemory) .conversationId(fixed-id) // ← 所有用户共享 .build() ) .build(); } // ✅ 正确写法在每次请求时动态覆盖 conversationId RestController public class ChatController { PostMapping(/chat) public CustomerServiceResponse chat( RequestBody ChatRequest request, RequestHeader(X-User-Id) String userId) { return chatClient.prompt() .user(request.message()) .advisors(advisor - // 关键用用户ID作为会话ID运行时动态覆盖 advisor.param( AbstractChatMemoryAdvisor.CONVERSATION_ID_KEY, userId ) ) .call() .entity(CustomerServiceResponse.class); } }⚠️踩坑记录我们曾经在测试环境用了固定 conversationId测试了一下午数据都是张冠李戴。发现问题时 Redis 里有 200 多条混杂的消息。上生产前一定要验证会话隔离。4.6 踩坑记录坑Memory Advisor 和 RAG Advisor 的顺序问题Advisor 的执行顺序由getOrder()决定顺序不对会导致 Memory 里的历史信息被 RAG 检索结果覆盖// ✅ 正确顺序Memory 先注入历史RAG 再追加检索结果 .defaultAdvisors( MessageChatMemoryAdvisor.builder(chatMemory).order(10).build(), QuestionAnswerAdvisor.builder(vectorStore).order(20).build(), // RAG new ToolCallAdvisor(), // 默认 order 最低最后执行 new SimpleLoggerAdvisor() )五、三件套组合有记忆的智能客服 v3现在把三件套组合起来实现一个完整的生产级客服系统。5.1 完整响应结构// 客服响应包含业务答案 调用信息便于前端展示AI使用了哪些工具 public record CustomerServiceResponse( String answer, // 给用户的回答 ListString calledTools, // 本轮调用了哪些工具可为空列表 String nextSuggestion // 建议用户下一步可以做什么 ) {}5.2 工具 BeanComponent public class CustomerServiceTools { // 见第三节的完整实现这里省略 // 三个工具queryOrderStatus / updateDeliveryAddress / queryPoints }5.3 核心配置ChatClient 装配Configuration public class CustomerServiceConfig { Bean public ChatClient customerServiceChatClient( ChatClient.Builder builder, ChatMemory chatMemory, CustomerServiceTools tools, ToolCallAdvisor toolCallAdvisor) { return builder .defaultSystem( 你是「XX商城」专属客服小助手。 你的能力查询订单状态、修改配送地址、查询积分余额。 沟通原则 1. 称呼用户为您语气友好专业 2. 如果需要订单号但用户没有提供主动向用户索要 3. 不要编造不存在的订单信息必须调用工具获取真实数据 4. 操作成功或失败都要明确告知用户 ) .defaultAdvisors( // 顺序说明 // order10: Memory 先执行把历史消息注入 Prompt // order20: ToolCall 后执行管理工具调用循环 // order30: Logger 最后执行记录完整请求响应 MessageChatMemoryAdvisor.builder(chatMemory) .order(10) .build(), toolCallAdvisor, // order 见 ToolCallConfig Bean new SimpleLoggerAdvisor() // 开发调试用生产可去掉 ) .defaultTools(tools) // 注册工具 .build(); } }5.4 Controller 层RestController RequestMapping(/api/customer-service) public class CustomerServiceController { private final ChatClient chatClient; PostMapping(/chat) public CustomerServiceResponse chat( RequestBody Valid ChatRequest request, RequestHeader(X-User-Id) String userId) { return chatClient.prompt() .user(request.message()) .advisors(advisor - advisor.param( AbstractChatMemoryAdvisor.CONVERSATION_ID_KEY, cs- userId // 加前缀避免与其他 Memory 冲突 ) ) .call() .entity(CustomerServiceResponse.class); } // 清除会话历史用户点击开始新对话时调用 DeleteMapping(/chat/{userId}/history) public void clearHistory(PathVariable String userId, ChatMemory chatMemory) { chatMemory.clear(cs- userId); } } public record ChatRequest(NotBlank String message) {}5.5 端到端演示Memory 的价值用CommandLineRunner模拟真实多轮对话Component Profile(demo) public class MultiTurnChatDemo implements CommandLineRunner { private final CustomerServiceController controller; Override public void run(String... args) { String userId demo-user-001; System.out.println( 开始多轮对话演示 \n); // 第一轮查询订单 var r1 controller.chat( new ChatRequest(我的订单 ORD-20240401 发货了吗), userId); System.out.println(用户我的订单 ORD-20240401 发货了吗); System.out.println(客服 r1.answer()); System.out.println(工具 r1.calledTools()); // 输出 // 客服您好您的订单 ORD-20240401 已于 4月2日 14:30 发货 // 承运商为顺丰预计 4月4日 送达您的地址。 // 工具[queryOrderStatus] System.out.println(); // 第二轮用代词指代Memory 的核心价值体现 var r2 controller.chat( new ChatRequest(这个单还能改地址吗我想改到公司), userId); System.out.println(用户这个单还能改地址吗我想改到公司); System.out.println(客服 r2.answer()); System.out.println(工具 r2.calledTools()); // ✅ 模型从 Memory 中知道这个单指的是 ORD-20240401 // ✅ 查询状态后发现已发货自动告知无法修改 // 输出 // 客服抱歉您的订单 ORD-20240401 已处于配送中状态 // 暂时无法修改收货地址。如有紧急情况可联系顺丰协商。 // 工具[updateDeliveryAddress] System.out.println(); // 第三轮换个话题 var r3 controller.chat( new ChatRequest(好吧那帮我查一下我的积分余额), userId); System.out.println(用户好吧那帮我查一下我的积分余额); System.out.println(客服 r3.answer()); // 输出您当前积分余额为 2,380 分可兑换价值约 23.8 元的优惠券... } }运行结果说明第二轮对话中用户说这个单模型从 Memory 中正确识别出ORD-20240401并调用了正确的工具——这就是三件套组合发挥作用的地方。5.6 流式响应Streaming对于较长的回答可以开启流式响应提升用户体验GetMapping(value /chat/stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public FluxString chatStream( RequestParam String message, RequestHeader(X-User-Id) String userId) { return chatClient.prompt() .user(message) .advisors(advisor - advisor.param(AbstractChatMemoryAdvisor.CONVERSATION_ID_KEY, cs- userId) ) .stream() .content(); // 返回 FluxString字符逐步输出 }⚠️注意流式响应不支持.entity()结构化输出只能用.content()返回纯文本。如果既需要流式又需要结构化需要在前端拼接完成后再解析或者分两个接口。六、生产上线 CheckList6.1 上线前必查项Token 消耗审计// 在 ToolCallAdvisor 的 afterCall 中记录 Token 消耗 .afterToolCall((toolDef, result) - { // 通过 Spring AI 的 Usage 对象获取消耗 log.info(本次请求 Token 消耗 - 输入:{}, 输出:{}, response.getMetadata().getUsage().getPromptTokens(), response.getMetadata().getUsage().getGenerationTokens()); })工具幂等性修改类工具如updateDeliveryAddress必须是幂等的同一请求重复调用不能产生副作用。Memory TTL 设置Redis 中的 Memory 数据必须设置过期时间避免无限增长。模型降级策略私有 Qwen 服务不可用时应有降级到云 API 的兜底方案。6.2 可观测性接入Spring AI 内置 Micrometer 指标可以直接接入 Prometheus Grafanamanagement: metrics: distribution: percentiles-histogram: spring.ai.chat.client.operation.seconds: true # 请求耗时分布 endpoints: web: exposure: include: prometheus, health关键监控指标spring.ai.chat.client.operation.seconds端到端响应时间tool.call.total自定义各工具调用次数spring.ai.chat.client.error错误率更完整的 LLMOps 监控方案后续会单独写一篇文章介绍 LangFuse 的接入实践。6.3 Spring AI 2.0 展望写这篇文章时Spring AI 2.0-M1 已经发布2025.12。几个值得关注的变化Recursive Advisors 正式化支持工具调用循环、结构化输出校验失败自动重试构建 Agentic Loop 更简洁MCP Annotations用McpTool替代Tool一个注解同时暴露为 MCP Server 工具关于 MCP可参考我之前的文章[MCP 协议实战用 Java 开发自定义 MCP Server]Spring AI 2.0 API 会有 Breaking Changes建议生产项目暂时锁定 1.1.x观望 2.0 GA 后再迁移总结回到开头的三个问题问题解决方案关键 API模型返回 JSON 格式不稳定Structured Output.entity(Class) 可选StructuredOutputValidationAdvisor工具调用静默失败Tool Calling 规范化Tool 好的 description ToolCallAdvisor监控用户换 Tab 就失忆Memory 持久化MessageChatMemoryAdvisor Redis 动态 conversationId三件套的核心价值不在于各自的功能而在于组合之后带来的可靠性模型不再随机输出格式不再编造数据不再失忆——这才是从 Demo 走向生产的关键一步。