演示环境里,一个 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_ONLYPRIMARY_RATE_LIMITEDLOW_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 更可靠;积累稳定反馈后,再用真实数据调整权重,才能让动态切换真正服务于质量、成本与可用性。

Logo

欢迎加入DeepSeek 技术社区。在这里,你可以找到志同道合的朋友,共同探索AI技术的奥秘。

更多推荐