Spring AI + Spring Boot:从 0 到 1 进化式搭建 LLM 多模型接入
面向 AI 应用开发新手。目标不是一次画出完美架构,而是跟着做:
先跑通一个模型 → 能换模型 → 能抽象多厂商 → 能路由/降级 → 再谈智能选模。
每一阶段都能独立验证,写完就能用。
手搓建立心智模型;学完进化路线后,可直接用文末 十四、Vibe Coding 的分阶段提示词,在 Cursor 等 AI IDE 里落地。
你将学到什么
读完并跟练后,你能回答这些问题:
- 为什么业务里要接「多个」大模型,而不只接一个?
- Spring AI 如何用最少代码先跑通对话?
- 为什么「改 YAML 换模型」不够,还要 Provider?
- 路由层解决什么问题?重试、降级、轮询分别何时用?
- 正确的进化顺序是什么,避免一上来就过度设计?
- 如何用 AI IDE(Vibe Coding)按同一进化路线分阶段实现,而不是一次性甩锅给 Agent?
一、为什么要实现 LLM 多模型接入
单模型够用吗?很多 Demo 够用。真实业务通常不够:
| 诉求 | 说明 |
|---|---|
| 方便切换 | 今天用通义,明天试 DeepSeek / GPT,不应改遍业务代码 |
| 按任务选模型 | 复杂推理用强模型;简单摘要/分类用小模型或本地模型 → 省 token、降成本、降延迟 |
| 可用性 | 某厂商限流/故障时,自动降级到备用模型 |
| AB / 对比 | 同一批请求分流到不同模型,对比效果与成本 |
多模型接入 = 把「选哪个模型」从业务代码里抽出来,变成可配置、可策略化的能力。
二、先建立心智模型:进化路线图(比最终架构更重要)
新手最容易犯的错:一上来抄完整分层(Router + 一堆 Strategy + AutoConfiguration),结果两天还调不通第一个 chat。
正确姿势是 进化式搭建:
阶段 1 跑通 1 个模型 ChatClient / ChatModel 直接调用
│
▼
阶段 2 用配置切换模型 仍是 Spring AI 自动装配,改 YAML 换 model
│
▼
阶段 3 Provider 抽象 多厂商统一接口,业务只认 ChatRequest/ChatResponse
│
▼
阶段 4 单一路由策略 例如 Priority:主模型失败 → 降级备用
│
▼
阶段 5 多策略可插拔 priority / round-robin / weighted-random
│
▼
阶段 6(进阶)智能选模 按任务类型、长度、成本预算动态选模型
每一阶段都对应一个「你多赚到的能力」:
| 阶段 | 你多赚到的能力 | 还没解决的问题 |
|---|---|---|
| 1 | 能对话 | 换厂商要改代码 |
| 2 | 同厂商换型号很方便 | 跨厂商 API 仍不一致 |
| 3 | 业务与厂商解耦,可同时挂多个 | 失败了不会自动换;选谁全靠手写 if |
| 4 | 有降级/重试,可用性上来 | 只有一种选模方式 |
| 5 | 策略可配置切换 | 选模仍偏静态 |
| 6 | 按场景智能选 | 需要评测与观测配套 |
下文按这个顺序写。文末附「目标架构总览」,那是阶段 5 做完后的样子,不要一开始就照着全量建目录。
三、阶段 1:接入 1 个大模型并验证
目标:项目能启动,发一个 HTTP 请求,拿到模型回复。
1.0 环境准备
- JDK 17+
- Spring Boot 3.x
- 一个可用的 API Key(本文以阿里云通义 DashScope 为例)
- (可选)IDEA / Cursor
新建或沿用已有 Spring Boot 工程即可。
1.1 引入依赖
以 Maven 为例(版本号按你选用的 Spring AI / Spring AI Alibaba 发行版调整):
<!-- Spring AI Alibaba:通义千问 -->
<dependency>
<groupId>com.alibaba.cloud.ai</groupId>
<artifactId>spring-ai-alibaba-starter-dashscope</artifactId>
</dependency>
若你用 OpenAI 官方或其它兼容接口,换成对应的 Spring AI starter 即可。阶段 1 的重点是 先跑通一条链路,厂商可替换。
1.2 配置 YAML
spring:
ai:
dashscope:
api-key: ${DASHSCOPE_API_KEY}
chat:
options:
model: qwen-plus
把 DASHSCOPE_API_KEY 放到环境变量或本地 application-local.yml(勿提交仓库)。
1.3 最简调用:ChatClient
@RestController
@RequestMapping("/api/v1/ai")
public class AiChatController {
private final ChatClient chatClient;
public AiChatController(ChatClient.Builder builder) {
this.chatClient = builder.build();
}
@PostMapping("/chat")
public Map<String, String> chat(@RequestBody Map<String, String> body) {
String reply = chatClient.prompt()
.user(body.get("message"))
.call()
.content();
return Map.of("content", reply);
}
}
1.4 验证
curl -X POST http://localhost:8080/api/v1/ai/chat \
-H "Content-Type: application/json" \
-d '{"message":"用一句话介绍你自己"}'
能返回文本,阶段 1 就完成了。
1.5 阶段 1 你该建立的认知
- Spring AI 把「HTTP 调厂商 API」封装成了
ChatModel/ChatClient。 - 业务代码此时 直接依赖 Spring AI,这很正常,也是正确起点。
- 还不要急着写 Router、SPI、策略模式。
四、阶段 2:改 YAML 切换「同生态」模型
目标:不改 Java,只改配置,从 qwen-plus 换成 qwen-turbo / qwen-max。
2.1 怎么做
改配置即可:
spring:
ai:
dashscope:
api-key: ${DASHSCOPE_API_KEY}
chat:
options:
model: qwen-max # 只改这一行
重启(或支持动态刷新则热更),再打一次 curl,观察回复风格/延迟/成本差异。
2.2 阶段 2 的边界(重要)
| 能做 | 不能做(或很痛苦) |
|---|---|
| 同厂商换型号 | 同时挂 OpenAI + 通义 + Ollama |
| 快速对比两个通义模型 | 主模型挂了自动切备用 |
| 配置驱动选型 | 业务里按「任务类型」选不同厂商 |
当你开始想「再接一个 DeepSeek / 本地 Ollama」时,就会碰到:
- 不同 starter、不同 Bean 名、不同 options
- Controller 里出现
if (provider == ...) { ... } - 测试和线上切换都靠改代码
→ 这时候进入阶段 3,引入 Provider 抽象。
五、阶段 3:引入 Provider,自由切换多厂商
目标:业务只认统一的 ChatRequest / ChatResponse;新增厂商 = 新实现类 + 一条配置。
⚠️注意:当前实现适合“学习 Provider 适配器模式”,但不适合后续大量使用 Spring AI 的工具、Advisor、Memory、RAG 和流式响应。
3.1 为什么需要 Provider(用痛点说话)
阶段 1/2 的代码本质是:
Controller → ChatClient(DashScope) → 通义
接第二个厂商后,如果没有抽象,会变成:
Controller → if/else → ChatClientA / RestClientB / OllamaC
if/else 会随着厂商数量线性膨胀,且 路由、重试、降级 无处安放。
Provider 做的事只有一件:把「某个具体模型的调用」收成同一个接口。
业务 → ModelProvider.chat(request) → 具体厂商实现
3.2 本阶段建议新建的最小目录
先别建 router/strategy,只加这些:
src/main/java/com/demo/www/ai/
├── model/
│ ├── AiModelProvider.java # 枚举:DASHSCOPE / OPENAI_COMPATIBLE / OLLAMA
│ ├── ModelInfo.java # 模型信息,具体通过 yaml 配置后注入 id、type、model、endpoint、apiKey、enabled...
│ ├── ChatRequest.java # 统一对话请求
│ └── ChatResponse.java # 统一对话请求
├── provider/
│ ├── ModelProvider.java # SPI,核心抽象类,定义
│ ├── AbstractModelProvider.java
│ ├── dashscope/DashScopeModelProvider.java
│ ├── openai/OpenAiModelProvider.java # 也覆盖 DeepSeek 等兼容接口
│ └── ollama/OllamaModelProvider.java
├── service/
│ ├── AiChatService.java
│ └── impl/AiChatServiceImpl.java
└── controller/
└── AiChatController.java
3.3 核心接口
public interface ModelProvider {
/** @return 厂商类型枚举,如 DASHSCOPE */
AiModelProvider getProviderType();
/**
* 配置中的逻辑 id({@code app.ai.providers[].id}),
* 例如 {@code qwen-plus}、{@code deepseek-v3}。
* 注意:这是「配置条目 id」,不一定等于厂商侧的真实 model 名。
*/
String getModelId();
/**
* 当前 Provider 是否可用。
* 阶段 3 通常只看 {@code enabled};后续阶段可扩展健康检查。
*/
boolean isAvailable();
/**
* 执行一次对话调用,返回统一响应。
* 实现类应把厂商差异消化在内部,不要把厂商异常直接泄漏给业务。
*/
ChatResponse chat(ChatRequest request);
/** @return 该 Provider 绑定的静态配置快照 */
ModelInfo getModelInfo();
}
统一请求/响应(字段可按需增减):
@Data
@AllArgsConstructor
@NoArgsConstructor
public class ChatRequest {
/** 多轮消息列表;与 {@link #message} 二选一即可 */
private List<ChatMessage> messages;
/** 系统提示词;可与 messages 并存,也可单独使用 */
private String systemPrompt;
/** 采样温度;为空则用厂商/配置默认值 */
private Double temperature;
/** 最大生成 token;为空则用默认 */
private Integer maxTokens;
/**
* 可选:调用方指定要用哪条配置(providers[].id)。
* 为空则使用 {@code app.ai.active-provider}。
*/
private String modelId;
/**
* 兼容阶段 1/2 的简单入参:只传一句话。
* {@link #resolveMessages()} 会把它转成一条 user 消息。
*/
private String message;
/**
* 解析最终要发给模型的消息列表。
* <ul>
* <li>若 {@code messages} 非空,复制其内容,避免 Provider 修改调用方的原集合</li>
* <li>否则把简单字段 {@code message} 转成一条 user 消息</li>
* <li>存在顶层 {@code systemPrompt} 且列表里没有 system 消息时,将其放在首位</li>
* </ul>
* 此处统一合并 systemPrompt,三个 Provider 只需转换消息协议,避免某个实现重复
* 发送两条 system 消息。
*
* @return 一个新的、允许 Provider 安全遍历和转换的消息列表
*/
public List<ChatMessage> resolveMessages() {
List<ChatMessage> resolved = new ArrayList<>();
if (messages != null && !messages.isEmpty()) {
resolved.addAll(messages);
} else if (message != null && !message.isBlank()) {
resolved.add(new ChatMessage("user", message, null));
}
boolean hasSystemMessage = resolved.stream()
.filter(java.util.Objects::nonNull)
.anyMatch(item -> "system".equalsIgnoreCase(item.getRole()));
if (systemPrompt != null && !systemPrompt.isBlank() && !hasSystemMessage) {
resolved.add(0, new ChatMessage("system", systemPrompt, null));
}
return resolved;
}
}
/**
* 统一对话响应。由 AbstractModelProvider 填充 modelId / provider / latencyMs,
* 具体 Provider 至少填充 content(可选 token 用量)。
*/
@Data
public class ChatResponse {
/** 模型回复正文 */
private String content;
/** 提示词消耗 token(若厂商返回) */
private Integer promptTokens;
/** 生成消耗 token(若厂商返回) */
private Integer completionTokens;
/** 实际使用的配置 id(providers[].id) */
private String modelId;
/** 实际使用的厂商类型 */
private AiModelProvider provider;
/** 端到端耗时(毫秒),由 AbstractModelProvider 统一计算 */
private long latencyMs;
}
3.4 基类:把脏活留在一处
/**
* 模型提供方 SPI(阶段 3 核心抽象)。
* <p>
* 每个具体厂商(DashScope / OpenAI 兼容 / Ollama)各自实现本接口;
* 业务层只依赖本接口,从而可以在不改业务代码的前提下切换/新增厂商。
* </p>
*/
public interface ModelProvider {
/** @return 厂商类型枚举,如 DASHSCOPE */
AiModelProvider getProviderType();
/**
* 配置中的逻辑 id({@code app.ai.providers[].id}),
* 例如 {@code qwen-plus}、{@code deepseek-v3}。
* 注意:这是「配置条目 id」,不一定等于厂商侧的真实 model 名。
*/
String getModelId();
/**
* 当前 Provider 是否可用。
* 阶段 3 通常只看 {@code enabled};后续阶段可扩展健康检查。
*/
boolean isAvailable();
/**
* 执行一次对话调用,返回统一响应。
* 实现类应把厂商差异消化在内部,不要把厂商异常直接泄漏给业务。
*/
ChatResponse chat(ChatRequest request);
/** @return 该 Provider 绑定的静态配置快照 */
ModelInfo getModelInfo();
}
/**
* Provider 抽象基类:把「计时、填充元数据、异常包装」等脏活留在一处。
* <p>
* 子类只需实现 {@link #doChat(ChatRequest)} 完成真正的厂商调用。
* </p>
*/
public abstract class AbstractModelProvider implements ModelProvider {
/** 该 Provider 实例绑定的配置(id / type / model / endpoint / apiKey / enabled) */
protected final ModelInfo modelInfo;
protected AbstractModelProvider(ModelInfo modelInfo) {
this.modelInfo = modelInfo;
}
@Override
public AiModelProvider getProviderType() {
return modelInfo.getType();
}
@Override
public String getModelId() {
return modelInfo.getId();
}
@Override
public boolean isAvailable() {
return modelInfo.isEnabled();
}
@Override
public ModelInfo getModelInfo() {
return modelInfo;
}
/**
* 模板方法:统一计时与元数据填充,再委托子类 {@link #doChat}。
* <p>
* 阶段 3 失败时统一包装为 {@link IllegalStateException},便于后续 Router 捕获后重试/降级。
* </p>
*/
@Override
public ChatResponse chat(ChatRequest request) {
long start = System.currentTimeMillis();
try {
ChatResponse response = doChat(request);
// 回填「实际用了哪条配置 / 哪家厂商」,方便观测与排查
response.setModelId(getModelId());
response.setProvider(getProviderType());
response.setLatencyMs(System.currentTimeMillis() - start);
return response;
} catch (Exception e) {
throw new IllegalStateException(
"模型调用失败: " + getModelId() + ", cause=" + e.getMessage(), e);
}
}
/**
* 子类实现:调用具体厂商 API,并组装 {@link ChatResponse} 的业务字段(至少 content)。
* modelId / provider / latencyMs 由本基类统一填充,子类无需重复设置。
*/
protected abstract ChatResponse doChat(ChatRequest request);
}
3.5 具体 Provider 怎么写(原则)
| Provider | 推荐实现方式 |
|---|---|
DashScopeModelProvider | 注入已有 DashScopeChatModel / ChatModel,复用 Spring AI Alibaba 自动配置,不要重复配 API Key |
OpenAiModelProvider | OpenAI 兼容协议(含 DeepSeek 等):RestClient 调 /v1/chat/completions |
OllamaModelProvider | 调本地 http://host:11434/api/chat |
示意(OpenAI 兼容):
/**
* OpenAI 兼容协议适配器(覆盖 OpenAI、DeepSeek、以及其它 /v1/chat/completions 兼容服务)。
* <p>
* 请求:POST {@code {endpoint}/v1/chat/completions}(若 endpoint 已含 /v1 则不再重复拼接)。
* 鉴权:Authorization: Bearer {apiKey}
* </p>
*/
public class OpenAiModelProvider extends AbstractModelProvider {
private final RestClient restClient;
public OpenAiModelProvider(ModelInfo modelInfo, RestClient.Builder builder) {
super(modelInfo);
// 使用 Spring 注入的 Builder,便于复用连接池 / 超时配置
this.restClient = builder.baseUrl(trimTrailingSlash(modelInfo.getEndpoint())).build();
}
/**
* OpenAI 兼容接口除 enabled 开关外还必须有 API Key。把校验放在可用性判断中,
* 可以在本机未配置 DeepSeek Key 时继续使用 DashScope,同时在显式选择该模型时
* 得到“模型不可用”,而不是发送空的 Bearer Token。
*/
@Override
public boolean isAvailable() {
return super.isAvailable()
&& modelInfo.getApiKey() != null
&& !modelInfo.getApiKey().isBlank();
}
@Override
protected ChatResponse doChat(ChatRequest request) {
List<Map<String, String>> messages = toOpenAiMessages(request);
if (messages.isEmpty()) {
throw new IllegalArgumentException("对话消息不能为空");
}
/*
* 只发送调用方明确指定的可选参数。未传 temperature/maxTokens 时让厂商使用
* 自己的默认值,避免本适配器擅自改变各模型行为。
*/
Map<String, Object> body = new HashMap<>();
body.put("model", modelInfo.getModel());
body.put("messages", messages);
if (request.getTemperature() != null) {
body.put("temperature", request.getTemperature());
}
if (request.getMaxTokens() != null) {
body.put("max_tokens", request.getMaxTokens());
}
JsonNode root = restClient.post()
.uri(chatCompletionsPath(modelInfo.getEndpoint()))
.contentType(MediaType.APPLICATION_JSON)
.header("Authorization", "Bearer " + modelInfo.getApiKey())
.body(body)
.retrieve()
.body(JsonNode.class);
if (root == null) {
throw new IllegalStateException("OpenAI 兼容接口返回空响应");
}
JsonNode contentNode = root.path("choices").path(0).path("message").path("content");
if (contentNode.isMissingNode() || contentNode.isNull()) {
throw new IllegalStateException("OpenAI 兼容响应缺少 choices[0].message.content");
}
ChatResponse response = new ChatResponse();
response.setContent(contentNode.asText());
// usage 并非所有兼容厂商都会返回;仅在字段真实存在时记录,缺失时保持 null。
JsonNode usage = root.path("usage");
if (usage.has("prompt_tokens")) {
response.setPromptTokens(usage.get("prompt_tokens").asInt());
}
if (usage.has("completion_tokens")) {
response.setCompletionTokens(usage.get("completion_tokens").asInt());
}
return response;
}
private List<Map<String, String>> toOpenAiMessages(ChatRequest request) {
List<Map<String, String>> list = new ArrayList<>();
// systemPrompt 已由 ChatRequest.resolveMessages() 合并,不在此处重复添加。
for (ChatMessage m : request.resolveMessages()) {
validateMessage(m);
list.add(Map.of(
"role", normalizeRole(m.getRole()),
"content", m.getContent()));
}
return list;
}
/**
* OpenAI chat/completions 只接受固定角色。提前拒绝拼写错误,可以避免把非法角色
* 原样发送到远端后才得到难懂的 400 响应。
*/
private static String normalizeRole(String role) {
String normalized = role == null ? "user" : role.trim().toLowerCase(Locale.ROOT);
return switch (normalized) {
case "system", "user", "assistant" -> normalized;
default -> throw new IllegalArgumentException("不支持的消息角色: " + role);
};
}
private static void validateMessage(ChatMessage message) {
if (message == null) {
throw new IllegalArgumentException("messages 不能包含 null 元素");
}
if (message.getContent() == null || message.getContent().isBlank()) {
throw new IllegalArgumentException("消息内容不能为空");
}
}
/**
* 避免 endpoint 已是 {@code https://api.xxx.com/v1} 时再拼成 {@code /v1/v1/...}。
*/
private static String chatCompletionsPath(String endpoint) {
String e = endpoint == null ? "" : endpoint.toLowerCase();
return e.endsWith("/v1") || e.contains("/v1/")
? "/chat/completions"
: "/v1/chat/completions";
}
private static String trimTrailingSlash(String endpoint) {
if (endpoint == null) {
return "";
}
return endpoint.endsWith("/") ? endpoint.substring(0, endpoint.length() - 1) : endpoint;
}
}
3.6 Service:按 modelId / active-provider 选取
阶段 3 还没有 Router,Service 里做最简单的选择即可:
/**
* AI 对话业务接口(阶段 3)。
* <p>
* 业务层只认统一的 {@link ChatRequest} / {@link ChatResponse},
* 不直接依赖 DashScope / OpenAI / Ollama 等厂商 SDK。
* </p>
*/
public interface AiChatService {
/**
* 发起一次对话。
* <p>
* 选型规则:优先使用 {@code request.modelId};为空则使用配置项
* {@code app.ai.active-provider}。
* </p>
*
* @param request 统一请求(可含 message / messages / modelId 等)
* @return 统一响应(含 content、耗时、实际使用的 modelId 等)
*/
ChatResponse chat(ChatRequest request);
}
/**
* 阶段 3 对话服务实现:按 modelId / active-provider 选择 {@link ModelProvider}。
* <p>
* 本阶段不做 Router、重试、降级——只做「选一个可用 Provider 并调用」。
* </p>
*/
@Service
public class AiChatServiceImpl implements AiChatService {
/**
* key = provider 配置里的 id(如 qwen-plus、deepseek-v3),
* value = 对应厂商适配器实例。
*/
private final Map<String, ModelProvider> modelProviders;
/**
* 默认模型 id,对应 YAML:{@code app.ai.active-provider}。
* 当请求未传 modelId 时使用该值。
*/
private final String activeProviderId;
/**
* @param providerList Spring 注入的全部 ModelProvider;{@link Qualifier} 明确指定
* {@code modelProviderList} Bean,避免 Spring 把它误解为“收集所有
* ModelProvider Bean”
* @param properties {@code app.ai} 的类型安全配置;默认模型从这里读取,避免再用
* {@code @Value} 重复解析同一个配置项
*/
public AiChatServiceImpl(
@Qualifier("modelProviderList") List<ModelProvider> providerList,
AiModelProviderProperties properties) {
/*
* 将 List 转成 Map,后续可以 O(1) 按配置 id 查找。
* 重复 id 不是可恢复问题,因此直接抛错,而不是悄悄覆盖其中一个 Provider。
*/
this.modelProviders = Objects.requireNonNull(providerList, "Provider 列表不能为空")
.stream()
.collect(Collectors.toMap(
ModelProvider::getModelId,
Function.identity(),
(first, second) -> {
throw new IllegalStateException(
"存在重复的模型配置 id: " + first.getModelId());
}));
/*
* 原报错的根因就在赋值处:字段名和构造参数拼写不一致时,很容易写成
* this.activeProviderId = activeProviderId 之外的“字段自赋值”。
* 这里直接从配置对象取值,并在启动阶段校验,避免 final 字段未初始化。
*/
String configuredActiveProviderId = properties.getActiveProvider();
if (configuredActiveProviderId == null || configuredActiveProviderId.isBlank()) {
throw new IllegalStateException("配置 app.ai.active-provider 不能为空");
}
this.activeProviderId = configuredActiveProviderId.trim();
ModelProvider activeProvider = this.modelProviders.get(this.activeProviderId);
if (activeProvider == null) {
throw new IllegalStateException(
"app.ai.active-provider 未匹配任何 providers[].id: " + this.activeProviderId);
}
if (!activeProvider.isAvailable()) {
throw new IllegalStateException(
"app.ai.active-provider 指向了已禁用的模型: " + this.activeProviderId);
}
}
@Override
public ChatResponse chat(ChatRequest request) {
Objects.requireNonNull(request, "对话请求不能为空");
// 1) 决定使用哪个模型:请求显式指定优先,否则走默认 active-provider
String id = (request.getModelId() != null && !request.getModelId().isBlank())
? request.getModelId().trim()
: activeProviderId;
// 2) 从注册表取出 Provider;不存在或 enabled=false 则拒绝
ModelProvider provider = modelProviders.get(id);
if (provider == null || !provider.isAvailable()) {
throw new IllegalArgumentException("模型不存在或者不可用: " + id);
}
// 3) 委托具体厂商实现完成调用(业务层到此为止,不再出现厂商 API)
return provider.chat(request);
}
}
配置仍可先写死「当前用哪个 providerId」,例如:
app:
ai:
active-provider: qwen-plus
providers:
- id: qwen-plus
type: DASHSCOPE
model: qwen-plus
api-key: ${DASHSCOPE_API_KEY:}
- id: deepseek-v3
type: OPENAI_COMPATIBLE
endpoint: https://api.deepseek.com
api-key: ${DEEPSEEK_API_KEY:}
model: deepseek-chat
- id: ollama-local
type: OLLAMA
endpoint: http://127.0.0.1:11434
model: qwen2.5:7b
enabled: false
如何注入:
@Data
@ConfigurationProperties(prefix = "app.ai")
public class AiModelProviderProperties {
/**
* 默认激活的 provider id(对应 {@link ProviderItem#id})。
* YAML 键名:{@code active-provider}。
*/
private String activeProvider;
/** 可同时配置多个厂商/型号;每个条目会生成一个 ModelProvider 实例。 */
private List<ProviderItem> providers = new ArrayList<>();
/**
* 单条模型配置。
*/
@Data
public static class ProviderItem {
/** 逻辑 id,业务通过 modelId / active-provider 引用它 */
private String id;
/** 厂商类型:DASHSCOPE / OPENAI_COMPATIBLE / OLLAMA(大小写不敏感) */
private String type;
/** 厂商侧真实模型名,如 qwen-plus、deepseek-chat */
private String model;
/** OpenAI 兼容 / Ollama 需要;DashScope 可为空(复用 Spring AI 自动配置) */
private String endpoint;
/** API Key;YAML 键名 {@code api-key} → 字段 apiKey。勿把真实密钥提交到仓库。 */
private String apiKey;
/** 是否启用;Ollama 本地未启动时可先设 false */
private boolean enabled = true;
}
}
/**
* 阶段 3 AI 模块装配:读取 {@code app.ai.providers},按 type 创建对应 {@link ModelProvider}。
* <p>
* 注意:本阶段还没有 Router;不要在这里做重试/降级。
* 具体 Provider 不要再标 {@code @Component},否则会与本工厂双重注册。
* </p>
*/
@Configuration
@EnableConfigurationProperties(AiModelProviderProperties.class)
public class AiModelAutoConfiguration {
/**
* 根据 YAML 配置创建全部 ModelProvider,供 {@code AiChatServiceImpl} 注入使用。
*
* @param properties {@code app.ai.*} 绑定结果
* @param chatModel Spring AI 已自动配置的 DashScope ChatModel(复用,避免第二套 Key)
* @param restClientBuilder Spring 管理的 RestClient.Builder(连接池可复用)
*/
@Bean("modelProviderList")
public List<ModelProvider> modelProviderList(AiModelProviderProperties properties,
ChatModel chatModel,
RestClient.Builder restClientBuilder) {
List<ModelProvider> providers = new ArrayList<>();
/*
* 必须遍历 properties.getProviders()(YAML 配置项),不要对刚创建的空
* providers 结果列表做 forEach。没有配置任何 Provider 时直接启动会导致
* 第一次请求才暴露问题,因此这里选择“启动即失败”。
*/
List<AiModelProviderProperties.ProviderItem> items = properties.getProviders();
if (items == null || items.isEmpty()) {
throw new IllegalStateException("至少需要配置一个 app.ai.providers 条目");
}
for (AiModelProviderProperties.ProviderItem item : items) {
validateProviderItem(item);
ModelInfo modelInfo = ModelInfo.builder()
.id(item.getId())
.model(item.getModel())
.provider(item.getType())
.type(AiModelProvider.fromValue(item.getType()))
.endpoint(item.getEndpoint())
// YAML 的 api-key 先绑定到 Properties.apiKey,再复制到运行时配置快照
.apiKey(item.getApiKey())
.enabled(item.isEnabled())
.build();
validateProtocolSpecificFields(modelInfo);
switch (modelInfo.getType()) {
case DASHSCOPE -> providers.add(new DashScopeModelProvider(modelInfo, chatModel));
case OPENAI_COMPATIBLE -> providers.add(new OpenAiModelProvider(modelInfo, restClientBuilder));
case OLLAMA -> providers.add(new OllamaModelProvider(modelInfo, restClientBuilder));
}
}
return providers;
}
/**
* 在创建客户端之前校验公共必填项。
* <p>
* 这样错误会在应用启动时以明确的配置提示暴露,而不是在第一次调用模型时
* 变成难以定位的空指针或非法 URL。
* </p>
*/
private static void validateProviderItem(AiModelProviderProperties.ProviderItem item) {
if (item == null) {
throw new IllegalStateException("app.ai.providers 不能包含空条目");
}
if (item.getId() == null || item.getId().isBlank()) {
throw new IllegalStateException("app.ai.providers[].id 不能为空");
}
if (item.getType() == null || item.getType().isBlank()) {
throw new IllegalStateException(
"模型 " + item.getId() + " 的 providers[].type 不能为空");
}
if (item.getModel() == null || item.getModel().isBlank()) {
throw new IllegalStateException(
"模型 " + item.getId() + " 的 providers[].model 不能为空");
}
}
/**
* 校验不同协议额外需要的字段。禁用条目不会真正被调用,因此允许其暂时没有
* endpoint/key,方便开发阶段先保留配置模板。
*/
private static void validateProtocolSpecificFields(ModelInfo modelInfo) {
if (!modelInfo.isEnabled()) {
return;
}
if (modelInfo.getType() == AiModelProvider.OPENAI_COMPATIBLE) {
if (modelInfo.getEndpoint() == null || modelInfo.getEndpoint().isBlank()) {
throw new IllegalStateException(
"OpenAI 兼容模型 " + modelInfo.getId() + " 的 endpoint 不能为空");
}
}
}
}
3.7 阶段 3 验证清单
☐ 指定 modelId=qwen-plus 能通
☐ 指定 modelId=deepseek-v3 能通
☐ 改 active-provider,不传 modelId 也能切
☐ 业务代码(Service 以上)没有出现 DashScope/OpenAI 专用 API
3.8 阶段 3 结束时的能力边界
你已经可以「自由切换」。但仍缺:
- 主模型超时/5xx 时不会自动换备用
- 没有轮询、加权分流
- 选模逻辑还在 Service 里,后续会越来越胖
→ 进入阶段 4,把「选谁 + 失败怎么办」抽成 Router。
六、阶段 4:引入「一种」路由策略(建议先做 Priority)
目标:主模型失败时自动降级;同模型可有限次重试。先只实现 Priority(优先级) 一种策略。
4.1 为什么先做 Priority,而不是一次做四种策略
- 生产里最刚需的是 可用性:挂了能切走。
- Priority 语义简单:
priority越小越优先,失败则试下一个。 - 先跑通「选模 → 调用 → 失败重试/降级」这条主循环,再复制出其它策略。
4.2 本阶段新增目录
router/
├── ModelRouter.java
├── DefaultModelRouter.java
├── RouterContext.java
└── strategy/
├── RouterStrategy.java
└── PriorityStrategy.java
配置升级为路由段(示例):
app:
ai:
router:
strategy: priority
retry:
max-attempts: 3
backoff-ms: 1000
fallback:
enabled: true
providers:
- id: qwen-plus
type: DASHSCOPE
model: qwen-plus
api-key: ${DASHSCOPE_API_KEY:}
priority: 1
timeout: 30s
- id: deepseek-v3
type: OPENAI_COMPATIBLE
endpoint: https://api.deepseek.com
api-key: ${DEEPSEEK_API_KEY:}
model: deepseek-chat
priority: 2
timeout: 30s
- id: ollama-local
type: OLLAMA
endpoint: http://127.0.0.1:11434
model: qwen2.5:7b
priority: 99
enabled: false
4.3 核心接口
public interface ModelRouter {
ChatResponse route(ChatRequest request);
void register(ModelProvider provider);
}
public interface RouterStrategy {
ModelProvider select(List<ModelProvider> candidates, RouterContext context);
String getName();
}
RouterContext 至少记录:
- 原始
ChatRequest - 已尝试过的
modelId列表 - 当前重试次数
4.4 DefaultModelRouter 主循环(心智图)
route(request)
1. 过滤:enabled && isAvailable()
2. strategy.select(candidates, context) → 选出一个 Provider
3. provider.chat(request)
成功 → 返回
失败 → 同模型重试未超限? → 回到 3
→ 否则标记已尝试,若允许 fallback → 回到 2
4. 全部失败 → 抛异常(带上尝试轨迹,方便排查)
4.5 PriorityStrategy 规则
- 按
priority升序排列 - 跳过
context里已尝试的 id - 取第一个
4.6 Service 变薄
// AiChatServiceImpl
return modelRouter.route(request);
选模、重试、降级都不再写在业务里。
4.7 阶段 4 怎么验证(建议做故障演练)
- 正常:只开
qwen-plus,请求成功。 - 人为把主模型 api-key 配错,确认能降级到
deepseek-v3。 - 日志里应看到:
try qwen-plus → fail → try deepseek-v3 → ok。 - 两个都失败时,错误信息包含尝试过的模型列表。
进阶提醒:别把「所有异常」当成同一种失败
AbstractModelProvider 把 doChat 的异常统一包成 IllegalStateException,新手这样写没问题——Router 只要「失败就重试 / 降级」就能跑通。上线后你会发现失败并不都一样:
| 类型 | 典型例子 | 建议策略 |
|---|---|---|
| 瞬时故障 | 超时、网络抖动、429 / 5xx | 同模型可重试;仍失败再降级 |
| 模型侧不可用 | 模型名不存在、配额耗尽、区域不可用 | 一般 不要死磕重试,尽快降级到下一候选 |
| 配置/鉴权类 | API Key 错误、权限不足 | 应降级(保证可用性),但要打 ERROR + 告警:换模型掩不住根因,Key 配错不会靠重试好 |
可选演进(阶段 4 验收通过后再做,不必阻塞主线):
- 抛出
ModelTemporaryException(可重试)/ModelPermanentException(直接降级),或统一带retryable的异常 /Result,供DefaultModelRouter决策; - HTTP 状态码与厂商 error code 在 Provider 内翻译成上述三类,避免 Router 解析各家报文。
故障演练时可多加两笔:① 人为超时(看会不会先重试再降级);② 主模型 Key 错误(应降级,且日志能区分「鉴权失败」与「偶发超时」)。
七、阶段 5:多种路由策略可插拔
目标:通过配置切换策略,而不是改 Java。
5.1 策略对照表
| 策略 | 适用场景 | 选择逻辑 |
|---|---|---|
| priority | 主备、成本优先、质量优先 | 按 priority 依次尝试 |
| round-robin | 多账号/多实例均摊 | 轮流选下一个可用模型 |
| weighted-random | AB 实验、按比例分流 | 按 weight 加权随机 |
接口不变,新增实现类即可:
strategy/
├── RouterStrategy.java
├── PriorityStrategy.java
├── RoundRobinStrategy.java
└── WeightedRandomStrategy.java
并发提醒:策略类默认是 Spring 单例,多请求会并发进入
select()。共享计数器或Random必须线程安全;教学阶段先跑通语义,上线前务必检查。
// RoundRobinStrategy:用 AtomicInteger,不要用普通 int++
private final AtomicInteger counter = new AtomicInteger(0);
public ModelProvider select(List<ModelProvider> candidates, RouterContext context) {
List<ModelProvider> list = /* 过滤已尝试后的候选 */;
int idx = Math.floorMod(counter.getAndIncrement(), list.size());
return list.get(idx);
}
// WeightedRandomStrategy:用 ThreadLocalRandom.current(),不要共享一个 java.util.Random
int r = ThreadLocalRandom.current().nextInt(totalWeight);
5.2 配置切换
app:
ai:
router:
strategy: weighted-random # 改这里
providers:
- id: qwen-plus
weight: 50
priority: 1
- id: deepseek-v3
weight: 30
priority: 2
- id: qwen-max
weight: 20
priority: 3
装配时按名称注入对应 RouterStrategy Bean(可用 Map<String, RouterStrategy> + strategy 配置项)。
5.3 重试 vs 降级(别混)
| 概念 | 含义 | 典型触发 |
|---|---|---|
| 同模型重试 | 还是这个模型,再打一次 | 瞬时超时、429、网络抖动 |
| 降级(换模型) | 换下一个候选 | 连续失败、鉴权错误、模型不可用 |
建议:max-attempts 控制「整体尝试次数」或「每模型重试次数」——在文档和代码注释里写清楚一种语义,团队统一即可。新手推荐:
- 每个模型最多重试
N次 - 仍失败则换下一个 priority / 策略给出的下一个候选
5.4 阶段 5 完成标志
- 改
strategy即可换行为 - 新增策略 = 新类 + 注册 Bean,不动 Router 主循环
- Controller / Service 零感知
八、阶段 6(进阶展望):智能切换模型
阶段 5 之前的选模都是 静态规则(配置写死 priority/weight)。智能选模是下一层:
常见思路(实现可后置):
- 规则引擎:
taskType=code→ 强模型;taskType=classify→ 小模型 - 启发式:按 prompt 长度、是否需要工具调用、用户等级选模
- 学习式:根据历史成功率、延迟、费用做在线决策(需埋点与评测)
建议等你有了:
- 统一的
ChatResponse元数据(modelId、latency、token) - 基础路由与降级
再做智能层,把它做成 又一个 RouterStrategy(例如 SmartStrategy),而不是打散进业务代码。
九、目标架构总览(阶段 5 完成后的样子)
下面这张图是「进化完成后」的全景,用来对照,不是第 1 天的施工图。
┌─────────────────────────────────────────────────────────────┐
│ controller/ │
│ AiChatController (对外 API) │
├─────────────────────────────────────────────────────────────┤
│ service/ │
│ AiChatService / AiChatServiceImpl │
│ (业务编排:预处理、持久化、后处理) │
├─────────────────────────────────────────────────────────────┤
│ router/ ← 路由层 │
│ ModelRouter → DefaultModelRouter │
│ ├── RoundRobinStrategy (轮询) │
│ ├── PriorityStrategy (优先级降级) │
│ ├── Retry(可内嵌在 Router 或独立 Strategy) │
│ └── WeightedRandomStrategy(加权随机) │
├─────────────────────────────────────────────────────────────┤
│ provider/ ← 模型接入层 │
│ ModelProvider (SPI) │
│ ├── DashScopeModelProvider (阿里通义) │
│ ├── OpenAiModelProvider (OpenAI / DeepSeek 兼容) │
│ └── OllamaModelProvider (本地部署) │
├─────────────────────────────────────────────────────────────┤
│ config/ ← 配置层 │
│ AiModelAutoConfiguration │
│ AiRouterProperties / AiModelProviderProperties │
├─────────────────────────────────────────────────────────────┤
│ model/ ← 通用模型 │
│ ChatRequest / ChatResponse / ModelInfo / AiModelProvider │
└─────────────────────────────────────────────────────────────┘
9.1 目标目录结构
src/main/java/com/demo/www/ai/
├── config/
│ ├── AiModelAutoConfiguration.java
│ └── properties/
│ ├── AiRouterProperties.java
│ └── AiModelProviderProperties.java
├── model/
│ ├── AiModelProvider.java
│ ├── ChatRequest.java
│ ├── ChatResponse.java
│ └── ModelInfo.java
├── provider/
│ ├── ModelProvider.java
│ ├── AbstractModelProvider.java
│ ├── dashscope/DashScopeModelProvider.java
│ ├── openai/OpenAiModelProvider.java
│ └── ollama/OllamaModelProvider.java
├── router/
│ ├── ModelRouter.java
│ ├── DefaultModelRouter.java
│ ├── RouterContext.java
│ └── strategy/
│ ├── RouterStrategy.java
│ ├── RoundRobinStrategy.java
│ ├── PriorityStrategy.java
│ └── WeightedRandomStrategy.java
├── service/
│ ├── AiChatService.java
│ └── impl/AiChatServiceImpl.java
└── controller/
└── AiChatController.java
9.2 分层职责
| 层次 | 职责 | 关键模式 |
|---|---|---|
| provider | 屏蔽厂商 API 差异 | SPI 插件化 |
| router | 选择、重试、降级、负载均衡 | 策略模式 |
| service | 业务编排、会话/持久化 | Facade |
| config | YAML 驱动装配 | 配置与代码分离 |
接入新模型只需:① 实现
ModelProvider② YAML 加一条 ③ 不改路由与业务代码。
9.3 依赖关系
YAML
→ AiRouterProperties / providers[]
→ ModelInfo
→ ModelProvider 实现们
→ RouterStrategy
→ DefaultModelRouter
→ AiChatService
→ AiChatController
9.4 编码顺序(仅在「你已决定一次建到阶段 5」时使用)
若你已经走完阶段 1/2,准备一次性补齐阶段 3–5 的代码,推荐依赖从底向上:
1. model/ 零依赖
2. config/properties/ 依赖枚举
3. provider/ 依赖 model
4. router/ 依赖 provider
5. service/ 依赖 router
6. controller/ 依赖 service
7. AiModelAutoConfiguration 串联 Bean
8. 补全 application-*.yml
这和「产品进化阶段」不矛盾:进化阶段决定 什么时候引入哪一层;编码顺序决定 某一阶段内部先写谁。
十、AutoConfiguration 在做什么(阶段 4/5 再看也不迟)
AiModelAutoConfiguration
├── 启用 @ConfigurationProperties → AiRouterProperties
├── 按 YAML providers[].type / id 绑定到对应 ModelProvider,写入 ModelInfo
├── 按 router.strategy 选出 RouterStrategy
└── 注册 DefaultModelRouter(List<ModelProvider>, Strategy, Properties)
与现有 DashScope 共存的注意点:若项目已通过 spring.ai.dashscope 自动配置了 DashScopeChatModel,则 DashScopeModelProvider 应 注入并复用 它,避免两套 API Key / 两套客户端。
十一、跟练 TODO(按进化阶段勾选)
阶段 1 跑通单模型
☐ 建 Spring Boot 工程(JDK 17+)
☐ 引入 DashScope(或其它)starter
☐ 配置 api-key + model
☐ ChatClient 写通 /api/v1/ai/chat
☐ curl 验证
阶段 2 配置切换同厂商模型
☐ 只改 YAML 的 model,验证换模成功
☐ 记录:延迟 / 效果差异(建立对比习惯)
阶段 3 Provider 抽象
☐ model/:ChatRequest、ChatResponse、ModelInfo、枚举
☐ ModelProvider + AbstractModelProvider
☐ 至少 2 个具体 Provider(建议 DashScope + OpenAI 兼容)
☐ Service 按 modelId / active-provider 调用
☐ 业务层不再依赖具体厂商 SDK
阶段 4 单一路由(Priority)
☐ RouterContext / ModelRouter / PriorityStrategy
☐ DefaultModelRouter:重试 + 降级主循环
☐ 故障演练:主模型失败能切备用
阶段 5 多策略
☐ RoundRobin / WeightedRandom
☐ YAML 切换 strategy
☐ AB:看流量是否按 weight 大致分布
阶段 6(可选)
☐ 按 taskType / 长度等做 SmartStrategy
☐ 补齐 token、延迟、成功率观测
十二、常见坑(新手向)
- 密钥进仓库:一律环境变量或本地 profile。
- 阶段跨太大:没有单模型验证就写 Router,出问题不知道哪层坏了。
- Provider 里塞业务:会话存储、敏感词、计费应放在 Service,不要写进厂商适配器。
- 降级静默失败:降级成功也要打日志(从哪个 model 切到哪个),否则线上很难查。
- OpenAI 兼容 endpoint 尾巴:有的要
/v1,有的文档已含,拼接时统一处理,避免//v1或漏路径。 - 本地 Ollama 默认别开:
enabled: false,避免没装 Ollama 时拖垮健康检查。 - 所有异常一视同仁:超时与 Key 错误若不加区分,重试会空转、根因被降级掩盖——见 4.7 进阶提醒;生产可拆
ModelTemporaryException/ModelPermanentException。 - 策略 Bean 非线程安全:
RoundRobinStrategy/WeightedRandomStrategy是单例,普通int++或共享Random在并发下会乱序甚至越界——用AtomicInteger/ThreadLocalRandom(见 5.1 并发提醒)。这是上线极易踩的坑。 RestClient.create()当生产客户端:教学可以;生产不会复用连接池,高并发下易导致端口耗尽。应注入RestClient.Builder(或WebClient.Builder)统一超时与连接管理。
十三、小结
多模型接入不是「一开始就上完整路由框架」,而是:
- 先能聊(Spring AI 单模型)
- 再能换(配置切换)
- 再能插(Provider SPI)
- 再能托底(Router + Priority 降级)
- 再能玩法多样(多策略)
- 最后才智能(按场景选模)
每一阶段都留下可运行的系统;下一阶段只解决上一阶段露出来的痛点。这样你既学得会,也建得稳。
原理清楚之后,不必全程手敲:下一章把同一条进化路线翻译成 可复制的分阶段提示词,用 AI IDE 加速落地。
十四、Vibe Coding:用 AI IDE 把上文「进化式搭建」跑出来
上文用手搓帮助你理解原理。现实里你也可以用 Cursor / Trae / Windsurf 等 AI IDE,用自然语言让 Agent 按阶段写代码——第十四章不是附录彩蛋,而是跟练的第二条路径(手搓 / Vibe Coding 二选一或交替均可)。
推荐用法:
- 先读懂进化路线(第二节),再让 AI 写——你知道它该停在哪一阶段。
- 优先分阶段提示:每阶段结束用 curl / 故障演练验收,再进下一阶段。
- 一步到位提示只适合:你已理解架构、只想快速出脚手架时用;出问题再拆回分阶段修。
- 把 API Key 放环境变量,提示词里写
${DASHSCOPE_API_KEY}即可,不要把真实密钥贴进对话。 - 包名、JDK、Spring Boot / Spring AI 版本按你工程实际改;下面示例用
com.demo.www.ai。
14.1 分阶段提示词(推荐)
每个提示词默认前提:当前仓库已是 Spring Boot 3.x + JDK 17+ 工程。若从空目录开始,在阶段 1 提示词前加一句:「用 Maven 新建 Spring Boot 3 工程」。
阶段 1:跑通单模型
复制到 Agent:
请在当前 Spring Boot 3 工程中,用 Spring AI Alibaba 接入阿里云通义 DashScope,完成「最小可运行」对话接口。
要求:
1. 引入 spring-ai-alibaba-starter-dashscope(或当前推荐的等价依赖),版本与 Spring Boot 兼容。
2. application.yml 配置:
- spring.ai.dashscope.api-key: ${DASHSCOPE_API_KEY:}
- spring.ai.dashscope.chat.options.model: qwen-plus
3. 写一个 AiChatController:POST /api/v1/ai/chat,请求体 JSON 含 message 字段,返回模型文本。
4. 用 ChatClient(或 ChatModel)调用,代码尽量短,不要引入 Router / Provider。
5. 给出 curl 验证示例。
6. 不要把真实 API Key 写进仓库;用环境变量。
验收:服务启动后,curl 能拿到通义回复。
阶段 1 → 2:配置切换同厂商模型
在现有「DashScope 单模型对话」工程上做阶段 2,不要大改架构。
要求:
1. 只通过修改 application.yml 的 spring.ai.dashscope.chat.options.model,支持在 qwen-plus / qwen-turbo(或同类)之间切换。
2. Java 代码尽量不动;若必须动,说明为什么。
3. 在 README 或注释里写清:阶段 2 的边界是「同生态换型号」,还不能优雅接 OpenAI / Ollama。
4. 给出切换模型后的验证步骤,以及建议记录的对比项:延迟、效果差异。
验收:改 YAML + 重启(或热加载)后,响应里能看出用了新模型(或日志打印 model)。
阶段 2 → 3:引入 Provider 抽象
在现有可运行的 Spring AI 对话工程上,进入阶段 3:引入 Provider,支持多厂商切换。严格按「进化式」做,本阶段不要做 Router / 重试 / 降级。
包根路径:com.demo.www.ai
请新建:
- model/:AiModelProvider 枚举(DASHSCOPE / OPENAI_COMPATIBLE / OLLAMA)、ModelInfo、ChatRequest、ChatResponse
- provider/:ModelProvider 接口、AbstractModelProvider 基类
- dashscope/DashScopeModelProvider:复用已有 DashScopeChatModel / ChatModel,不要重复配一套 Key
- openai/OpenAiModelProvider:OpenAI 兼容协议,RestClient 调 {endpoint}/v1/chat/completions(覆盖 DeepSeek 等)
- ollama/OllamaModelProvider:调本地 /api/chat;默认 enabled=false
- service/:AiChatService + Impl,按 request.modelId,否则用 app.ai.active-provider 选 Provider
- controller/:对外仍暴露 POST /api/v1/ai/chat,请求体改为统一 ChatRequest(可兼容旧的 message 字段)
配置示例(写入 application.yml):
app:
ai:
active-provider: qwen-plus
providers:
- id: qwen-plus
type: DASHSCOPE
model: qwen-plus
api-key: ${DASHSCOPE_API_KEY:}
- id: deepseek-v3
type: OPENAI_COMPATIBLE
endpoint: https://api.deepseek.com
api-key: ${DEEPSEEK_API_KEY:}
model: deepseek-chat
- id: ollama-local
type: OLLAMA
endpoint: http://127.0.0.1:11434
model: qwen2.5:7b
enabled: false
约束:
1. 业务层(Service 以上)不得直接依赖 DashScope/OpenAI SDK 专用 API。
2. AbstractModelProvider 统一记 latencyMs、填充 modelId/provider、包装异常信息。
3. 本阶段不做路由策略、不做 AutoConfiguration 复杂装配,能用 @Component + @ConfigurationProperties 即可。
4. 给出验证清单:指定 modelId、改 active-provider、业务无厂商泄漏。
验收:至少 DashScope + 一个 OpenAI 兼容模型能通。
阶段 3 → 4:单一路由 Priority + 重试降级
在已有 Provider 多模型工程上进入阶段 4:引入 ModelRouter,先只实现 Priority 一种策略。
新增目录 com.demo.www.ai.router:
- ModelRouter、DefaultModelRouter、RouterContext
- strategy/RouterStrategy、PriorityStrategy
行为(DefaultModelRouter.route):
1. 过滤 enabled && isAvailable()
2. strategy.select(candidates, context) 选出 Provider
3. provider.chat;失败则同模型重试(max-attempts / backoff-ms)
4. 仍失败则标记已尝试,fallback.enabled 时选下一个;全部失败抛异常并带上尝试轨迹
5. PriorityStrategy:priority 升序,跳过已尝试 id,取第一个
配置改为:
app.ai.router.strategy: priority
app.ai.router.retry.max-attempts / backoff-ms
app.ai.router.fallback.enabled
providers 增加 priority、timeout;去掉仅靠 active-provider 硬选的逻辑(可保留 modelId 指定时优先该模型)
Service 变薄:AiChatServiceImpl 只调用 modelRouter.route(request)。
约束:
1. 不要一次实现 round-robin / weighted-random。
2. 降级成功必须打日志:from → to。
3. 给出故障演练步骤:主模型 Key 配错 → 应落到备用模型。
4. 异常分类进阶(可选):瞬时故障可重试,鉴权/模型不存在宜直接降级并告警(见文档 4.7)。
验收:正常请求成功;主模型失败能降级;双失败错误信息含尝试列表。
阶段 4 → 5:多策略可插拔
在已有 Priority 路由工程上进入阶段 5:策略可配置切换,不改业务代码。
新增:
- RoundRobinStrategy:在可用候选上轮询;计数器用 AtomicInteger,保证线程安全
- WeightedRandomStrategy:按 weight 加权随机;用 ThreadLocalRandom,勿共享 Random
保持 RouterStrategy 接口不变;DefaultModelRouter 主循环复用。
通过 app.ai.router.strategy: priority | round-robin | weighted-random 切换。
providers 增加 weight 字段。
可选:补一个简单的 AiModelAutoConfiguration,用 @ConfigurationProperties 绑定 AiRouterProperties,按 strategy 名称注入对应 RouterStrategy Bean。
约束:
1. 区分「重试」(同模型)与「降级」(换模型),配置与日志语义清晰。
2. 给出 AB 验证思路:weighted-random 下多次请求,流量大致按 weight 分布。
3. 不要做阶段 6 的智能选模。
验收:只改 YAML 的 strategy 即可切换行为;三种策略都能跑通。
(可选)阶段 5 → 6:智能选模展望
在阶段 5 多策略工程上,做一个「最小可用」的智能选模原型(可标为实验特性)。
要求:
1. 新增 SmartStrategy(或独立 SmartModelSelector):根据 ChatRequest 的简单信号选模型,例如:
- messages 总长度 / maxTokens 超过阈值 → 选强模型
- 短文本摘要/分类 → 选小模型或本地 Ollama
- 可先用规则 if/else,不要上复杂 ML
2. 配置里增加 taskType 或 rules 段(保持 YAML 驱动)。
3. 必须保留手动指定 modelId 的覆盖能力。
4. 补充最基本观测:每次响应带 modelId、latencyMs;日志打印选型原因。
5. 明确标注:这是进阶展望,规则需结合业务评测再固化。
验收:构造「短请求」与「长/复杂请求」各一次,走不同模型,且日志能说明为何如此选择。
14.2 一步到位的提示词(理解架构后再用)
适合:你已经读完本文阶段 1–5,想让 AI 一次性生成到「多策略可插拔」脚手架。
不适合:完全没跑通过单模型对话——出 bug 时很难分层排查。
请基于 Spring Boot 3 + JDK 17,按「进化完成后的目标架构」实现 LLM 多模型接入脚手架(做到阶段 5 即可,阶段 6 只留扩展点注释)。
包根:com.demo.www.ai
目录与职责:
- config/:AiModelAutoConfiguration;properties/AiRouterProperties、AiModelProviderProperties
- model/:AiModelProvider、ChatRequest、ChatResponse、ModelInfo
- provider/:ModelProvider、AbstractModelProvider
- dashscope/DashScopeModelProvider(复用 Spring AI Alibaba DashScopeChatModel)
- openai/OpenAiModelProvider(OpenAI 兼容 /v1/chat/completions,含 DeepSeek)
- ollama/OllamaModelProvider(本地,默认 enabled=false)
- router/:ModelRouter、DefaultModelRouter、RouterContext
- strategy/:RouterStrategy + PriorityStrategy + RoundRobinStrategy + WeightedRandomStrategy
- service/:AiChatService + Impl(只调 router)
- controller/:AiChatController,POST /api/v1/ai/chat
核心行为:
1. YAML 驱动注册多个 providers;strategy 可配置切换。
2. DefaultModelRouter:过滤可用 → 策略选模 → 同模型重试 → 失败降级 → 全失败抛带轨迹的异常。
3. Priority:priority 越小越优先;RoundRobin 均摊(AtomicInteger);WeightedRandom 按 weight(ThreadLocalRandom)。
4. 密钥全部环境变量;Ollama 默认关闭。
5. 若项目已有 spring.ai.dashscope 自动配置,DashScope Provider 必须复用,避免两套客户端。
6. OpenAI 兼容 Provider 生产环境注入 RestClient.Builder,勿只用 RestClient.create()。
7. 提供 example application.yml、curl 示例、简短 README(如何切 strategy、如何做主备故障演练)。
明确不要做:
- 不要在 Provider 里写会话存储/敏感词/计费
- 不要一上来做复杂智能选模(阶段 6 仅注释扩展点)
- 不要把真实 API Key 写入文件
验收标准:
- 能启动;至少一种云模型 chat 成功
- 改 strategy 无需改 Java
- 主模型 Key 错误时可降级到备用(priority 模式)
14.3 和 AI 协作时的小技巧
| 技巧 | 说明 |
|---|---|
| 一次只升一阶段 | 提示词结尾写清「不要提前实现下一阶段」 |
| 贴上文约束 | 把本文对应阶段的目录树 / 接口签名贴进对话,减少 AI 自由发挥 |
| 先验收再继续 | 每阶段用第十一节 TODO 勾选;红了先修,再发下一阶段提示词 |
| 让 AI 写验证 | 追加:「请补充集成测试或 shell 脚本做故障演练」 |
| 出问题就降级提问 | 「不要重构,只修:主模型失败没有降级」比「帮我优化架构」更有效 |
| 对照第十二节坑 | 密钥、阶段跨太大、Provider 塞业务、降级无日志、endpoint /v1 拼接、策略非线程安全、RestClient 连接池 |