开发实践 / Spring AI 实战

Spring AI 2.0 多模型调用:Qwen 结构化输出失败之后切换到 DeepSeek

结构化输出校验仍然失败时,怎样用 Java 业务代码切换模型,并区分重试、降级和失败处理。

大家好,我是一言。

在写结构化输出的时候,有位朋友提出一个很实际的问题:模型没有按照Schema生成JSON,框架重试之后,Java对象转换仍然有可能失败。

同一个模型连续失败了,可以换一个模型再试一下吗?

可以,这一步要由业务代码来完成。

Spring AI 2.0的validateSchema()在当前的ChatClient调用链中会再次请求。它可以把校验错误加入Prompt,使当前模型修正输出。重复失败之后,框架不会自动选择另外一个ChatClient。

这一篇继续用工单分类的例子,主模型用qwen3.6-flash,备用模型用deepseek-v4-pro。常规请求先走Qwen,如果连续不能返回可用结果,就切换到DeepSeek。

下面的代码使用Java 21、Spring Boot 4.0.8和Spring AI 2.0.1。

Qwen在Schema重试之后仍然失败了,于是业务层切换到DeepSeek。任意模型通过结构化输出校验之后,还要继续进行Java对象转换、Bean Validation以及业务规则检查。

Spring AI 2.0 多模型调用链:Qwen 校验失败后切换 DeepSeek,最终返回 Java 对象
Spring AI 2.0 多模型调用链:Qwen 校验失败后切换 DeepSeek,最终返回 Java 对象

分三类处理三种失败#

Java项目中“重试”的部分经常被混在一起,这里至少有三层:

层次 处理的问题 处理方式
模型访问重试 超时、限流、部分服务端错误 spring.ai.retry.*
Schema 自纠错 响应没有通过 JSON Schema StructuredOutputValidationAdvisor
模型切换 当前模型持续无法完成任务 业务层切换 ChatClient

第一层解决接口访问的问题,第二层使当前模型修改结果,第三层更换执行任务的模型。

本文要补充的是第三层。

配置两个模型#

阿里云百炼的OpenAI兼容接口可以同时支持qwen3.6-flash和deepseek-v4-pro。两个模型可以共用Base URL和API Key,只需要设置不同的模型ID:

properties
spring.ai.openai.api-key=${DASHSCOPE_API_KEY}
spring.ai.openai.base-url=https://${DASHSCOPE_WORKSPACE_ID}.cn-beijing.maas.aliyuncs.com/compatible-mode/v1

app.ai.primary-model=qwen3.6-flash
app.ai.fallback-model=deepseek-v4-pro

DASHSCOPE_WORKSPACE_ID要替换成百炼工作空间ID,API Key和接口地域要保持一致。这里使用的是北京地域的工作空间域名。

创建两个ChatClient:

java
@Configuration
public class MultiModelConfig {

    @Bean
    @Primary
    ChatClient primaryChatClient(
            ChatClient.Builder builder,
            @Value("${app.ai.primary-model}") String model) {
        return builder
                .defaultOptions(ChatOptions.builder().model(model))
                .build();
    }

    @Bean
    ChatClient fallbackChatClient(
            ChatClient.Builder builder,
            @Value("${app.ai.fallback-model}") String model) {
        return builder
                .defaultOptions(ChatOptions.builder().model(model))
                .build();
    }
}

Spring Boot自动配置的ChatClient.Builder是prototype scope。两个Bean方法都会得到各自的Builder,然后设置不同的模型ID。

2.0版本的变化是:defaultOptions()接收ChatOptions.Builder,所以直接传入:

java
ChatOptions.builder().model(model)

传入已经.build()的ChatOptions将会无法编译。

每个模型只纠错一次#

直接调用validateSchema()的时候,默认最多会重复3次。再加上第一次请求,同一个模型最多会被调用4次。

后面还有备用模型。如果两个模型都用默认值,一次业务请求最多会触发8次模型调用。

将每个模型的重复次数都设为1:

java
private final StructuredOutputValidationAdvisor schemaAdvisor =
        StructuredOutputValidationAdvisor.builder()
                .outputType(TicketResult.class)
                .maxRepeatAttempts(1)
                .build();

主模型最多调用两次,备用模型最多调用两次。业务层的上限为4次模型调用。

这里计算的是ChatModel被调用的次数。Provider和SDK层的HTTP重试也会继续增加网络请求,也应该被包含在时间以及成本预算中。

调用失败之后更换模型#

主要的代码很简洁。先用主模型,如果不行再用备用模型:

java
public ClassificationResult classify(String ticketText) {
    RuntimeException primaryFailure;

    try {
        TicketResult result = invokeAndValidate(primaryClient, ticketText);
        return new ClassificationResult(result, ModelRoute.PRIMARY);
    }
    catch (RuntimeException ex) {
        primaryFailure = ex;
    }

    try {
        TicketResult result = invokeAndValidate(fallbackClient, ticketText);
        return new ClassificationResult(result, ModelRoute.FALLBACK);
    }
    catch (RuntimeException fallbackFailure) {
        TicketClassificationException exception =
                new TicketClassificationException(
                        "Primary and fallback models both failed",
                        fallbackFailure);

        exception.addSuppressed(primaryFailure);
        throw exception;
    }
}

返回的结果中保留本次命中的模型路线:

java
public record ClassificationResult(
        TicketResult ticket,
        ModelRoute route) {

    public enum ModelRoute {
        PRIMARY, FALLBACK
    }
}

route后面可以进入日志和监控。DeepSeek命中率突然提高,一般表示Qwen、Prompt或者模型服务的状态发生了改变。

换模型之后再校验一下#

模型切换就是更换调用对象,校验部分仍然要保留。

两个ChatClient最后都会用到同一个方法:

java
private TicketResult invokeAndValidate(
        ChatClient client,
        String ticketText) {

    TicketResult result = client.prompt()
            .system("你是电商客服工单分类器,只返回符合要求的结果。")
            .user(ticketText)
            .advisors(schemaAdvisor)
            .call()
            .entity(TicketResult.class);

    if (result == null) {
        throw new IllegalStateException("Model returned no result");
    }

    var violations = validator.validate(result);
    if (!violations.isEmpty()) {
        throw new IllegalStateException(
                "Model result failed Bean Validation: " + violations);
    }

    return result;
}

有两条检查的内容是:

  1. StructuredOutputValidationAdvisor用来验证模型输出是否符合JSON Schema;
  2. Jakarta Bean Validation用来检查Java对象中的非空、长度以及格式约束。

退款金额、用户权限以及订单状态等业务规则仍然放在这两层之后。

什么情况下需要更换模型#

Spring AI 2.0.1的Schema纠错次数用完之后,仍然会返回最后一次模型回答。

最后一份内容为非法JSON时,converter可能在转换Java对象时抛出异常。内容可以反序列化但是缺少字段时,Java对象中可能出现null。

所以备用模型要包含三种失败的情况:

  • 模型接口调用失败;
  • JSON转换错误;
  • 转换成功之后校验不通过。

示例为了完整地捕获边界,在调用模型的方法之外捕获RuntimeException。生产项目可以缩小捕获范围,并为模型访问、转换、Schema、Bean Validation和业务校验分别记录指标。

最后#

整个链条最后的结果是:

text
主模型
  ↓ Schema 失败
同模型纠错一次
  ↓ 仍然失败
备用模型
  ↓ 重新执行 Schema、Bean 和业务校验
成功返回 / 可控降级

重试次数、模型切换以及最后的校验都用Java代码来控制,这样多模型链路就可以被观察到并被调试了。

参考资料#

  • Spring AI 2.0.1:ChatClient API
  • Spring AI 2.0.1:OpenAI Chat Retry Properties
  • Spring AI 2.0.1:Schema Validation & Self-Correction
  • Spring AI 2.0:Upgrade Notes
  • Spring AI 2.0.1:StructuredOutputValidationAdvisor 源码
  • 阿里云百炼:OpenAI 兼容 Chat API
  • 阿里云百炼:DeepSeek V4 API

公众号同名

微信搜索「雷一言说」

关注公众号,继续看我的文章。

手机阅读时,可以保存二维码后在微信中识别。

雷一言说微信公众号二维码
扫码关注公众号