Appearance
国内多模型统一适配层:一套代码切换三家厂商
1. 本节产出
一个支持 DeepSeek / 通义千问 / 智谱 三家厂商的配置化适配层:改 yml 里的一个名字就能换厂商,业务代码零改动,并且三家都能正常调用工具(Function Calling)。
2. 前置依赖
- 01-02 第一个 ChatClient:已跑通单模型调用
- 00-03 版本矩阵:starter 命名已按当前版本核对
- 三家厂商各一把可用 API Key(至少一家)
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. 生产避坑
- 不要在业务代码里出现厂商名字符串。一旦出现,后面做灰度、A/B、灾备都得改业务。厂商名只允许出现在配置类和路由策略里。判断标准:把 yml 里所有厂商名换掉,业务代码能一行不改地跑起来。
- 每家厂商的超时和限流阈值不同,别用一套。有的厂商并发上限低、有的响应慢。适配层里应该把超时、最大重试次数也做成按厂商配置——否则一家的限流会拖垮整体。这条会在 03B 可靠性篇正式展开。
- 工具调用一定要逐模型实测,不要相信文档。厂商文档写「支持 Function Calling」常指旗舰模型,兼容端点上的轻量模型可能完全不触发。做法:写一个最小工具(返回当前时间),对每个模型跑一次,把结果写进配置。这个表是你的资产,不是文档能替代的。
8. 延伸与锚点
- 思考题:现在切换厂商要重启应用。如何做到运行时热切换?(答案在 04-02 模型网关与路由降级)
- 代码锚点:
git checkout ch01-03-multi-model - 上一课时:01-02 第一个 ChatClient · 下一课时:01-04 流式输出 SSE
- 对应课件:L01-03 国内多模型统一适配层