Spring AI 多模型路由实战:如何在 DeepSeek、OpenAI 与本地模型之间动态切换
演示环境里,一个 ChatModel Bean 往往就能完成对话;进入生产环境后,模型调用却会迅速变成需要治理的基础能力。高质量云模型不能经济地承接所有简单请求,供应商限流与网络抖动会放大单点故障,内部合同和客户资料又不能离开内网,不同模型对上下文、结构化输出和工具调用的支持也并不相同。
因此,“动态切换模型”不能理解为在 Controller 里写三段 if-else。一个可维护的多模型方案需要把业务意图翻译成可计算条件,以统一协议屏蔽供应商差异,在超时、限流和预算不足时执行有边界的降级,并通过指标验证质量、成本与可用性。本文以抽奖系统等其它系统的运营助手为例:在企业级应用中,活动规则查询偏重准确与低延迟,营销文案更看重表达质量,内部经营分析则包含不能出网的字段。模型名称、价格与限额均应以实际账号和所用 Spring AI 版本为准。
一、把一次请求建模为可审计的路由上下文
“复杂问题走强模型”不是可执行规则,因为复杂度没有定义。更稳妥的方式是先把业务事实收敛为不可变的 RouteContext。它只描述请求约束,不直接指定供应商:
/**
* AI任务类型枚举
* 用于定义不同的业务AI场景,路由算法可根据任务类型差异化打分、选择最优模型
* FAQ场景侧重低成本、低延迟,文案场景侧重生成质量,数据分析侧重准确性与数据安全
*/
public enum TaskType {
/** 智能问答、规则咨询、常见问题解答 */
FAQ,
/** 营销文案、海报文案、话术生成 */
COPYWRITING,
/** 内部经营数据、业务报表、数据分析推理 */
DATA_ANALYSIS,
/** 代码补全、代码解释、代码纠错、开发辅助 */
CODE_ASSIST
}
/**
* 数据密级等级枚举
* 核心安全约束,用于控制模型调用的出网权限,杜绝敏感数据外泄
*/
public enum DataLevel {
/** 公开数据:可调用任意云端大模型,无安全限制 */
PUBLIC,
/** 内部数据:仅限内网环境模型,禁止公网出网 */
INTERNAL,
/** 机密数据:核心客户资料、隐私信息、核心业务数据,仅本地私有化模型可用 */
CONFIDENTIAL
}
/**
* AI请求路由上下文实体(不可变记录类)
* 收敛所有AI请求的业务约束、Token预算、能力需求、安全等级
* 核心作用:将模糊的业务需求,转化为可代码计算、可审计的路由规则
* 所有模型过滤、打分、降级、预算校验均基于该上下文执行
*
* @param requestId 请求唯一ID,用于链路追踪、日志排查、故障复盘
* @param tenantId 租户ID,用于多租户隔离、套餐权限校验
* @param taskType AI业务任务类型,区分不同场景的模型偏好策略
* @param dataLevel 数据安全密级,核心出网安全约束
* @param estimatedInputTokens 预估输入Token数(用户提问+系统提示词+检索内容)
* @param reservedOutputTokens 预留输出Token上限(匹配模型最大输出配置)
* @param toolDefinitionTokens 工具定义Token数(Agent工具调用场景的Schema描述占用Token)
* @param safetyMarginTokens Token预算安全冗余,防止上下文溢出、预留波动空间
* @param needJsonSchema 是否需要严格JSON结构化输出(硬约束:过滤不支持结构化的模型)
* @param needToolCalling 是否需要工具调用能力(硬约束:过滤无工具能力的模型)
* @param streaming 是否流式响应(区分普通响应/流式响应,适配不同降级策略)
* @param serviceLevel 服务等级(SLA等级,区分普通/高级用户,控制优先级与超时策略)
*/
public record RouteContext(
String requestId,
String tenantId,
TaskType taskType,
DataLevel dataLevel,
int estimatedInputTokens,
int reservedOutputTokens,
int toolDefinitionTokens,
int safetyMarginTokens,
boolean needJsonSchema,
boolean needToolCalling,
boolean streaming,
String serviceLevel) {
/**
* 紧凑构造器(JDK Record专属)
* 全局Token预算参数合法性校验
* 禁止任意Token参数为负数,避免路由计算数值异常
*/
public RouteContext {
if (estimatedInputTokens < 0
|| reservedOutputTokens < 0
|| toolDefinitionTokens < 0
|| safetyMarginTokens < 0) {
throw new IllegalArgumentException(
"token budgets must be non-negative");
}
}
/**
* 计算当前请求所需的总上下文Token容量
* 包含输入、输出、工具定义、安全冗余全部占用量
* 用于前置过滤:判断模型上下文窗口是否能够承载当前请求
*
* @return 总所需Token数
*/
public long requiredContextTokens() {
return (long) estimatedInputTokens
+ reservedOutputTokens
+ toolDefinitionTokens
+ safetyMarginTokens;
}
}
estimatedInputTokens 不必在路由前做到绝对精确,但不能把 Java 字符数直接等同于 Token 数。工程上可按目标模型的 tokenizer 预估,或者采用“字符估算 + 安全系数”,真正调用前仍由适配器执行一次窗口校验。reservedOutputTokens 必须与最终 ModelRequest 的输出上限来自同一份请求策略,toolDefinitionTokens 包含本轮发送的工具 Schema;没有工具时为 0。把这些值收敛进 RouteContext,候选过滤和调用前校验才能使用同一预算口径。
数据等级必须来自可信业务规则。例如上传文件属于某客户租户且包含身份证字段,服务端在解析阶段就将其标为 CONFIDENTIAL。如果让模型依据 Prompt 自行决定数据能否出网,提示词注入就可能绕过安全边界。租户套餐同样由鉴权上下文提供,不能接受请求体中随意传入的“高级用户”标志。
还应把“能力要求”与“模型偏好”区分开。needJsonSchema=true 表示调用方必须得到可验证的结构化结果,这是硬约束;“文案更喜欢某模型”只是评分项。硬约束用于过滤候选,软偏好用于排序。两者混用会导致降级模型虽然能返回文本,却根本无法交付业务所需的格式。
二、用能力目录描述模型,而不是散落模型名称
路由器需要一份模型能力目录。目录可以来自配置中心,也可以在启动时注册。至少应描述上下文上限、部署位置、工具调用与结构化输出能力、逻辑模型组、超时预算和运行状态。
/**
* 大模型能力档案配置实体(不可变记录类)
* 统一维护所有接入大模型的能力属性、资源限制、成本参数
* 作为模型路由的核心数据源,为路由过滤、评分、择优提供可计算依据
* 所有模型能力、安全、容量、成本差异统一收敛在此配置,避免硬编码判断
*
* @param id 模型唯一标识(路由核心匹配ID,全局唯一)
* @param provider 模型供应商(deepseek/openai/local等)
* @param modelName 模型具体名称(对应供应商真实模型名)
* @param local 是否为本地私有化部署模型(核心安全字段,区分内网/公网模型)
* @param supportsTools 是否支持工具调用能力
* @param supportsJsonSchema 是否支持原生JSON Schema结构化输出
* @param maxContextTokens 模型最大上下文Token窗口容量
* @param priority 模型基础优先级(路由打分基础值,数值越小优先级越高)
* @param estimatedInputCostPerMillion 每百万输入Token预估成本
* @param estimatedOutputCostPerMillion 每百万输出Token预估成本
*/
public record ModelProfile(
String id,
String provider,
String modelName,
boolean local,
boolean supportsTools,
boolean supportsJsonSchema,
int maxContextTokens,
int priority,
BigDecimal estimatedInputCostPerMillion,
BigDecimal estimatedOutputCostPerMillion) {
/**
* 校验当前模型是否满足本次AI请求的所有硬约束条件
* 路由第一阶段核心过滤方法:不满足条件的模型直接淘汰,不进入评分择优阶段
* 校验维度:数据安全合规性、功能能力匹配、上下文容量充足性
*
* @param ctx AI请求路由上下文,包含本次所有请求约束
* @return true=模型满足所有硬约束,可参与路由择优;false=模型直接过滤淘汰
*/
public boolean satisfies(RouteContext ctx) {
// 安全约束:机密数据禁止调用公网云端模型,防止敏感数据外泄
if (ctx.dataLevel() == DataLevel.CONFIDENTIAL && !local) {
return false;
}
// 能力约束:需要工具调用时,过滤不支持工具能力的模型
if (ctx.needToolCalling() && !supportsTools) {
return false;
}
// 能力约束:需要结构化JSON输出时,过滤不支持Schema校验的模型
if (ctx.needJsonSchema() && !supportsJsonSchema) {
return false;
}
// 容量约束:校验模型上下文窗口是否足以承载本次请求全部Token开销
return ctx.requiredContextTokens() <= maxContextTokens;
}
}
能力目录不要永久硬编码“某供应商一定支持什么”。同一供应商的不同模型、同一模型的不同部署版本都可能存在差异,应按部署 ID 维护能力,并在灰度验证通过后更新。对于本地模型,还要把 GPU 节点可用区、量化版本和最大并发纳入运行时状态,而不是只配置一个 URL。
成本字段也只是估算参数。结算价格可能变化,命中缓存、批处理以及输入输出 Token 的计费规则也不同。路由器可以用估算成本做相对比较,账单核对则应由独立的成本归集任务完成,不能把路由估值直接当成财务数据。
三、统一模型网关与 Spring AI 适配层
不同模型在消息格式、异常码、流式事件、用量字段和结构化输出支持上存在差异。如果业务层直接注入三个供应商客户端,差异会蔓延到 Service 和 Controller。可以先定义一个尽量小的网关协议:
/**
* 大模型统一请求实体(网关标准协议)
* 抹平各厂商模型的入参差异,对外提供统一、通用的AI调用请求结构
* 所有业务层、路由层请求均构造该对象,由适配器适配为不同厂商参数
*
* @param systemPrompt 系统提示词,用于定义模型角色、行为规则、输出规范
* @param userPrompt 用户输入提问内容/业务指令
* @param variables 模板变量参数,用于动态填充Prompt模板
* @param temperature 温度系数,控制随机性(0=严谨确定,1=创意随机)
* @param maxOutputTokens 最大输出Token上限,限制模型生成长度,防止超限
*/
public record ModelRequest(
String systemPrompt,
String userPrompt,
Map<String, Object> variables,
Double temperature,
Integer maxOutputTokens) {
}
/**
* 大模型统一响应实体(网关标准协议)
* 归一化所有模型厂商的返回结果、耗时、用量、状态信息
* 业务层无需感知底层是OpenAI、DeepSeek还是本地模型,完全解耦
*
* @param content 模型生成的最终文本内容
* @param modelId 实际调用的模型唯一ID,用于溯源、日志、成本统计
* @param firstTokenMillis 首字响应耗时(ms),用于用户体验监控、流式SLA统计
* @param totalMillis 本次请求总耗时(ms),用于接口耗时观测与告警
* @param inputTokens 本次输入消耗Token数,用于成本核算与用量统计
* @param outputTokens 本次输出消耗Token数,用于成本核算与用量统计
* @param finishReason 模型结束原因(正常结束/超长截断/安全拦截/异常终止)
*/
public record ModelResponse(
String content,
String modelId,
long firstTokenMillis,
long totalMillis,
long inputTokens,
long outputTokens,
String finishReason) {
}
/**
* 模型调用适配器顶层接口
* 适配层核心接口,遵循适配器设计模式
* 统一所有大模型的调用入参、出参、异常、超时规范
* 新增模型只需实现该接口,无需改动路由与业务代码,符合开闭原则
*/
public interface ModelAdapter {
/**
* 获取当前适配器绑定的模型唯一ID
*
* @return 模型唯一标识
*/
String modelId();
/**
* 执行大模型同步调用
*
* @param request 统一模型请求参数
* @param timeout 本次请求超时时间预算
* @return 归一化后的统一模型响应结果
*/
ModelResponse call(ModelRequest request, Duration timeout);
}
每个 ModelAdapter 内部可以基于对应的 Spring AI ChatClient 构建请求,但对外统一返回 ModelResponse。通用 system prompt、日志观察器和内容过滤 Advisor 可放在客户端构建阶段,模型专属选项则留在适配器内部。业务层因此不需要知道 DeepSeek 是否通过 OpenAI 兼容协议接入,也不关心本地部署使用 Ollama 还是其他推理服务。
/**
* Spring AI 通用模型适配器实现
* 基于 Spring AI ChatClient 封装所有云端/本地模型调用逻辑
* 屏蔽不同供应商(DeepSeek/OpenAI/Ollama)协议差异,对外输出统一响应结构
* 统一处理请求构造、耗时统计、结果归一化,实现业务与底层模型解耦
*/
public final class SpringAiModelAdapter implements ModelAdapter {
/** 当前适配器对应的模型唯一ID */
private final String modelId;
/** Spring AI 核心对话客户端,封装模型底层调用能力 */
private final ChatClient chatClient;
/**
* 构造方法:绑定模型ID与对应ChatClient实例
*
* @param modelId 模型唯一标识
* @param chatClient Spring AI 对话客户端
*/
public SpringAiModelAdapter(String modelId, ChatClient chatClient) {
this.modelId = modelId;
this.chatClient = chatClient;
}
/**
* 获取当前模型唯一标识
*/
@Override
public String modelId() {
return modelId;
}
/**
* 执行模型调用,统一封装请求、耗时、结果归一化逻辑
*
* @param req 网关统一请求参数
* @param timeout 本次请求超时时间预算
* @return 归一化后的模型响应
*/
@Override
public ModelResponse call(ModelRequest req, Duration timeout) {
// 记录开始时间,用于统计总耗时
long start = System.nanoTime();
// 构建Spring AI请求并调用模型
ChatResponse response = chatClient.prompt()
.system(req.systemPrompt())
.user(u -> u.text(req.userPrompt()).params(req.variables()))
.call()
.chatResponse();
// 统计本次请求总耗时
long total = Duration.ofNanos(System.nanoTime() - start).toMillis();
// 归一化不同模型的返回结果,统一封装为网关响应实体
return ModelResponses.normalize(modelId, response, total);
}
}
/**
* 模型响应归一化工具类(工具方法示意)
* 作用:统一不同厂商模型的Token用量、结束原因、首帧耗时等字段
* 消除底层模型返回字段差异,保证上层观测、成本统计一致
*/
public final class ModelResponses {
public static ModelResponse normalize(String modelId, ChatResponse response, long totalMillis) {
// 归一化适配逻辑,根据不同模型解析对应字段
return new ModelResponse(
response.getResult().getOutput().getText(),
modelId,
0,
totalMillis,
(long) response.getUsage().getInputTokens(),
(long) response.getUsage().getOutputTokens(),
response.getFinishReason()
);
}
}
上述代码是Spring AI 在一定版本状况下的示例,具体元数据访问方法应与项目锁定的 Spring AI 版本一致。更关键的是,timeout 不能只作为方法参数摆设:实际项目要在 HTTP 客户端层设置连接、读取和整体响应超时,并在执行器外层设置业务总预算。只让 Future 超时却不取消底层 HTTP 请求,会出现调用方已经降级,旧请求仍占用连接池和供应商并发的情况。
四、路由算法:先过滤硬约束,再对软目标评分
我问AI,他推荐了“两阶段选择”的路由算法。更容易解释和回放。第一阶段过滤不满足安全、上下文和功能要求的模型;第二阶段按任务质量偏好、健康度、延迟和成本计算分数。相比一开始就训练不可解释的路由模型,规则评分更适合第一版,因为线上每次选择都能说明原因。
/**
* 大模型路由核心组件
* 采用【硬约束过滤 + 软目标评分】两阶段路由算法
* 可解释、可回放、可灰度,规避黑盒AI路由不可控问题
* 动态根据请求约束、模型健康度、成本、质量择优选择模型
*/
@Component
public class ModelRouter {
/** 模型能力目录:存储所有已注册模型的能力配置 */
private final ModelCatalog catalog;
/** 模型健康度快照:实时获取模型延迟、失败率、流量状态 */
private final HealthSnapshot health;
/**
* 获取本次请求的所有可用候选模型
* 第一阶段:硬约束过滤,淘汰不满足安全、能力、容量、健康条件的模型
* 第二阶段:根据评分排序,分数越小模型优先级越高
*
* @param ctx 路由上下文,包含本次所有请求约束
* @return 排序后的可用模型列表
*/
public List<ModelProfile> candidates(RouteContext ctx) {
return catalog.enabledProfiles().stream()
// 过滤1:满足本次请求所有硬约束(安全、能力、Token容量)
.filter(p -> p.satisfies(ctx))
// 过滤2:模型健康、正常承接流量(熔断、故障模型直接剔除)
.filter(p -> health.of(p.id()).acceptingTraffic())
// 按综合评分升序排序,分数越小优先级越高
.sorted(Comparator.comparingDouble(p -> score(p, ctx)))
.toList();
}
/**
* 模型综合评分算法(软目标择优)
* 加权维度:基础优先级、P95延迟、失败率、场景质量/成本权重
* 文案场景优先质量,其余场景优先低成本
*
* @param p 待评分模型档案
* @param ctx 请求路由上下文
* @return 模型综合评分
*/
private double score(ModelProfile p, RouteContext ctx) {
// 1. 基础优先级分数
double result = p.priority();
// 2. 延迟惩罚:P95越高,分数越高(越差)
result += health.of(p.id()).p95Millis() / 500.0;
// 3. 故障惩罚:失败率越高,分数越高(越差)
result += health.of(p.id()).recentFailureRate() * 20;
// 4. 场景差异化打分:文案场景看质量,其他场景控成本
result += ctx.taskType() == TaskType.COPYWRITING
? qualityPenalty(p.id())
: estimatedCostPenalty(p, ctx);
return result;
}
/**
* 质量惩罚分:质量越差,惩罚分越高
*/
private double qualityPenalty(String modelId) {
// 离线评测质量得分换算惩罚
return 0.0;
}
/**
* 成本惩罚分:单位Token成本越高,惩罚分越高
*/
private double estimatedCostPenalty(ModelProfile p, RouteContext ctx) {
// 基于输入输出Token预估成本计算加权分数
return 0.0;
}
}
/**
* 模型目录、健康快照、异常、尝试记录 基础示意类
*/
// 模型目录:加载配置中心所有模型能力配置
public interface ModelCatalog {
List<ModelProfile> enabledProfiles();
}
// 模型健康度快照:滑动窗口统计延迟、失败率、流量状态
public interface HealthSnapshot {
HealthInfo of(String modelId);
interface HealthInfo {
boolean acceptingTraffic();
long p95Millis();
double recentFailureRate();
}
}
// 模型调用异常枚举分类
public enum ErrorCategory {
TEMPORARY_FAULT, CAPACITY_FAULT, PERMANENT_ERROR,
SAFETY_REJECT, PARSE_ERROR, UNKNOWN;
public boolean allowsFallback() {
return this == TEMPORARY_FAULT || this == CAPACITY_FAULT;
}
}
// 模型单次尝试记录,用于故障复盘
public record ModelAttempt(String modelId, ErrorCategory category, boolean success) {
public static ModelAttempt failed(String id, ErrorCategory c) {
return new ModelAttempt(id, c, false);
}
}
// 自定义模型调用异常
public class ModelCallException extends RuntimeException {
private final ErrorCategory category;
public ErrorCategory category() { return category; }
}
// 全部模型不可用异常
public class AllModelsUnavailableException extends RuntimeException {
public AllModelsUnavailableException(String requestId, List<ModelAttempt> attempts) {}
}
给出的示例约定分数越小越优。不要让实时指标未经平滑就直接参与路由,否则一两个慢请求会造成流量在模型之间反复摆动。健康快照应使用滑动窗口或指数移动平均,并设置最小样本数;熔断恢复时只开放少量探测流量,确认稳定后再逐步恢复权重。
对运营助手可形成清晰策略:公开 FAQ (智能问答、规则咨询、常见问题解答)优先低成本云模型,本地模型作为备用;高质量文案优先离线评测得分更好的云模型;内部数据分析只保留本地候选;需要特定工具或 JSON Schema 时先按能力过滤。若过滤后候选为空,应返回明确的“不满足执行条件”,不能为了表面成功率偷偷把敏感数据发往公网。
五、执行与降级:先定义错误分类
降级的关键不在于“多试几个”,而在于哪些错误允许重试或换模型。建议把供应商异常归一化为稳定枚举,而不是在错误消息中搜索字符串。
| 错误类型 | 典型情况 | 建议动作 |
|---|---|---|
| 短暂故障 | 连接重置、服务端 5xx | 在剩余预算内有限重试或换候选 |
| 容量故障 | 429、推理队列已满 | 尊重重试时间,切换并限制流量 |
| 永久请求错误 | 参数非法、上下文超限 | 修正请求,不盲目换模型 |
| 安全拒绝 | 内容策略、权限限制 | 返回可解释拒绝,不能绕过 |
| 业务解析错误 | JSON 不符合 Schema | 最多一次修复提示,仍失败则终止 |
| 结果未知 | 超时但服务端可能已完成 | 纯生成可谨慎重试,副作用工具先确认 |
执行器要携带总时间预算。假设接口 SLA (服务等级协议,Service Level Agreement:系统承诺给用户的性能、可用性、超时标准)为 8 秒,主模型已经消耗 7.5 秒,再调用备用模型通常没有意义。可以按候选分配预算,并把连接等待、线程排队都算入总耗时。
/**
* AI请求执行器 & 降级容错核心类
* 核心能力:时间预算管控、分级重试、模型自动降级、防流量放大
* 基于错误分类执行差异化容错策略,杜绝盲目重试、无效降级
*/
@Component
public class ModelGatewayExecutor {
private final ModelRouter router;
private final Map<String, ModelAdapter> adapters;
/**
* 执行AI模型调用,自动路由、重试、降级
* 核心规则:全局时间预算优先、剩余时间不足直接终止、仅可容错异常允许降级
*
* @param ctx 路由上下文
* @param request 统一模型请求
* @return 模型归一化响应结果
*/
public ModelResponse execute(RouteContext ctx, ModelRequest request) {
// 全局SLA时间预算:整体接口最大超时8秒
Deadline deadline = Deadline.after(Duration.ofSeconds(8));
// 记录所有尝试记录,用于日志复盘与可观测统计
List<ModelAttempt> attempts = new ArrayList<>();
// 遍历排序后的最优候选模型,依次尝试调用
for (ModelProfile profile : router.candidates(ctx)) {
// 剩余时间不足500ms,不再尝试新模型,直接终止
if (deadline.remaining().compareTo(Duration.ofMillis(500)) < 0) {
break;
}
try {
// 取剩余时间与模型独立超时的最小值,分配单次调用预算
Duration budget = min(deadline.remaining(), timeoutOf(profile.id()));
// 执行模型调用
return adapters.get(profile.id()).call(request, budget);
} catch (ModelCallException ex) {
// 记录本次失败尝试
attempts.add(ModelAttempt.failed(profile.id(), ex.category()));
// 不可降级异常(参数错误/安全拦截),直接抛出,不继续尝试
if (!ex.category().allowsFallback()) {
throw ex;
}
}
}
// 所有候选模型全部尝试失败,抛出统一异常
throw new AllModelsUnavailableException(ctx.requestId(), attempts);
}
/**
* 获取指定模型的独立超时配置
*/
private Duration timeoutOf(String modelId) {
return Duration.ofSeconds(3);
}
/**
* 取两个时长最小值
*/
private Duration min(Duration d1, Duration d2) {
return d1.compareTo(d2) < 0 ? d1 : d2;
}
}
重试与降级必须防止流量放大。每个候选最多一到两次尝试,总尝试次数有硬上限;熔断的模型不进入普通候选;备用模型保留独立并发配额,避免主模型故障时全部流量同时冲垮备用。批量离线任务可以返回稍后重试,让在线请求优先使用最后的容量。
六、流式响应有一条不可跨越的切换边界(流式调用与非流式调用的切换取舍)
非流式调用在返回前失败,可以更换模型并让用户无感知;流式调用一旦输出首个业务 Token,语义就完全不同。此时切换可能造成语气突变、重复段落,甚至半个 JSON 接上另一份 JSON,客户端无法判断哪部分可信。
工程上应定义明确规则:首个业务 Token 发出前允许切换,发出后只允许结束、提示中断或由用户显式重试。 流式适配器应区分“连接建立”“收到供应商事件”“向客户端提交首帧”三个时刻。心跳帧和元数据帧是否算业务输出,也要在服务端与客户端协议中约定。
对于必须生成完整 JSON 的任务,不建议直接向用户透传模型原始流。可以在服务端缓冲,完成 Schema 校验后一次性返回;或者设计可增量校验的事件协议,不能把半成品对象当作成功结果。
监控中需分别记录首帧前失败和首帧后失败。只看接口 200 比例会掩盖“经常输出一半断开”这种严重体验问题。流式结果若已经产生部分计费 Token,也要计入成本,不能只统计最终成功请求。
七、配置、密钥与故障域隔离
多模型配置应把业务路由参数和供应商连接参数分开。前者可由配置中心动态调整,后者包含密钥和内网地址,需要更严格的访问控制。下面只是结构示意,模型能力必须通过实际版本验证:nacos等配置中心的yaml文件
ai:
routing:
total-timeout: 8s
max-attempts: 3
profiles:
deepseek-chat:
provider: deepseek
model: ${DEEPSEEK_MODEL}
local: false
openai-quality:
provider: openai
model: ${OPENAI_MODEL}
supports-json-schema: true
local: false
local-private:
provider: local
model: ${LOCAL_MODEL}
endpoint: ${LOCAL_MODEL_ENDPOINT}
local: true
max-context-tokens: 16000
max-concurrency: 4
API Key 应通过密钥管理服务、环境变量或工作负载身份注入,不进入 Git,不回显到 Actuator,也不写进异常栈。即使 DeepSeek 通过 OpenAI 兼容协议接入,也应创建独立客户端、连接池和观测标签,不能因为协议兼容就共用限流状态。
多供应商还不等于多故障域。如果三个客户端共用同一公网出口、代理、DNS 或凭证服务,它们可能同时失败。高可用评审要把网络、区域、连接池、账号配额和部署节点分别画出来。备用模型的并发能力也要定期演练,不能只在主模型真正故障时才首次承接峰值流量。
本地模型没有公网账单,却不是免费资源。GPU 折旧、电力、节点空闲率、镜像维护和扩容时间都应进入容量决策。云与本地的比较应同时看单位请求成本、峰值容量、质量门槛和数据安全要求。
八、处理上下文、结构化输出与跨模型差异
模型切换并非只替换 URL。不同模型的系统消息、角色顺序、停止词、最大输出、工具协议和 JSON 严格程度可能不同。统一网关维护规范请求,适配器负责转换,但不能假设所有能力都能无损转换。
例如主模型支持原生 JSON Schema,备用模型只支持提示词约束。若业务要求严格结构,就应将 supportsJsonSchema 作为硬能力;若业务允许弱化,则显式定义另一个降级等级,并在结果中标记 validationMode=prompt_only,由调用方决定是否接受,而不是静默降低保证。
长上下文也不能只看宣传窗口。系统提示、历史对话、检索片段、工具描述和预留输出都占用空间。调用前应计算:
estimatedInputTokens <= maxContextTokens - reservedOutputTokens - toolDefinitionTokens - safetyMarginTokens
超限时优先压缩历史、减少低相关检索片段或让用户缩小范围。简单从尾部截断可能恰好丢掉最新问题。每次裁剪策略和前后 Token 数都应写入 Trace,以便解释质量变化。模型输出进入业务系统前还要经过空结果、长度、Schema、引用和业务枚举校验;路由成功只表示模型返回了数据,并不代表结果已经可用。
九、可观测性与成本核算
至少为每次请求记录:requestId、任务类型、数据等级、候选列表、最终模型、规则版本、尝试次数、错误类别、总耗时、首字延迟、输入输出 Token、估算成本和是否降级。Prompt 原文和用户资料应遵循最小化原则,通常记录模板版本、摘要和长度即可。
核心指标分成可用性、体验、质量和成本四组。可用性包括成功率、超时率、限流率与候选耗尽率;体验包括 P95、首字延迟和流中断率;质量包括固定测试集得分、结构解析成功率与任务完成率;成本与容量包括平均 Token、本地队列长度、GPU 利用率和并发拒绝数。
选择原因最好输出稳定枚举,例如 CONFIDENTIAL_LOCAL_ONLY、PRIMARY_RATE_LIMITED、LOW_COST_DEFAULT。路由规则也要有版本号,配置变化后才能回放。成本监控还要统计失败尝试:主模型超时后切备用,前一次可能已经产生账单,若只归集最终成功模型会系统性低估降级成本。
十、测试与性能指标采集方法
路由规则单测为能力目录和健康快照构造固定数据,验证敏感数据绝不进入云候选、超长上下文被过滤、工具请求只选择具备能力的模型。执行器测试覆盖主模型超时、429、永久参数错误、总预算耗尽和候选全部失败;流式测试分别在首帧前、首帧后注入断连。
集成环境可提供三个可控 Mock Server,按请求头模拟 200、429、500、慢响应和非法 JSON。敏感请求测试不只断言抛出异常,还要断言外部服务请求数始终为零。
/**
* 多模型路由单元测试
* 核心校验:敏感数据绝对禁止公网模型降级、路由硬约束生效
*/
@SpringBootTest
public class ModelGatewayRouteTest {
@MockBean
private MockWebServer localServer;
@MockBean
private MockWebServer openAiServer;
@MockBean
private MockWebServer deepSeekServer;
@Autowired
private ModelGateway gateway;
/**
* 测试机密数据请求:绝对不会降级调用任何云端公网模型
* 校验安全硬约束生效,杜绝敏感数据外泄
*/
@Test
void confidentialRequestNeverFallsBackToCloud() {
// 模拟本地模型超时故障
localServer.enqueueTimeout();
// 执行机密等级请求,预期抛出全部模型不可用异常
assertThatThrownBy(() -> gateway.chat(confidentialContext(), request()))
.isInstanceOf(AllModelsUnavailableException.class);
// 断言:所有公网模型请求数为0,完全未被调用
assertThat(openAiServer.requestCount()).isZero();
assertThat(deepSeekServer.requestCount()).isZero();
}
/** 构造机密级路由上下文 */
private RouteContext confidentialContext() {
return new RouteContext(
"test-001",
"tenant-001",
TaskType.DATA_ANALYSIS,
DataLevel.CONFIDENTIAL,
1000,
500,
0,
200,
true,
false,
false,
"STANDARD"
);
}
/** 构造测试请求体 */
private ModelRequest request() {
return new ModelRequest("系统提示", "测试提问", Collections.emptyMap(), 0.7, 1024);
}
}
性能评估必须说明方法,不能编造“提升多少”。固定请求集、并发曲线和配置,分别运行单模型基线与路由版本,采集端到端 P95、首字延迟、成功率、平均 Token、降级率、质量得分和备用峰值并发;同时报告硬件、模型版本、上下文分布、预热方式和统计窗口。
十一、常见误区与总结
常见误区包括:认为模型越多可用性必然越高,却忽略共享网络故障域;所有异常都重试,导致安全拒绝和参数错误被绕过;只按单价选模型,却不统计返工;默认本地模型天然安全,却把完整 Prompt 写入内部日志。
“约束、选择、失败、验证”是Spring AI 多模型路由的一个较好的方案。主模型流式输出一半失败时,首 Token 后不拼接备用结果;主模型故障时用独立并发配额、熔断和流量整形保护备用;敏感数据通过可信分级、候选硬过滤、网络出口和错误出网告警共同限制;路由优劣则在同一测试集与流量分布下,比较达到相同质量门槛时的可用性、延迟和总成本。
Spring AI 多模型路由的核心不是创建三个客户端,而是建立可解释、可隔离、可验证的调用机制。第一版无须追求复杂的学习型路由:先把安全硬约束、流式边界、错误分类、备用容量和评测方法做对,简单规则就会比散落的 if-else 更可靠;积累稳定反馈后,再用真实数据调整权重,才能让动态切换真正服务于质量、成本与可用性。
更多推荐



所有评论(0)