Spring AI 1.1 三件套实战:Structured Output + Tool Calling + Memory 从踩坑到生产落地

以企业智能客服为例,手把手搭建有记忆、能调工具、输出可靠的 AI 服务

环境说明: Spring AI 1.1.x(2025.11 GA)· Spring Boot 3.3+ · Java 17+ · 模型:私有部署 Qwen3.5(vLLM OpenAI 兼容接口)


前言:三行代码的 Demo 上了生产就崩

你可能见过 Spring AI 最简单的 Hello World:

java 复制代码
String 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 新写法 备注
工具注册 FunctionCallback @Tool 注解 旧方式仍兼容但已标记废弃
Structured Output BeanOutputConverter 手动解析 .entity(MyRecord.class) 1.1.1 新增原生支持
Memory ChatMemory 直接注入 Advisors API 架构彻底重构
工具调用拦截 ToolCallAdvisor hook 1.1.1 新增
对话 ID 无标准化方式 CONVERSATION_ID_KEY 参数 多会话隔离关键

一、依赖配置

1.1 pom.xml

使用 BOM 统一管理版本,避免依赖冲突(这是 Spring AI 初学者最常踩的第一个坑):

java 复制代码
<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>org.springframework.ai</groupId>
      <artifactId>spring-ai-bom</artifactId>
      <version>1.1.1</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>

<dependencies>
  <dependency>
    <groupId>org.springframework.ai</groupId>
    <!-- 注意:私有部署 Qwen 走 OpenAI 兼容接口,用这个 starter 即可 -->
    <artifactId>spring-ai-starter-model-openai</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
    <!-- Memory 持久化用 Redis,生产环境必须,不能用 InMemory -->
  </dependency>
</dependencies>

1.2 application.yml

XML 复制代码
spring:
  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 为什么字符串响应不够用

先看问题代码:

java 复制代码
// ❌ 脆弱的写法
String raw = chatClient.prompt()
    .system("返回 JSON 格式的订单状态")
    .user("查询订单 ORD-20240401")
    .call()
    .content();

// 这行有一定概率崩溃
OrderStatus status = objectMapper.readValue(raw, OrderStatus.class);

崩溃原因:不同模型、不同版本,对"返回 JSON"的理解不同。Qwen 有时会返回:

java 复制代码
好的,以下是订单状态:
```json
{"orderId": "ORD-20240401", "status": "SHIPPED"}
java 复制代码
这带了自然语言前缀和 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"
    List<String> trackingEvents // 物流轨迹列表
) {}

然后在 Service 中直接使用:

java 复制代码
@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(高可靠场景)

对于关键业务场景(比如财务、合规),不能接受任何格式错误,可以加校验重试:

java 复制代码
@Bean
public ChatClient reliableChatClient(ChatClient.Builder builder) {
    return builder
        .defaultAdvisors(
            // 校验失败时最多重试 3 次,重试时将校验错误信息反馈给模型
            StructuredOutputValidationAdvisor.builder()
                .maxRetries(3)
                .build()
        )
        .build();
}

⚠️ 踩坑记录 :加了 StructuredOutputValidationAdvisor 后,每次重试都是一次完整 LLM 调用,Token 消耗最多增加 3 倍。建议只在关键路径上使用,不要全局加。

2.5 嵌套结构与 Optional 字段处理

java 复制代码
// 支持嵌套 Record
public record CustomerServiceResponse(
    String answer,                    // 回答内容
    List<String> calledTools,         // 调用了哪些工具
    @Nullable String recommendation,  // 可能为 null 的建议
    ConfidenceLevel confidence        // 枚举
) {}

public enum ConfidenceLevel { HIGH, MEDIUM, LOW }

⚠️ 踩坑记录 :枚举字段在某些模型上会被返回为小写(如 "high"),Spring AI 默认大小写不敏感,但如果你自定义了 ObjectMapper,注意加 MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS


三、Tool Calling:给模型装上手和脚

3.1 Tool Calling 的完整生命周期

很多文章只讲"怎么注册工具",但不讲工具调用的完整循环------这是理解 Agent 的关键:

python 复制代码
用户输入
    ↓
LLM 分析:需要调用工具?选哪个工具?参数是什么?
    ↓
返回 tool_calls(不是最终答案,是调用指令)
    ↓
Spring AI 拦截,执行工具方法(Java 代码)
    ↓
工具执行结果作为新消息返回 LLM
    ↓
LLM 结合工具结果生成最终自然语言回答
    ↓
用户看到答案

这个循环可能执行多次(比如查了订单,发现需要再查一次物流详情)。Spring AI 1.1 的 ToolCallAdvisor 负责管理这个循环。

3.2 注册工具:@Tool 注解(1.1 推荐方式)

java 复制代码
@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 工具描述:好坏对比

这是最影响工具调用准确率的因素,却是大多数文章忽略的细节。

java 复制代码
// ❌ 坏的 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 机制,可以在工具调用前后插入自定义逻辑:

java 复制代码
@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 编译参数,参数名会被优化掉,变成 arg0arg1,模型完全不知道怎么传参。

pom.xml 中加:

XML 复制代码
<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-compiler-plugin</artifactId>
  <configuration>
    <compilerArgs>
      <arg>-parameters</arg>
    </compilerArgs>
  </configuration>
</plugin>

坑二:工具返回内容过长导致上下文溢出

如果工具返回了一个 10KB 的 JSON(比如订单详情包含所有历史操作),会把上下文撑爆。解决方案:

java 复制代码
@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 默认串行执行,如果需要并行,需要配置:

java 复制代码
@Bean
public ToolCallingManager toolCallingManager() {
    return ToolCallingManager.builder()
        .parallelToolCalls(true)  // 开启并行工具调用
        .build();
}

注意:并行工具调用要求工具方法本身是线程安全的。


四、Memory:让模型记住你是谁

4.1 Memory 的本质:上下文注入,不是魔法

先消除一个常见误解:LLM 本身是无状态的,每次调用都是全新的。Spring AI 的 Memory 做的事情是:在每次请求时,把历史对话消息塞回 Prompt

python 复制代码
第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

java 复制代码
// ❌ 只能在开发时用,服务重启数据全丢,多实例部署数据不共享
ChatMemory memory = new InMemoryChatMemory();

// ✅ 生产用 Redis(Spring AI 1.1 官方支持)
@Bean
public ChatMemory chatMemory(RedisTemplate<String, Object> redisTemplate) {
    return RedisChatMemory.builder()
        .redisTemplate(redisTemplate)
        .defaultTtl(Duration.ofHours(24))   // 对话24小时后自动清理
        .build();
}

4.4 Memory 窗口管理:上下文不能无限增长

随着对话轮次增加,每次注入的历史越来越长,Token 消耗线性增长。必须设置窗口:

java 复制代码
// 方案一:按消息条数截断(简单,但可能截断一半的多轮对话)
MessageChatMemoryAdvisor.builder(chatMemory)
    .windowSize(20)   // 最多保留最近20条消息
    .build()

// 方案二:按 Token 数截断(推荐,更精确)
// 需要配合 TokenCountingChatMemory
@Bean
public ChatMemory tokenAwareChatMemory(RedisTemplate<String, Object> redis,
                                        TiktokenTokenCountEstimator tokenCounter) {
    return TokenCountingChatMemory.builder()
        .delegate(RedisChatMemory.builder().redisTemplate(redis).build())
        .maxTokens(4096)    // 保留最近 4096 Token 的历史
        .tokenCounter(tokenCounter)
        .build();
}

4.5 多会话隔离:最高频 Bug

这是生产中最常见的 Memory 问题,必须单独强调:

java 复制代码
// ❌ 错误写法: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 检索结果覆盖:

javascript 复制代码
// ✅ 正确顺序:Memory 先注入历史,RAG 再追加检索结果
.defaultAdvisors(
    MessageChatMemoryAdvisor.builder(chatMemory).order(10).build(),
    QuestionAnswerAdvisor.builder(vectorStore).order(20).build(),  // RAG
    new ToolCallAdvisor(),  // 默认 order 最低,最后执行
    new SimpleLoggerAdvisor()
)

五、三件套组合:有记忆的智能客服 v3

现在把三件套组合起来,实现一个完整的生产级客服系统。

5.1 完整响应结构

java 复制代码
// 客服响应:包含业务答案 + 调用信息(便于前端展示"AI使用了哪些工具")
public record CustomerServiceResponse(
    String answer,                  // 给用户的回答
    List<String> calledTools,       // 本轮调用了哪些工具(可为空列表)
    String nextSuggestion           // 建议用户下一步可以做什么
) {}

5.2 工具 Bean

java 复制代码
@Component
public class CustomerServiceTools {
    // 见第三节的完整实现,这里省略
    // 三个工具:queryOrderStatus / updateDeliveryAddress / queryPoints
}

5.3 核心配置:ChatClient 装配

java 复制代码
@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(
                // 顺序说明:
                // order=10: Memory 先执行,把历史消息注入 Prompt
                // order=20: ToolCall 后执行,管理工具调用循环
                // order=30: Logger 最后执行,记录完整请求响应
                MessageChatMemoryAdvisor.builder(chatMemory)
                    .order(10)
                    .build(),
                toolCallAdvisor,          // order 见 ToolCallConfig Bean
                new SimpleLoggerAdvisor() // 开发调试用,生产可去掉
            )
            .defaultTools(tools)          // 注册工具
            .build();
    }
}

5.4 Controller 层

java 复制代码
@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 模拟真实多轮对话:

java 复制代码
@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)

对于较长的回答,可以开启流式响应,提升用户体验:

java 复制代码
@GetMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> 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();  // 返回 Flux<String>,字符逐步输出
}

⚠️ 注意 :流式响应不支持 .entity() 结构化输出,只能用 .content() 返回纯文本。如果既需要流式又需要结构化,需要在前端拼接完成后再解析,或者分两个接口。


六、生产上线 CheckList

6.1 上线前必查项

Token 消耗审计

javascript 复制代码
// 在 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 + Grafana:

XML 复制代码
management:
  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 走向生产的关键一步。

相关推荐
云烟成雨TD2 小时前
Spring AI 1.x 系列【21】ToolCallbackProvider 动态工具集成
java·人工智能·spring
档案宝档案管理2 小时前
档案管理系统:数据可视化+多维度报表,档案管理决策更科学
大数据·信息可视化·数据分析
beyond阿亮2 小时前
OpenClaw接入企业微信
人工智能·ai·企业微信·openclaw
芯智工坊2 小时前
第4章 Mosquitto命令行工具快速上手
网络·人工智能·mqtt·开源
咚咚王者2 小时前
人工智能之语音领域 语音处理 第五章 语音处理实践落地与常见问题解决
人工智能
VBsemi-专注于MOSFET研发定制2 小时前
面向电动车直流快充桩的功率MOSFET选型分析——以高功率密度、高可靠电源与模块化系统为例
人工智能
夏沫の梦2 小时前
Agent Skills技术详解与实战
人工智能·a·skill
财迅通Ai2 小时前
科创芯片ETF(589100)大涨超3.5%,AI+涨价潮点燃芯片景气
人工智能·科创芯片etf
薛定猫AI2 小时前
【技术干货】Gemma 4 上手深度指南:本地多模态大模型的新基线
人工智能·架构·自动化