Skip to content

国内多模型统一适配层:一套代码切换三家厂商 ​

1. 本节产出 ​

一个支持 DeepSeek / 通义千问 / 智谱 三家厂商的配置化适配层:改 yml 里的一个名字就能换厂商,业务代码零改动,并且三家都能正常调用工具(Function Calling)。

2. 前置依赖 ​

3. 为什么需要适配层 ​

直接引三个 starter、写三个 Service,是最直觉也最糟糕的做法:

java
// 反面教材:每接一家就要改一次业务代码
if ("deepseek".equals(vendor)) {
    return deepSeekClient.prompt().user(q).call().content();
} else if ("qwen".equals(vendor)) {
    return qwenClient.prompt().user(q).call().content();
}

问题不在「if 多」,而在三件事:

问题后果
业务代码里出现厂商概念想做 A/B 对比、灰度、灾备切换都得改业务
每个 starter 的选项类型不同DeepSeekChatOptions / QwenChatOptions 各自为政,默认值不一致
工具调用能力参差三家对 Function Calling 的支持度不同,代码里要写分支兼容

核心判断:厂商差异应该被关在配置层,业务层只认识 Spring AI 的标准抽象(ChatModel / ChatClient)。这是依赖倒置在 AI 场景下的直接应用——你对着接口编程,对着配置换实现。

4. 核心原理 ​

4.1 为什么「OpenAI 兼容」是关键 ​

你的代码
   │
   ▼
Spring AI 的 OpenAiChatModel          ← 只认 OpenAI 协议
   │
   ▼  HTTP: POST {base-url}/chat/completions
   ├──────────────┬──────────────┬──────────────┐
   ▼              ▼              ▼              ▼
DeepSeek        通义千问        智谱            OpenAI
(兼容端点)     (兼容端点)    (兼容端点)    (原生)

国内主流厂商都提供了 OpenAI 协议兼容端点。这意味着:

  • 依赖只引一个 spring-ai-starter-model-openai;
  • 换厂商 = 换 base-url + api-key + model 三个配置;
  • 代价:厂商特有能力(比如某些独有的推理参数)用不了,需要用 customHeaders 或降级到厂商专用 starter。

什么时候该引厂商专用 starter:需要用厂商独占能力(特殊推理模式、独有多模态参数、特定计费参数)。其余情况一律走兼容端点,能省下大量维护成本。

4.2 三种装配策略对比 ​

策略做法适用场景代价
A 单一主模型一个 ChatModel Bean演示、小项目无法切换
B 多 Bean + 限定符每个厂商一个 Bean,@Qualifier 注入需要同时用两家(如:贵的做生成、便宜的做分类)业务代码要知道用哪个
C 路由代理(推荐)一个 ChatModel 接口实现,内部按上下文路由生产、需要灰度/降级多一层抽象

本课程用 B 打底 + C 收口:先做出多个 Bean 让切换可行,再用一个 ModelRouter 做默认路由,业务代码只注入一个 ChatClient。

4.3 版本差异提示 ​

yaml
# Spring AI 1.x 时代(旧写法,仅作对照)
#   依赖名:spring-ai-openai-spring-boot-starter
#   base-url 属性:spring.ai.openai.base-url   (同)

# Spring AI 2.x(本课时使用)
#   依赖名:spring-ai-starter-model-openai
#   配置前缀一致,但 options 内部结构有调整

提示:starter 命名在 1.x → 2.x 之间统一改成了 -starter-model- 形式。如果你的项目依赖拉不下来,第一件事就是核对命名,不要急着改代码。

5. 代码走查 ​

5.1 依赖 ​

xml
<!-- pom.xml:一个 starter 对接所有 OpenAI 兼容厂商 -->
<dependency>
  <groupId>org.springframework.ai</groupId>
  <artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>

5.2 配置:把厂商差异写进 yml ​

yaml
# src/main/resources/application.yml
spring:
  ai:
    openai:
      api-key: ${AI_API_KEY}
      base-url: ${AI_BASE_URL}
      chat:
        options:
          model: ${AI_MODEL}
          temperature: 0.7
          max-tokens: 2000

ai:
  vendors:
    deepseek:
      base-url: https://api.deepseek.com
      model: deepseek-chat
      api-key: ${DEEPSEEK_KEY}
      supports-tools: true
    qwen:
      base-url: https://dashscope.aliyuncs.com/compatible-mode/v1
      model: qwen-plus
      api-key: ${QWEN_KEY}
      supports-tools: true
    zhipu:
      base-url: https://open.bigmodel.cn/api/paas/v4
      model: glm-4-flash
      api-key: ${ZHIPU_KEY}
      supports-tools: true

提示:dashscope 的兼容端点路径是 /compatible-mode/v1,很多人直接写 /v1 导致 404。base-url 里是否还需要补 /v1 取决于 starter 是否自动拼接,配完必须立刻发一次请求验证,不要假设。

5.3 配置属性类 ​

java
// src/main/java/com/example/aibasics/config/ModelProperties.java
@ConfigurationProperties(prefix = "ai.vendors")
public record ModelProperties(Map<String, Vendor> vendors) {

    public record Vendor(String baseUrl, String model, String apiKey, boolean supportsTools) {}

    public Vendor require(String name) {
        Vendor v = vendors.get(name);
        if (v == null) throw new IllegalArgumentException("未知厂商: " + name + ",可用: " + vendors.keySet());
        return v;
    }
}

用 record 而不是 class 的收益:配置不可变,启动后不会被意外改写;require() 的错误信息直接列出可用厂商,省掉一半排查时间。

5.4 按厂商构建 ChatModel ​

java
// src/main/java/com/example/aibasics/config/MultiModelConfig.java
@Configuration
@EnableConfigurationProperties(ModelProperties.class)
public class MultiModelConfig {

    @Bean
    public Map<String, ChatModel> chatModels(ModelProperties props) {
        Map<String, ChatModel> models = new LinkedHashMap<>();
        props.vendors().forEach((name, v) -> {
            OpenAiApi api = OpenAiApi.builder()
                    .apiKey(v.apiKey())
                    .baseUrl(v.baseUrl())
                    .build();
            models.put(name, OpenAiChatModel.builder()
                    .openAiApi(api)
                    .defaultOptions(OpenAiChatOptions.builder()
                            .model(v.model())
                            .temperature(0.7)
                            .maxTokens(2000)
                            .build())
                    .build());
        });
        return models;
    }
}

关键点:每个厂商一个独立的 OpenAiApi 实例,因为 base-url 和 api-key 是绑在 Api 上的,不是绑在 ChatModel 上的。这是最容易写错的地方——很多人试图共用一个 Api 然后改 url,结果请求打到了错误的地址。

5.5 路由:业务层只认一个 Bean ​

java
// src/main/java/com/example/aibasics/config/ChatClientRouter.java
@Service
public class ChatModelRouter implements ChatModel {

    private final Map<String, ChatModel> delegates;
    private final String defaultVendor;

    public ChatModelRouter(Map<String, ChatModel> delegates,
                           @Value("${ai.default-vendor:deepseek}") String defaultVendor) {
        this.delegates = delegates;
        this.defaultVendor = defaultVendor;
    }

    /** 按租户或场景选模型;本节先用默认厂商,03B 会接入路由策略 */
    public ChatModel select(String hint) {
        return delegates.getOrDefault(hint, delegates.get(defaultVendor));
    }

    @Override
    public ChatResponse call(Prompt prompt) {
        return select(defaultVendor).call(prompt);
    }

    @Override
    public ChatOptions getDefaultOptions() {
        return select(defaultVendor).getDefaultOptions();
    }
}

实现 ChatModel 接口的好处:上层 ChatClient、Advisor、工具调用全都无感知。你只是换了一个 ChatModel 的实现,其余代码一行不改。这就是适配层的价值。

5.6 工具调用的能力差异处理 ​

java
// src/main/java/com/example/aibasics/config/ChatConfig.java
@Bean
public ChatClient chatClient(ChatClient.Builder builder, ChatModelRouter router) {
    return builder
            .chatModel(router)                       // 用路由后的模型
            .defaultSystem("你是 Java 技术助理,回答简洁。")
            .defaultTools(dateTimeTools())           // 只有支持工具的厂商才生效
            .build();
}

提示:不是所有厂商的所有模型都支持工具调用。同一家厂商的 xxx-chat 支持、xxx-reasoner 不支持是常见情况。必须为每个模型实测一次,并把结果写进 yml 的 supports-tools 字段,运行时据此决定是否挂载工具。

6. 跑起来 ​

bash
git checkout ch01-03-multi-model
export DEEPSEEK_KEY=sk-xxx
export QWEN_KEY=sk-yyy
export ZHIPU_KEY=zzz
mvn spring-boot:run

逐家验证(三家都要跑,不要只跑默认那家):

bash
# 1. 看当前生效厂商
curl http://localhost:8080/api/vendor

# 2. 指定厂商发一次请求
curl -X POST http://localhost:8080/api/chat?vendor=qwen \
  -H "Content-Type: text/plain" -d "用一句话说明什么是幂等"

# 3. 验证工具调用(三家分别测)
curl -X POST http://localhost:8080/api/chat?vendor=deepseek \
  -H "Content-Type: text/plain" -d "今天星期几?"

期望结果:

检查项通过标准
/api/vendor返回当前默认厂商名,与 ai.default-vendor 一致
三家分别调用三家都返回自然语言回答,无鉴权错误
工具调用至少两家能正确触发日期工具并返回星期
日志每条日志带厂商名,能看出请求打到了哪个 base-url

如果某家 404:检查 base-url 是否多/少了 /v1。如果某家工具不触发:确认 supports-tools 与该模型实际能力一致。

7. 生产避坑 ​

  1. 不要在业务代码里出现厂商名字符串。一旦出现,后面做灰度、A/B、灾备都得改业务。厂商名只允许出现在配置类和路由策略里。判断标准:把 yml 里所有厂商名换掉,业务代码能一行不改地跑起来。
  2. 每家厂商的超时和限流阈值不同,别用一套。有的厂商并发上限低、有的响应慢。适配层里应该把超时、最大重试次数也做成按厂商配置——否则一家的限流会拖垮整体。这条会在 03B 可靠性篇正式展开。
  3. 工具调用一定要逐模型实测,不要相信文档。厂商文档写「支持 Function Calling」常指旗舰模型,兼容端点上的轻量模型可能完全不触发。做法:写一个最小工具(返回当前时间),对每个模型跑一次,把结果写进配置。这个表是你的资产,不是文档能替代的。

8. 延伸与锚点 ​