🏆本文收录于《滚雪球学SpringBoot 3.x》,专门攻坚指数提升,本年度国内最系统+最专业+最详细(永久更新)。
  
该专栏致力打造最硬核 SpringBoot3 从零基础到进阶系列学习内容,🚀均为全网独家首发,打造精品专栏,专栏持续更新中…欢迎大家订阅持续学习。 如果想快速定位学习,可以看这篇【SpringBoot3教程导航帖】,你想学习的都被收集在内,快速投入学习!!两不误。
  
若还想学习更多,可直接订阅 《Spring Boot实战合集》,一次订阅,持续学习,后续更新内容无需重复付费,适合长期收藏与系统进阶。

演示环境说明:

  • 开发工具:IDEA 2021.3
  • JDK版本: JDK 17(推荐使用 JDK 17 或更高版本,因为 Spring Boot 3.x 系列要求 Java 17,Spring Boot 3.5.4 基于 Spring Framework 6.x 和 Jakarta EE 9,它们都要求至少 JDK 17。)
  • Spring Boot版本:3.5.4(于25年7月24日发布)
  • Maven版本:3.8.2 (或更高)
  • Gradle:(如果使用 Gradle 构建工具的话):推荐使用 Gradle 7.5 或更高版本,确保与 JDK 17 兼容。
  • 操作系统:Windows 11

全文目录:

1. 为什么要做多模型路由,而不是只接一个大模型?

很多初学者在接触 AI 应用开发时,第一反应往往是:

  • 选一个模型;
  • 拿到 API Key;
  • 调一个接口;
  • 把结果返回给前端。

从入门角度看,这没有问题。但只要系统进入真实业务阶段,你很快会遇到几个现实问题:

第一,不同模型的能力差异非常明显。有的模型长于通用问答,有的模型更适合结构化抽取,有的模型在代码生成方面表现更好,还有些本地模型更适合处理不方便上传到公有云的私有数据。

第二,不同供应商的价格差异非常大。在高并发场景下,如果你把所有请求都交给最高性能、最高价格的模型,成本往往会迅速失控。

第三,不同模型的响应速度不一样。有时用户只是发起一个简单的分类、改写、摘要请求,没有必要让顶级模型出场;一个更轻量的模型在 1 秒内返回结果,体验反而更好。

第四,供应商并不总是稳定。云端模型可能偶发限流、区域故障、账号额度用尽、网络抖动、返回格式变化等问题。只接一个供应商,意味着一旦它出问题,你的业务就会整体不可用。

第五,企业越来越重视混合部署。一部分请求走云模型,一部分敏感任务走本地模型,一部分低成本任务走开源模型,这是越来越常见的架构选择。

所以,多模型路由并不是“高级玩法”,而是 AI 系统逐步走向工程化后的必然结果。你可以把它理解为:

  • 对上:向业务暴露统一 AI 能力接口;
  • 对下:屏蔽不同模型供应商的差异;
  • 对中:根据策略自动选择最合适的模型执行任务。

也就是说,多模型路由的本质不是多接几个接口,而是建立一层“模型选择与治理中台”

2. Spring Boot 3.x 为什么适合承担 AI 应用基础设施层?

2.1 Spring Boot 3.x 的技术基础升级

在写这篇文章之前,我们先要明确:为什么这个主题必须建立在 Spring Boot 3.x 的技术基础上,而不是泛泛讲 Java Web?

Spring Boot 3.x 相比 2.x,有几个非常关键的基础变化:

  • 基于 Spring Framework 6
  • 最低要求 Java 17
  • 全面切换到 Jakarta EE 命名空间
  • 对 AOT、原生编译、现代化应用部署支持更好;
  • 与现代观测体系(Micrometer、Observation)集成更自然。

这些升级对于 AI 应用并不是“可有可无”的背景知识,而是直接影响我们如何做一个现代化的模型路由服务。

2.2 为什么 AI 网关服务特别适合 Spring Boot 3.x?

AI 路由层本质上是一个典型的中台服务,它有这些特点:

  • 大量 HTTP 调用外部模型接口;
  • 对超时、重试、熔断很敏感;
  • 需要统一配置与环境隔离;
  • 需要良好的监控与链路追踪;
  • 需要快速扩展不同供应商适配器;
  • 需要一定程度的响应式支持和异步能力;
  • 后续可能需要容器化、云原生部署甚至 GraalVM 原生镜像。

Spring Boot 3.x 刚好在这些方向上都有很好的工程基础,因此它非常适合作为多模型路由系统的后端基础框架。

2.3 与 Spring AI 的关系

很多同学会问:既然有 Spring AI,为什么还要自己做统一抽象层?

答案是:

  • Spring AI 可以帮助我们简化模型接入;
  • 你的业务级路由策略、供应商治理、成本控制、故障切换规则,仍然需要自己设计。

你可以把 Spring AI 理解为“连接模型的工具层”,而本文要做的是“在工具层之上,再构建一层业务可控的模型治理层”。

3. OpenAI、Azure OpenAI、Ollama 三类模型服务的能力差异

这一节非常关键。做路由之前,你必须先理解“被路由的对象”到底有什么不同。

3.1 OpenAI 的特点

OpenAI 通常具备以下特点:

  • 通用能力强;
  • 模型生态成熟;
  • 文本理解、生成、工具调用支持较完善;
  • 上手快,文档和社区生态丰富;
  • 对外部创业产品、PoC、国际化业务非常友好。

但它也有明显现实问题:

  • 成本相对较高;
  • 某些场景下网络链路和区域问题较敏感;
  • 企业内部合规要求可能限制直接使用公有云模型;
  • 某些国家或企业网络环境访问不稳定。

因此,在路由策略里,OpenAI 往往适合作为:

  • 高质量默认模型;
  • 高准确率任务的主选项;
  • 本地模型结果不理想时的升级线路;
  • 云端高质量兜底模型。

3.2 Azure OpenAI 的特点

Azure OpenAI 从能力上看,本质上仍然是微软云上托管的大模型服务,但工程使用体验与原生 OpenAI API 并不完全相同。

它的优势通常包括:

  • 更适合企业级接入;
  • 与 Azure 生态整合较深;
  • 更方便结合企业网络、身份、区域与合规体系;
  • 在某些企业组织内审批和采购路径更顺畅。

它的挑战包括:

  • 接口路径和认证方式与 OpenAI 原生接口存在差异;
  • 模型部署名称、API 版本管理需要额外关注;
  • 迁移时容易因为 endpoint、deploymentName、api-version 等细节导致错误。

所以在统一抽象时,我们不能想当然地把 Azure OpenAI 完全当作 OpenAI 的一个 URL 替换。它是相似但不相同的供应商实现。

3.3 Ollama 的特点

Ollama 是很多开发者接触本地大模型的第一站,因为它让本地模型运行这件事变得非常简单。你可以把它看作一个“本地模型运行与管理器”,常见优点包括:

  • 部署简单,适合本地和内网环境;
  • 数据不出本机或局域网,适合隐私敏感场景;
  • 成本可控,没有按 token 持续计费压力;
  • 适合做离线开发、实验、内网助手。

但它的局限也很明显:

  • 模型性能依赖本地机器资源;
  • 推理速度可能不如云端高性能服务;
  • 不同开源模型质量差异大;
  • 上下文长度、工具调用、结构化输出稳定性不一定和商业模型一致;
  • 运维层面需要自己负责机器资源、模型管理、版本管理。

因此,Ollama 在混合部署里非常适合:

  • 低成本批量任务;
  • 内部敏感文本处理;
  • 简单摘要、分类、改写;
  • 开发测试环境;
  • 云端模型的前置过滤或本地预处理。

3.4 三者能力对比思路

这里不给你做“绝对排名”,而是给你一个工程视角的判断维度:

  • 准确率:复杂推理、复杂指令理解、代码生成的可靠性;
  • 速度:首 token 时间与完整响应时间;
  • 成本:按请求、按 token、按设备资源的综合成本;
  • 可用性:供应商稳定性、可达性、限流风险;
  • 合规性:是否适合敏感数据、是否满足企业要求;
  • 可控性:是否可本地部署、是否便于统一治理;
  • 扩展性:是否支持流式、结构化输出、工具调用等。

在真实项目中,没有哪个模型会在所有维度上都最优。这正是多模型路由存在的价值。

4. 混合部署的目标:成本、速度、准确率与可用性的平衡

很多文章在讲多模型时,只停留在“支持多个模型供应商”。这还远远不够。

真正成熟的混合部署应该回答下面这几个问题:

  1. 什么请求应该优先走低成本模型?
  2. 什么请求应该优先走高质量模型?
  3. 请求失败时是否自动切换模型?
  4. 本地模型什么时候该优先使用?
  5. 如何避免简单任务误用昂贵模型?
  6. 如何避免高价值任务被低质量模型误伤?

这几个问题最终会收敛到三个核心目标:

4.1 成本控制

成本控制并不是“一味用最便宜的模型”,而是在满足质量目标的前提下,让单位请求成本最优。

例如:

  • 用户只是让系统“把一段文案变得更口语化”,本地模型完全可以胜任;
  • 用户要“根据一份复杂合同提炼法律风险”,你可能更愿意走高质量云模型;
  • 用户发起的是批量摘要任务,可以使用分层策略,先本地处理,再对低置信结果升级到云端。

4.2 响应速度

用户体验往往对“感知时延”非常敏感。并不是每个任务都需要“最聪明”的模型,有时更快返回一个足够好的结果,体验更佳。

因此我们要建立这样的思想:

  • 快速任务优先低延迟模型;
  • 实时交互任务优先稳定、快响应模型;
  • 非实时任务允许走慢但便宜的线路。

4.3 准确率与稳定性

当任务复杂度提高时,模型质量往往比几百毫秒的延迟更加重要。尤其是:

  • 代码生成;
  • 结构化抽取;
  • 报告生成;
  • 敏感业务问答;
  • 复杂工作流中的关键步骤。

这时就应当让路由系统具备“按任务等级分配模型”的能力。

4.4 高可用与故障切换

单个模型供应商不可避免会遇到:

  • 限流;
  • 超时;
  • 认证问题;
  • 区域不可达;
  • 临时服务波动。

一旦你的系统有统一抽象层,故障切换就不再是“改代码换接口”,而是“改策略自动切流”。这就是架构价值。

5. 总体架构设计:从单一供应商调用走向统一抽象层

先来看一张总体架构图。

从这张图里你可以看到,系统并不是 Controller 直接调用某个模型,而是多了一整层治理结构。

这层结构的核心职责包括:

  • 接收统一请求;
  • 根据路由规则选择供应商;
  • 在异常情况下自动故障切换;
  • 记录每次调用的模型、耗时、失败原因与成本估算;
  • 屏蔽不同供应商之间的协议差异。

这其实就是典型的“面向业务的统一抽象 + 面向供应商的适配器实现”。

6. 核心概念:模型、供应商、路由策略、降级链路、观测指标

在写代码之前,我们必须先统一几个概念,不然后面很容易把系统做乱。

6.1 模型(Model)

模型是具体执行推理任务的能力实体。例如:

  • OpenAI 某个聊天模型;
  • Azure 上部署的某个模型实例;
  • Ollama 里运行的 qwen、llama、mistral 等模型。

模型的关注点是“能力本身”。

6.2 供应商(Provider)

供应商是提供模型访问能力的服务实现。例如:

  • OpenAI;
  • Azure OpenAI;
  • Ollama。

供应商的关注点是“调用方式、认证方式、地址路径、能力边界”。

6.3 路由策略(Routing Policy)

路由策略是选择模型的规则集合。比如:

  • 简单任务优先 Ollama;
  • 复杂任务优先 OpenAI;
  • 企业网络内优先 Azure;
  • 夜间批处理优先低成本模型;
  • 本地模型失败后切云端模型。

6.4 降级链路(Fallback Chain)

降级链路表示主模型失败后,应该按什么顺序尝试备用模型。例如:

  • 首选 Ollama -> 失败转 Azure -> 再失败转 OpenAI;
  • 首选 OpenAI -> 限流时切 Azure;
  • 首选高质量云模型 -> 预算超限后切本地模型。

6.5 观测指标(Observability Metrics)

只有能观测,才能治理。最少要记录这些指标:

  • provider
  • model
  • success/failure
  • latency
  • fallbackCount
  • timeoutCount
  • estimatedCost
  • promptLength
  • responseLength

这些指标将决定你后续如何优化策略。

7. 项目实战一:搭建一个可运行的 Spring Boot 3.x 多模型路由项目

下面我们进入实战。为了保证文章中的案例可运行,我们先定义一个清晰的项目结构。

7.1 技术选型

本项目采用如下技术:

  • Java 17
  • Spring Boot 3.3.x(你也可以使用 3.2.x 或 3.4.x,思路一致)
  • spring-boot-starter-web
  • spring-boot-starter-validation
  • spring-boot-starter-actuator
  • spring-boot-starter-aop
  • resilience4j-spring-boot3
  • micrometer-registry-prometheus(可选)
  • lombok(可选,不强依赖)

为了更容易理解,本文主要使用清晰的 Java POJO 写法,不大量依赖复杂框架魔法。

7.2 Maven 依赖

<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.3.5</version>
        <relativePath/>
    </parent>

    <groupId>com.example</groupId>
    <artifactId>multi-model-router</artifactId>
    <version>1.0.0</version>
    <name>multi-model-router</name>
    <description>Spring Boot 3.x 多模型路由示例</description>

    <properties>
        <java.version>17</java.version>
        <resilience4j.version>2.2.0</resilience4j.version>
    </properties>

    <dependencies>
        <!-- Web 开发依赖 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <!-- 参数校验 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>

        <!-- 监控与健康检查 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>

        <!-- AOP,可用于统一日志与切面观测 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-aop</artifactId>
        </dependency>

        <!-- Resilience4j:重试、熔断、限流、隔离 -->
        <dependency>
            <groupId>io.github.resilience4j</groupId>
            <artifactId>resilience4j-spring-boot3</artifactId>
            <version>${resilience4j.version}</version>
        </dependency>

        <!-- Prometheus 指标导出,可选 -->
        <dependency>
            <groupId>io.micrometer</groupId>
            <artifactId>micrometer-registry-prometheus</artifactId>
        </dependency>

        <!-- 简化样板代码,可选 -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>

        <!-- 测试依赖 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

代码解析

这份 pom.xml 有几个重点:

  1. Java 17 是 Spring Boot 3.x 的基础要求,不要再用 Java 8/11 的旧思路。
  2. 我们引入了 actuator,因为 AI 路由服务上线后必须有健康检查和指标。
  3. 我们引入 resilience4j-spring-boot3,因为多模型路由一定会涉及超时、重试、熔断等稳定性治理。
  4. 没有一开始就引入太多复杂依赖,是为了让你先掌握“结构”,再逐步叠加能力。

7.3 项目目录建议

src/main/java/com/example/router
├── MultiModelRouterApplication.java
├── controller
│   └── ChatController.java
├── model
│   ├── ChatRequest.java
│   ├── ChatResponse.java
│   ├── Message.java
│   ├── ProviderType.java
│   ├── TaskType.java
│   └── RoutingDecision.java
├── config
│   ├── AiProviderProperties.java
│   ├── HttpClientConfig.java
│   └── ResilienceConfig.java
├── provider
│   ├── AiProvider.java
│   ├── AbstractAiProvider.java
│   ├── OpenAiProvider.java
│   ├── AzureOpenAiProvider.java
│   └── OllamaProvider.java
├── router
│   ├── RoutingEngine.java
│   ├── RoutingRule.java
│   └── DefaultRoutingEngine.java
├── service
│   ├── ChatService.java
│   └── FallbackService.java
├── metrics
│   └── AiMetricsRecorder.java
└── exception
    ├── ProviderInvokeException.java
    └── GlobalExceptionHandler.java

这个目录结构有一个核心理念:

  • model:定义统一的数据结构;
  • provider:屏蔽不同供应商差异;
  • router:只负责做选择;
  • service:组织业务流程;
  • metrics:记录观测信息;
  • config:做环境配置与基础 Bean 装配。

请注意,我们没有把“路由逻辑”写进 Controller,也没有把“HTTP 调用逻辑”塞进 Service。这样的分层会让代码更加清晰,扩展新供应商也不会混乱。

8. 项目实战二:定义统一请求与统一响应模型

在多模型架构里,第一件事不是接 OpenAI 接口,而是先定义自己的统一协议

8.1 Message 模型

package com.example.router.model;

/**
 * 对话消息模型
 * 用于统一表示用户消息、系统消息、助手消息
 */
public class Message {

    /**
     * 角色,例如 system、user、assistant
     */
    private String role;

    /**
     * 消息内容
     */
    private String content;

    public Message() {
    }

    public Message(String role, String content) {
        this.role = role;
        this.content = content;
    }

    public String getRole() {
        return role;
    }

    public void setRole(String role) {
        this.role = role;
    }

    public String getContent() {
        return content;
    }

    public void setContent(String content) {
        this.content = content;
    }
}

代码解析

为什么要自己定义 Message?因为不同供应商的消息结构并不完全一致,但对业务系统来说,“一条消息”无非就是角色和内容。统一建模后,后续适配器只需要负责“转换”,而不是让上层业务知道每个厂商的 JSON 长什么样。

8.2 TaskType 枚举

package com.example.router.model;

/**
 * 任务类型枚举
 * 用于帮助路由引擎决定应该使用哪个模型
 */
public enum TaskType {

    /**
     * 一般聊天问答
     */
    GENERAL_CHAT,

    /**
     * 文本摘要
     */
    SUMMARY,

    /**
     * 文本分类
     */
    CLASSIFICATION,

    /**
     * 代码生成
     */
    CODE_GENERATION,

    /**
     * 结构化抽取
     */
    STRUCTURED_EXTRACTION,

    /**
     * 私有数据分析
     */
    PRIVATE_DATA_ANALYSIS,

    /**
     * 高精度复杂任务
     */
    HIGH_ACCURACY
}

代码解析

很多人做 AI 接入时只传一个 prompt,没有任务分类,这会导致路由策略非常难做。因为系统根本不知道这次调用属于什么场景。

所以,建议你从一开始就为请求增加 taskType。这样,系统才能根据任务类型去做有根据的路由。

8.3 ProviderType 枚举

package com.example.router.model;

/**
 * 模型供应商类型
 */
public enum ProviderType {

    /**
     * OpenAI
     */
    OPENAI,

    /**
     * Azure OpenAI
     */
    AZURE_OPENAI,

    /**
     * Ollama 本地模型
     */
    OLLAMA
}

8.4 ChatRequest 请求对象

package com.example.router.model;

import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;

import java.util.List;

/**
 * 统一聊天请求对象
 */
public class ChatRequest {

    /**
     * 对话消息列表
     */
    @NotEmpty(message = "消息列表不能为空")
    private List<Message> messages;

    /**
     * 任务类型,用于路由决策
     */
    @NotNull(message = "任务类型不能为空")
    private TaskType taskType;

    /**
     * 是否优先低成本
     */
    private boolean preferLowCost;

    /**
     * 是否优先低延迟
     */
    private boolean preferLowLatency;

    /**
     * 是否包含敏感数据
     */
    private boolean sensitive;

    /**
     * 可选:指定供应商,若为空则由路由引擎自动决策
     */
    private ProviderType provider;

    public List<Message> getMessages() {
        return messages;
    }

    public void setMessages(List<Message> messages) {
        this.messages = messages;
    }

    public TaskType getTaskType() {
        return taskType;
    }

    public void setTaskType(TaskType taskType) {
        this.taskType = taskType;
    }

    public boolean isPreferLowCost() {
        return preferLowCost;
    }

    public void setPreferLowCost(boolean preferLowCost) {
        this.preferLowCost = preferLowCost;
    }

    public boolean isPreferLowLatency() {
        return preferLowLatency;
    }

    public void setPreferLowLatency(boolean preferLowLatency) {
        this.preferLowLatency = preferLowLatency;
    }

    public boolean isSensitive() {
        return sensitive;
    }

    public void setSensitive(boolean sensitive) {
        this.sensitive = sensitive;
    }

    public ProviderType getProvider() {
        return provider;
    }

    public void setProvider(ProviderType provider) {
        this.provider = provider;
    }
}

代码解析

这个请求对象已经开始体现“路由”思维了。

  • taskType:告诉系统任务性质;
  • preferLowCost:告诉系统用户或业务方更关注成本;
  • preferLowLatency:告诉系统是否要优先快;
  • sensitive:告诉系统是否涉及敏感数据;
  • provider:允许手动指定,便于调试、A/B 测试、灰度验证。

这比只传一个 prompt 专业得多。

8.5 ChatResponse 响应对象

package com.example.router.model;

/**
 * 统一响应对象
 */
public class ChatResponse {

    /**
     * 最终输出内容
     */
    private String content;

    /**
     * 实际使用的供应商
     */
    private ProviderType provider;

    /**
     * 实际使用的模型名称
     */
    private String model;

    /**
     * 是否发生了故障切换
     */
    private boolean fallback;

    /**
     * 响应耗时,单位毫秒
     */
    private long latencyMs;

    public String getContent() {
        return content;
    }

    public void setContent(String content) {
        this.content = content;
    }

    public ProviderType getProvider() {
        return provider;
    }

    public void setProvider(ProviderType provider) {
        this.provider = provider;
    }

    public String getModel() {
        return model;
    }

    public void setModel(String model) {
        this.model = model;
    }

    public boolean isFallback() {
        return fallback;
    }

    public void setFallback(boolean fallback) {
        this.fallback = fallback;
    }

    public long getLatencyMs() {
        return latencyMs;
    }

    public void setLatencyMs(long latencyMs) {
        this.latencyMs = latencyMs;
    }
}

代码解析

响应里必须体现:

  • 最终内容;
  • 实际使用的供应商;
  • 实际模型;
  • 是否降级过;
  • 耗时。

很多系统只返回 content,这样做虽然简单,但在排查路由问题和用户体验问题时几乎没有帮助。

9. 项目实战三:设计供应商抽象层与适配器模式

这一节是整篇文章的核心之一。统一抽象层做得好不好,决定了你的多模型系统能不能长期维护。

9.1 统一 Provider 接口

package com.example.router.provider;

import com.example.router.model.ChatRequest;
import com.example.router.model.ChatResponse;
import com.example.router.model.ProviderType;

/**
 * AI 供应商统一接口
 * 所有模型供应商都要实现此接口
 */
public interface AiProvider {

    /**
     * 当前供应商类型
     *
     * @return 供应商枚举
     */
    ProviderType providerType();

    /**
     * 调用模型完成聊天任务
     *
     * @param request 统一请求对象
     * @return 统一响应对象
     */
    ChatResponse chat(ChatRequest request);

    /**
     * 当前供应商是否可用
     *
     * @return true 表示可用,false 表示不可用
     */
    boolean isAvailable();

    /**
     * 当前供应商默认模型名称
     *
     * @return 模型名称
     */
    String defaultModel();
}

代码解析

这个接口非常重要。它意味着:

  • 上层业务只跟 AiProvider 打交道;
  • 不需要关心 OpenAI 还是 Azure 还是 Ollama;
  • 增加新供应商时,只需新增一个实现类;
  • 路由引擎只负责选哪个 Provider,不负责具体怎么调。

这正是典型的适配器模式思想。

9.2 抽象父类 AbstractAiProvider

package com.example.router.provider;

import com.example.router.exception.ProviderInvokeException;
import com.example.router.model.ChatRequest;
import com.example.router.model.ChatResponse;

/**
 * 供应商抽象父类
 * 抽取公共的异常处理、耗时统计等逻辑
 */
public abstract class AbstractAiProvider implements AiProvider {

    @Override
    public ChatResponse chat(ChatRequest request) {
        long start = System.currentTimeMillis();
        try {
            ChatResponse response = doChat(request);
            response.setLatencyMs(System.currentTimeMillis() - start);
            return response;
        } catch (Exception e) {
            throw new ProviderInvokeException(
                    "调用供应商失败,provider=" + providerType() + ", message=" + e.getMessage(), e);
        }
    }

    /**
     * 子类实现真正的模型调用逻辑
     *
     * @param request 请求对象
     * @return 响应对象
     */
    protected abstract ChatResponse doChat(ChatRequest request);
}

代码解析

使用抽象父类的好处是:

  • 统一统计耗时;
  • 统一封装异常;
  • 子类只关心具体如何发请求。

这会让你的供应商实现类更加干净。

9.3 自定义异常

package com.example.router.exception;

/**
 * 供应商调用异常
 */
public class ProviderInvokeException extends RuntimeException {

    public ProviderInvokeException(String message) {
        super(message);
    }

    public ProviderInvokeException(String message, Throwable cause) {
        super(message, cause);
    }
}

10. 项目实战四:接入 OpenAI

接下来我们实现第一个具体供应商:OpenAI。

10.1 配置对象

package com.example.router.config;

import org.springframework.boot.context.properties.ConfigurationProperties;

/**
 * AI 供应商配置属性
 */
@ConfigurationProperties(prefix = "ai")
public class AiProviderProperties {

    private Openai openai = new Openai();
    private Azure azure = new Azure();
    private Ollama ollama = new Ollama();

    public Openai getOpenai() {
        return openai;
    }

    public void setOpenai(Openai openai) {
        this.openai = openai;
    }

    public Azure getAzure() {
        return azure;
    }

    public void setAzure(Azure azure) {
        this.azure = azure;
    }

    public Ollama getOllama() {
        return ollama;
    }

    public void setOllama(Ollama ollama) {
        this.ollama = ollama;
    }

    public static class Openai {
        private boolean enabled;
        private String baseUrl;
        private String apiKey;
        private String model;

        public boolean isEnabled() {
            return enabled;
        }

        public void setEnabled(boolean enabled) {
            this.enabled = enabled;
        }

        public String getBaseUrl() {
            return baseUrl;
        }

        public void setBaseUrl(String baseUrl) {
            this.baseUrl = baseUrl;
        }

        public String getApiKey() {
            return apiKey;
        }

        public void setApiKey(String apiKey) {
            this.apiKey = apiKey;
        }

        public String getModel() {
            return model;
        }

        public void setModel(String model) {
            this.model = model;
        }
    }

    public static class Azure {
        private boolean enabled;
        private String endpoint;
        private String apiKey;
        private String deploymentName;
        private String apiVersion;

        public boolean isEnabled() {
            return enabled;
        }

        public void setEnabled(boolean enabled) {
            this.enabled = enabled;
        }

        public String getEndpoint() {
            return endpoint;
        }

        public void setEndpoint(String endpoint) {
            this.endpoint = endpoint;
        }

        public String getApiKey() {
            return apiKey;
        }

        public void setApiKey(String apiKey) {
            this.apiKey = apiKey;
        }

        public String getDeploymentName() {
            return deploymentName;
        }

        public void setDeploymentName(String deploymentName) {
            this.deploymentName = deploymentName;
        }

        public String getApiVersion() {
            return apiVersion;
        }

        public void setApiVersion(String apiVersion) {
            this.apiVersion = apiVersion;
        }
    }

    public static class Ollama {
        private boolean enabled;
        private String baseUrl;
        private String model;

        public boolean isEnabled() {
            return enabled;
        }

        public void setEnabled(boolean enabled) {
            this.enabled = enabled;
        }

        public String getBaseUrl() {
            return baseUrl;
        }

        public void setBaseUrl(String baseUrl) {
            this.baseUrl = baseUrl;
        }

        public String getModel() {
            return model;
        }

        public void setModel(String model) {
            this.model = model;
        }
    }
}

10.2 开启配置绑定

package com.example.router;

import com.example.router.config.AiProviderProperties;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;

/**
 * 应用启动类
 */
@SpringBootApplication
@EnableConfigurationProperties(AiProviderProperties.class)
public class MultiModelRouterApplication {

    public static void main(String[] args) {
        SpringApplication.run(MultiModelRouterApplication.class, args);
    }
}

10.3 RestClient 配置

Spring Boot 3.x 推荐使用更现代的 HTTP 客户端能力。这里我们使用 RestClient.Builder

package com.example.router.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestClient;

/**
 * HTTP 客户端配置
 */
@Configuration
public class HttpClientConfig {

    /**
     * 提供统一的 RestClient.Builder
     *
     * @return RestClient.Builder
     */
    @Bean
    public RestClient.Builder restClientBuilder() {
        return RestClient.builder();
    }
}

10.4 OpenAI Provider 实现

package com.example.router.provider;

import com.example.router.config.AiProviderProperties;
import com.example.router.model.ChatRequest;
import com.example.router.model.ChatResponse;
import com.example.router.model.Message;
import com.example.router.model.ProviderType;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * OpenAI 供应商实现
 */
@Component
public class OpenAiProvider extends AbstractAiProvider {

    private final RestClient restClient;
    private final AiProviderProperties properties;

    public OpenAiProvider(RestClient.Builder restClientBuilder, AiProviderProperties properties) {
        this.properties = properties;
        this.restClient = restClientBuilder.baseUrl(properties.getOpenai().getBaseUrl()).build();
    }

    @Override
    public ProviderType providerType() {
        return ProviderType.OPENAI;
    }

    @Override
    public boolean isAvailable() {
        return properties.getOpenai().isEnabled()
                && properties.getOpenai().getApiKey() != null
                && !properties.getOpenai().getApiKey().isBlank();
    }

    @Override
    public String defaultModel() {
        return properties.getOpenai().getModel();
    }

    @Override
    protected ChatResponse doChat(ChatRequest request) {
        Map<String, Object> body = new HashMap<>();
        body.put("model", defaultModel());
        body.put("messages", convertMessages(request.getMessages()));
        body.put("temperature", 0.3);

        Map responseMap = restClient.post()
                .uri("/v1/chat/completions")
                .contentType(MediaType.APPLICATION_JSON)
                .header(HttpHeaders.AUTHORIZATION, "Bearer " + properties.getOpenai().getApiKey())
                .body(body)
                .retrieve()
                .body(Map.class);

        String content = extractContent(responseMap);

        ChatResponse response = new ChatResponse();
        response.setContent(content);
        response.setProvider(ProviderType.OPENAI);
        response.setModel(defaultModel());
        response.setFallback(false);
        return response;
    }

    /**
     * 将统一消息转换成 OpenAI 接口需要的格式
     */
    private List<Map<String, String>> convertMessages(List<Message> messages) {
        return messages.stream()
                .map(msg -> {
                    Map<String, String> map = new HashMap<>();
                    map.put("role", msg.getRole());
                    map.put("content", msg.getContent());
                    return map;
                })
                .toList();
    }

    /**
     * 从 OpenAI 返回结果中提取内容
     */
    private String extractContent(Map responseMap) {
        List choices = (List) responseMap.get("choices");
        if (choices == null || choices.isEmpty()) {
            return "";
        }

        Map firstChoice = (Map) choices.get(0);
        Map message = (Map) firstChoice.get("message");
        if (message == null) {
            return "";
        }
        Object content = message.get("content");
        return content == null ? "" : content.toString();
    }
}

代码解析

这里有几个你必须掌握的点:

  1. Provider 只处理供应商细节,不关心路由规则。
  2. convertMessages() 负责把统一格式转换成 OpenAI 所需格式,这一步就是适配器职责。
  3. extractContent() 负责解析厂商响应,这样上层永远只拿到统一的 ChatResponse
  4. 这里使用 Map 是为了便于初学者快速理解;真实项目中建议定义明确的请求/响应 DTO。

10.5 OpenAI 配置示例

server:
  port: 8080

ai:
  openai:
    enabled: true
    base-url: https://api.openai.com
    api-key: ${OPENAI_API_KEY:}
    model: gpt-4o-mini
  azure:
    enabled: false
    endpoint: ""
    api-key: ""
    deployment-name: ""
    api-version: "2024-10-21"
  ollama:
    enabled: false
    base-url: http://localhost:11434
    model: qwen2.5:7b

management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics,prometheus

11. 项目实战五:接入 Azure OpenAI

很多开发者第一次接入 Azure OpenAI 时最大的误区是:把它当作“换个域名的 OpenAI”。这并不准确。

Azure OpenAI 的典型路径包含:

  • endpoint
  • deploymentName
  • api-version
  • api-key

因此实现类应该单独处理。

11.1 Azure Provider 实现

package com.example.router.provider;

import com.example.router.config.AiProviderProperties;
import com.example.router.model.ChatRequest;
import com.example.router.model.ChatResponse;
import com.example.router.model.Message;
import com.example.router.model.ProviderType;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * Azure OpenAI 供应商实现
 */
@Component
public class AzureOpenAiProvider extends AbstractAiProvider {

    private final RestClient restClient;
    private final AiProviderProperties properties;

    public AzureOpenAiProvider(RestClient.Builder restClientBuilder, AiProviderProperties properties) {
        this.properties = properties;
        this.restClient = restClientBuilder.baseUrl(properties.getAzure().getEndpoint()).build();
    }

    @Override
    public ProviderType providerType() {
        return ProviderType.AZURE_OPENAI;
    }

    @Override
    public boolean isAvailable() {
        return properties.getAzure().isEnabled()
                && properties.getAzure().getApiKey() != null
                && !properties.getAzure().getApiKey().isBlank()
                && properties.getAzure().getDeploymentName() != null
                && !properties.getAzure().getDeploymentName().isBlank();
    }

    @Override
    public String defaultModel() {
        return properties.getAzure().getDeploymentName();
    }

    @Override
    protected ChatResponse doChat(ChatRequest request) {
        Map<String, Object> body = new HashMap<>();
        body.put("messages", convertMessages(request.getMessages()));
        body.put("temperature", 0.3);

        String uri = "/openai/deployments/" + defaultModel() + "/chat/completions?api-version="
                + properties.getAzure().getApiVersion();

        Map responseMap = restClient.post()
                .uri(uri)
                .contentType(MediaType.APPLICATION_JSON)
                .header("api-key", properties.getAzure().getApiKey())
                .body(body)
                .retrieve()
                .body(Map.class);

        String content = extractContent(responseMap);

        ChatResponse response = new ChatResponse();
        response.setContent(content);
        response.setProvider(ProviderType.AZURE_OPENAI);
        response.setModel(defaultModel());
        response.setFallback(false);
        return response;
    }

    /**
     * 将统一消息转换成 Azure OpenAI 接口需要的格式
     */
    private List<Map<String, String>> convertMessages(List<Message> messages) {
        return messages.stream()
                .map(msg -> {
                    Map<String, String> map = new HashMap<>();
                    map.put("role", msg.getRole());
                    map.put("content", msg.getContent());
                    return map;
                })
                .toList();
    }

    /**
     * 提取模型输出内容
     */
    private String extractContent(Map responseMap) {
        List choices = (List) responseMap.get("choices");
        if (choices == null || choices.isEmpty()) {
            return "";
        }

        Map firstChoice = (Map) choices.get(0);
        Map message = (Map) firstChoice.get("message");
        if (message == null) {
            return "";
        }
        Object content = message.get("content");
        return content == null ? "" : content.toString();
    }
}

代码解析

这里最值得你关注的是 URI 拼接逻辑。Azure OpenAI 的调用路径中,deploymentName 不是一个普通的模型名参数,而是 URL 路径的一部分。

这也是为什么我们在统一抽象层里既要保留“模型名称”的概念,又要允许不同供应商用不同形式去表达。

12. 项目实战六:接入 Ollama 本地模型

相比云端服务,Ollama 的接入方式通常更直接,但也要考虑本地不可用、模型未拉取、机器资源不足等问题。

12.1 Ollama Provider 实现

package com.example.router.provider;

import com.example.router.config.AiProviderProperties;
import com.example.router.model.ChatRequest;
import com.example.router.model.ChatResponse;
import com.example.router.model.Message;
import com.example.router.model.ProviderType;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * Ollama 本地模型供应商实现
 */
@Component
public class OllamaProvider extends AbstractAiProvider {

    private final RestClient restClient;
    private final AiProviderProperties properties;

    public OllamaProvider(RestClient.Builder restClientBuilder, AiProviderProperties properties) {
        this.properties = properties;
        this.restClient = restClientBuilder.baseUrl(properties.getOllama().getBaseUrl()).build();
    }

    @Override
    public ProviderType providerType() {
        return ProviderType.OLLAMA;
    }

    @Override
    public boolean isAvailable() {
        return properties.getOllama().isEnabled()
                && properties.getOllama().getBaseUrl() != null
                && !properties.getOllama().getBaseUrl().isBlank();
    }

    @Override
    public String defaultModel() {
        return properties.getOllama().getModel();
    }

    @Override
    protected ChatResponse doChat(ChatRequest request) {
        Map<String, Object> body = new HashMap<>();
        body.put("model", defaultModel());
        body.put("messages", convertMessages(request.getMessages()));
        body.put("stream", false);

        Map responseMap = restClient.post()
                .uri("/api/chat")
                .contentType(MediaType.APPLICATION_JSON)
                .body(body)
                .retrieve()
                .body(Map.class);

        String content = extractContent(responseMap);

        ChatResponse response = new ChatResponse();
        response.setContent(content);
        response.setProvider(ProviderType.OLLAMA);
        response.setModel(defaultModel());
        response.setFallback(false);
        return response;
    }

    /**
     * 转换消息格式
     */
    private List<Map<String, String>> convertMessages(List<Message> messages) {
        return messages.stream()
                .map(msg -> {
                    Map<String, String> map = new HashMap<>();
                    map.put("role", msg.getRole());
                    map.put("content", msg.getContent());
                    return map;
                })
                .toList();
    }

    /**
     * 提取 Ollama 返回内容
     */
    private String extractContent(Map responseMap) {
        Map message = (Map) responseMap.get("message");
        if (message == null) {
            return "";
        }
        Object content = message.get("content");
        return content == null ? "" : content.toString();
    }
}

代码解析

Ollama 与 OpenAI/Azure 的响应结构不同,这再次说明:统一抽象层非常有必要。

如果没有 Provider 适配器,上层业务就必须知道:

  • OpenAI 返回 choices[0].message.content
  • Ollama 返回 message.content

这会让调用方代码变得极其丑陋且难维护。

12.2 Ollama 配置示例

ai:
  openai:
    enabled: false
    base-url: https://api.openai.com
    api-key: ${OPENAI_API_KEY:}
    model: gpt-4o-mini
  azure:
    enabled: false
    endpoint: ${AZURE_OPENAI_ENDPOINT:}
    api-key: ${AZURE_OPENAI_API_KEY:}
    deployment-name: ${AZURE_OPENAI_DEPLOYMENT:}
    api-version: 2024-10-21
  ollama:
    enabled: true
    base-url: http://localhost:11434
    model: qwen2.5:7b

13. 项目实战七:实现基于成本、速度、准确率的动态路由

终于来到“路由引擎”本身。

很多同学一听“路由”,就想到 if else。其实最简单版本确实就是 if else,但关键在于:

  • 规则是否清晰;
  • 规则是否可扩展;
  • 是否能表达优先级;
  • 是否能承载故障切换。

13.1 定义路由决策对象

package com.example.router.model;

/**
 * 路由决策结果
 */
public class RoutingDecision {

    /**
     * 首选供应商
     */
    private ProviderType primaryProvider;

    /**
     * 备用供应商列表,按顺序降级
     */
    private java.util.List<ProviderType> fallbackProviders;

    public ProviderType getPrimaryProvider() {
        return primaryProvider;
    }

    public void setPrimaryProvider(ProviderType primaryProvider) {
        this.primaryProvider = primaryProvider;
    }

    public java.util.List<ProviderType> getFallbackProviders() {
        return fallbackProviders;
    }

    public void setFallbackProviders(java.util.List<ProviderType> fallbackProviders) {
        this.fallbackProviders = fallbackProviders;
    }
}

13.2 路由引擎接口

package com.example.router.router;

import com.example.router.model.ChatRequest;
import com.example.router.model.RoutingDecision;

/**
 * 路由引擎接口
 */
public interface RoutingEngine {

    /**
     * 根据请求生成路由决策
     *
     * @param request 聊天请求
     * @return 路由决策结果
     */
    RoutingDecision decide(ChatRequest request);
}

13.3 默认路由实现

package com.example.router.router;

import com.example.router.model.ChatRequest;
import com.example.router.model.ProviderType;
import com.example.router.model.RoutingDecision;
import com.example.router.model.TaskType;
import org.springframework.stereotype.Component;

import java.util.ArrayList;
import java.util.List;

/**
 * 默认路由引擎实现
 * 根据任务类型、成本偏好、延迟偏好、敏感性进行决策
 */
@Component
public class DefaultRoutingEngine implements RoutingEngine {

    @Override
    public RoutingDecision decide(ChatRequest request) {
        RoutingDecision decision = new RoutingDecision();

        // 如果用户显式指定供应商,则优先使用指定供应商
        if (request.getProvider() != null) {
            decision.setPrimaryProvider(request.getProvider());
            decision.setFallbackProviders(defaultFallbacks(request.getProvider()));
            return decision;
        }

        // 如果包含敏感数据,优先本地模型或企业云
        if (request.isSensitive()) {
            decision.setPrimaryProvider(ProviderType.OLLAMA);
            decision.setFallbackProviders(List.of(ProviderType.AZURE_OPENAI, ProviderType.OPENAI));
            return decision;
        }

        // 如果优先低成本,则优先本地模型
        if (request.isPreferLowCost()) {
            decision.setPrimaryProvider(ProviderType.OLLAMA);
            decision.setFallbackProviders(List.of(ProviderType.AZURE_OPENAI, ProviderType.OPENAI));
            return decision;
        }

        // 如果优先低延迟,也优先选较轻量或本地可达模型
        if (request.isPreferLowLatency()) {
            decision.setPrimaryProvider(ProviderType.OLLAMA);
            decision.setFallbackProviders(List.of(ProviderType.OPENAI, ProviderType.AZURE_OPENAI));
            return decision;
        }

        // 根据任务类型做默认选择
        if (request.getTaskType() == TaskType.CODE_GENERATION
                || request.getTaskType() == TaskType.HIGH_ACCURACY
                || request.getTaskType() == TaskType.STRUCTURED_EXTRACTION) {
            decision.setPrimaryProvider(ProviderType.OPENAI);
            decision.setFallbackProviders(List.of(ProviderType.AZURE_OPENAI, ProviderType.OLLAMA));
            return decision;
        }

        if (request.getTaskType() == TaskType.PRIVATE_DATA_ANALYSIS) {
            decision.setPrimaryProvider(ProviderType.OLLAMA);
            decision.setFallbackProviders(List.of(ProviderType.AZURE_OPENAI, ProviderType.OPENAI));
            return decision;
        }

        // 其余场景默认走 Azure OpenAI,再兜底到 OpenAI 和 Ollama
        decision.setPrimaryProvider(ProviderType.AZURE_OPENAI);
        decision.setFallbackProviders(List.of(ProviderType.OPENAI, ProviderType.OLLAMA));
        return decision;
    }

    /**
     * 当显式指定供应商时,自动生成默认降级链路
     */
    private List<ProviderType> defaultFallbacks(ProviderType primary) {
        List<ProviderType> providers = new ArrayList<>();
        for (ProviderType type : ProviderType.values()) {
            if (type != primary) {
                providers.add(type);
            }
        }
        return providers;
    }
}

代码解析

这里我们实现的是“规则优先级式”路由:

  1. 用户手动指定优先级最高;
  2. 敏感数据优先本地;
  3. 低成本优先本地;
  4. 低延迟优先本地;
  5. 高精度任务优先高质量云模型;
  6. 普通任务默认走企业云。

这就是一个非常典型、易懂、可维护的第一版路由策略。

13.4 路由流程图

14. 项目实战八:实现故障切换与自动重试

有了路由决策还不够,我们还要把“主选失败后怎么切换”的链路做出来。

14.1 ChatService 统一调度服务

package com.example.router.service;

import com.example.router.model.ChatRequest;
import com.example.router.model.ChatResponse;
import com.example.router.model.ProviderType;
import com.example.router.model.RoutingDecision;
import com.example.router.provider.AiProvider;
import com.example.router.router.RoutingEngine;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;

/**
 * 聊天服务
 * 负责根据路由决策调用不同供应商,并执行故障切换
 */
@Service
public class ChatService {

    private final RoutingEngine routingEngine;
    private final Map<ProviderType, AiProvider> providerMap;

    public ChatService(RoutingEngine routingEngine, List<AiProvider> providers) {
        this.routingEngine = routingEngine;
        this.providerMap = providers.stream()
                .collect(Collectors.toMap(AiProvider::providerType, Function.identity()));
    }

    /**
     * 统一聊天入口
     *
     * @param request 请求对象
     * @return 响应对象
     */
    public ChatResponse chat(ChatRequest request) {
        RoutingDecision decision = routingEngine.decide(request);

        // 先尝试主供应商
        ChatResponse primaryResponse = tryInvoke(decision.getPrimaryProvider(), request, false);
        if (primaryResponse != null) {
            return primaryResponse;
        }

        // 主供应商失败,按顺序尝试降级供应商
        for (ProviderType fallbackProvider : decision.getFallbackProviders()) {
            ChatResponse fallbackResponse = tryInvoke(fallbackProvider, request, true);
            if (fallbackResponse != null) {
                return fallbackResponse;
            }
        }

        throw new IllegalStateException("所有模型供应商均调用失败");
    }

    /**
     * 尝试调用指定供应商
     */
    private ChatResponse tryInvoke(ProviderType providerType, ChatRequest request, boolean fallback) {
        AiProvider provider = providerMap.get(providerType);
        if (provider == null || !provider.isAvailable()) {
            return null;
        }

        try {
            ChatResponse response = provider.chat(request);
            response.setFallback(fallback);
            return response;
        } catch (Exception e) {
            return null;
        }
    }
}

代码解析

这是系统运行的关键流程:

  • 路由引擎只给出决策;
  • ChatService 负责执行;
  • 主供应商失败则按备用列表依次尝试;
  • 一旦某个供应商成功就立即返回。

这种结构非常适合扩展。

14.2 故障切换时序图

14.3 为什么不能把异常直接吞掉?

上面示例为了突出主流程,在 tryInvoke() 中简化成了失败返回 null。但在真实项目里,你应当:

  • 记录失败原因;
  • 打印 provider、异常类型、耗时、traceId;
  • 统计失败次数;
  • 区分可重试异常与不可重试异常。

所以接下来我们继续补上稳定性治理。

15. 项目实战九:本地模型与云模型组合实践

“本地模型 + 云模型”的组合并不是简单的二选一,而可以形成很多非常实用的协同模式。

15.1 模式一:本地优先,云端兜底

适用场景:

  • 文本改写
  • 摘要
  • 简单问答
  • 批量处理
  • 预算敏感型业务

路由思路:

  • 主选 Ollama
  • 失败或结果置信不足时切 Azure/OpenAI

15.2 模式二:本地预处理,云端精加工

适用场景:

  • 大段文本先本地清洗、分块、摘要;
  • 核心复杂问题再交给云模型;
  • 降低云端 token 消耗。

例如:

  1. 本地模型先将 20 页文档压缩成 2000 字摘要;
  2. 再把摘要交给高质量云模型做分析与结论输出。

这种模式可以显著降低成本。

15.3 模式三:敏感数据本地处理,非敏感结果上云

适用场景:

  • 企业内网文档
  • 用户隐私信息
  • 合规要求较高的数据处理

处理步骤:

  1. 本地模型先脱敏;
  2. 仅把脱敏后的摘要上传到云模型;
  3. 云模型输出增强结果。

这是一种非常典型的企业混合部署策略。

15.4 组合式服务示例

package com.example.router.service;

import com.example.router.model.ChatRequest;
import com.example.router.model.ChatResponse;
import com.example.router.model.Message;
import com.example.router.model.ProviderType;
import com.example.router.model.TaskType;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.List;

/**
 * 组合式 AI 服务示例
 * 演示本地模型预处理 + 云模型增强的组合链路
 */
@Service
public class HybridPipelineService {

    private final ChatService chatService;

    public HybridPipelineService(ChatService chatService) {
        this.chatService = chatService;
    }

    /**
     * 先用本地模型进行摘要,再用云模型做高精度分析
     *
     * @param rawText 原始文本
     * @return 最终分析结果
     */
    public ChatResponse summarizeThenAnalyze(String rawText) {
        // 第一步:本地模型先做摘要
        ChatRequest summaryRequest = new ChatRequest();
        summaryRequest.setTaskType(TaskType.SUMMARY);
        summaryRequest.setPreferLowCost(true);
        summaryRequest.setProvider(ProviderType.OLLAMA);
        summaryRequest.setMessages(List.of(
                new Message("system", "你是一个擅长提炼重点的助手,请将输入内容总结为简洁摘要。"),
                new Message("user", rawText)
        ));

        ChatResponse summaryResponse = chatService.chat(summaryRequest);

        // 第二步:将摘要结果交给云模型做深入分析
        ChatRequest analyzeRequest = new ChatRequest();
        analyzeRequest.setTaskType(TaskType.HIGH_ACCURACY);
        analyzeRequest.setProvider(ProviderType.OPENAI);

        List<Message> messages = new ArrayList<>();
        messages.add(new Message("system", "你是高级分析助手,请基于给定摘要输出关键问题、风险点和行动建议。"));
        messages.add(new Message("user", "以下是文档摘要,请进行深入分析:\n" + summaryResponse.getContent()));
        analyzeRequest.setMessages(messages);

        return chatService.chat(analyzeRequest);
    }
}

代码解析

这个案例非常有代表性,它体现了混合部署不只是“路由一个模型”,而是“构建组合式推理链路”。

你可以看到:

  • 低成本工作交给本地模型;
  • 高价值分析交给高质量云模型;
  • 两者组合比直接全量走云更省成本。

16. 项目实战十:可观测性、日志链路与指标监控

只要系统上线,你就必须知道:

  • 哪个供应商最常被调用?
  • 哪个供应商失败率高?
  • 哪个路由策略最耗钱?
  • 哪个任务平均耗时最长?
  • 回退是不是太频繁?

这些都离不开监控。

16.1 指标记录器

package com.example.router.metrics;

import com.example.router.model.ProviderType;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import org.springframework.stereotype.Component;

import java.util.concurrent.TimeUnit;

/**
 * AI 调用指标记录器
 */
@Component
public class AiMetricsRecorder {

    private final MeterRegistry meterRegistry;

    public AiMetricsRecorder(MeterRegistry meterRegistry) {
        this.meterRegistry = meterRegistry;
    }

    /**
     * 记录成功调用次数
     */
    public void recordSuccess(ProviderType providerType) {
        Counter.builder("ai.provider.success")
                .tag("provider", providerType.name())
                .register(meterRegistry)
                .increment();
    }

    /**
     * 记录失败调用次数
     */
    public void recordFailure(ProviderType providerType) {
        Counter.builder("ai.provider.failure")
                .tag("provider", providerType.name())
                .register(meterRegistry)
                .increment();
    }

    /**
     * 记录调用耗时
     */
    public void recordLatency(ProviderType providerType, long latencyMs) {
        Timer.builder("ai.provider.latency")
                .tag("provider", providerType.name())
                .register(meterRegistry)
                .record(latencyMs, TimeUnit.MILLISECONDS);
    }
}

16.2 在 ChatService 中接入指标

package com.example.router.service;

import com.example.router.metrics.AiMetricsRecorder;
import com.example.router.model.ChatRequest;
import com.example.router.model.ChatResponse;
import com.example.router.model.ProviderType;
import com.example.router.model.RoutingDecision;
import com.example.router.provider.AiProvider;
import com.example.router.router.RoutingEngine;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;

/**
 * 增强版聊天服务,加入日志和指标
 */
@Service
public class ObservedChatService {

    private static final Logger log = LoggerFactory.getLogger(ObservedChatService.class);

    private final RoutingEngine routingEngine;
    private final Map<ProviderType, AiProvider> providerMap;
    private final AiMetricsRecorder metricsRecorder;

    public ObservedChatService(RoutingEngine routingEngine,
                               List<AiProvider> providers,
                               AiMetricsRecorder metricsRecorder) {
        this.routingEngine = routingEngine;
        this.providerMap = providers.stream()
                .collect(Collectors.toMap(AiProvider::providerType, Function.identity()));
        this.metricsRecorder = metricsRecorder;
    }

    /**
     * 带观测能力的聊天入口
     */
    public ChatResponse chat(ChatRequest request) {
        RoutingDecision decision = routingEngine.decide(request);
        log.info("路由决策完成,primary={}, fallbacks={}",
                decision.getPrimaryProvider(), decision.getFallbackProviders());

        ChatResponse primaryResponse = tryInvoke(decision.getPrimaryProvider(), request, false);
        if (primaryResponse != null) {
            return primaryResponse;
        }

        for (ProviderType fallbackProvider : decision.getFallbackProviders()) {
            ChatResponse fallbackResponse = tryInvoke(fallbackProvider, request, true);
            if (fallbackResponse != null) {
                return fallbackResponse;
            }
        }

        throw new IllegalStateException("所有模型供应商均调用失败");
    }

    /**
     * 尝试调用某个供应商,并记录指标
     */
    private ChatResponse tryInvoke(ProviderType providerType, ChatRequest request, boolean fallback) {
        AiProvider provider = providerMap.get(providerType);
        if (provider == null || !provider.isAvailable()) {
            log.warn("供应商不可用,provider={}", providerType);
            return null;
        }

        try {
            ChatResponse response = provider.chat(request);
            response.setFallback(fallback);
            metricsRecorder.recordSuccess(providerType);
            metricsRecorder.recordLatency(providerType, response.getLatencyMs());
            log.info("供应商调用成功,provider={}, latency={}ms", providerType, response.getLatencyMs());
            return response;
        } catch (Exception e) {
            metricsRecorder.recordFailure(providerType);
            log.error("供应商调用失败,provider={}, error={}", providerType, e.getMessage(), e);
            return null;
        }
    }
}

代码解析

通过 MeterRegistry,我们可以把:

  • 成功次数
  • 失败次数
  • 调用耗时

都导出到监控平台中。上线后你会非常依赖这些数据。

17. 使用 Resilience4j 增强重试、熔断与超时控制

前面的 try/catch + fallback 只是最基础的容错方式。到了生产环境,你还需要成熟的稳定性治理组件。

17.1 为什么需要 Resilience4j

多模型路由系统面对的是外部依赖,而外部依赖最常见的问题就是:

  • 不稳定;
  • 慢;
  • 间歇性失败;
  • 限流;
  • 在高并发下雪崩。

如果你没有超时、熔断和重试机制,轻则接口偶尔抖动,重则线程池被拖死、服务不可用。

17.2 Resilience4j 配置示例

resilience4j:
  retry:
    instances:
      openaiRetry:
        max-attempts: 2
        wait-duration: 500ms
      azureRetry:
        max-attempts: 2
        wait-duration: 500ms
      ollamaRetry:
        max-attempts: 1
        wait-duration: 200ms

  circuitbreaker:
    instances:
      openaiCircuitBreaker:
        sliding-window-size: 10
        failure-rate-threshold: 50
        wait-duration-in-open-state: 10s
      azureCircuitBreaker:
        sliding-window-size: 10
        failure-rate-threshold: 50
        wait-duration-in-open-state: 10s
      ollamaCircuitBreaker:
        sliding-window-size: 10
        failure-rate-threshold: 50
        wait-duration-in-open-state: 5s

  timelimiter:
    instances:
      openaiTimeLimiter:
        timeout-duration: 15s
      azureTimeLimiter:
        timeout-duration: 15s
      ollamaTimeLimiter:
        timeout-duration: 8s

17.3 在 Provider 中加入稳定性注解(示意)

package com.example.router.provider;

import com.example.router.config.AiProviderProperties;
import com.example.router.model.ChatRequest;
import com.example.router.model.ChatResponse;
import com.example.router.model.ProviderType;
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
import io.github.resilience4j.retry.annotation.Retry;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;

/**
 * 带重试与熔断示意的 OpenAI Provider
 */
@Component
public class StableOpenAiProvider extends OpenAiProvider {

    public StableOpenAiProvider(RestClient.Builder restClientBuilder, AiProviderProperties properties) {
        super(restClientBuilder, properties);
    }

    @Override
    @Retry(name = "openaiRetry")
    @CircuitBreaker(name = "openaiCircuitBreaker")
    public ChatResponse chat(ChatRequest request) {
        return super.chat(request);
    }

    @Override
    public ProviderType providerType() {
        return ProviderType.OPENAI;
    }
}

代码解析

引入 Resilience4j 后,你的系统就具备了以下能力:

  • 短暂网络抖动自动重试
  • 连续失败后自动熔断,避免继续打挂外部系统;
  • 超时快速失败,避免线程长期阻塞;
  • 与 fallback 配合后,形成完整的高可用链路。

这才是面向生产的路由系统。

18. Controller 层:对外暴露统一接口

18.1 ChatController 实现

package com.example.router.controller;

import com.example.router.model.ChatRequest;
import com.example.router.model.ChatResponse;
import com.example.router.service.ChatService;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

/**
 * 对外统一聊天接口
 */
@RestController
@RequestMapping("/api/chat")
public class ChatController {

    private final ChatService chatService;

    public ChatController(ChatService chatService) {
        this.chatService = chatService;
    }

    /**
     * 统一聊天入口
     *
     * @param request 请求对象
     * @return 统一响应对象
     */
    @PostMapping
    public ChatResponse chat(@Valid @RequestBody ChatRequest request) {
        return chatService.chat(request);
    }
}

18.2 全局异常处理

package com.example.router.exception;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.util.HashMap;
import java.util.Map;

/**
 * 全局异常处理器
 */
@RestControllerAdvice
public class GlobalExceptionHandler {

    /**
     * 处理参数校验异常
     */
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, Object>> handleValidationException(MethodArgumentNotValidException e) {
        Map<String, Object> result = new HashMap<>();
        result.put("code", 400);
        result.put("message", e.getBindingResult().getFieldError() != null
                ? e.getBindingResult().getFieldError().getDefaultMessage()
                : "参数校验失败");
        return ResponseEntity.badRequest().body(result);
    }

    /**
     * 处理通用异常
     */
    @ExceptionHandler(Exception.class)
    public ResponseEntity<Map<String, Object>> handleException(Exception e) {
        Map<String, Object> result = new HashMap<>();
        result.put("code", HttpStatus.INTERNAL_SERVER_ERROR.value());
        result.put("message", e.getMessage());
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(result);
    }
}

代码解析

Controller 层应该保持轻量:

  • 只接收参数;
  • 做基础校验;
  • 把业务转交给 Service;
  • 不要把路由逻辑塞进 Controller。

这是一条非常重要的工程原则。

19. 接口调用示例与测试案例

为了帮助你真正跑起来,下面给出几个测试请求示例。

19.1 普通问答请求

{
  "messages": [
    {
      "role": "system",
      "content": "你是一个专业的 Java 架构助手。"
    },
    {
      "role": "user",
      "content": "请解释什么是 Spring Boot 3.x 的自动配置机制?"
    }
  ],
  "taskType": "GENERAL_CHAT",
  "preferLowCost": false,
  "preferLowLatency": false,
  "sensitive": false
}

预期效果

  • 默认情况下可能走 Azure OpenAI;
  • 若 Azure 不可用,则回退 OpenAI 或 Ollama;
  • 返回中可看到实际使用的 provider 和 latency。

19.2 敏感数据场景请求

{
  "messages": [
    {
      "role": "system",
      "content": "你是一个擅长内部文档分析的助手。"
    },
    {
      "role": "user",
      "content": "请总结这份包含内部财务信息的文档要点。"
    }
  ],
  "taskType": "PRIVATE_DATA_ANALYSIS",
  "preferLowCost": true,
  "preferLowLatency": false,
  "sensitive": true
}

预期效果

  • 路由优先选择 Ollama;
  • 若本地模型不可用,则降级 Azure OpenAI;
  • 尽量避免默认直接走公有云。

19.3 高精度代码生成请求

{
  "messages": [
    {
      "role": "system",
      "content": "你是资深 Spring Boot 架构师。"
    },
    {
      "role": "user",
      "content": "请生成一个基于 Spring Boot 3.x 的订单创建接口,要求使用 DTO、Service、Repository 分层。"
    }
  ],
  "taskType": "CODE_GENERATION",
  "preferLowCost": false,
  "preferLowLatency": false,
  "sensitive": false
}

预期效果

  • 优先走 OpenAI;
  • 失败后切 Azure,再次失败可切 Ollama;
  • 这样可以在质量和可用性之间取得平衡。

20. 进一步升级:把规则写成可配置而不是硬编码

前面的 DefaultRoutingEngine 是硬编码版,适合学习。但到了中大型项目,你一定会希望:

  • 通过配置文件调整策略;
  • 针对不同环境启用不同规则;
  • 动态更新策略。

20.1 一个简化的规则配置结构示例

router:
  rules:
    sensitive-primary: OLLAMA
    sensitive-fallbacks:
      - AZURE_OPENAI
      - OPENAI
    high-accuracy-primary: OPENAI
    high-accuracy-fallbacks:
      - AZURE_OPENAI
      - OLLAMA
    default-primary: AZURE_OPENAI
    default-fallbacks:
      - OPENAI
      - OLLAMA

20.2 对应配置类

package com.example.router.config;

import com.example.router.model.ProviderType;
import org.springframework.boot.context.properties.ConfigurationProperties;

import java.util.ArrayList;
import java.util.List;

/**
 * 路由规则配置类
 */
@ConfigurationProperties(prefix = "router")
public class RoutingRuleProperties {

    private Rules rules = new Rules();

    public Rules getRules() {
        return rules;
    }

    public void setRules(Rules rules) {
        this.rules = rules;
    }

    public static class Rules {
        private ProviderType sensitivePrimary;
        private List<ProviderType> sensitiveFallbacks = new ArrayList<>();
        private ProviderType highAccuracyPrimary;
        private List<ProviderType> highAccuracyFallbacks = new ArrayList<>();
        private ProviderType defaultPrimary;
        private List<ProviderType> defaultFallbacks = new ArrayList<>();

        public ProviderType getSensitivePrimary() {
            return sensitivePrimary;
        }

        public void setSensitivePrimary(ProviderType sensitivePrimary) {
            this.sensitivePrimary = sensitivePrimary;
        }

        public List<ProviderType> getSensitiveFallbacks() {
            return sensitiveFallbacks;
        }

        public void setSensitiveFallbacks(List<ProviderType> sensitiveFallbacks) {
            this.sensitiveFallbacks = sensitiveFallbacks;
        }

        public ProviderType getHighAccuracyPrimary() {
            return highAccuracyPrimary;
        }

        public void setHighAccuracyPrimary(ProviderType highAccuracyPrimary) {
            this.highAccuracyPrimary = highAccuracyPrimary;
        }

        public List<ProviderType> getHighAccuracyFallbacks() {
            return highAccuracyFallbacks;
        }

        public void setHighAccuracyFallbacks(List<ProviderType> highAccuracyFallbacks) {
            this.highAccuracyFallbacks = highAccuracyFallbacks;
        }

        public ProviderType getDefaultPrimary() {
            return defaultPrimary;
        }

        public void setDefaultPrimary(ProviderType defaultPrimary) {
            this.defaultPrimary = defaultPrimary;
        }

        public List<ProviderType> getDefaultFallbacks() {
            return defaultFallbacks;
        }

        public void setDefaultFallbacks(List<ProviderType> defaultFallbacks) {
            this.defaultFallbacks = defaultFallbacks;
        }
    }
}

代码解析

规则配置化后,最大的收益是:

  • 策略不再写死在代码里;
  • 不同环境可灵活调整;
  • 更适合 A/B 测试与逐步演进。

21. 成本、速度、准确率三维路由如何进一步量化

前面我们讲了三大目标:成本、速度、准确率。现在更进一步,看看如何把它们“工程化”。

21.1 为什么要量化

如果你只写这样的规则:

  • “复杂任务走高质量模型”
  • “简单任务走低成本模型”

这在概念上没问题,但在系统里不够严谨。更好的做法是建立评分机制。

21.2 一个简化评分模型

我们可以给每个供应商建立一个基础画像:

  • 成本分(越低越便宜)
  • 延迟分(越低越快)
  • 质量分(越高越强)
  • 隐私分(越高越适合敏感数据)

例如:

Provider 成本分 延迟分 质量分 隐私分
OLLAMA 1 2 6 10
AZURE_OPENAI 6 5 8 8
OPENAI 7 5 9 6

然后根据请求特征计算加权得分。

21.3 示例:评分式路由引擎

package com.example.router.router;

import com.example.router.model.ChatRequest;
import com.example.router.model.ProviderType;
import com.example.router.model.RoutingDecision;
import org.springframework.stereotype.Component;

import java.util.Arrays;
import java.util.Comparator;
import java.util.List;

/**
 * 评分式路由引擎示例
 * 通过综合评分选择最优模型供应商
 */
@Component
public class ScoreBasedRoutingEngine implements RoutingEngine {

    @Override
    public RoutingDecision decide(ChatRequest request) {
        List<ProviderScore> scores = Arrays.stream(ProviderType.values())
                .map(provider -> new ProviderScore(provider, score(provider, request)))
                .sorted(Comparator.comparingInt(ProviderScore::score).reversed())
                .toList();

        RoutingDecision decision = new RoutingDecision();
        decision.setPrimaryProvider(scores.get(0).provider());
        decision.setFallbackProviders(scores.stream().skip(1).map(ProviderScore::provider).toList());
        return decision;
    }

    /**
     * 根据供应商和请求计算综合得分
     */
    private int score(ProviderType provider, ChatRequest request) {
        int total = 0;

        switch (provider) {
            case OLLAMA -> {
                // 成本低
                total += request.isPreferLowCost() ? 40 : 10;
                // 低延迟时加分
                total += request.isPreferLowLatency() ? 30 : 10;
                // 敏感数据优先本地
                total += request.isSensitive() ? 50 : 0;
            }
            case AZURE_OPENAI -> {
                // 企业云适中
                total += request.isSensitive() ? 20 : 15;
                total += request.isPreferLowCost() ? 10 : 15;
                total += request.isPreferLowLatency() ? 10 : 15;
            }
            case OPENAI -> {
                // 高精度任务优先高质量模型
                switch (request.getTaskType()) {
                    case CODE_GENERATION, HIGH_ACCURACY, STRUCTURED_EXTRACTION -> total += 60;
                    default -> total += 20;
                }
                total += request.isPreferLowCost() ? 0 : 15;
            }
            default -> {
            }
        }

        return total;
    }

    /**
     * 供应商得分记录
     */
    private record ProviderScore(ProviderType provider, int score) {
    }
}

代码解析

评分式路由的优势在于:

  • 规则更灵活;
  • 更容易叠加新维度;
  • 更适合未来做策略平台;
  • 能为“为什么选这个模型”提供一定解释基础。

但它的缺点也要注意:

  • 规则复杂度上升;
  • 分数权重容易拍脑袋;
  • 需要结合线上数据不断调优。

所以学习阶段建议先掌握规则式,再进阶评分式。

22. 供应商抽象层的进一步升级:支持能力标签而非仅按品牌路由

当系统越来越成熟时,你会发现:

用户关心的不是“OpenAI 还是 Azure”,而是“我要一个更快的、便宜的、适合私有数据的、擅长代码的模型”。

因此,你可以把路由从“按供应商品牌”升级为“按能力标签”。

22.1 能力标签示例

  • CHEAP
  • FAST
  • ACCURATE
  • PRIVATE
  • CODE_STRONG
  • STRUCTURED_OUTPUT
  • TOOL_CALLING

22.2 Provider 能力声明示例

package com.example.router.model;

/**
 * 模型能力标签
 */
public enum CapabilityTag {
    CHEAP,
    FAST,
    ACCURATE,
    PRIVATE,
    CODE_STRONG,
    STRUCTURED_OUTPUT
}
package com.example.router.provider;

import com.example.router.model.CapabilityTag;

import java.util.Set;

/**
 * 可声明能力标签的 Provider 接口扩展
 */
public interface CapabilityAwareProvider extends AiProvider {

    /**
     * 返回当前供应商的能力标签集合
     */
    Set<CapabilityTag> capabilities();
}

代码解析

这样做的好处是:

  • 路由逻辑可以越来越“面向能力”而不是“面向品牌”;
  • 更适合未来接更多模型;
  • 当你接入 Anthropic、DeepSeek、Gemini、通义、百川等供应商时,规则不会失控。

这也是多模型平台长期演进的方向。

23. 安全设计:密钥隔离、限流、审计与敏感信息控制

做 AI 应用,安全不是附加项,而是基础能力。

23.1 密钥管理

错误示范:

  • 把 API Key 硬编码进 Java 类;
  • 把 Key 直接写进公开仓库;
  • 不区分开发、测试、生产环境。

正确做法:

  • 使用环境变量、KMS、Vault 等安全存储;
  • 不同环境使用不同 Key;
  • 不同供应商单独配置;
  • 定期轮换密钥;
  • 配置最小权限。

23.2 对外接口限流

如果你的 /api/chat 没有限流,风险很大:

  • 可能被恶意刷爆;
  • 容易引发成本失控;
  • 本地模型机器可能被打满;
  • 云模型额度被快速耗尽。

你可以考虑:

  • 网关层限流;
  • 用户级限流;
  • 租户级限流;
  • 按任务类型限流。

23.3 敏感信息脱敏

如果业务涉及:

  • 手机号
  • 身份证号
  • 邮箱
  • 合同编号
  • 企业内部敏感字段

那么在发送到云模型之前,建议先做脱敏处理。

脱敏示例代码

package com.example.router.service;

import org.springframework.stereotype.Service;

import java.util.regex.Pattern;

/**
 * 文本脱敏服务
 */
@Service
public class MaskingService {

    private static final Pattern PHONE_PATTERN = Pattern.compile("1\\d{10}");
    private static final Pattern EMAIL_PATTERN = Pattern.compile("[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}");

    /**
     * 对输入文本进行简单脱敏
     *
     * @param text 原始文本
     * @return 脱敏后的文本
     */
    public String mask(String text) {
        if (text == null || text.isBlank()) {
            return text;
        }

        String result = PHONE_PATTERN.matcher(text).replaceAll("[PHONE]");
        result = EMAIL_PATTERN.matcher(result).replaceAll("[EMAIL]");
        return result;
    }
}

代码解析

虽然这只是一个简化示例,但已经能说明一件事:

多模型架构不是只关注“能不能调用”,还要关注“调用之前是否需要治理输入数据”。

这在企业场景中非常重要。

24. 性能优化:连接池、并发、缓存与流式输出

24.1 连接复用

在高并发场景下,如果每次请求都重新建立连接,性能会很差。Spring Boot 3.x 配合底层 HTTP 客户端可以实现连接复用,因此建议:

  • 统一管理 HTTP 客户端 Bean;
  • 设置合理连接池;
  • 设置连接超时和读取超时。

24.2 并发控制

对于本地模型特别要注意:

  • GPU/CPU 资源有限;
  • 并发过高会导致响应时间急剧上升;
  • 甚至出现整体雪崩。

所以对于 Ollama,你应该考虑:

  • 单机最大并发;
  • 队列长度;
  • 超时快速失败;
  • 将重任务导向云模型。

24.3 缓存策略

某些重复性很高的请求可以考虑缓存,例如:

  • FAQ 问答;
  • 标准文案改写模板;
  • 规则性很强的摘要任务。

当然,缓存要注意:

  • 相同请求的定义;
  • 敏感数据不得误缓存;
  • TTL 的合理设置;
  • 是否允许用户看到缓存结果。

24.4 流式输出

如果面向聊天类前端,流式响应可以显著提升用户体验。不同供应商对流式的支持不完全一致,因此你在抽象层设计时要提前考虑:

  • 是否提供同步接口与流式接口两套能力;
  • 是否允许某些 Provider 不支持流式;
  • 前端如何区分 complete 和 stream。

这部分实现较长,本文不展开完整代码,但你要知道,流式输出会直接影响统一抽象层的接口设计

25. 常见问题与排错清单

这一节非常实用,建议你在专栏发布时保留。

25.1 OpenAI 调用 401/403

常见原因:

  • API Key 配置错误;
  • Key 为空;
  • 网络代理问题;
  • 账号权限受限。

排查建议:

  • 检查 ai.openai.api-key 是否正确注入;
  • 打印配置是否启用,但不要输出明文 Key;
  • 用 curl/Postman 验证独立调用是否正常。

25.2 Azure OpenAI 返回 404

常见原因:

  • endpoint 不正确;
  • deploymentName 写错;
  • api-version 不匹配;
  • 部署未完成或区域错误。

排查建议:

  • 检查 endpoint 结尾路径;
  • 确保 deploymentName 是“部署名”而不是模型别名;
  • 对照控制台确认 API version。

25.3 Ollama 返回连接失败

常见原因:

  • 本地 Ollama 服务未启动;
  • 端口不是 11434;
  • 模型未下载;
  • 服务绑定地址不对。

排查建议:

  • 本地执行 ollama list 查看模型;
  • 检查 http://localhost:11434/api/tags 是否可访问;
  • 核对 Docker/宿主机网络映射。

25.4 所有模型都失败

可能原因:

  • 路由策略选择了不可用供应商;
  • 所有 Provider 都未启用;
  • 请求消息格式异常;
  • 网络配置异常;
  • 统一异常处理把真实原因吞掉了。

排查建议:

  • 启动时打印各 Provider 是否 available;
  • 打印最终路由决策;
  • 打印每次 fallback 的失败原因;
  • 通过 actuator 暴露健康状态。

26. 生产落地建议:如何把 Demo 演进为真实可用系统

到这里,你已经拥有一个“教学上完整、工程上合理”的多模型路由基础版本。但生产系统还需要进一步升级。

26.1 把模型治理从代码中抽离

建议逐步增加:

  • 路由规则配置中心;
  • 模型元数据中心;
  • 成本配置表;
  • 任务类型字典;
  • 灰度开关与实验分流能力。

26.2 对每个 Provider 做独立健康检查

可以增加如下能力:

  • 定时 ping 外部服务;
  • 将健康状态缓存到本地;
  • 路由时优先跳过不健康 Provider;
  • 在监控面板上显示 Provider 健康度。

26.3 成本治理要接入账单视角

仅有“理论成本偏好”还不够,真实系统应考虑:

  • 日预算;
  • 月预算;
  • 每租户预算;
  • 模型单价变化;
  • token 统计与成本归因。

26.4 建立评估体系

你不能只凭感觉判断路由策略好不好。建议建立:

  • 响应时间评估;
  • 成本评估;
  • 用户满意度;
  • 任务成功率;
  • fallback 触发率;
  • 模型输出质量抽样评估。

这样你才能持续优化。

27. 专栏总结:从零基础到多模型治理的完整认知链路

回顾本文,我们并不是简单地教你“分别调用 OpenAI、Azure OpenAI 和 Ollama”。真正重要的是,你已经建立了以下一条完整认知链路:

27.1 你理解了为什么必须做统一抽象层

因为不同供应商的:

  • 认证方式不同;
  • URL 结构不同;
  • 消息格式不同;
  • 响应结构不同;
  • 能力和成本不同。

如果没有抽象层,业务代码会迅速失控。

27.2 你理解了路由的本质不是 if else,而是策略治理

多模型路由的关键不是“能切换”,而是:

  • 为什么这样选;
  • 什么情况下切换;
  • 如何保障成本、速度、准确率的平衡;
  • 如何通过数据不断优化策略。

27.3 你掌握了多模型混合部署的核心思路

  • 本地模型适合低成本、敏感数据、预处理;
  • 云模型适合高精度、高复杂任务;
  • 二者组合可以形成更优的工程解。

27.4 你掌握了可运行的 Spring Boot 3.x 代码骨架

本文给出的核心代码已经覆盖了:

  • 统一请求/响应模型;
  • Provider 抽象层;
  • OpenAI 接入;
  • Azure OpenAI 接入;
  • Ollama 接入;
  • 路由引擎;
  • fallback 机制;
  • 混合管道实践;
  • 指标监控与异常处理。

这已经足够你搭起一个真正的 Demo 项目,并在此基础上继续迭代。

28. 给读者的扩展练习

为了让你真正吃透这篇文章,建议你继续做下面这些练习:

  1. 为系统增加 stream=true 的流式输出能力;
  2. 把路由规则从代码迁移到数据库或配置中心;
  3. 为每个 Provider 增加健康检查接口;
  4. 给路由决策增加 explain 字段,告诉用户本次为什么选择这个模型;
  5. 统计 token 使用量与请求成本;
  6. 增加 Redis 缓存层;
  7. 增加租户级限流;
  8. 接入更多供应商并保持统一抽象不变;
  9. 用 Spring AI 重构部分接入逻辑,比较两种实现方式;
  10. 增加模型评测模块,为不同任务自动选择最优模型。

这些练习做完后,你就不仅仅是在“学 Spring Boot 接 AI 接口”,而是在真正理解 AI 应用基础设施工程化

29. 附:一个完整可运行的 application.yml 示例

server:
  port: 8080

spring:
  application:
    name: multi-model-router

ai:
  openai:
    enabled: true
    base-url: https://api.openai.com
    api-key: ${OPENAI_API_KEY:}
    model: gpt-4o-mini
  azure:
    enabled: true
    endpoint: ${AZURE_OPENAI_ENDPOINT:}
    api-key: ${AZURE_OPENAI_API_KEY:}
    deployment-name: ${AZURE_OPENAI_DEPLOYMENT:}
    api-version: 2024-10-21
  ollama:
    enabled: true
    base-url: http://localhost:11434
    model: qwen2.5:7b

router:
  rules:
    sensitive-primary: OLLAMA
    sensitive-fallbacks:
      - AZURE_OPENAI
      - OPENAI
    high-accuracy-primary: OPENAI
    high-accuracy-fallbacks:
      - AZURE_OPENAI
      - OLLAMA
    default-primary: AZURE_OPENAI
    default-fallbacks:
      - OPENAI
      - OLLAMA

management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics,prometheus
  endpoint:
    health:
      show-details: always

logging:
  level:
    root: info
    com.example.router: info

resilience4j:
  retry:
    instances:
      openaiRetry:
        max-attempts: 2
        wait-duration: 500ms
      azureRetry:
        max-attempts: 2
        wait-duration: 500ms
      ollamaRetry:
        max-attempts: 1
        wait-duration: 200ms
  circuitbreaker:
    instances:
      openaiCircuitBreaker:
        sliding-window-size: 10
        failure-rate-threshold: 50
        wait-duration-in-open-state: 10s
      azureCircuitBreaker:
        sliding-window-size: 10
        failure-rate-threshold: 50
        wait-duration-in-open-state: 10s
      ollamaCircuitBreaker:
        sliding-window-size: 10
        failure-rate-threshold: 50
        wait-duration-in-open-state: 5s

30. 结语

如果你把本文真正理解透彻,你得到的不只是一个“能调几个模型接口”的小 Demo,而是一套非常重要的工程思维:

  • Spring Boot 3.x 作为现代 Java AI 服务基础;
  • 统一抽象层 屏蔽供应商差异;
  • 路由策略 平衡成本、速度、准确率;
  • 故障切换 保证系统可用;
  • 本地模型 + 云模型组合 达到更优解;
  • 监控与治理 让系统真正走向生产。

这,才是《零基础学 Spring Boot 3.x》这类专栏真正应该带给读者的价值:不是会写几个接口,而是逐步建立从框架到架构、从功能到工程、从 Demo 到落地的完整认知。

愿你在学会接入模型之后,更进一步,学会治理模型、设计系统、平衡技术与业务。
这才是真正的进阶。🚀

如果你准备把这篇文章继续打磨成发布版,下一步最值得补充的内容包括:
1)补一份“完整项目源码清单”;
2)补“Spring AI 版本实现对照”;
3)补“流式输出与 SSE 实战”;
4)补“Docker Compose 一键启动 OpenAI 代理 + Ollama + Spring Boot 示例”;
5)补“单元测试与集成测试代码”。

31. 从零开始补齐基础:为什么本篇必须建立在 Spring Boot 3.x 语境下

很多读者会有一个疑问:文章主题明明是“多模型路由”,为什么还要反复强调 Spring Boot 3.x

原因很简单:专栏既然叫《零基础学 Spring Boot 3.x》,那么文章就不能只讲 AI 概念,而必须让读者理解:

  • 为什么是 Spring Boot 3.x;
  • Spring Boot 3.x 与旧版本相比,到底给这类项目带来了什么;
  • 在项目设计时,哪些写法是 3.x 时代更推荐的方式;
  • 为什么现代化 AI 网关更适合建立在 3.x 的能力体系上。

31.1 从 Java 8 时代到 Java 17 时代,开发思维已经变化

Spring Boot 2.x 的大量项目诞生于 Java 8/11 时代,而 Spring Boot 3.x 明确要求 Java 17 以上。这不是一个简单的版本门槛,而意味着:

  • 你可以更自然地使用 record、模式匹配、增强的 switch 等现代 Java 特性;
  • 你可以在建模时更偏向不可变对象与更清晰的数据表达;
  • 你可以更方便地配合现代云原生部署、容器化镜像、JDK 性能优化;
  • 你在依赖生态上会更容易接入新的 AI、Observability、HTTP 客户端组件。

对“多模型路由”这种服务来说,现代 Java 的意义在于:更适合构建中间层、治理层、编排层和集成层。

31.2 Jakarta 命名空间变化的影响

Spring Boot 3.x 建立在 Spring Framework 6 之上,而 Spring Framework 6 全面转向 jakarta.* 命名空间。

因此你在写代码时会看到:

  • jakarta.validation.constraints.NotNull
  • jakarta.servlet.*
  • jakarta.persistence.*

这对初学者是一个必须越过的门槛。你在旧博客中看到的大量 javax.* 写法,放到新项目里经常已经不适用了。

这也是为什么本篇示例中的校验注解全部基于 jakarta.validation,而不是旧版本包名。

31.3 现代观测体系的重要性比过去更高

传统业务系统大多数时候调用的是数据库、缓存、消息队列、内部服务。但 AI 系统的典型特征是:

  • 高度依赖外部模型服务;
  • 响应耗时波动大;
  • 成本与请求量强相关;
  • 输出质量并不绝对稳定;
  • 供应商差异显著;
  • 容错策略复杂。

这就要求后端框架必须具备更好的监控能力。Spring Boot 3.x 在这一点上更加现代化:

  • 与 Micrometer 集成自然;
  • Actuator 能力成熟;
  • 更适合对接 Prometheus、Grafana、OpenTelemetry 等现代观测体系。

所以,Spring Boot 3.x 不是仅仅“能做 AI 接口开发”,而是“更适合做 AI 基础设施服务”。

32. 多模型路由不是“调接口”,而是“模型治理”

为了帮助零基础读者真正理解,我们再把“路由”这件事讲透一点。

32.1 从业务视角看路由

业务方通常不会说:

  • “请帮我调用 OpenAI”
  • “请帮我调用 Azure OpenAI”
  • “请帮我调用 Ollama”

业务方真正关心的是:

  • 这个任务能不能快一点返回?
  • 能不能便宜一点?
  • 能不能更准一点?
  • 敏感数据能不能不要出企业内网?
  • 某个供应商挂了,业务能不能别受影响?

所以,从业务视角看,模型供应商只是实现手段,真正的核心是 服务目标与治理目标

32.2 从平台视角看路由

平台团队在设计多模型系统时,关心的是另外一组问题:

  • 哪类请求应该路由到哪类模型;
  • 如何对模型进行分层;
  • 如何记录和评估每个模型的效果;
  • 如何避免高成本模型被滥用;
  • 如何在故障时快速切流;
  • 如何扩展新模型而不影响旧业务。

这意味着,多模型路由系统本质上已经接近“平台层能力”,而不只是普通业务接口。

32.3 从架构视角看路由

从架构视角,多模型路由其实具备非常强的“网关化”特征:

  • 对外统一入口;
  • 对内连接多个异构后端;
  • 中间进行规则决策;
  • 具备重试、熔断、限流、监控等治理能力;
  • 最终向业务屏蔽复杂性。

这也是为什么很多企业最终会把“模型路由层”做成一个独立服务,而不是散落在各个业务项目中。

33. 能力差异的更细粒度拆解:不要只看“哪个模型更强”

做模型选型时,一个非常普遍的误区是:

只问“哪个模型最强”,而不问“这个任务到底需要哪种强”。

实际上,模型能力差异至少可以拆成下面这些维度。

33.1 指令遵循能力

有些模型特别擅长按指令办事,例如:

  • 严格输出 JSON;
  • 按你要求的格式分点回答;
  • 不偏题;
  • 不乱补内容。

这个能力在结构化抽取、流程编排、Agent 调用里尤其重要。

33.2 长文本理解能力

当你要处理:

  • 多页合同;
  • 长篇会议纪要;
  • 技术设计文档;
  • 多轮上下文对话;

模型是否能在较长上下文下保持稳定理解,差异会非常明显。

33.3 代码理解与生成能力

“代码生成”不是简单写一段示例,而是包括:

  • 理解已有代码结构;
  • 生成符合框架约束的代码;
  • 尽量避免明显编译错误;
  • 知道合理的分层与命名。

在这类任务里,模型间差异通常比普通聊天更明显。

33.4 成本可接受性

再好的模型,如果成本高到业务无法承担,也不适合大规模接入。特别是在:

  • 高频问答;
  • 批量摘要;
  • 运营内容加工;
  • 自动分类与审核;

这类场景里,单位调用成本会非常影响最终方案。

33.5 可控性与私有化能力

对企业来说,这一维经常比“绝对最强”更重要。因为现实里很多请求并不是想不想走本地,而是 必须能走本地

这正是 Ollama 这类本地模型方案的重要价值所在。

34. 模型分层方法论:低成本层、标准层、高精度层、本地隐私层

为了让系统更有工程感,我们可以把模型按职责做分层,而不是“平铺几个供应商”。

34.1 低成本层

这层模型主要承担:

  • 简单改写;
  • 简单摘要;
  • 批量预处理;
  • 低价值问答;
  • 内部辅助任务。

通常适合放:

  • 本地轻量模型;
  • 成本较低的小模型;
  • 企业自托管模型。

34.2 标准层

这层模型负责:

  • 大部分常规业务问答;
  • 中等复杂内容生成;
  • 基本结构化任务;
  • 作为默认模型出口。

通常适合放:

  • 企业云托管模型;
  • 稳定性较高、速度和质量均衡的模型。

34.3 高精度层

这层模型适合:

  • 代码生成;
  • 复杂分析;
  • 关键业务推理;
  • 高价值场景输出;
  • 重要报告、复杂抽取。

通常会选择质量最强的一组模型,但调用次数需要控制。

34.4 本地隐私层

这层模型不一定追求最强,但必须可控。它适合:

  • 机密文档初处理;
  • 敏感数据脱敏前处理;
  • 企业内网场景;
  • 法务、财务、人事相关内容的预分析。

分层示意图

这种分层方式比“写几个 if else”更容易被团队理解,也更便于做中长期治理。

35. 更贴近生产的 Provider 注册中心设计

当供应商数量从 3 个增长到 5 个、8 个、10 个时,单纯依赖 List<AiProvider> 再转 Map 虽然仍能工作,但治理信息不够丰富。我们可以进一步设计一个 Provider 注册中心。

35.1 注册中心要解决什么问题

它至少应该能回答:

  • 当前系统注册了哪些 Provider;
  • 哪些 Provider 是启用状态;
  • 哪些 Provider 当前健康;
  • 哪些 Provider 支持哪些能力标签;
  • 默认模型是什么;
  • 当前可参与路由的候选集有哪些。

35.2 注册中心示例代码

package com.example.router.provider;

import com.example.router.model.ProviderType;
import org.springframework.stereotype.Component;

import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;

/**
 * Provider 注册中心
 * 用于统一管理系统内所有供应商实例
 */
@Component
public class ProviderRegistry {

    /**
     * 供应商映射表
     */
    private final Map<ProviderType, AiProvider> providerMap = new ConcurrentHashMap<>();

    public ProviderRegistry(List<AiProvider> providers) {
        for (AiProvider provider : providers) {
            providerMap.put(provider.providerType(), provider);
        }
    }

    /**
     * 根据类型获取供应商
     */
    public Optional<AiProvider> getProvider(ProviderType providerType) {
        return Optional.ofNullable(providerMap.get(providerType));
    }

    /**
     * 获取所有已注册供应商
     */
    public Collection<AiProvider> getAllProviders() {
        return providerMap.values();
    }

    /**
     * 获取当前可用供应商列表
     */
    public List<AiProvider> getAvailableProviders() {
        return providerMap.values().stream()
                .filter(AiProvider::isAvailable)
                .toList();
    }
}

代码解析

注册中心的价值在于:

  • ChatService 不必自己管理 providerMap;
  • 为后续加入健康状态、动态开关、能力标签提供统一入口;
  • 更适合平台化演进。

36. 加入健康检查:路由前先识别“谁现在能用”

很多初版 Demo 只检查“配置里启没启用”,但生产环境真正重要的是“当前是否健康”。

36.1 为什么需要健康检查

一个 Provider “配置完整”并不等于“可用”。例如:

  • OpenAI Key 正确,但网络不通;
  • Azure endpoint 正确,但部署已经失效;
  • Ollama 配置存在,但本地服务没启动;
  • 服务在线,但响应时间过慢,已经不适合接实时流量。

36.2 定义健康状态对象

package com.example.router.model;

/**
 * Provider 健康状态
 */
public class ProviderHealthStatus {

    /**
     * 供应商类型
     */
    private ProviderType providerType;

    /**
     * 是否健康
     */
    private boolean healthy;

    /**
     * 最近探测耗时
     */
    private long latencyMs;

    /**
     * 状态描述
     */
    private String message;

    public ProviderType getProviderType() {
        return providerType;
    }

    public void setProviderType(ProviderType providerType) {
        this.providerType = providerType;
    }

    public boolean isHealthy() {
        return healthy;
    }

    public void setHealthy(boolean healthy) {
        this.healthy = healthy;
    }

    public long getLatencyMs() {
        return latencyMs;
    }

    public void setLatencyMs(long latencyMs) {
        this.latencyMs = latencyMs;
    }

    public String getMessage() {
        return message;
    }

    public void setMessage(String message) {
        this.message = message;
    }
}

36.3 健康检查服务示例

package com.example.router.service;

import com.example.router.model.ProviderHealthStatus;
import com.example.router.model.ProviderType;
import com.example.router.provider.ProviderRegistry;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.List;

/**
 * Provider 健康检查服务
 */
@Service
public class ProviderHealthService {

    private final ProviderRegistry providerRegistry;

    public ProviderHealthService(ProviderRegistry providerRegistry) {
        this.providerRegistry = providerRegistry;
    }

    /**
     * 简化版健康检查:当前示例通过 isAvailable 判定
     * 真实项目可扩展为主动探测接口
     */
    public List<ProviderHealthStatus> checkAll() {
        List<ProviderHealthStatus> result = new ArrayList<>();
        providerRegistry.getAllProviders().forEach(provider -> {
            ProviderHealthStatus status = new ProviderHealthStatus();
            status.setProviderType(provider.providerType());
            status.setHealthy(provider.isAvailable());
            status.setLatencyMs(-1);
            status.setMessage(provider.isAvailable() ? "配置可用" : "配置不可用");
            result.add(status);
        });
        return result;
    }

    /**
     * 判断某个供应商是否健康
     */
    public boolean isHealthy(ProviderType providerType) {
        return providerRegistry.getProvider(providerType)
                .map(provider -> provider.isAvailable())
                .orElse(false);
    }
}

代码解析

这份代码是“教学版”的健康检查,重点是帮你建立结构意识。真正的生产实现通常会做:

  • 主动探测轻量接口;
  • 周期性后台刷新;
  • 降低探测频率避免额外成本;
  • 记录最近成功时间、失败次数、平均时延;
  • 与熔断器状态联动。

37. 路由前置处理:Prompt 规范化、长度估算与任务识别

优秀的模型路由系统,不会直接把前端传来的内容原封不动扔给模型,而会先做预处理。

37.1 为什么要做前置处理

因为很多请求本身信息不完整:

  • 没有 system 提示词;
  • 任务类型传错了;
  • 文本太长可能超出本地模型舒适区;
  • 某些敏感字段尚未脱敏;
  • 请求内容不适合当前模型。

所以,路由前的预处理非常重要。

37.2 Prompt 预处理服务示例

package com.example.router.service;

import com.example.router.model.ChatRequest;
import com.example.router.model.Message;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.List;

/**
 * Prompt 预处理服务
 */
@Service
public class PromptPreprocessService {

    /**
     * 对请求进行预处理,例如补充默认系统提示词
     *
     * @param request 原始请求
     * @return 处理后的请求
     */
    public ChatRequest preprocess(ChatRequest request) {
        if (request.getMessages() == null || request.getMessages().isEmpty()) {
            return request;
        }

        boolean hasSystem = request.getMessages().stream()
                .anyMatch(msg -> "system".equalsIgnoreCase(msg.getRole()));

        if (!hasSystem) {
            List<Message> newMessages = new ArrayList<>();
            newMessages.add(new Message("system", "你是一个严谨、清晰、专业的 AI 助手,请基于用户输入提供准确回答。"));
            newMessages.addAll(request.getMessages());
            request.setMessages(newMessages);
        }

        return request;
    }
}

代码解析

这个例子很简单,但它体现了一个重要思想:

在 AI 系统里,输入本身也是需要治理的。

将来你可以在这里继续扩展:

  • 自动补系统提示词;
  • 自动脱敏;
  • 自动裁剪超长内容;
  • 根据任务类型插入格式约束;
  • 自动识别是否需要结构化输出。

38. 结构化输出案例:让模型返回 JSON,而不是随意文本

在真实业务中,很多时候我们并不需要自然语言大段回答,而是希望模型输出一个结构化结果,比如:

  • 文本分类标签;
  • 实体抽取结果;
  • 风险等级;
  • 是否命中规则;
  • 摘要字段集合。

38.1 结构化抽取请求示例

{
  "messages": [
    {
      "role": "system",
      "content": "请从输入文本中提取合同名称、签署方、金额、日期,并以 JSON 返回。"
    },
    {
      "role": "user",
      "content": "甲方为北京某科技公司,乙方为上海某服务公司,签署日期为2026年3月1日,合同金额为人民币50万元。"
    }
  ],
  "taskType": "STRUCTURED_EXTRACTION",
  "preferLowCost": false,
  "preferLowLatency": false,
  "sensitive": true
}

38.2 定义结构化输出对象

package com.example.router.model;

/**
 * 合同抽取结果示例
 */
public class ContractExtractResult {

    /**
     * 合同名称
     */
    private String contractName;

    /**
     * 甲方
     */
    private String partyA;

    /**
     * 乙方
     */
    private String partyB;

    /**
     * 金额
     */
    private String amount;

    /**
     * 日期
     */
    private String signDate;

    public String getContractName() {
        return contractName;
    }

    public void setContractName(String contractName) {
        this.contractName = contractName;
    }

    public String getPartyA() {
        return partyA;
    }

    public void setPartyA(String partyA) {
        this.partyA = partyA;
    }

    public String getPartyB() {
        return partyB;
    }

    public void setPartyB(String partyB) {
        this.partyB = partyB;
    }

    public String getAmount() {
        return amount;
    }

    public void setAmount(String amount) {
        this.amount = amount;
    }

    public String getSignDate() {
        return signDate;
    }

    public void setSignDate(String signDate) {
        this.signDate = signDate;
    }
}

38.3 结构化输出解析服务示例

package com.example.router.service;

import com.example.router.model.ContractExtractResult;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Service;

/**
 * 结构化输出解析服务
 */
@Service
public class StructuredOutputService {

    private final ObjectMapper objectMapper;

    public StructuredOutputService(ObjectMapper objectMapper) {
        this.objectMapper = objectMapper;
    }

    /**
     * 将模型返回的 JSON 字符串解析为对象
     *
     * @param json 模型返回内容
     * @return 解析后的对象
     */
    public ContractExtractResult parseContractJson(String json) {
        try {
            return objectMapper.readValue(json, ContractExtractResult.class);
        } catch (Exception e) {
            throw new IllegalArgumentException("模型输出不是合法 JSON:" + json, e);
        }
    }
}

代码解析

这里最重要的思想是:

  • 高质量模型更适合做结构化输出;
  • 结构化任务在路由层应当被识别为高精度任务;
  • 输出之后还需要在服务端做解析和校验,而不是盲信模型结果。

39. 再加一个重要案例:按任务级别控制“先快后准”

在某些前端交互场景下,我们想要的不是直接等待“最优答案”,而是:

  • 先给用户一个较快的初步结果;
  • 再异步补充更准确的结果;
  • 前端可以展示“快速答案”和“增强答案”。

这种思路非常适合:

  • 客服助手;
  • 代码建议;
  • 知识问答;
  • 文案写作辅助。

39.1 “先快后准”服务示例

package com.example.router.service;

import com.example.router.model.ChatRequest;
import com.example.router.model.ChatResponse;
import com.example.router.model.Message;
import com.example.router.model.ProviderType;
import com.example.router.model.TaskType;
import org.springframework.stereotype.Service;

import java.util.List;

/**
 * 先快后准策略服务
 */
@Service
public class FastThenAccurateService {

    private final ChatService chatService;

    public FastThenAccurateService(ChatService chatService) {
        this.chatService = chatService;
    }

    /**
     * 先使用本地模型给出快速结果,再用高质量模型生成增强结果
     */
    public FastThenAccurateResult execute(String question) {
        ChatRequest fastRequest = new ChatRequest();
        fastRequest.setTaskType(TaskType.GENERAL_CHAT);
        fastRequest.setPreferLowLatency(true);
        fastRequest.setProvider(ProviderType.OLLAMA);
        fastRequest.setMessages(List.of(
                new Message("system", "请快速给出简洁答案。"),
                new Message("user", question)
        ));

        ChatResponse fastResponse = chatService.chat(fastRequest);

        ChatRequest accurateRequest = new ChatRequest();
        accurateRequest.setTaskType(TaskType.HIGH_ACCURACY);
        accurateRequest.setProvider(ProviderType.OPENAI);
        accurateRequest.setMessages(List.of(
                new Message("system", "请基于问题提供更完整、更严谨的答案。"),
                new Message("user", question)
        ));

        ChatResponse accurateResponse = chatService.chat(accurateRequest);

        FastThenAccurateResult result = new FastThenAccurateResult();
        result.setFastContent(fastResponse.getContent());
        result.setAccurateContent(accurateResponse.getContent());
        return result;
    }

    /**
     * 返回对象
     */
    public static class FastThenAccurateResult {
        private String fastContent;
        private String accurateContent;

        public String getFastContent() {
            return fastContent;
        }

        public void setFastContent(String fastContent) {
            this.fastContent = fastContent;
        }

        public String getAccurateContent() {
            return accurateContent;
        }

        public void setAccurateContent(String accurateContent) {
            this.accurateContent = accurateContent;
        }
    }
}

代码解析

这段代码的价值在于让你理解:

路由系统不仅可以做“单次选择”,还可以做“多阶段编排”。

这会让你的 AI 中台更有实际价值。

40. AOP 日志切面:统一记录请求、路由与结果摘要

AI 请求的日志不能只打普通接口日志。因为调试时你往往要知道:

  • 输入任务类型是什么;
  • 路由到哪个 Provider;
  • 是否 fallback;
  • 耗时多久;
  • 输出长度多长;
  • 是否包含异常。

40.1 路由日志切面示例

package com.example.router.metrics;

import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;

/**
 * AI 调用日志切面
 */
@Aspect
@Component
public class AiInvokeLogAspect {

    private static final Logger log = LoggerFactory.getLogger(AiInvokeLogAspect.class);

    /**
     * 对 ChatService 的 chat 方法做统一日志记录
     */
    @Around("execution(* com.example.router.service.ChatService.chat(..))")
    public Object aroundChat(ProceedingJoinPoint joinPoint) throws Throwable {
        long start = System.currentTimeMillis();
        try {
            Object result = joinPoint.proceed();
            long cost = System.currentTimeMillis() - start;
            log.info("AI 请求处理完成,cost={}ms", cost);
            return result;
        } catch (Throwable e) {
            long cost = System.currentTimeMillis() - start;
            log.error("AI 请求处理失败,cost={}ms, error={}", cost, e.getMessage(), e);
            throw e;
        }
    }
}

代码解析

用 AOP 统一做日志的好处是:

  • 不需要在每个方法手写重复日志;
  • 更容易做链路观测;
  • 后续可以结合 TraceId、MDC、用户 ID、租户 ID 等信息完善审计能力。

41. 单元测试:先保证路由规则可靠,再考虑模型输出质量

很多同学做 AI 项目容易忽略测试,理由通常是:“模型输出本来就不完全稳定,怎么测?”

其实多模型系统里最值得测试的,往往不是模型文本内容本身,而是:

  • 路由规则对不对;
  • fallback 是否按预期执行;
  • 配置是否生效;
  • 某些请求是否真的走到了本地或云端。

41.1 路由引擎单元测试示例

package com.example.router.router;

import com.example.router.model.ChatRequest;
import com.example.router.model.ProviderType;
import com.example.router.model.RoutingDecision;
import com.example.router.model.TaskType;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;

/**
 * 路由引擎单元测试
 */
public class DefaultRoutingEngineTest {

    private final DefaultRoutingEngine routingEngine = new DefaultRoutingEngine();

    /**
     * 测试敏感数据是否优先走本地模型
     */
    @Test
    void shouldChooseOllamaWhenSensitive() {
        ChatRequest request = new ChatRequest();
        request.setTaskType(TaskType.PRIVATE_DATA_ANALYSIS);
        request.setSensitive(true);

        RoutingDecision decision = routingEngine.decide(request);

        Assertions.assertEquals(ProviderType.OLLAMA, decision.getPrimaryProvider());
        Assertions.assertFalse(decision.getFallbackProviders().isEmpty());
    }

    /**
     * 测试代码生成是否优先走 OpenAI
     */
    @Test
    void shouldChooseOpenAiWhenCodeGeneration() {
        ChatRequest request = new ChatRequest();
        request.setTaskType(TaskType.CODE_GENERATION);

        RoutingDecision decision = routingEngine.decide(request);

        Assertions.assertEquals(ProviderType.OPENAI, decision.getPrimaryProvider());
    }

    /**
     * 测试低成本场景是否优先本地模型
     */
    @Test
    void shouldChooseOllamaWhenPreferLowCost() {
        ChatRequest request = new ChatRequest();
        request.setTaskType(TaskType.GENERAL_CHAT);
        request.setPreferLowCost(true);

        RoutingDecision decision = routingEngine.decide(request);

        Assertions.assertEquals(ProviderType.OLLAMA, decision.getPrimaryProvider());
    }
}

代码解析

这类测试的意义非常大。它确保:

  • 路由逻辑修改后不会误伤原本规则;
  • 以后团队成员改策略时有“护栏”;
  • 在持续集成环境里能快速发现回归问题。

42. 服务层测试:验证 fallback 是否按预期生效

路由对了,还要测试主模型失败时是否真能切换到备用模型。

42.1 使用 Mockito 的示例思路

package com.example.router.service;

import com.example.router.model.ChatRequest;
import com.example.router.model.ChatResponse;
import com.example.router.model.Message;
import com.example.router.model.ProviderType;
import com.example.router.model.RoutingDecision;
import com.example.router.model.TaskType;
import com.example.router.provider.AiProvider;
import com.example.router.router.RoutingEngine;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;

import java.util.List;

/**
 * ChatService 故障切换测试
 */
public class ChatServiceTest {

    /**
     * 测试主供应商失败时是否会切换到备用供应商
     */
    @Test
    void shouldFallbackWhenPrimaryFails() {
        RoutingEngine routingEngine = Mockito.mock(RoutingEngine.class);
        AiProvider openAiProvider = Mockito.mock(AiProvider.class);
        AiProvider azureProvider = Mockito.mock(AiProvider.class);

        RoutingDecision decision = new RoutingDecision();
        decision.setPrimaryProvider(ProviderType.OPENAI);
        decision.setFallbackProviders(List.of(ProviderType.AZURE_OPENAI));

        Mockito.when(routingEngine.decide(Mockito.any())).thenReturn(decision);

        Mockito.when(openAiProvider.providerType()).thenReturn(ProviderType.OPENAI);
        Mockito.when(openAiProvider.isAvailable()).thenReturn(true);
        Mockito.when(openAiProvider.chat(Mockito.any())).thenThrow(new RuntimeException("OpenAI 调用失败"));

        ChatResponse fallbackResponse = new ChatResponse();
        fallbackResponse.setContent("Azure 响应成功");
        fallbackResponse.setProvider(ProviderType.AZURE_OPENAI);

        Mockito.when(azureProvider.providerType()).thenReturn(ProviderType.AZURE_OPENAI);
        Mockito.when(azureProvider.isAvailable()).thenReturn(true);
        Mockito.when(azureProvider.chat(Mockito.any())).thenReturn(fallbackResponse);

        ChatService chatService = new ChatService(routingEngine, List.of(openAiProvider, azureProvider));

        ChatRequest request = new ChatRequest();
        request.setTaskType(TaskType.GENERAL_CHAT);
        request.setMessages(List.of(new Message("user", "你好")));

        ChatResponse response = chatService.chat(request);

        Assertions.assertEquals("Azure 响应成功", response.getContent());
        Assertions.assertTrue(response.isFallback());
    }
}

代码解析

这个测试比“直接看接口能不能通”更重要,因为它验证了系统最关键的高可用能力。

43. 本地运行与 Docker Compose 组合实践

很多读者希望不仅看到 Java 代码,还希望知道怎么把环境跑起来。下面给一个适合学习的 Docker Compose 思路。

43.1 场景说明

这个示例的目标是:

  • 本机运行 Spring Boot 服务;
  • 使用本地 Ollama 作为本地模型;
  • 将云端 OpenAI/Azure 作为可选 fallback。

如果你希望把 Spring Boot 和 Ollama 一起容器化,也可以用 Compose 编排。

43.2 Docker Compose 示例

version: '3.9'

services:
  ollama:
    image: ollama/ollama:latest
    container_name: ollama
    ports:
      - "11434:11434"
    volumes:
      - ollama_data:/root/.ollama
    restart: unless-stopped

  app:
    image: eclipse-temurin:17-jdk
    container_name: multi-model-router-app
    working_dir: /app
    volumes:
      - ./:/app
    command: ["./mvnw", "spring-boot:run"]
    ports:
      - "8080:8080"
    environment:
      OPENAI_API_KEY: ${OPENAI_API_KEY}
      AZURE_OPENAI_ENDPOINT: ${AZURE_OPENAI_ENDPOINT}
      AZURE_OPENAI_API_KEY: ${AZURE_OPENAI_API_KEY}
      AZURE_OPENAI_DEPLOYMENT: ${AZURE_OPENAI_DEPLOYMENT}
    depends_on:
      - ollama

volumes:
  ollama_data:

代码解析

这个 Compose 不是最优生产写法,但作为学习示例足够直观:

  • ollama 提供本地模型接口;
  • app 直接运行 Spring Boot 项目;
  • 你可以在本地通过环境变量注入云端配置;
  • 这样就形成了“本地 + 云端”混合实验环境。

43.3 本地启动流程建议

  1. 启动 Ollama 服务;
  2. 拉取一个本地模型,例如 qwen2.5:7b
  3. 配置 application.yml
  4. 启动 Spring Boot 服务;
  5. 用 Postman 或 curl 调用 /api/chat

44. 一个更完整的请求生命周期图

为了帮助初学者把调用过程在脑海里串起来,我们再画一张更细的流程图。

这张图的意义在于帮助读者理解:

  • 路由不是一个点,而是一条链路;
  • 真正的工程实现远比“发个 HTTP 请求”复杂;
  • 每一层都应该职责清晰。

45. 文章级别的深入解释:成本、速度、准确率为什么总会互相拉扯?

这一节更偏认知层,适合你写成专栏中的“思考性段落”,能明显提升文章质量。

45.1 成本和准确率的拉扯

一般来说,更强的模型通常意味着:

  • 更高的训练与推理成本;
  • 更高的 token 单价;
  • 更复杂的系统资源消耗。

这就导致一个现实问题:

如果所有请求都走最高质量模型,你的系统往往会“效果很好,但账单爆炸”。

所以你必须承认一个事实:业务系统几乎永远在做“足够好”与“绝对最好”之间的平衡。

45.2 速度和准确率的拉扯

高质量模型经常意味着:

  • 推理链更复杂;
  • 输出更长;
  • 响应时间更高;
  • 并发承载压力更大。

这对实时交互类产品并不总是友好。很多时候用户更喜欢:

  • 先在 1 秒内看到一个可用答案;
  • 再逐步看到更优答案。

这就是前面“先快后准”策略的理论基础。

45.3 成本和速度的拉扯

便宜的模型不一定更快;快的模型不一定更便宜。本地模型虽然不按 token 收费,但如果本地机器资源不足,实际响应时间可能并不理想。

所以,评价“成本”时不能只看云厂商单价,还要看:

  • 本地 GPU/CPU 成本;
  • 运维成本;
  • 研发接入成本;
  • 故障与不稳定带来的间接成本。

这也是为什么成熟团队不会用单一维度做模型选择。

46. 让文章更落地:给出几种典型业务路由策略模板

为了帮助读者快速迁移到自己的项目里,下面我给出几个非常实用的策略模板。

46.1 模板一:客服问答系统

特点:

  • 请求量大;
  • 时延敏感;
  • 大量问题属于重复或简单问答;
  • 复杂问题占少数。

建议策略:

  • FAQ 命中 -> 缓存/检索直接答;
  • 普通问答 -> Azure/OpenAI 标准层;
  • 简单重写与分类 -> Ollama;
  • 高复杂工单 -> OpenAI 高精度层。

46.2 模板二:企业文档分析系统

特点:

  • 数据敏感;
  • 文本较长;
  • 合规要求高;
  • 质量要求也高。

建议策略:

  • 敏感原文预处理 -> Ollama;
  • 脱敏后复杂分析 -> Azure/OpenAI;
  • 最终报告输出 -> 高精度模型;
  • 失败时尽量优先切企业云而非外部公有线路。

46.3 模板三:研发助手系统

特点:

  • 代码生成和解释需求多;
  • 对准确率和结构质量要求高;
  • 但也有大量简单说明、注释补全等低价值请求。

建议策略:

  • 代码解释/补注释 -> 标准层或本地层;
  • 复杂代码生成 -> 高精度层;
  • 批量文档整理 -> 低成本层;
  • 私有仓库内容先本地摘要再上云增强。

这些策略模板能帮助读者从“理解原理”真正走向“知道如何用”。

47. 生产环境中的组织协作建议:平台、业务、运维如何分工

为了本期文章内容显得更成熟,我还可以站在团队协作角度补充一节。

47.1 平台团队负责什么?

平台团队一般适合负责:

  • 模型供应商统一接入;
  • 路由策略基础框架;
  • 指标、监控、审计;
  • 成本治理与配额;
  • 统一 SDK 或 API。

47.2 业务团队负责什么?

业务团队更适合负责:

  • 任务类型定义;
  • Prompt 设计;
  • 具体场景的策略偏好;
  • 结果质量评估;
  • 业务级 fallback 处理。

47.3 运维/基础设施团队负责什么?

运维或基础设施团队通常负责:

  • 本地模型机器资源;
  • 容器化部署;
  • 网络、代理、证书;
  • 监控告警;
  • 灰度发布和容量规划。

为什么要写这一节?

因为多模型系统不是单纯一个 Java 工程师能独立长期维护好的,它最终会涉及平台化和团队协作。专栏如果能讲到这里,专业感会明显更强。

48. 再补一个增强案例:基于请求长度和复杂度的路由

前面我们主要按任务类型来路由,下面再补一个更贴近实际的例子:根据输入文本长度和复杂度来调整策略。

48.1 为什么长度和复杂度重要?

原因很简单:

  • 本地模型对长上下文的处理通常更容易受资源影响;
  • 云模型虽然更强,但长文本成本更高;
  • 简单短文本没有必要走高质量昂贵模型;
  • 复杂长文本需要更慎重的选择。

48.2 复杂度评估工具示例

package com.example.router.service;

import com.example.router.model.ChatRequest;
import org.springframework.stereotype.Service;

/**
 * 请求复杂度分析服务
 */
@Service
public class RequestComplexityService {

    /**
     * 估算请求总文本长度
     */
    public int estimateLength(ChatRequest request) {
        if (request.getMessages() == null) {
            return 0;
        }
        return request.getMessages().stream()
                .mapToInt(msg -> msg.getContent() == null ? 0 : msg.getContent().length())
                .sum();
    }

    /**
     * 简化版复杂度判断:文本过长视为复杂
     */
    public boolean isComplex(ChatRequest request) {
        return estimateLength(request) > 2000;
    }
}

48.3 长文本路由策略示例

package com.example.router.router;

import com.example.router.model.ChatRequest;
import com.example.router.model.ProviderType;
import com.example.router.model.RoutingDecision;
import com.example.router.model.TaskType;
import com.example.router.service.RequestComplexityService;
import org.springframework.stereotype.Component;

import java.util.List;

/**
 * 带长度与复杂度判断的路由引擎示例
 */
@Component
public class ComplexityAwareRoutingEngine implements RoutingEngine {

    private final RequestComplexityService complexityService;

    public ComplexityAwareRoutingEngine(RequestComplexityService complexityService) {
        this.complexityService = complexityService;
    }

    @Override
    public RoutingDecision decide(ChatRequest request) {
        RoutingDecision decision = new RoutingDecision();

        // 长文本且高精度任务,优先高质量云模型
        if (complexityService.isComplex(request)
                && (request.getTaskType() == TaskType.HIGH_ACCURACY
                || request.getTaskType() == TaskType.STRUCTURED_EXTRACTION)) {
            decision.setPrimaryProvider(ProviderType.OPENAI);
            decision.setFallbackProviders(List.of(ProviderType.AZURE_OPENAI, ProviderType.OLLAMA));
            return decision;
        }

        // 长文本但敏感,优先本地预处理再云端降级
        if (complexityService.isComplex(request) && request.isSensitive()) {
            decision.setPrimaryProvider(ProviderType.OLLAMA);
            decision.setFallbackProviders(List.of(ProviderType.AZURE_OPENAI));
            return decision;
        }

        // 其他走默认逻辑
        decision.setPrimaryProvider(ProviderType.AZURE_OPENAI);
        decision.setFallbackProviders(List.of(ProviderType.OPENAI, ProviderType.OLLAMA));
        return decision;
    }
}

代码解析

这段代码强调的是:

  • 路由规则可以不仅依赖业务显式参数;
  • 也可以从请求内容本身推导额外特征;
  • 当系统成熟后,这种“特征驱动路由”会越来越重要。

49. 给零基础读者的认知提醒:不要把“本地模型”想得过于理想化

我为了保持文章的客观性,我们也要专门谈谈本地模型的边界。

49.1 本地模型不是天然更快

很多人第一次接触 Ollama 时会想:既然服务跑在本地,那一定比云端更快。实际并不总是这样,因为:

  • 本地机器算力有限;
  • 模型体积可能很大;
  • CPU 推理速度可能较慢;
  • GPU 显存不足会导致性能显著下降;
  • 多并发下本地系统可能很快拥塞。

49.2 本地模型不是天然更准

本地模型更大的优势通常在:

  • 可控;
  • 可私有化;
  • 成本可预期;
  • 适合特定场景。

但在复杂推理、复杂代码生成等任务上,云端高质量模型通常仍有明显优势。

49.3 本地模型也有运维成本

很多“免费本地模型”的讨论,会忽略这些成本:

  • 机器采购;
  • 显卡成本;
  • 运行维护;
  • 模型管理;
  • 容量规划;
  • 日志监控;
  • 升级与兼容问题。

所以,本地模型不是云模型的简单替代,而是混合部署中的一个关键角色。

50. 给零基础读者的认知提醒:不要把“云模型”想得过于万能

同样地,云模型也不是没有代价的“银弹”。

50.1 云模型的主要优势

  • 能力通常更强;
  • 上手快;
  • 文档和生态更成熟;
  • 很适合快速启动项目和复杂任务场景。

50.2 云模型的主要代价

  • 按 token 成本可能不低;
  • 高并发时账单增长明显;
  • 外部依赖不可完全控制;
  • 某些场景存在网络与合规挑战;
  • 一旦供应商波动,会影响业务稳定性。

所以,对于成熟系统,最好的方式通常不是“全本地”或“全云”,而是 组合

51. 面向未来扩展:如果要再接入 Anthropic、Gemini、DeepSeek,该怎么办?

虽然本文主题集中在 OpenAI、Azure、Ollama,但架构设计应该具有前瞻性。

51.1 为什么本文的抽象具有扩展性?

因为我们已经把系统拆成了:

  • 统一请求模型;
  • 统一响应模型;
  • Provider 接口;
  • 路由引擎;
  • 服务编排;
  • fallback 机制。

这意味着新增一个供应商时,原则上只需要:

  1. 新增枚举值;
  2. 实现一个新的 Provider;
  3. 在配置中加入对应参数;
  4. 在路由规则中声明其角色。

51.2 这就是架构抽象的价值

很多初学者觉得抽象层“写起来麻烦”。但当需求变化时,抽象层的价值就会非常明显。

这也正是技术文章里最值得传达给读者的:

不要只看眼前能不能跑,还要看半年后还能不能维护。

52. 本文终极总结:为什么这是一篇真正从 0 到 1 再到 1 到 N 的 Spring Boot 3.x AI 工程文章?

写本期内容我真正想教会读者的,不是某个接口怎么调,而是下面这条完整成长路径:

第一步,学会在 Spring Boot 3.x 里构建现代化后端服务;
第二步,理解 AI 能力接入背后的供应商差异;
第三步,建立统一抽象,而不是把业务代码绑死在某一个厂商上;
第四步,学会根据成本、速度、准确率设计路由;
第五步,学会用 fallback、熔断、重试保证高可用;
第六步,学会把本地模型和云模型组合成真正可用的业务能力;
第七步,学会通过监控、日志、测试和配置化让系统可持续演进。

当读者真正走完这条路径时,他学到的已经不只是“Spring Boot 3.x 接 AI”,而是:

  • 如何用现代 Java 做 AI 基础设施;
  • 如何理解平台层抽象;
  • 如何在架构中平衡技术理想与业务现实;
  • 如何让一个 Demo 逐步长成可落地系统。

这也是《零基础学 Spring Boot 3.x》专栏最应该为大家提供的价值。

ok,同学们,本节课就上到这儿,下课~

🧧 学习福利 · 限时开放 🧧

当然,无论你是计算机专业在读学生,还是对编程充满兴趣的入门者,都强烈建议系统学习SpringBoot全体系专栏:👉 「滚雪球学 Spring Boot」;涵盖SpringBoot所有教学内容。

该专栏以“循序渐进 + 实战驱动”为核心理念,从基础到进阶到就业到架构师逐层展开,帮助你快速建立完整的 Spring Boot 技术体系,带你玩转SpringBoot框架。

📌 学习承诺:
通过该专栏,你将能够:

  • 快速掌握 Spring Boot 核心开发能力
  • 构建完整的后端项目认知体系
  • 实现从“入门”到“独立开发”的跃迁

就像“滚雪球”一样,知识不断积累、能力持续放大,实现指数级成长 🚀

最后,如果这篇文章对你有所帮助,帮忙给作者来个一键三连,关注、点赞、收藏,您的支持就是我坚持写作最大的动力。

同时欢迎大家关注技术号:「猿圈奇妙屋」 ,以便学习更多同类型的技术文章,免费白嫖最新BAT互联网公司面试题、4000G PDF编程电子书、简历模板、技术文章Markdown文档等海量资料。

ps:本文涉及所有源代码,均已上传至Gitee开源,供同学们直接对照学习 Gitee传送门,同时,原创开源不易,欢迎给个star🌟,想体验下被🌟的感jio,非常感谢❗

🫵 Who am I?

我是 bug菌:

更多高质量技术内容及成长资料,可查看这个合集入口 👉 点击查看 👈️

硬核技术号 「猿圈奇妙屋」 期待你的加入,一起进阶、一起打怪升级。

- End -

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐