Skip to content

AI多提供商适配与Embedding向量嵌入服务:构建企业级AI基础设施

作者: 必码 | bima.cc


前言

在企业级应用开发中,人工智能(AI)能力的集成已经从"锦上添花"转变为"必备基础设施"。从智能客服、知识库问答到代码生成、数据分析,大语言模型(LLM)正在深刻地改变着软件系统的交互方式和功能边界。然而,当企业真正开始将AI能力嵌入到生产系统中时,一个棘手的问题迅速浮出水面:如何在一个统一的架构下,灵活地对接多个AI提供商?

这个问题的背后,隐藏着一系列深层次的技术挑战。首先,不同AI提供商的API接口格式存在显著差异——OpenAI使用 /v1/chat/completions 端点,返回结构化的 choices 数组;Ollama使用 /api/chat/api/generate 端点,返回格式截然不同;而国内众多大模型服务商虽然在接口设计上向OpenAI靠拢,但在认证方式、错误码定义、流式响应格式等细节上仍有各自的特点。其次,企业往往需要在本地部署和云端API之间做出权衡——某些业务场景要求数据绝对不能离开企业网络边界,必须使用本地部署的模型;而另一些场景则需要云端最强模型的推理能力。再者,不同用户、不同业务线可能需要使用不同的模型和参数配置,这就要求系统具备细粒度的模型管理能力。

与此同时,随着RAG(Retrieval-Augmented Generation,检索增强生成)技术的成熟,向量嵌入(Embedding)服务也成为了AI基础设施中不可或缺的一环。将文本转换为高维向量,存储到向量数据库中,再通过相似度检索为大模型提供上下文信息——这一技术路径已经成为构建企业知识库、智能文档问答等应用的标准方案。然而,向量嵌入服务同样面临着多提供商适配的问题:不同提供商的嵌入模型维度不同、API格式不同、性能特征不同。

面对这些挑战,smart-scaffold-springboot 项目构建了一套完整的AI基础设施,包括多提供商统一适配层、聊天客户端工厂、向量嵌入服务、用户级模型配置管理等核心组件。本文将基于该项目源码,深入解析这套AI基础设施的设计理念、核心实现和生产环境最佳实践。

本文适合的读者群体:

  • 正在或即将在企业项目中集成AI能力的后端架构师和开发者
  • 需要同时对接多个AI提供商(OpenAI、Ollama、国产大模型等)的技术团队
  • 对向量嵌入和RAG技术感兴趣,希望了解生产级实现方案的学习者
  • 关注AI基础设施架构设计、希望借鉴成熟方案的技术决策者

阅读本文前,建议读者具备以下知识储备:

  • Java基础及Spring Boot开发经验
  • RESTful API设计基本概念
  • 大语言模型(LLM)基本概念和使用经验
  • JSON数据格式和HTTP协议基础知识

一、企业级AI集成的挑战与架构决策

1.1 多AI提供商统一适配的需求

在企业级AI集成场景中,"多提供商"并不是一个可选项,而是一个必然的现实。这种必然性来源于多个维度的考量。

第一,供应商风险分散。 将所有AI能力绑定在单一提供商上,意味着企业的核心业务功能完全依赖于该提供商的可用性和定价策略。一旦提供商出现服务中断、API变更、价格大幅上涨或停止服务等情况,企业的业务将面临严重风险。2023年以来,我们已经见证了多次大模型API服务波动事件,这促使越来越多的企业开始采用"多提供商"策略来分散风险。

第二,成本优化。 不同AI提供商的定价模型差异巨大。OpenAI GPT-4o的API调用成本可能是Ollama本地部署的数百倍(考虑GPU硬件投入后),而国产大模型如DeepSeek、通义千问等在中文场景下的性价比往往优于OpenAI。企业需要根据不同的业务场景,选择最具成本效益的提供商——对于简单的文本分类任务,使用低成本模型即可;对于复杂的推理任务,可能需要调用最强模型。

第三,数据合规要求。 在金融、医疗、政务等行业,数据安全合规是不可逾越的红线。某些业务数据涉及用户隐私或商业机密,绝对不能发送到外部API服务。这类场景必须使用本地部署的模型(如Ollama),确保数据在处理过程中不离开企业网络边界。而另一些非敏感业务场景,则可以自由使用云端API。

第四,模型能力互补。 不同的模型在不同任务上各有优势。GPT-4o在复杂推理和多语言理解上表现优异,Claude在长文本处理上独具优势,国产大模型在中文语境理解上更加精准,而本地部署的开源模型在特定垂直领域经过微调后可能达到最佳效果。企业需要能够灵活地选择最适合当前任务的模型。

然而,实现多提供商适配面临着显著的技术挑战。不同提供商的API在以下方面存在差异:

差异维度OpenAIOllama兼容OpenAI的第三方
API端点/v1/chat/completions/api/chat/api/generate通常兼容 /v1/chat/completions
认证方式Bearer Token无需认证(本地)Bearer Token 或自定义
请求格式标准OpenAI格式自定义JSON格式兼容OpenAI格式(可能有扩展)
响应格式choices[0].message.contentmessage.contentresponse通常兼容OpenAI格式
流式格式SSE,data: [DONE] 结束NDJSON或SSE通常兼容OpenAI SSE格式
嵌入接口/v1/embeddings/api/embeddings通常兼容 /v1/embeddings

面对这些差异,如果为每个提供商编写独立的调用代码,不仅会导致大量重复代码,还会使得后续的提供商切换和新增变得极其困难。因此,构建一个统一的多提供商适配层,是企业级AI集成的首要任务。

1.2 本地部署与云端API的深度对比

在架构设计阶段,本地部署和云端API的选择是每一个技术团队都需要面对的核心决策。这个决策不仅影响技术架构,还深刻地影响着成本结构、运维模式和安全策略。

本地部署方案(以Ollama为代表):

Ollama是目前最流行的本地大模型运行框架之一。它提供了极其简便的模型下载、运行和管理能力,开发者只需一条命令即可在本地启动一个功能完备的大模型服务。在企业环境中,Ollama通常部署在配备GPU的服务器上,通过内网提供服务。

本地部署的核心优势在于数据安全和成本可控。所有推理请求和数据都在企业内部网络中处理,不存在数据泄露到外部的风险。对于处理敏感数据的场景(如医疗诊断辅助、金融风控分析),这是不可妥协的要求。在成本方面,虽然前期需要投入GPU硬件,但对于高频调用场景,本地部署的边际成本几乎为零——无论调用多少次,硬件成本是固定的。

然而,本地部署也有其局限性。首先,开源模型的能力与最顶级的闭源模型(如GPT-4o)之间仍存在差距,特别是在复杂推理、多步逻辑链等场景中。其次,本地部署需要专业的运维能力,包括GPU驱动管理、模型版本更新、服务监控等。再者,本地部署的并发能力受限于硬件资源,当并发请求量激增时,可能需要横向扩展GPU服务器。

云端API方案(以OpenAI为代表):

云端API方案的最大优势是"零运维"和"最强模型能力"。开发者无需关心GPU采购、驱动安装、模型部署等底层细节,只需通过API调用即可使用最先进的模型。OpenAI、Anthropic等厂商持续推出新模型,企业可以立即享受到模型能力提升带来的红利。

但云端API也有明显的短板。首先是数据安全风险——所有发送到API的请求内容都会经过提供商的服务器,虽然主流提供商都承诺不会使用API数据训练模型,但这一承诺在法律和操作层面的约束力仍需评估。其次是成本不可控——API调用按token计费,在用户量增长或使用频率提高时,成本可能快速攀升。最后是网络依赖——API调用需要稳定的互联网连接,在网络环境不佳或提供商服务波动时,业务可用性会受到影响。

smart-scaffold-springboot的架构决策:

在充分评估两种方案的优劣后,smart-scaffold-springboot项目采用了"双轨并行、统一适配"的架构策略。系统同时支持本地部署和云端API两种模式,并通过统一的适配层屏蔽底层差异,使得上层业务代码无需关心具体的提供商类型。

这种架构策略的核心价值在于:

  1. 灵活切换: 同一套业务代码,只需修改配置即可在本地模型和云端API之间切换
  2. 按需选择: 不同业务模块可以使用不同的AI提供商,实现最优的成本-效果平衡
  3. 风险分散: 不依赖单一提供商,当某个提供商不可用时,可以快速切换到备用方案
  4. 渐进迁移: 可以先用本地模型快速验证业务可行性,再逐步迁移到云端API获取更强的模型能力

1.3 模型配置的动态管理需求

在企业级AI集成中,模型配置的动态管理是一个经常被忽视但极其重要的需求。传统的做法是将模型配置写在 application.yml 或环境变量中,这种方式在开发和小规模部署场景下是可行的,但在生产环境中会暴露出明显的不足。

静态配置的局限性:

首先,静态配置无法满足多租户/多用户的需求。在一个多租户SaaS系统中,不同的租户可能需要使用不同的模型——金融行业租户可能要求使用本地部署的模型以确保数据安全,而电商行业租户可能更倾向于使用云端API以获取更强的推理能力。静态配置无法实现这种细粒度的模型分配。

其次,静态配置的修改需要重启服务。当需要调整模型参数(如temperature、maxTokens)或切换模型版本时,必须修改配置文件并重启应用,这在生产环境中是不可接受的——重启意味着服务中断,影响用户体验。

再者,静态配置无法实现用户自主配置。越来越多的企业应用希望允许用户自定义AI模型参数,例如选择偏好的模型、调整创造性程度、设置最大输出长度等。这些个性化配置显然不可能通过静态配置来实现。

动态配置的核心需求:

基于上述分析,企业级AI集成需要一套动态配置管理体系,满足以下核心需求:

  1. 用户级配置: 每个用户可以配置自己偏好的AI提供商、模型名称、API密钥等参数
  2. 运行时切换: 配置变更无需重启服务,立即生效
  3. 默认值兜底: 当用户没有自定义配置时,使用系统默认配置
  4. 配置优先级: 用户自定义配置优先于系统默认配置
  5. 安全存储: API密钥等敏感信息需要加密存储
  6. 配置验证: 对配置参数进行合法性校验(如temperature范围、token限制等)

在smart-scaffold-springboot项目中,这套动态配置管理体系通过 ModelService 组件和 user_model 数据库表来实现,后续章节将详细解析其设计和实现。

1.4 smart-scaffold-springboot的AI模块架构总览

在深入各个组件的实现细节之前,让我们先从全局视角了解smart-scaffold-springboot项目中AI模块的整体架构。

smart-scaffold-springboot/
├── smart-scaffold-common/          # 公共模块
│   └── po/
│       └── ModelPO.java            # 模型配置值对象(纯数据载体)
├── smart-scaffold-dao/             # 数据访问层
│   ├── entity/db1/
│   │   └── UserModel.java          # 用户模型实体(数据库映射)
│   ├── dto/db1/
│   │   ├── UserModelDTO.java       # 用户模型传输对象
│   │   └── UserModelQueryDTO.java  # 用户模型查询对象
│   └── mapper/db1/
│       └── UserModelMapper.java    # 用户模型数据访问接口
├── smart-scaffold-service/         # 业务逻辑层
│   ├── ModelService.java           # 模型配置管理服务(核心)
│   ├── ChatClientFactory.java      # 聊天客户端工厂
│   └── EmbeddingConfig.java        # 向量嵌入服务配置
└── smart-scaffold-web/             # Web接口层
    └── controller/
        └── AIController.java       # AI功能REST接口

这个架构遵循了经典的分层设计原则:

  • 公共模块(common): 定义跨层使用的数据结构,如 ModelPO 作为模型配置的纯数据载体
  • 数据访问层(dao): 负责用户模型配置的持久化,包括实体定义、DTO转换和Mapper接口
  • 业务逻辑层(service): 实现AI集成的核心逻辑,包括模型配置管理、聊天客户端创建、向量嵌入等
  • Web接口层(web): 对外暴露REST API,处理HTTP请求和响应

各层之间通过接口进行解耦,数据通过DTO(Data Transfer Object)在各层之间传递。这种分层设计使得每一层都可以独立演进和测试,同时也便于后续的扩展和维护。


二、ModelService多提供商统一适配

2.1 设计理念与核心职责

ModelService 是整个AI基础设施的核心枢纽,承担着"配置管理中心"和"适配调度中心"的双重角色。它的设计理念可以用一句话概括:让上层业务代码完全感知不到底层AI提供商的差异。

从职责划分来看,ModelService 需要处理以下几个核心问题:

  1. 配置解析: 从系统配置文件和用户数据库配置中读取模型参数
  2. 优先级合并: 当系统默认配置和用户自定义配置同时存在时,按照优先级规则进行合并
  3. 提供商适配: 根据API类型(OLLAMA/OPENAI/COMPATIBLE_OPENAI)选择对应的适配策略
  4. 客户端创建: 为不同的使用场景(聊天、嵌入向量)创建配置好的客户端实例
  5. 配置校验: 对模型参数进行合法性验证

这种设计将"配置管理"和"AI调用"彻底解耦。上层业务代码只需要告诉 ModelService "我需要为某个用户创建一个聊天客户端",ModelService 就会自动处理配置读取、优先级合并、提供商适配等所有底层细节,返回一个可以直接使用的客户端实例。

2.2 bima.ai.default-api-type配置体系

smart-scaffold-springboot项目通过 bima.ai 前缀的配置属性来管理系统级别的AI默认配置。这套配置体系的设计充分考虑了灵活性和可维护性。

核心配置项说明:

yaml
# 教学示例 - 系统默认AI配置
bima:
  ai:
    # 默认AI提供商类型
    # OLLAMA: 本地Ollama部署
    # OPENAI: OpenAI官方API
    # COMPATIBLE_OPENAI: 兼容OpenAI接口的第三方服务
    default-api-type: OLLAMA

    # Ollama提供商专属配置
    ollama:
      base-url: http://localhost:11434
      model-name: llama3
      temperature: 0.7
      max-tokens: 4096
      embedding-model: nomic-embed-text

    # OpenAI提供商专属配置
    openai:
      base-url: https://api.openai.com
      model-name: gpt-4o
      api-key: ${OPENAI_API_KEY}
      temperature: 0.7
      max-tokens: 4096
      embedding-model: text-embedding-3-large

    # 兼容OpenAI的第三方服务配置
    compatible-openai:
      base-url: https://api.deepseek.com
      model-name: deepseek-chat
      api-key: ${DEEPSEEK_API_KEY}
      temperature: 0.7
      max-tokens: 4096
      embedding-model: deepseek-embedding

这套配置的设计有几个值得关注的要点:

第一,三种提供商类型各有独立配置块。 ollamaopenaicompatible-openai 三个配置块各自独立,互不干扰。每个配置块都包含了该提供商所需的全部参数:base-url(服务地址)、model-name(模型名称)、api-key(API密钥,Ollama不需要)、temperature(创造性参数)、max-tokens(最大输出token数)、embedding-model(嵌入模型名称)。

第二,API密钥通过环境变量注入。 注意 openai.api-keycompatible-openai.api-key 使用了 ${OPENAI_API_KEY}${DEEPSEEK_API_KEY} 的Spring属性引用语法,实际值从环境变量中读取。这是生产环境的安全最佳实践——敏感信息不应该明文写在配置文件中,而应该通过环境变量、密钥管理服务(如Vault)或K8s Secret等方式注入。

第三,default-api-type 决定默认提供商。 系统启动时,default-api-type 的值决定了当用户没有自定义配置时,系统使用哪个提供商。在开发环境中,可以设置为 OLLAMA 使用免费的本地模型;在生产环境中,可以设置为 OPENAICOMPATIBLE_OPENAI 使用云端API。

第四,每种提供商都有独立的 embedding-model 配置。 这是因为不同的提供商支持的嵌入模型不同——Ollama通常使用 nomic-embed-textmxbai-embed-large,OpenAI使用 text-embedding-3-large,DeepSeek使用自己的嵌入模型。将嵌入模型配置与聊天模型配置分离,使得系统可以在聊天和嵌入场景中使用不同的模型。

2.3 三种提供商类型的配置差异

虽然三种提供商类型在配置结构上保持一致(都包含baseUrl、modelName等字段),但在具体使用上存在重要差异。理解这些差异对于正确配置和使用至关重要。

OLLAMA类型:

Ollama是本地部署方案,其配置和使用有以下特点:

  • 无需API密钥: Ollama默认不启用认证,因此 apiKey 字段为空。在生产环境中,如果需要为Ollama启用认证,可以通过配置Nginx反向代理或Ollama的API密钥功能来实现。
  • base-url指向本地服务: 通常为 http://localhost:11434 或内网地址。Ollama默认监听11434端口。
  • 模型需要预先拉取: 使用某个模型前,需要先通过 ollama pull <model-name> 命令将模型下载到本地。如果指定的模型不存在,Ollama会自动尝试拉取(如果配置了远程模型仓库)。
  • 嵌入模型选择有限: Ollama支持的嵌入模型主要是开源的嵌入模型,如 nomic-embed-text(768维)、mxbai-embed-large(1024维)等。

OPENAI类型:

OpenAI是云端API方案的代表,其配置特点如下:

  • 必须配置API密钥: OpenAI的API认证基于Bearer Token机制,apiKey 是必须的配置项。
  • base-url通常不需要修改: 默认为 https://api.openai.com。但如果使用代理服务(如Azure OpenAI或API转发服务),则需要修改为对应的地址。
  • 模型选择丰富: 支持GPT-4o、GPT-4o-mini、o1、o3等模型,以及text-embedding-3-large、text-embedding-3-small等嵌入模型。
  • 按token计费: 需要关注API调用的token消耗和费用。

COMPATIBLE_OPENAI类型:

兼容OpenAI的第三方服务是目前国内企业使用最广泛的方案,其配置特点如下:

  • 接口格式兼容OpenAI: 请求和响应格式与OpenAI基本一致,但可能在某些细节上有差异(如错误码、流式响应格式等)。
  • base-url指向第三方服务: 如DeepSeek的 https://api.deepseek.com、通义千问的 https://dashscope.aliyuncs.com/compatible-mode 等。
  • API密钥格式各异: 不同服务商的API密钥格式和获取方式不同,需要参考各自的文档。
  • 模型名称不同: 需要使用各服务商自己的模型名称,如 deepseek-chatqwen-turbo 等。

以下是三种提供商类型的关键差异对照表:

配置项OLLAMAOPENAICOMPATIBLE_OPENAI
apiKey不需要(可为空)必须配置必须配置
baseUrl本地地址api.openai.com第三方服务地址
认证方式无(或自定义)Bearer TokenBearer Token
聊天端点/api/chat/v1/chat/completions/v1/chat/completions
嵌入端点/api/embeddings/v1/embeddings/v1/embeddings
流式格式NDJSONSSE (data: [DONE])通常兼容OpenAI SSE
计费方式免费(硬件成本)按token计费按token计费
典型模型llama3, qwen2gpt-4o, o1deepseek-chat, qwen-turbo

2.4 系统默认配置与用户自定义配置的合并策略

在实际应用中,系统默认配置和用户自定义配置需要协同工作。ModelService 实现了一套清晰的配置合并策略,其核心原则是:用户自定义配置优先于系统默认配置,但用户未配置的字段使用系统默认值。

这种合并策略可以用以下伪代码来描述:

java
// 教学示例 - 配置合并策略
public ModelPO resolveModelConfig(Long userId) {
    // 第一步:加载系统默认配置
    ModelPO systemConfig = loadSystemDefaultConfig();

    // 第二步:查询用户自定义配置
    UserModel userConfig = userModelMapper.selectByUserId(userId);

    // 第三步:如果用户有自定义配置,进行合并
    if (userConfig != null && userConfig.getStatus() == 1) {
        ModelPO merged = new ModelPO();

        // 用户配置的字段优先使用用户值
        merged.setApiType(
            userConfig.getApiType() != null
                ? userConfig.getApiType()
                : systemConfig.getApiType()
        );
        merged.setBaseUrl(
            StringUtils.isNotBlank(userConfig.getBaseUrl())
                ? userConfig.getBaseUrl()
                : systemConfig.getBaseUrl()
        );
        merged.setModelName(
            StringUtils.isNotBlank(userConfig.getModelName())
                ? userConfig.getModelName()
                : systemConfig.getModelName()
        );
        merged.setApiKey(
            StringUtils.isNotBlank(userConfig.getApiKey())
                ? decryptApiKey(userConfig.getApiKey())
                : systemConfig.getApiKey()
        );
        // ... 其他字段类似处理

        return merged;
    }

    // 第四步:用户无自定义配置,直接使用系统默认
    return systemConfig;
}

这段教学示例展示了配置合并的核心逻辑。有几个关键的设计决策值得深入讨论:

第一,用户配置的"激活"机制。 注意代码中检查了 userConfig.getStatus() == 1。这意味着用户配置有一个"启用/禁用"的状态字段。即使数据库中存在用户配置记录,如果状态为禁用(0),系统也会忽略该配置,回退到系统默认配置。这个设计为管理员提供了控制手段——当某个用户的自定义配置导致问题时,可以快速禁用而无需删除配置记录。

第二,API密钥的解密处理。 用户配置中的 apiKey 在数据库中是加密存储的(使用AES-256加密),在合并配置时需要先解密再使用。这个安全设计确保了即使数据库被泄露,攻击者也无法直接获取用户的API密钥。

第三,字段级别的合并。 合并策略是字段级别的,而非整体替换。这意味着用户可以只覆盖部分字段(如只修改 temperature),而其他字段(如 baseUrlmodelName)仍然使用系统默认值。这种细粒度的合并方式提供了极大的灵活性。

2.5 createEmbeddingService方法解析

除了聊天模型配置,ModelService 还负责创建向量嵌入服务的配置。createEmbeddingService 方法是这一功能的核心入口。

向量嵌入服务的配置与聊天模型配置既有相似之处,也有重要区别。相似之处在于它们都需要确定提供商类型、服务地址和API密钥;区别在于它们使用不同的模型——聊天使用的是大语言模型(如GPT-4o、Llama3),而嵌入使用的是专门的嵌入模型(如text-embedding-3-large、nomic-embed-text)。

java
// 教学示例 - 创建嵌入服务配置
public ModelPO createEmbeddingService(Long userId) {
    // 复用配置合并逻辑
    ModelPO config = resolveModelConfig(userId);

    // 覆盖为嵌入模型名称
    // 嵌入模型与聊天模型是独立的配置项
    UserModel userConfig = userModelMapper.selectByUserId(userId);
    if (userConfig != null
            && StringUtils.isNotBlank(userConfig.getEmbeddingModel())) {
        config.setModelName(userConfig.getEmbeddingModel());
    } else {
        // 使用系统默认的嵌入模型
        config.setModelName(getDefaultEmbeddingModel(config.getApiType()));
    }

    return config;
}

这段教学示例揭示了嵌入服务配置的一个关键设计决策:嵌入模型名称是独立配置的。user_model 表中,embedding_model 是一个独立的字段,与 model_name(聊天模型名称)分开存储。这种设计允许用户为聊天和嵌入场景选择不同的模型——例如,聊天使用 gpt-4o(追求推理质量),而嵌入使用 text-embedding-3-small(追求成本效益)。

不同提供商的默认嵌入模型如下:

提供商类型默认嵌入模型向量维度
OLLAMAnomic-embed-text768
OPENAItext-embedding-3-large3072
COMPATIBLE_OPENAI取决于服务商取决于服务商

三、ChatClientFactory聊天客户端工厂

3.1 工厂模式的设计动机

在软件设计中,工厂模式(Factory Pattern)是一种经典的设计模式,其核心思想是将对象的创建过程封装起来,调用者无需关心对象的具体创建细节。在AI集成场景中,ChatClientFactory 正是工厂模式的典型应用。

为什么需要聊天客户端工厂?

首先,创建一个功能完备的聊天客户端涉及大量的配置工作。需要设置基础URL、API密钥、模型名称、温度参数、最大token数等,还需要配置HTTP客户端的超时时间、缓冲策略等。如果将这些配置逻辑散落在各个业务方法中,不仅会导致代码重复,还会使得配置变更变得极其困难。

其次,不同的AI提供商需要不同类型的客户端配置。Ollama的客户端不需要API密钥头,而OpenAI的客户端必须配置 Authorization: Bearer <api-key> 请求头。如果让业务代码来处理这些差异,势必会导致大量的 if-else 分支。

再者,聊天客户端的创建可能涉及昂贵的资源初始化(如HTTP连接池),使用工厂模式可以实现客户端的复用和缓存,避免重复创建。

ChatClientFactory 的设计目标可以概括为:

  1. 统一创建接口: 提供一个简洁的方法签名,隐藏所有创建细节
  2. 提供商自适应: 根据配置自动选择正确的客户端类型
  3. 配置注入: 将模型配置(ModelPO)自动注入到客户端中
  4. 可测试性: 工厂方法可以轻松替换为Mock实现,便于单元测试

3.2 基于WebClient的HTTP调用方案

ChatClientFactory 使用Spring WebFlux的 WebClient 作为HTTP客户端,而非传统的 RestTemplate。这个技术选型决策有着深远的影响。

为什么选择WebClient而非RestTemplate?

RestTemplate 是Spring框架中经典的同步HTTP客户端,它使用阻塞式I/O模型——发送请求后,当前线程会被阻塞,直到收到响应或超时。在大模型调用场景中,这种阻塞模型会带来严重的问题:

  1. 流式响应无法支持: RestTemplate 只能等待整个响应体接收完毕后才能处理,无法实现逐块推送的流式效果
  2. 线程资源浪费: 大模型API的响应时间通常较长(几秒到几十秒),阻塞线程意味着大量线程被占用,在高并发场景下可能导致线程池耗尽
  3. 扩展性差: 随着并发量增加,需要不断增大线程池,但线程上下文切换的开销也会随之增大

WebClient 是Spring WebFlux提供的非阻塞HTTP客户端,基于Reactor和Netty构建,完美解决了上述问题:

  1. 原生流式支持: 通过 bodyToFlux() 方法可以轻松实现流式响应处理
  2. 非阻塞I/O: 基于事件循环模型,少量线程即可处理大量并发连接
  3. 响应式编程: 与Project Reactor无缝集成,支持 mapfilterflatMap 等操作符链
java
// 教学示例 - WebClient构建聊天客户端
public class ChatClientFactory {

    private final WebClient.Builder webClientBuilder;

    public ChatClientFactory(WebClient.Builder webClientBuilder) {
        this.webClientBuilder = webClientBuilder;
    }

    /**
     * 根据模型配置创建WebClient实例
     */
    private WebClient createWebClient(ModelPO modelConfig) {
        WebClient.Builder builder = webClientBuilder
            .baseUrl(modelConfig.getBaseUrl())
            .defaultHeader(HttpHeaders.CONTENT_TYPE,
                MediaType.APPLICATION_JSON_VALUE);

        // OpenAI和兼容OpenAI的服务需要API密钥认证
        if (modelConfig.getApiType() != ApiType.OLLAMA
                && StringUtils.isNotBlank(modelConfig.getApiKey())) {
            builder.defaultHeader(HttpHeaders.AUTHORIZATION,
                "Bearer " + modelConfig.getApiKey());
        }

        return builder.build();
    }
}

这段教学示例展示了WebClient的构建过程。有几个值得注意的细节:

  • baseUrl的动态设置: WebClient的baseUrl从模型配置中读取,不同的提供商使用不同的服务地址
  • 条件性API密钥头: 只有非OLLAMA类型且配置了API密钥时,才添加 Authorization 请求头。Ollama本地部署通常不需要认证
  • 使用Builder模式: WebClient采用Builder模式构建,配置过程清晰且链式调用

3.3 chat方法:同步获取完整响应

chat 方法是 ChatClientFactory 中最基础的方法,用于同步获取大模型的完整响应。它适用于不需要流式展示的场景,如后台任务处理、批量文本分析等。

java
// 教学示例 - 同步聊天方法
public String chat(ModelPO modelConfig, String userMessage) {
    WebClient webClient = createWebClient(modelConfig);

    // 构建请求体
    Map<String, Object> requestBody = new HashMap<>();
    requestBody.put("model", modelConfig.getModelName());
    requestBody.put("temperature", modelConfig.getTemperature());
    requestBody.put("max_tokens", modelConfig.getMaxTokens());

    // 构建消息列表
    List<Map<String, String>> messages = new ArrayList<>();
    Map<String, String> message = new HashMap<>();
    message.put("role", "user");
    message.put("content", userMessage);
    messages.add(message);
    requestBody.put("messages", messages);

    // 根据API类型选择不同的端点和响应解析方式
    String endpoint = resolveEndpoint(modelConfig.getApiType());

    // 发送请求并获取响应
    String responseJson = webClient.post()
        .uri(endpoint)
        .bodyValue(requestBody)
        .retrieve()
        .bodyToMono(String.class)
        .block(); // 阻塞等待完整响应

    // 解析响应,提取文本内容
    return parseResponse(responseJson, modelConfig.getApiType());
}

这段教学示例展示了同步聊天的核心流程:

  1. 创建WebClient: 根据模型配置创建带有正确baseUrl和认证头的WebClient实例
  2. 构建请求体: 按照OpenAI兼容格式构建JSON请求体,包含model、temperature、max_tokens和messages
  3. 选择端点: 根据API类型选择不同的请求端点(Ollama使用 /api/chat,OpenAI兼容的使用 /v1/chat/completions
  4. 发送请求: 使用WebClient发送POST请求,.block() 方法阻塞等待完整响应
  5. 解析响应: 根据API类型选择不同的响应解析策略,提取模型生成的文本内容

同步方法的适用场景:

  • 后台批处理任务(如批量文本摘要、情感分析)
  • 不需要实时展示的非交互式场景
  • 作为流式方法的降级方案(当流式不可用时回退到同步)
  • 单元测试和调试

3.4 chatStream方法:流式响应的完整实现

chatStream 方法是 ChatClientFactory 中最核心也最复杂的方法,它实现了大模型流式响应的完整处理链路。与 chat 方法返回完整的字符串不同,chatStream 方法返回一个 Flux<String> 响应式流,每个元素代表模型生成的一个文本片段。

java
// 教学示例 - 流式聊天方法
public Flux<String> chatStream(ModelPO modelConfig, String userMessage) {
    WebClient webClient = createWebClient(modelConfig);

    // 构建请求体(与同步方法类似,但添加stream:true)
    Map<String, Object> requestBody = buildChatRequest(modelConfig, userMessage);
    requestBody.put("stream", true); // 启用流式响应

    String endpoint = resolveEndpoint(modelConfig.getApiType());

    return webClient.post()
        .uri(endpoint)
        .contentType(MediaType.APPLICATION_JSON)
        .accept(MediaType.TEXT_EVENT_STREAM) // 接受SSE流
        .bodyValue(requestBody)
        .retrieve()
        .bodyToFlux(String.class) // 将响应体作为字符串流处理
        .filter(chunk -> !"[DONE]".equals(chunk.trim())) // 过滤结束标记
        .map(chunk -> parseStreamChunk(chunk, modelConfig.getApiType())) // 解析每个片段
        .filter(content -> content != null && !content.isEmpty()); // 过滤空内容
}

这段教学示例揭示了流式响应处理的几个关键技术点:

第一,stream: true 参数。 在请求体中设置 stream: true 是启用流式响应的关键。无论是OpenAI还是Ollama,都通过这个参数来区分同步响应和流式响应。

第二,MediaType.TEXT_EVENT_STREAM 设置 accept 头为 text/event-stream,告知服务器客户端期望接收SSE格式的流式响应。这个MIME类型是SSE协议的标准类型。

第三,bodyToFlux(String.class) 这是WebClient处理流式响应的核心方法。与 bodyToMono 等待完整响应不同,bodyToFlux 将响应体切分为多个字符串块,每个块作为一个Flux元素发射。

第四,过滤和解析链。 流式响应的数据需要经过多步处理:

  • 过滤 [DONE] 标记(OpenAI流式响应的结束信号)
  • 解析每个数据块,提取文本内容
  • 过滤空内容(某些数据块可能不包含有效文本)

3.5 OpenAI与Ollama响应格式的自适应解析

流式响应处理中最棘手的问题之一,就是不同提供商的响应格式差异。ChatClientFactory 通过自适应解析策略来应对这一挑战。

OpenAI兼容格式的流式响应:

OpenAI的流式响应遵循SSE协议,每个数据块是一个JSON对象,格式如下:

data: {"id":"chatcmpl-xxx","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"Hello"},"finish_reason":null}]}

data: {"id":"chatcmpl-xxx","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":" world"},"finish_reason":null}]}

data: [DONE]

解析逻辑需要从 choices[0].delta.content 中提取文本内容:

java
// 教学示例 - OpenAI格式流式响应解析
private String parseOpenAIStreamChunk(String chunk) {
    try {
        ObjectMapper mapper = new ObjectMapper();
        JsonNode root = mapper.readTree(chunk);

        JsonNode choices = root.path("choices");
        if (choices.isArray() && choices.size() > 0) {
            JsonNode delta = choices.get(0).path("delta");
            return delta.path("content").asText("");
        }
    } catch (Exception e) {
        // 解析失败,返回空字符串
    }
    return "";
}

Ollama格式的流式响应:

Ollama的流式响应使用NDJSON(Newline-Delimited JSON)格式,每行一个JSON对象:

{"model":"llama3","message":{"role":"assistant","content":"Hello"},"done":false}
{"model":"llama3","message":{"role":"assistant","content":" world"},"done":false}
{"model":"llama3","message":{"role":"assistant","content":""},"done":true}

解析逻辑需要从 message.content 中提取文本内容:

java
// 教学示例 - Ollama格式流式响应解析
private String parseOllamaStreamChunk(String chunk) {
    try {
        ObjectMapper mapper = new ObjectMapper();
        JsonNode root = mapper.readTree(chunk);

        // 检查是否已完成
        boolean done = root.path("done").asBoolean(false);
        if (done) {
            return ""; // 完成信号,不包含有效内容
        }

        return root.path("message").path("content").asText("");
    } catch (Exception e) {
        // 解析失败,返回空字符串
    }
    return "";
}

自适应解析的统一入口:

java
// 教学示例 - 自适应解析入口
private String parseStreamChunk(String chunk, ApiType apiType) {
    if (apiType == ApiType.OLLAMA) {
        return parseOllamaStreamChunk(chunk);
    } else {
        // OPENAI和COMPATIBLE_OPENAI使用相同的解析逻辑
        return parseOpenAIStreamChunk(chunk);
    }
}

这种设计将解析逻辑按提供商类型分离,通过统一的入口方法进行分发。当需要支持新的提供商类型时,只需添加新的解析方法并在入口方法中添加一个分支即可。

3.6 禁用缓冲的关键配置

在流式响应场景中,一个经常被忽视但极其重要的配置是HTTP缓冲的禁用。如果中间代理(如Nginx)或Web容器对响应进行缓冲,流式效果将被破坏——用户将看到长时间的白屏,然后突然显示所有内容,而不是逐字显示。

ChatClientFactory 通过两个HTTP头来禁用缓冲:

java
// 教学示例 - 禁用缓冲的HTTP头配置
WebClient webClient = webClientBuilder
    .baseUrl(modelConfig.getBaseUrl())
    .defaultHeader("X-Accel-Buffering", "no")    // 禁用Nginx缓冲
    .defaultHeader("Cache-Control", "no-cache")    // 禁用缓存
    .defaultHeader(HttpHeaders.CONTENT_TYPE,
        MediaType.APPLICATION_JSON_VALUE)
    .build();

X-Accel-Buffering: no

这是Nginx特有的响应头,用于禁用Nginx的代理缓冲。当Nginx作为反向代理时,默认会对上游服务器的响应进行缓冲——等待收集一定量的数据后再发送给客户端。这种缓冲机制在普通HTTP请求中可以提高性能,但在SSE流式响应中会导致严重的延迟。

设置 X-Accel-Buffering: no 后,Nginx会立即将上游服务器的每个数据块转发给客户端,不做任何缓冲。这是实现真正的实时流式推送的关键配置。

Cache-Control: no-cache

Cache-Control: no-cache 头告知中间代理和浏览器不要缓存响应内容。虽然SSE响应通常是动态生成的,缓存的可能性不大,但添加这个头可以防止某些激进的缓存策略导致的问题。

生产环境中的完整缓冲禁用策略:

在实际部署中,除了在代码中设置HTTP头外,还需要在基础设施层面进行配置:

  1. Nginx配置: 在Nginx的location块中添加 proxy_buffering off;X-Accel-Buffering: no;
  2. Spring Boot配置: 确保Tomcat或Netty的输出缓冲被禁用
  3. CDN配置: 如果使用CDN,需要配置CDN不对SSE端点进行缓存和缓冲

四、EmbeddingConfig向量嵌入服务

4.1 向量嵌入技术概述

向量嵌入(Embedding)是将文本、图像等非结构化数据转换为固定维度数值向量的技术。这些向量在高维空间中保留了原始数据的语义信息——语义相似的文本在向量空间中距离较近,语义差异较大的文本距离较远。

在AI应用中,向量嵌入是RAG(Retrieval-Augmented Generation,检索增强生成)技术的核心基础。RAG的基本流程如下:

  1. 文档分块: 将长文档切分为较小的文本块(chunk)
  2. 向量嵌入: 使用嵌入模型将每个文本块转换为向量
  3. 向量存储: 将向量存储到向量数据库(如ChromaDB、Pinecone、Milvus)
  4. 相似度检索: 当用户提问时,将问题转换为向量,在向量数据库中检索最相似的文本块
  5. 上下文增强: 将检索到的文本块作为上下文,与用户问题一起发送给大模型
  6. 生成回答: 大模型基于检索到的上下文生成准确的回答

RAG技术解决了大模型的两个核心问题:知识时效性(大模型的训练数据有截止日期)和幻觉问题(大模型可能生成看似合理但实际错误的内容)。通过提供准确的上下文信息,RAG显著提升了大模型回答的准确性和可靠性。

4.2 @Configuration与@PostConstruct启动加载

EmbeddingConfig 使用Spring的 @Configuration 注解标记为配置类,并通过 @PostConstruct 注解实现在应用启动时自动加载和初始化向量嵌入服务。

java
// 教学示例 - EmbeddingConfig基础结构
@Configuration
public class EmbeddingConfig {

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

    @Value("${bima.ai.default-api-type:OLLAMA}")
    private String defaultApiType;

    @Value("${bima.ai.ollama.base-url:http://localhost:11434}")
    private String ollamaBaseUrl;

    @Value("${bima.ai.ollama.embedding-model:nomic-embed-text}")
    private String ollamaEmbeddingModel;

    @Value("${bima.ai.openai.base-url:https://api.openai.com}")
    private String openaiBaseUrl;

    @Value("${bima.ai.openai.api-key:}")
    private String openaiApiKey;

    @Value("${bima.ai.openai.embedding-model:text-embedding-3-large}")
    private String openaiEmbeddingModel;

    // ... 其他配置字段

    private CloseableHttpClient httpClient;

    @PostConstruct
    public void init() {
        log.info("初始化向量嵌入服务...");
        log.info("默认AI提供商类型: {}", defaultApiType);

        // 初始化Apache HttpClient5
        this.httpClient = HttpClients.custom()
            .setDefaultRequestConfig(RequestConfig.custom()
                .setConnectTimeout(Timeout.ofSeconds(30))
                .setResponseTimeout(Timeout.ofSeconds(120))
                .build())
            .build();

        log.info("向量嵌入服务初始化完成");
    }

    @PreDestroy
    public void destroy() {
        if (httpClient != null) {
            try {
                httpClient.close();
            } catch (IOException e) {
                log.error("关闭HttpClient异常", e);
            }
        }
    }
}

这段教学示例展示了 EmbeddingConfig 的初始化结构,有几个关键设计点:

第一,使用 @Value 注入配置。ModelService 从数据库读取用户配置不同,EmbeddingConfig 使用 @Value 注解直接从Spring配置文件中读取系统默认配置。这是因为 EmbeddingConfig 负责的是系统级别的嵌入服务初始化,而用户级别的嵌入配置由 ModelService 在运行时动态处理。

第二,@PostConstruct 生命周期钩子。 @PostConstruct 注解标记的方法会在Spring容器完成Bean的依赖注入后自动调用。在这个方法中执行耗时的初始化操作(如创建HTTP客户端),可以确保在应用开始处理请求之前,所有必要的资源已经准备就绪。

第三,@PreDestroy 资源清理。@PostConstruct 对应,@PreDestroy 注解标记的方法会在Spring容器销毁Bean时调用。在这里关闭HTTP客户端,释放底层资源,防止资源泄漏。

第四,Apache HttpClient5的使用。 注意这里使用的是Apache HttpClient5(CloseableHttpClient),而非Spring的 WebClient。这是因为向量嵌入服务通常使用同步调用(发送一批文本,等待所有向量返回),使用同步HTTP客户端更加简洁直观。Apache HttpClient5是HttpClient的最新主要版本,提供了更好的性能和更丰富的功能。

4.3 Apache HttpClient5调用/v1/embeddings接口

EmbeddingConfig 使用Apache HttpClient5调用嵌入模型的API接口。虽然不同提供商的嵌入接口端点略有不同(Ollama使用 /api/embeddings,OpenAI使用 /v1/embeddings),但请求和响应的数据格式基本一致。

java
// 教学示例 - 调用嵌入API
public List<List<Double>> embedTexts(List<String> texts, ModelPO config) {
    if (texts == null || texts.isEmpty()) {
        return Collections.emptyList();
    }

    try {
        // 构建请求JSON
        ObjectMapper mapper = new ObjectMapper();
        ObjectNode requestNode = mapper.createObjectNode();
        requestNode.put("model", config.getModelName());
        ArrayNode inputArray = requestNode.putArray("input");
        for (String text : texts) {
            inputArray.add(text);
        }

        String jsonBody = mapper.writeValueAsString(requestNode);

        // 构建HTTP请求
        String endpoint = resolveEmbeddingEndpoint(config.getApiType());
        HttpPost httpPost = new HttpPost(config.getBaseUrl() + endpoint);
        httpPost.setHeader(HttpHeaders.CONTENT_TYPE, "application/json");

        // 设置认证头(非Ollama类型)
        if (config.getApiType() != ApiType.OLLAMA
                && StringUtils.isNotBlank(config.getApiKey())) {
            httpPost.setHeader(HttpHeaders.AUTHORIZATION,
                "Bearer " + config.getApiKey());
        }

        httpPost.setEntity(new StringEntity(jsonBody,
            ContentType.APPLICATION_JSON));

        // 发送请求
        try (CloseableHttpResponse response = httpClient.execute(httpPost)) {
            String responseBody = EntityUtils.toString(response.getEntity());

            // 解析响应
            return parseEmbeddingResponse(responseBody);
        }
    } catch (Exception e) {
        log.error("向量嵌入请求失败", e);
        throw new RuntimeException("向量嵌入服务调用失败", e);
    }
}

这段教学示例展示了嵌入API调用的完整流程:

  1. 构建请求体: 按照OpenAI兼容格式构建JSON请求,包含 model(模型名称)和 input(文本列表)。注意 input 是一个数组,支持批量嵌入。
  2. 选择端点: 根据API类型选择不同的嵌入端点。
  3. 设置认证: 非Ollama类型需要设置 Authorization: Bearer <api-key> 请求头。
  4. 发送请求: 使用HttpClient5发送POST请求。
  5. 解析响应: 从响应JSON中提取向量数据。

4.4 批量文本嵌入的实现

批量嵌入是向量嵌入服务中一个非常重要的优化。与其为每个文本单独发送一次API请求,不如将多个文本合并为一次请求,这可以显著减少网络往返次数和API调用开销。

java
// 教学示例 - 批量嵌入实现
public List<List<Double>> batchEmbed(List<String> texts, ModelPO config) {
    // 大多数嵌入API对单次请求的文本数量有限制
    // OpenAI: 最大2048个文本/请求
    // Ollama: 通常无硬性限制,但受请求体大小限制
    final int BATCH_SIZE = 100;

    List<List<Double>> allEmbeddings = new ArrayList<>();

    // 分批处理
    for (int i = 0; i < texts.size(); i += BATCH_SIZE) {
        int end = Math.min(i + BATCH_SIZE, texts.size());
        List<String> batch = texts.subList(i, end);

        log.debug("处理嵌入批次 {}/{},文本数量: {}",
            (i / BATCH_SIZE + 1),
            (texts.size() + BATCH_SIZE - 1) / BATCH_SIZE,
            batch.size());

        List<List<Double>> batchResult = embedTexts(batch, config);
        allEmbeddings.addAll(batchResult);
    }

    return allEmbeddings;
}

这段教学示例展示了批量嵌入的分批处理逻辑。关键设计点包括:

第一,批次大小控制。 BATCH_SIZE 设置为100,这是一个在性能和稳定性之间的平衡值。批次太大可能导致请求超时或超过API的限制,批次太小则无法充分发挥批量处理的优势。

第二,分批切割。 使用 subList 方法将大列表切割为多个小批次,每个批次独立发送请求。

第三,结果合并。 将所有批次的嵌入结果合并到一个列表中返回。注意返回结果的顺序与输入文本的顺序一一对应——第i个输入文本的嵌入向量是结果列表中的第i个元素。

4.5 OpenAI兼容的请求/响应格式

EmbeddingConfig 采用OpenAI兼容的请求/响应格式作为统一标准。这种选择基于以下考虑:

  1. 事实标准: OpenAI的嵌入API格式已经成为行业事实标准,大多数提供商(包括国产大模型服务商)都提供了兼容的接口
  2. 简化适配: 使用统一格式意味着只需维护一套请求构建和响应解析逻辑
  3. 未来兼容: 新的提供商通常会优先支持OpenAI兼容格式,降低了后续适配的成本

请求格式:

json
{
    "model": "text-embedding-3-large",
    "input": ["第一段文本", "第二段文本", "第三段文本"]
}

响应格式:

json
{
    "object": "list",
    "data": [
        {
            "object": "embedding",
            "embedding": [0.0023, -0.0094, 0.0152, ...],
            "index": 0
        },
        {
            "object": "embedding",
            "embedding": [0.0031, -0.0078, 0.0123, ...],
            "index": 1
        }
    ],
    "model": "text-embedding-3-large",
    "usage": {
        "prompt_tokens": 12,
        "total_tokens": 12
    }
}

响应解析逻辑:

java
// 教学示例 - 嵌入响应解析
private List<List<Double>> parseEmbeddingResponse(String json) {
    try {
        ObjectMapper mapper = new ObjectMapper();
        JsonNode root = mapper.readTree(json);
        JsonNode data = root.path("data");

        List<List<Double>> embeddings = new ArrayList<>();

        if (data.isArray()) {
            for (JsonNode item : data) {
                JsonNode embeddingNode = item.path("embedding");
                List<Double> vector = new ArrayList<>();
                for (JsonNode value : embeddingNode) {
                    vector.add(value.asDouble());
                }
                embeddings.add(vector);
            }
        }

        // 按index排序,确保顺序与输入一致
        embeddings.sort(Comparator.comparingInt(
            e -> embeddings.indexOf(e)));

        return embeddings;
    } catch (Exception e) {
        log.error("解析嵌入响应失败: {}", json, e);
        throw new RuntimeException("嵌入响应解析失败", e);
    }
}

4.6 默认模型text-embedding-3-large

EmbeddingConfig 中,OpenAI类型的默认嵌入模型设置为 text-embedding-3-large。这个选择经过了仔细的评估。

text-embedding-3-large的特性:

属性
向量维度3072
最大输入token数8191
性能(MTEB基准)优秀
价格$0.13 / 1M tokens
发布时间2024年1月

text-embedding-3-large是OpenAI第三代嵌入模型中维度最高的版本,在多项基准测试中表现优异。3072维的向量能够捕获丰富的语义信息,适合对检索精度要求较高的场景。

模型选择建议:

  • 高精度场景(企业知识库、法律文档检索): 使用 text-embedding-3-large(3072维)
  • 平衡场景(一般RAG应用): 使用 text-embedding-3-small(1536维),成本更低
  • 本地部署场景: 使用 nomic-embed-text(768维)或 mxbai-embed-large(1024维)
  • 中文优化场景: 考虑使用国产模型的嵌入服务,如通义千问的text-embedding-v3

五、用户大模型配置表设计

5.1 user_model表结构设计

user_model 表是整个动态配置管理体系的数据基础,它存储了每个用户的AI模型自定义配置。表结构的设计需要在灵活性、安全性和可维护性之间取得平衡。

sql
-- 教学示例 - user_model表结构
CREATE TABLE `user_model` (
    `id`          BIGINT       NOT NULL AUTO_INCREMENT COMMENT '主键ID',
    `user_id`     BIGINT       NOT NULL COMMENT '用户ID',
    `api_type`    VARCHAR(32)  DEFAULT NULL COMMENT 'API类型: OLLAMA/OPENAI/COMPATIBLE_OPENAI',
    `base_url`    VARCHAR(512) DEFAULT NULL COMMENT 'API基础地址',
    `model_name`  VARCHAR(128) DEFAULT NULL COMMENT '聊天模型名称',
    `api_key`     VARCHAR(512) DEFAULT NULL COMMENT 'API密钥(AES-256加密存储)',
    `temperature` DECIMAL(3,2) DEFAULT NULL COMMENT '温度参数(0.00-2.00)',
    `max_tokens`  INT          DEFAULT NULL COMMENT '最大输出token数',
    `embedding_model` VARCHAR(128) DEFAULT NULL COMMENT '嵌入模型名称',
    `is_default`  TINYINT      DEFAULT 0 COMMENT '是否为默认配置: 0-否, 1-是',
    `status`      TINYINT      DEFAULT 1 COMMENT '状态: 0-禁用, 1-启用',
    `create_time` DATETIME     DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    `update_time` DATETIME     DEFAULT CURRENT_TIMESTAMP
                              ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
    `create_by`   VARCHAR(64)  DEFAULT NULL COMMENT '创建人',
    `update_by`   VARCHAR(64)  DEFAULT NULL COMMENT '更新人',
    `deleted`     TINYINT      DEFAULT 0 COMMENT '逻辑删除: 0-未删除, 1-已删除',
    PRIMARY KEY (`id`),
    UNIQUE KEY `uk_user_id` (`user_id`),
    KEY `idx_api_type` (`api_type`),
    KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户大模型配置表';

这个表结构的设计有几个值得深入讨论的要点:

第一,user_id 的唯一约束。 uk_user_id 唯一索引确保每个用户只能有一条配置记录。这种"一对一"的设计简化了查询逻辑——查询某个用户的配置只需一条简单的SELECT语句,无需处理多条记录的优先级问题。

如果未来需要支持一个用户配置多个模型(例如,一个用户同时配置"日常对话用GPT-4o-mini,代码生成用GPT-4o"),可以将唯一约束改为联合唯一约束 (user_id, scene),通过 scene 字段区分不同的使用场景。

第二,所有配置字段允许NULL。 注意 api_typebase_urlmodel_name 等字段都设置了 DEFAULT NULL。这意味着用户可以选择性地覆盖部分配置——只设置需要修改的字段,未设置的字段将使用系统默认值。这种设计实现了字段级别的配置覆盖,提供了极大的灵活性。

第三,embedding_model 独立字段。 嵌入模型与聊天模型分开存储,允许用户为不同的使用场景选择不同的模型。这是一个重要的设计决策,因为嵌入模型的选择标准与聊天模型不同——嵌入模型更关注向量质量和维度,而聊天模型更关注推理能力和生成质量。

第四,is_default 字段。 这个字段标记了某条配置是否为"默认配置"。在多配置场景中,系统需要知道当用户没有指定使用哪个配置时,应该使用哪一条。虽然当前设计中每个用户只有一条配置,但 is_default 字段为未来的多配置扩展预留了空间。

第五,审计字段。 create_timeupdate_timecreate_byupdate_by 是标准的审计字段,记录了配置的创建和修改历史。deleted 字段实现了逻辑删除——删除配置时不物理删除记录,而是标记为已删除,保留历史数据。

5.2 API密钥的AES-256加密存储

API密钥是用户配置中最敏感的信息。如果数据库被泄露,明文存储的API密钥将直接暴露给攻击者,导致严重的经济损失和安全风险。因此,user_model 表中的 api_key 字段采用AES-256加密存储。

AES-256加密方案:

AES(Advanced Encryption Standard)是美国国家标准与技术研究院(NIST)采用的对称加密标准,256位密钥长度提供了极高的安全强度。在当前的计算能力下,暴力破解AES-256密钥在计算上是不可行的。

java
// 教学示例 - API密钥加密/解密工具
@Component
public class ApiKeyEncryptor {

    @Value("${bima.security.encrypt-key}")
    private String encryptKey;

    private static final String ALGORITHM = "AES/CBC/PKCS5Padding";
    private static final String TRANSFORMATION = "AES";

    /**
     * 加密API密钥
     */
    public String encrypt(String plainText) {
        try {
            // 从配置中获取密钥并转换为SecretKey
            byte[] keyBytes = decryptBase64Key(encryptKey);
            SecretKeySpec secretKey = new SecretKeySpec(keyBytes, TRANSFORMATION);

            // 生成随机IV(初始化向量)
            byte[] iv = new byte[16];
            SecureRandom random = new SecureRandom();
            random.nextBytes(iv);
            IvParameterSpec ivSpec = new IvParameterSpec(iv);

            // 执行加密
            Cipher cipher = Cipher.getInstance(ALGORITHM);
            cipher.init(Cipher.ENCRYPT_MODE, secretKey, ivSpec);
            byte[] encrypted = cipher.doFinal(plainText.getBytes(
                StandardCharsets.UTF_8));

            // 将IV和密文合并存储(IV无需保密,但每次加密必须不同)
            byte[] combined = new byte[iv.length + encrypted.length];
            System.arraycopy(iv, 0, combined, 0, iv.length);
            System.arraycopy(encrypted, 0, combined, iv.length,
                encrypted.length);

            return Base64.getEncoder().encodeToString(combined);
        } catch (Exception e) {
            throw new RuntimeException("API密钥加密失败", e);
        }
    }

    /**
     * 解密API密钥
     */
    public String decrypt(String encryptedText) {
        try {
            byte[] combined = Base64.getDecoder().decode(encryptedText);

            // 提取IV(前16字节)
            byte[] iv = new byte[16];
            System.arraycopy(combined, 0, iv, 0, 16);
            IvParameterSpec ivSpec = new IvParameterSpec(iv);

            // 提取密文
            byte[] encrypted = new byte[combined.length - 16];
            System.arraycopy(combined, 16, encrypted, 0, encrypted.length);

            // 执行解密
            byte[] keyBytes = decryptBase64Key(encryptKey);
            SecretKeySpec secretKey = new SecretKeySpec(keyBytes, TRANSFORMATION);

            Cipher cipher = Cipher.getInstance(ALGORITHM);
            cipher.init(Cipher.DECRYPT_MODE, secretKey, ivSpec);
            byte[] decrypted = cipher.doFinal(encrypted);

            return new String(decrypted, StandardCharsets.UTF_8);
        } catch (Exception e) {
            throw new RuntimeException("API密钥解密失败", e);
        }
    }
}

这段教学示例展示了AES-256-CBC加密方案的完整实现。有几个关键的安全设计点:

第一,CBC模式 + 随机IV。 AES-CBC(Cipher Block Chaining)模式通过将每个明文块与前一个密文块进行异或操作来增强安全性。每次加密都生成随机的IV(初始化向量),确保相同的明文每次加密后产生不同的密文。这防止了攻击者通过比较密文来推断明文内容。

第二,IV与密文合并存储。 IV不需要保密(它不是密钥),但必须与密文一起存储才能正确解密。实现中将IV(16字节)拼接在密文前面,一起进行Base64编码后存储到数据库。

第三,密钥从外部配置注入。 加密密钥通过 bima.security.encrypt-key 配置项注入,不硬编码在代码中。在生产环境中,这个密钥应该通过环境变量或密钥管理服务(如HashiCorp Vault、AWS KMS)注入。

加密存储的完整流程:

  1. 用户在前端输入API密钥
  2. 前端通过HTTPS将密钥发送到后端
  3. 后端使用 ApiKeyEncryptor.encrypt() 加密密钥
  4. 加密后的密文存储到 user_model.api_key 字段
  5. 需要使用密钥时,后端使用 ApiKeyEncryptor.decrypt() 解密
  6. 解密后的密钥仅在内存中使用,不写入日志或返回给前端

5.3 temperature参数约束设计

temperature 是大模型API中最重要的参数之一,它控制模型输出的"创造性"或"随机性"。在 user_model 表中,temperature 字段定义为 DECIMAL(3,2),取值范围为 0.00 到 2.00。

temperature参数的含义:

值域效果适用场景
0.0 - 0.3输出高度确定,几乎每次相同代码生成、数据提取、事实性问答
0.4 - 0.7适度创造性,平衡准确性和多样性通用对话、文本摘要、翻译
0.8 - 1.0较高创造性,输出更多样创意写作、头脑风暴、故事生成
1.0 - 2.0极高创造性,输出可能不太连贯实验性应用、艺术创作

数据库层面的约束:

sql
-- 通过DECIMAL(3,2)类型约束
-- 最大值: 99.99(但需要在应用层限制为2.00)
`temperature` DECIMAL(3,2) DEFAULT NULL

DECIMAL(3,2) 类型允许存储3位数字,其中2位小数,因此可以表示的范围是 -9.99 到 99.99。虽然数据库类型本身无法精确限制范围为 0.00-2.00,但应用层会进行严格的参数校验。

应用层的参数校验:

java
// 教学示例 - temperature参数校验
public void validateTemperature(BigDecimal temperature) {
    if (temperature != null) {
        if (temperature.compareTo(BigDecimal.ZERO) < 0
                || temperature.compareTo(new BigDecimal("2.00")) > 0) {
            throw new IllegalArgumentException(
                "temperature参数必须在0.00到2.00之间");
        }
    }
}

这种"数据库类型约束 + 应用层校验"的双重保障策略,确保了无论配置通过何种途径写入数据库,最终存储的值都是合法的。

5.4 配置状态管理与逻辑删除

user_model 表中的 statusdeleted 字段共同构成了配置的生命周期管理机制。

status字段(启用/禁用):

status 字段用于控制配置的启用状态。当 status = 1 时,配置处于启用状态,系统会使用该配置;当 status = 0 时,配置被禁用,系统会忽略该配置,回退到系统默认配置。

这个设计为管理员提供了快速干预的能力。当某个用户的自定义配置导致问题(如使用了不兼容的模型、设置了不合理的参数等),管理员可以立即禁用该配置,而无需删除配置记录。禁用操作是即时生效的,不需要重启服务。

deleted字段(逻辑删除):

deleted 字段实现了逻辑删除机制。当用户或管理员"删除"一条配置记录时,实际上只是将 deleted 字段设置为1,记录仍然保留在数据库中。

逻辑删除的优势:

  1. 数据可恢复: 误删除的配置可以轻松恢复
  2. 审计追踪: 保留完整的配置变更历史
  3. 关联完整性: 避免物理删除导致的关联数据问题
  4. 数据分析: 可以分析用户的配置偏好和使用模式

查询时的过滤:

java
// 教学示例 - 查询用户配置时过滤已删除记录
public UserModel selectByUserId(Long userId) {
    return userModelMapper.selectOne(new LambdaQueryWrapper<UserModel>()
        .eq(UserModel::getUserId, userId)
        .eq(UserModel::getDeleted, 0)  // 只查询未删除的记录
        .eq(UserModel::getStatus, 1)   // 只查询已启用的记录
    );
}

六、Spring AI Chroma向量数据库预留

6.1 向量数据库在AI架构中的角色

向量数据库是RAG(检索增强生成)架构中不可或缺的组件。它的核心功能是存储和检索高维向量,支持基于相似度的快速查询。

在传统的数据库中,数据通过精确匹配或范围查询来检索。例如,SQL查询 WHERE name = '张三'WHERE age BETWEEN 20 AND 30。但在AI应用中,我们需要的是"语义相似度"查询——找到与给定文本在语义上最相似的内容。这种查询无法通过传统的索引结构来实现,需要专门的向量索引和检索算法。

向量数据库通过以下核心能力来支持语义检索:

  1. 向量索引: 使用HNSW(Hierarchical Navigable Small World)、IVF(Inverted File Index)等算法构建高效的向量索引,支持在数百万甚至数十亿向量中快速检索
  2. 相似度计算: 支持余弦相似度、欧氏距离、内积等多种相似度度量方式
  3. 元数据过滤: 在向量检索的基础上,支持按元数据(如文档来源、创建时间、分类标签等)进行过滤
  4. CRUD操作: 支持向量的增删改查,便于数据的维护和更新

6.2 spring.ai.vectorstore.chroma配置解析

smart-scaffold-springboot项目在配置文件中预留了Spring AI与Chroma向量数据库的集成配置,为后续的RAG功能实现做好了准备。

yaml
# 教学示例 - Spring AI Chroma向量数据库配置
spring:
  ai:
    vectorstore:
      chroma:
        # Chroma服务地址
        host: ${CHROMA_HOST:localhost}
        port: ${CHROMA_PORT:8000}
        # 集合名称(类似数据库中的"表")
        collection-name: ${CHROMA_COLLECTION:bima-docs}
        # 初始化模式:ALWAYS每次启动都初始化,NEVER不初始化
        initialize-schema: true

配置项详解:

  • host / port: Chroma数据库服务的地址和端口。Chroma默认监听8000端口。在生产环境中,Chroma通常部署在独立的服务器上,通过内网访问。
  • collection-name: 集合名称,类似于关系数据库中的"表名"。不同的业务场景可以使用不同的集合——例如,bima-docs 用于存储文档知识库,bima-faq 用于存储常见问题。
  • initialize-schema: 是否在应用启动时自动创建集合。设置为 true 可以简化部署流程,避免手动创建集合的步骤。

6.3 ChromaDB的核心特性

ChromaDB是目前最流行的开源向量数据库之一,其设计理念是"简单、易用、开发者友好"。以下是ChromaDB的核心特性:

第一,轻量级部署。 ChromaDB可以通过Python包或Docker容器快速部署,无需复杂的配置。对于开发和小规模生产环境,ChromaDB的内置模式(in-memory)甚至不需要独立的服务进程。

第二,丰富的客户端支持。 ChromaDB提供了Python、JavaScript/TypeScript、Java等语言的客户端SDK。在Java/Spring生态中,通过Spring AI的Chroma模块可以无缝集成。

第三,元数据过滤。 ChromaDB支持在向量检索的同时进行元数据过滤。例如,可以检索"与给定问题最相似的文档片段,且文档来源为'合同文件',创建时间在2024年之后"。这种组合查询在实际业务中非常有用。

第四,多模态支持。 ChromaDB不仅支持文本向量,还支持图像向量的存储和检索,为多模态AI应用提供了基础。

6.4 RAG场景的架构准备

虽然smart-scaffold-springboot项目当前的AI功能主要集中在聊天和嵌入服务上,但通过预留的Chroma配置和已实现的嵌入服务,已经为RAG场景做好了架构层面的准备。

RAG系统的完整架构:

用户提问


┌─────────────────┐
│  问题向量化      │ ← EmbeddingConfig
│  (Embedding)    │
└────────┬────────┘


┌─────────────────┐
│  向量相似度检索  │ ← ChromaDB
│  (Retrieval)    │
└────────┬────────┘


┌─────────────────┐
│  上下文组装      │
│  (Context Build) │
└────────┬────────┘


┌─────────────────┐
│  大模型生成回答  │ ← ChatClientFactory
│  (Generation)   │
└────────┬────────┘


    返回用户

在这个架构中:

  1. EmbeddingConfig 负责将用户的问题文本转换为向量
  2. ChromaDB 负责存储文档向量并在检索时返回最相似的文档片段
  3. 上下文组装 将检索到的文档片段与用户问题组合成增强的提示词
  4. ChatClientFactory 使用增强的提示词调用大模型生成最终回答

当前项目中,步骤1和步骤4的核心组件已经实现并经过验证。当需要实现完整的RAG功能时,只需补充文档处理管道(分块、清洗、嵌入、入库)和检索增强逻辑即可。

文档处理管道的预留设计:

java
// 教学示例 - 文档处理管道接口(预留)
public interface DocumentPipeline {
    /**
     * 将文档切分为文本块
     */
    List<TextChunk> split(Document document, ChunkStrategy strategy);

    /**
     * 为文本块生成向量
     */
    List<TextChunk> embed(List<TextChunk> chunks, ModelPO config);

    /**
     * 将文本块存储到向量数据库
     */
    void store(List<TextChunk> embeddedChunks, String collectionName);

    /**
     * 检索与查询最相似的文本块
     */
    List<TextChunk> retrieve(String query, ModelPO config,
                             String collectionName, int topK);
}

七、AIController接口设计

7.1 RESTful接口设计原则

AIController 是AI功能的Web入口,负责接收HTTP请求、调用业务逻辑、返回HTTP响应。其接口设计遵循RESTful设计原则,同时针对AI场景的特殊需求进行了优化。

RESTful设计原则在AI接口中的应用:

  1. 资源导向: 每个接口对应一个明确的资源或操作。/api/ai/chat 对应聊天操作,/api/ai/stream 对应流式聊天操作。
  2. HTTP方法语义化: 使用POST方法进行聊天请求(因为每次请求都会产生新的响应),使用GET方法进行健康检查和配置查询。
  3. 状态码规范: 使用标准的HTTP状态码表示请求结果——200表示成功,400表示请求参数错误,500表示服务器内部错误。
  4. 统一响应格式: 所有接口返回统一的JSON响应格式,包含状态码、消息和数据。

7.2 chat/chatBatch/chatAsync接口

AIController 提供了三种聊天接口,分别适用于不同的使用场景。

chat接口(同步聊天):

java
// 教学示例 - 同步聊天接口
@RestController
@RequestMapping("/api/ai")
public class AIController {

    @Autowired
    private ChatClientFactory chatClientFactory;

    @Autowired
    private ModelService modelService;

    /**
     * 同步聊天接口
     * 适用于不需要流式展示的场景
     */
    @PostMapping("/chat")
    public Result<String> chat(@RequestBody ChatRequest request) {
        // 获取当前用户的模型配置
        Long userId = getCurrentUserId();
        ModelPO modelConfig = modelService.resolveModelConfig(userId);

        // 调用聊天服务
        String response = chatClientFactory.chat(modelConfig,
            request.getMessage());

        return Result.success(response);
    }
}

chatBatch接口(批量聊天):

java
// 教学示例 - 批量聊天接口
/**
 * 批量聊天接口
 * 适用于需要同时处理多条消息的场景
 */
@PostMapping("/chatBatch")
public Result<List<String>> chatBatch(
        @RequestBody List<ChatRequest> requests) {
    Long userId = getCurrentUserId();
    ModelPO modelConfig = modelService.resolveModelConfig(userId);

    List<String> responses = new ArrayList<>();
    for (ChatRequest request : requests) {
        String response = chatClientFactory.chat(modelConfig,
            request.getMessage());
        responses.add(response);
    }

    return Result.success(responses);
}

chatAsync接口(异步聊天):

java
// 教学示例 - 异步聊天接口
/**
 * 异步聊天接口
 * 适用于耗时较长的聊天场景,立即返回任务ID
 */
@PostMapping("/chatAsync")
public Result<String> chatAsync(@RequestBody ChatRequest request) {
    Long userId = getCurrentUserId();
    ModelPO modelConfig = modelService.resolveModelConfig(userId);

    // 提交异步任务
    String taskId = UUID.randomUUID().toString();
    CompletableFuture.runAsync(() -> {
        try {
            String response = chatClientFactory.chat(modelConfig,
                request.getMessage());
            // 存储结果到缓存或数据库,供后续查询
            cacheService.set("chat:result:" + taskId, response);
        } catch (Exception e) {
            log.error("异步聊天任务失败: {}", taskId, e);
            cacheService.set("chat:error:" + taskId, e.getMessage());
        }
    }, asyncExecutor);

    return Result.success(taskId);
}

三种接口的对比:

接口调用方式响应方式适用场景
chat同步阻塞等待完整响应后返回后台任务、批量处理
chatBatch同步阻塞返回多个完整响应批量文本分析
chatAsync非阻塞立即返回任务ID长文本生成、耗时任务

7.3 generateWithPrompt模板生成接口

generateWithPrompt 接口提供了一种基于模板的文本生成能力。与普通的聊天接口不同,它允许调用者预定义提示词模板,在运行时填充变量,生成结构化的输出。

java
// 教学示例 - 模板生成接口
/**
 * 基于提示词模板生成内容
 * 适用于需要结构化输出的场景
 */
@PostMapping("/generate")
public Result<String> generateWithPrompt(
        @RequestBody PromptGenerateRequest request) {
    Long userId = getCurrentUserId();
    ModelPO modelConfig = modelService.resolveModelConfig(userId);

    // 构建系统提示词
    String systemPrompt = request.getSystemPrompt();
    if (StringUtils.isBlank(systemPrompt)) {
        systemPrompt = "你是一个专业的助手,请根据用户的要求生成内容。";
    }

    // 构建完整的消息列表
    List<Map<String, String>> messages = new ArrayList<>();

    // 添加系统提示词
    Map<String, String> systemMessage = new HashMap<>();
    systemMessage.put("role", "system");
    systemMessage.put("content", systemPrompt);
    messages.add(systemMessage);

    // 添加用户消息
    Map<String, String> userMessage = new HashMap<>();
    userMessage.put("role", "user");
    userMessage.put("content", request.getUserPrompt());
    messages.add(userMessage);

    // 调用聊天服务
    String response = chatClientFactory.chatWithMessages(
        modelConfig, messages);

    return Result.success(response);
}

模板生成接口的典型应用场景包括:

  1. 文本摘要: 系统提示词设定"你是一个文本摘要专家",用户消息传入需要摘要的文本
  2. 信息提取: 系统提示词设定"从文本中提取关键信息,以JSON格式返回",用户消息传入原始文本
  3. 内容改写: 系统提示词设定"将以下内容改写为更正式的商务语气",用户消息传入原始内容
  4. 代码生成: 系统提示词设定"你是一个Java开发专家,请根据需求生成代码",用户消息传入需求描述

7.4 streamChat流式聊天接口

streamChat 接口是AI聊天功能中最具交互性的接口,它通过SSE(Server-Sent Events)协议将大模型的流式响应实时推送给客户端。

java
// 教学示例 - 流式聊天接口
/**
 * 流式聊天接口
 * 通过SSE协议实时推送大模型的响应
 */
@PostMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<String>> streamChat(
        @RequestBody ChatRequest request) {
    Long userId = getCurrentUserId();
    ModelPO modelConfig = modelService.resolveModelConfig(userId);

    return chatClientFactory.chatStream(modelConfig, request.getMessage())
        .map(content -> ServerSentEvent.<String>builder()
            .data(content)
            .build())
        .concatWith(Flux.just(
            // 发送完成信号
            ServerSentEvent.<String>builder()
                .event("complete")
                .data("[DONE]")
                .build()
        ));
}

这段教学示例展示了流式聊天接口的关键实现细节:

第一,produces = MediaType.TEXT_EVENT_STREAM_VALUE 这个注解属性告诉Spring框架,该接口的响应Content-Type为 text/event-stream,Spring会自动配置SSE相关的响应处理逻辑。

第二,Flux<ServerSentEvent<String>> 返回类型。 返回一个 Flux 流,每个元素是一个 ServerSentEvent 对象。Spring WebFlux会自动将这个流转换为SSE格式的HTTP响应。

第三,concatWith 完成信号。 在模型响应流结束后,通过 concatWith 追加一个特殊的事件,通知客户端流式响应已完成。客户端收到这个信号后,可以关闭SSE连接,更新UI状态(如隐藏"正在输入"的指示器)。

流式聊天的前端对接:

前端使用浏览器的 EventSource API 或 fetch API 接收SSE流:

javascript
// 教学示例 - 前端SSE接收(使用fetch API)
async function streamChat(message) {
    const response = await fetch('/api/ai/stream', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ message: message })
    });

    const reader = response.body.getReader();
    const decoder = new TextDecoder();

    while (true) {
        const { done, value } = await reader.read();
        if (done) break;

        const text = decoder.decode(value);
        // 解析SSE数据并更新UI
        const lines = text.split('\n');
        for (const line of lines) {
            if (line.startsWith('data:')) {
                const data = line.substring(5);
                if (data === '[DONE]') {
                    console.log('流式响应完成');
                } else {
                    // 将文本追加到聊天界面
                    appendToChat(data);
                }
            }
        }
    }
}

7.5 健康检查与系统配置接口

除了核心的聊天功能,AIController 还提供了健康检查和系统配置查询接口,这些接口对于运维监控和前端配置展示非常重要。

健康检查接口:

java
// 教学示例 - AI服务健康检查
/**
 * AI服务健康检查
 * 检查各AI提供商的连通性
 */
@GetMapping("/health")
public Result<Map<String, Object>> healthCheck() {
    Map<String, Object> healthInfo = new LinkedHashMap<>();

    // 检查Ollama连通性
    try {
        boolean ollamaOk = checkConnectivity(
            ollamaBaseUrl + "/api/tags");
        healthInfo.put("ollama", ollamaOk ? "UP" : "DOWN");
    } catch (Exception e) {
        healthInfo.put("ollama", "DOWN: " + e.getMessage());
    }

    // 检查OpenAI连通性
    try {
        boolean openaiOk = checkConnectivity(
            openaiBaseUrl + "/v1/models");
        healthInfo.put("openai", openaiOk ? "UP" : "DOWN");
    } catch (Exception e) {
        healthInfo.put("openai", "DOWN: " + e.getMessage());
    }

    return Result.success(healthInfo);
}

系统配置查询接口:

java
// 教学示例 - 获取系统AI配置
/**
 * 获取系统默认AI配置
 * 前端可用于展示可用的模型选项
 */
@GetMapping("/config")
public Result<Map<String, Object>> getSystemConfig() {
    Map<String, Object> config = new LinkedHashMap<>();

    config.put("defaultApiType", defaultApiType);
    config.put("supportedApiTypes", Arrays.asList(
        "OLLAMA", "OPENAI", "COMPATIBLE_OPENAI"));

    // 返回各提供商的可用模型列表(不暴露API密钥)
    Map<String, Object> ollamaInfo = new HashMap<>();
    ollamaInfo.put("baseUrl", ollamaBaseUrl);
    ollamaInfo.put("modelName", ollamaModelName);
    ollamaInfo.put("embeddingModel", ollamaEmbeddingModel);
    config.put("ollama", ollamaInfo);

    // ... 其他提供商配置

    return Result.success(config);
}

安全注意事项:

系统配置查询接口返回的信息需要经过脱敏处理:

  1. API密钥不返回: 绝对不能将API密钥返回给前端,即使密钥是加密存储的
  2. 内部地址脱敏: 如果baseUrl包含内网IP地址,考虑是否需要脱敏
  3. 权限控制: 健康检查接口可能需要管理员权限,系统配置接口需要登录权限

八、生产环境AI集成最佳实践

8.1 API Key安全管理

API密钥的安全管理是生产环境AI集成的首要关注点。一个泄露的API密钥可能导致严重的经济损失——攻击者可以使用泄露的密钥调用大模型API,产生巨额费用。

密钥安全管理的多层次防护策略:

第一层:传输安全。 所有涉及API密钥的通信必须使用HTTPS加密。这包括:

  • 前端到后端的通信(用户配置API密钥时)
  • 后端到AI提供商的通信(调用大模型API时)
  • 后端到密钥管理服务的通信(获取加密密钥时)

第二层:存储安全。 API密钥在数据库中使用AES-256加密存储,加密密钥通过环境变量或密钥管理服务注入。绝不能将明文密钥写入日志文件、异常堆栈或错误消息中。

java
// 教学示例 - 安全的日志记录
// 错误做法:直接记录包含密钥的信息
log.error("API调用失败, apiKey: {}", apiKey); // 危险!

// 正确做法:对敏感信息进行脱敏
log.error("API调用失败, apiKey: {}***{}",
    apiKey.substring(0, 4),
    apiKey.substring(apiKey.length() - 4));

第三层:访问控制。 API密钥的访问权限需要严格控制:

  • 普通用户只能查看和修改自己的API密钥(脱敏显示)
  • 管理员可以查看所有用户的密钥状态(是否已配置),但不能查看明文
  • 数据库管理员可以看到加密后的密文,但没有加密密钥也无法解密

第四层:密钥轮换。 建议定期轮换API密钥,并在密钥泄露时能够快速撤销。可以设置密钥的有效期,过期后自动禁用并通知用户更新。

第五层:使用监控。 监控API密钥的使用情况,检测异常调用模式:

  • 短时间内大量调用(可能是密钥泄露后的滥用)
  • 非常规时间段的调用
  • 来自异常IP地址的调用

8.2 请求频率限制

大模型API的调用通常需要付费,且提供商会对调用频率设置限制。如果不进行频率控制,可能导致:

  • API配额耗尽,影响正常业务
  • 产生意外的巨额费用
  • 触发提供商的速率限制,导致请求被拒绝

频率限制的实现策略:

java
// 教学示例 - 基于用户的频率限制
@Component
public class AiRateLimiter {

    // 使用令牌桶算法
    private final Map<Long, TokenBucket> userBuckets = new ConcurrentHashMap<>();

    /**
     * 检查是否允许请求
     * @param userId 用户ID
     * @param limit 时间窗口内最大请求数
     * @param windowSeconds 时间窗口(秒)
     */
    public boolean allowRequest(Long userId, int limit, int windowSeconds) {
        TokenBucket bucket = userBuckets.computeIfAbsent(userId,
            k -> new TokenBucket(limit, windowSeconds));
        return bucket.tryConsume();
    }
}

多层频率限制策略:

限制层级限制对象典型配置目的
用户级每个用户60次/分钟防止单个用户滥用
租户级每个租户1000次/分钟防止租户超量使用
系统级整个系统10000次/分钟保护后端资源
提供商级每个API提供商根据提供商限制避免触发提供商限制

频率限制的降级策略:

当请求超过频率限制时,系统应该返回明确的错误信息,而不是简单地拒绝请求:

json
{
    "code": 429,
    "message": "请求频率超过限制,请稍后再试",
    "data": {
        "retryAfter": 30,
        "limit": 60,
        "remaining": 0,
        "resetAt": "2024-01-15T10:31:00Z"
    }
}

返回 retryAfter 字段告知客户端何时可以重试,limitremaining 字段帮助客户端了解当前的配额使用情况。

8.3 模型切换策略

在生产环境中,模型切换是不可避免的——可能是由于提供商服务故障、成本优化需求、或者模型升级。一个健壮的AI基础设施需要支持平滑的模型切换。

自动故障转移:

当主模型服务不可用时,系统应该能够自动切换到备用模型:

java
// 教学示例 - 自动故障转移
public String chatWithFallback(ModelPO primaryConfig,
                               ModelPO fallbackConfig,
                               String message) {
    try {
        return chatClientFactory.chat(primaryConfig, message);
    } catch (Exception e) {
        log.warn("主模型调用失败,切换到备用模型: {}", e.getMessage());

        // 记录故障转移事件
        metricsService.incrementCounter(
            "ai.fallback", "primary", primaryConfig.getModelName());

        return chatClientFactory.chat(fallbackConfig, message);
    }
}

灰度发布策略:

当需要切换到新模型时,可以采用灰度发布策略,逐步将流量从旧模型切换到新模型:

  1. 内部测试: 先让内部测试人员使用新模型,验证功能和性能
  2. 小流量灰度: 将5%的用户流量切换到新模型,观察错误率和用户反馈
  3. 逐步扩大: 如果小流量运行正常,逐步扩大到20%、50%、100%
  4. 快速回滚: 如果发现问题,立即将流量切回旧模型

模型版本管理:

建议在配置中记录模型版本信息,便于追踪和回滚:

yaml
# 教学示例 - 模型版本管理
bima:
  ai:
    model-registry:
      - name: gpt-4o
        version: "2024-01"
        status: active
      - name: gpt-4o
        version: "2023-11"
        status: deprecated
      - name: gpt-4o-mini
        version: "2024-01"
        status: active

8.4 成本控制策略

AI API调用的成本控制是生产环境中必须面对的现实问题。以下是一些有效的成本控制策略:

第一,模型分级使用。 根据任务复杂度选择不同成本的模型:

任务类型推荐模型成本等级
简单分类/提取GPT-4o-mini / 本地模型
通用对话GPT-4o-mini / DeepSeek
复杂推理GPT-4o / Claude-3.5
代码生成GPT-4o / DeepSeek-Coder中-高

第二,Token使用优化。

java
// 教学示例 - Token使用优化
public class TokenOptimizer {

    /**
     * 截断过长的输入文本
     * 避免发送不必要的token到API
     */
    public String truncateInput(String text, int maxTokens) {
        // 粗略估算:1个中文字符约等于2个token
        int maxChars = maxTokens * 2;
        if (text.length() > maxChars) {
            return text.substring(0, maxChars) + "...(已截断)";
        }
        return text;
    }

    /**
     * 缓存相同输入的响应
     * 避免重复调用API
     */
    public String cachedChat(String cacheKey, ModelPO config,
                             String message) {
        // 先查缓存
        String cached = cacheService.get("ai:chat:" + cacheKey);
        if (cached != null) {
            return cached;
        }

        // 缓存未命中,调用API
        String response = chatClientFactory.chat(config, message);

        // 写入缓存(设置合理的过期时间)
        cacheService.set("ai:chat:" + cacheKey, response, 3600);

        return response;
    }
}

第三,用量监控和告警。 建立完善的用量监控体系,实时跟踪API调用的token消耗和费用:

  • 实时仪表盘: 展示当日/当月的token消耗量和费用
  • 趋势分析: 分析用量趋势,预测未来的费用
  • 预算告警: 当用量接近预算阈值时,发送告警通知
  • 异常检测: 检测异常的用量激增,可能是Bug或滥用

第四,本地模型优先策略。 对于可以接受较低精度的场景,优先使用本地部署的免费模型。只有当本地模型无法满足需求时,才调用付费的云端API。

java
// 教学示例 - 本地模型优先策略
public String smartChat(String message, QualityLevel quality) {
    if (quality == QualityLevel.BASIC) {
        // 基础质量需求,使用本地模型
        ModelPO localConfig = modelService.getSystemConfig(ApiType.OLLAMA);
        return chatClientFactory.chat(localConfig, message);
    } else {
        // 高质量需求,使用云端模型
        ModelPO cloudConfig = modelService.getSystemConfig(ApiType.OPENAI);
        return chatClientFactory.chat(cloudConfig, message);
    }
}

8.5 异常处理与降级策略

在调用外部AI服务时,异常是不可避免的。网络超时、服务不可用、配额耗尽、模型错误等各种异常都可能发生。一个健壮的系统需要完善的异常处理和降级策略。

异常分类与处理:

异常类型原因处理策略
连接超时网络问题、服务过载重试(带退避)→ 切换备用提供商
响应超时模型推理时间过长重试 → 返回部分结果 → 降级响应
401认证失败API密钥无效或过期通知用户更新密钥 → 使用系统默认配置
429速率限制请求过于频繁排队等待 → 降级到低成本模型
500服务错误提供商内部错误重试 → 切换备用提供商 → 返回友好提示
响应解析失败响应格式异常重试 → 记录原始响应 → 返回友好提示

降级策略实现:

java
// 教学示例 - 多级降级策略
public String chatWithDegradation(String message) {
    // 第一级:尝试用户配置的主模型
    try {
        ModelPO userConfig = modelService.resolveModelConfig(userId);
        return chatClientFactory.chat(userConfig, message);
    } catch (Exception e) {
        log.warn("主模型调用失败: {}", e.getMessage());
    }

    // 第二级:尝试系统默认的备用模型
    try {
        ModelPO fallbackConfig = modelService.getFallbackConfig();
        return chatClientFactory.chat(fallbackConfig, message);
    } catch (Exception e) {
        log.warn("备用模型调用失败: {}", e.getMessage());
    }

    // 第三级:返回预设的降级响应
    return "抱歉,AI服务暂时不可用,请稍后再试。";
}

重试策略:

对于暂时性故障(如网络超时、500错误),合理的重试策略可以显著提高系统的可用性:

java
// 教学示例 - 指数退避重试
public String chatWithRetry(ModelPO config, String message,
                            int maxRetries) {
    int attempt = 0;
    Exception lastException = null;

    while (attempt < maxRetries) {
        try {
            return chatClientFactory.chat(config, message);
        } catch (TransientException e) {
            lastException = e;
            attempt++;

            if (attempt < maxRetries) {
                // 指数退避:1s, 2s, 4s, 8s...
                long waitMs = (long) Math.pow(2, attempt) * 1000;
                log.warn("第{}次重试,等待{}ms", attempt, waitMs);
                Thread.sleep(waitMs);
            }
        }
    }

    throw new RuntimeException(
        "AI服务调用失败,已重试" + maxRetries + "次", lastException);
}

8.6 可观测性建设

AI服务的可观测性建设对于生产环境运维至关重要。通过完善的日志、指标和链路追踪,可以快速定位问题、优化性能、控制成本。

日志规范:

java
// 教学示例 - AI调用日志规范
// 请求日志(调用前记录)
log.info("AI请求开始 - userId: {}, apiType: {}, model: {}, " +
         "inputTokens: {}, messageType: {}",
    userId, config.getApiType(), config.getModelName(),
    estimateTokens(message), "chat");

// 响应日志(调用后记录)
log.info("AI请求完成 - userId: {}, latency: {}ms, " +
         "outputTokens: {}, status: {}",
    userId, latencyMs, estimateTokens(response), "success");

// 错误日志(调用失败时记录)
log.error("AI请求失败 - userId: {}, apiType: {}, model: {}, " +
          "error: {}, latency: {}ms",
    userId, config.getApiType(), config.getModelName(),
    e.getMessage(), latencyMs, e);

核心监控指标:

指标名称类型说明
ai.request.totalCounterAI请求总数
ai.request.successCounter成功请求数
ai.request.failureCounter失败请求数
ai.request.latencyHistogram请求延迟分布
ai.tokens.inputCounter输入token总数
ai.tokens.outputCounter输出token总数
ai.cost.estimatedGauge估算费用
ai.fallback.countCounter故障转移次数

告警规则建议:

  • 错误率超过5%:发送警告通知
  • 错误率超过20%:发送紧急通知,触发自动故障转移
  • 单日费用超过预算的80%:发送成本预警
  • 请求延迟P99超过30秒:发送性能预警

总结与展望

本文基于 smart-scaffold-springboot 项目源码,深入解析了一套企业级AI基础设施的完整设计与实现。从多提供商统一适配、聊天客户端工厂、向量嵌入服务到用户级配置管理,这套架构覆盖了企业AI集成的核心需求。

核心技术成果回顾:

  1. ModelService多提供商适配: 通过 bima.ai.default-api-type 配置和用户级配置的合并策略,实现了OLLAMA、OPENAI、COMPATIBLE_OPENAI三种提供商类型的统一适配。系统默认配置与用户自定义配置的优先级合并机制,为多租户场景提供了灵活的模型管理能力。

  2. ChatClientFactory聊天客户端工厂: 基于WebClient的非阻塞HTTP调用方案,支持同步聊天和流式聊天两种模式。OpenAI和Ollama两种响应格式的自适应解析,以及 X-Accel-Buffering: noCache-Control: no-cache 的缓冲禁用配置,确保了流式响应的实时性。

  3. EmbeddingConfig向量嵌入服务: 通过 @Configuration + @PostConstruct 的启动加载机制,使用Apache HttpClient5实现了高效的批量文本嵌入。OpenAI兼容的请求/响应格式和默认的 text-embedding-3-large 模型配置,为RAG场景做好了准备。

  4. 用户模型配置管理: user_model 表的精细化设计(API密钥AES-256加密、temperature约束、逻辑删除、状态管理)为用户级AI配置提供了安全可靠的存储方案。

  5. Spring AI Chroma预留: 通过 spring.ai.vectorstore.chroma 配置预留,为后续的RAG功能实现做好了架构准备。

  6. AIController接口设计: chat/chatBatch/chatAsync/generateWithPrompt/streamChat等接口覆盖了同步、异步、流式、模板生成等多种使用场景,配合健康检查和系统配置接口,形成了完整的API体系。

  7. 生产环境最佳实践: API Key安全管理、请求频率限制、模型切换策略、成本控制、异常处理与降级、可观测性建设等方面的实践总结,为AI服务的生产部署提供了全面的指导。

未来展望:

随着AI技术的快速演进,企业级AI基础设施也将持续发展。以下几个方向值得关注:

  • 多模态集成: 支持图片、音频、视频等多模态输入,拓展AI能力的应用边界
  • Agent框架: 在基础聊天能力之上,构建具备工具调用、任务规划、自主决策能力的AI Agent
  • RAG增强: 结合ChromaDB向量数据库,实现完整的企业知识库问答系统
  • 模型微调集成: 支持基于企业数据的模型微调,提升垂直场景的模型效果
  • 成本智能优化: 基于任务复杂度自动选择最优模型,实现成本和效果的最佳平衡

smart-scaffold-springboot 项目将持续迭代,为开发者提供更加完善、易用的AI基础设施。欢迎访问 bima.cc 获取完整项目代码和最新文档。


版权声明: 本文为必码(bima.cc)原创技术文章,仅供学习交流。

本文内容基于实际项目源码解析整理,代码示例均为教学简化版本,仅供学习参考。

文档内容提取自项目源码与配置文件,如需获取完整项目代码,请访问 bima.cc