第14篇:Docker 部署 AI 大模型推理服务:GPU 容器、vLLM、Ollama 与 Spring AI 全栈实战

系列:Docker 从入门到精深——Java SaaS 实战系列 · 第 14 篇(★ 精深核心篇)
难度:⭐⭐⭐⭐⭐ 适合高级开发者 / AI 工程师
预计阅读时间:48 分钟
关键词:Docker 部署 LLM、vLLM Docker GPU、Ollama Docker Compose、nvidia-docker 配置、Spring AI 私有推理、RAG 向量数据库 Qdrant Docker、AI 推理服务弹性伸缩、Java SaaS AI 集成


速览摘要

本篇是本系列技术密度最高的一篇,也是 AI 工程与容器技术结合最深处的实战指南。我们解决四个核心问题:如何在 Docker 容器里访问 GPU(NVIDIA Container Toolkit 的原理和配置);如何用 Docker 部署生产级的 vLLM 推理服务(比 Ollama 吞吐量高出 3~5 倍,支持 OpenAI 兼容 API);如何在 Compose 里组合 Ollama + Qdrant + Spring Boot 形成完整的 RAG(检索增强生成)应用栈;以及 AI 推理服务的弹性伸缩——为什么 GPU 资源的伸缩和普通 CPU 应用完全不同,K8s 的 GPU 调度又是怎么实现的。每一段配置代码都有"为什么这样写、不这样写会怎样"的解释,不做配置的搬运工。


一、为什么需要专门讲"AI 推理服务的容器化"

前面十三篇的容器化经验,大部分可以直接用于 AI 推理服务的部署——Docker Compose、健康检查、数据卷管理,这些通用知识都适用。但 AI 推理服务有几个特殊性,让它不能直接套用 Java 应用的经验:

GPU 资源的特殊性。CPU 的容器化是通过 cgroup 的 CPU 配额实现的,对容器完全透明。GPU 不一样——容器里的应用必须通过 NVIDIA 的 CUDA 驱动库才能访问 GPU,而驱动库和宿主机的 GPU 驱动版本强耦合,容器镜像里必须包含匹配版本的 CUDA 运行时。这个耦合关系比 CPU 容器复杂得多。

推理服务的资源消耗模式。Java Web 应用的 CPU 和内存使用相对平滑,偶有峰值。LLM 推理则截然不同——空载时 GPU 和内存使用很低,一旦接到推理请求,GPU 利用率可能瞬间从 5% 跳到 95%,而且请求的处理时间从几秒到几十秒不等。这种模式让常规的"CPU 使用率 > 70% 触发 HPA 扩容"策略完全失效。

冷启动问题。LLM 模型文件动辄几 GB 到几十 GB,容器启动时需要把模型权重从磁盘加载到 GPU 显存,这个过程可能需要 30 秒到 5 分钟。如果 K8s 把 AI 推理 Pod 调度到一个没有缓存模型文件的节点,冷启动时间是不可接受的。

理解了这三个特殊性,后面所有配置决策都有了清晰的依据。


二、NVIDIA Container Toolkit:让容器能访问 GPU

在没有 NVIDIA Container Toolkit 之前,容器里的进程根本看不到 GPU——CUDA 驱动调用会直接失败,nvidia-smi 命令找不到任何设备。Toolkit 的作用是在容器启动时,把宿主机的 NVIDIA 驱动和 GPU 设备透传进容器,同时提供容器镜像内 CUDA 库和宿主机驱动之间的兼容层。

明白了这一点,就能理解为什么 NVIDIA 对 CUDA 版本有严格的要求:容器镜像里的 CUDA 运行时版本,必须和宿主机安装的 NVIDIA 驱动版本兼容(CUDA 向前兼容,但不向后兼容——较新的 CUDA 运行时需要较新的驱动,旧驱动无法运行新 CUDA 的容器)。这是部署 GPU 容器最常见的坑,也是镜像选型要优先考虑的约束。

# ── Step 1:在 GPU 宿主机上安装 NVIDIA 驱动(如果还没安装)──────────────

# 查看当前驱动版本(已安装时)
nvidia-smi
# 输出示例:
# +-----------------------------------------------------------------------------+
# | NVIDIA-SMI 535.104.12   Driver Version: 535.104.12   CUDA Version: 12.2    |
# +-----------------------------------------------------------------------------+
# 记住这两个版本号:Driver Version 和 CUDA Version
# CUDA Version 是驱动支持的最高 CUDA 版本,不是系统上安装的 CUDA toolkit 版本

# ── Step 2:安装 NVIDIA Container Toolkit ──────────────────────────────────
# 这个工具包让 Docker 能够把 GPU 传递给容器

# 配置 NVIDIA Container Toolkit 的软件源
curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey | \
    sudo gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg

curl -s -L https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list | \
    sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' | \
    sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list

sudo apt-get update
sudo apt-get install -y nvidia-container-toolkit

# 配置 Docker 使用 NVIDIA Runtime
# 这一步告诉 Docker:有 --gpus 参数时,用 NVIDIA 的 runtime 处理 GPU 透传
sudo nvidia-ctk runtime configure --runtime=docker
sudo systemctl restart docker

# ── Step 3:验证 GPU 容器能正常工作 ────────────────────────────────────────
# 运行 nvidia-smi 容器,如果能看到 GPU 信息,说明配置成功
docker run --rm --gpus all nvidia/cuda:12.2-base-ubuntu22.04 nvidia-smi

执行验证命令时,如果看到和宿主机 nvidia-smi 相同的 GPU 信息,说明 Toolkit 配置成功。如果报错 Error: unknown flag: --gpus,说明 Docker 版本太旧(需要 Docker 19.03+);如果报错 driver/library version mismatch,说明容器镜像里的 CUDA 版本比宿主机驱动支持的版本高,需要降低镜像里的 CUDA 版本。

踩坑记录:在公司内网的 GPU 服务器上配置 Container Toolkit 时,遇到过一个让人困惑的问题——docker run --gpus all 命令报 failed to create shim task: OCI runtime create failed,但 nvidia-smi 在宿主机上完全正常。排查了一个小时才发现原因:/etc/docker/daemon.json 里有一条之前测试留下的 "default-runtime": "runc" 配置,显式指定了默认 runtime,覆盖了 NVIDIA Container Toolkit 配置的 nvidia runtime。把那行删掉重启 Docker 后立刻好了。教训:每次修改 daemon.json 后,用 docker info | grep Runtime 确认当前 runtime 列表是否包含 nvidia


三、选择推理框架:Ollama vs vLLM,不是非此即彼

在部署 LLM 推理服务时,最先需要做的决策是推理框架的选择。Ollama 和 vLLM 是目前最主流的两个选项,各自有清晰的适用场景,正确的选择取决于你的业务需求而不是哪个"更好"。

Ollama 的设计目标是"本地开发者友好"。它的优势是极其简单——一个命令就能运行一个 LLM,自动下载模型,支持几十种主流模型,有直观的 CLI 界面和管理 API。对于开发者的本地测试环境、小团队的内部工具(日均请求量 < 1000)、或者在没有 GPU 的机器上用 CPU 推理,Ollama 是最好的选择。它也支持 GPU 加速,但对并发请求的处理能力不如 vLLM(Ollama 默认是串行处理推理请求的,即使有 GPU,同时只处理一个请求)。

vLLM 的设计目标是"高吞吐量生产推理"。它的核心创新是 PagedAttention 技术——把 KV Cache(注意力机制的缓存)用类似操作系统虚拟内存分页的方式管理,极大提升了 GPU 显存的利用率和并发推理能力。在相同 GPU 上,vLLM 的吞吐量通常是 Ollama 的 3~8 倍。它提供完整的 OpenAI 兼容 API(/v1/chat/completions 端点),Spring AI 可以直接切换 baseURL 指向 vLLM,代码无需修改。代价是部署复杂度更高,不支持 CPU 推理,启动时间更长。

这个决策框架很清晰:开发测试 → Ollama;生产 AI 服务,预期并发 > 5 → vLLM。很多团队的最终方案是两者并存:本地开发用 Ollama(零配置),生产环境用 vLLM(高吞吐量)。


四、Ollama 完整部署配置

先把 Ollama 的生产级配置讲清楚,因为本系列第 04 篇 Compose 配置里已经出现了 Ollama,这里深入讲它的配置细节和常见问题:

# docker-compose.ai.yml — Ollama 完整生产配置
# 这份配置假设宿主机有 NVIDIA GPU,如果没有 GPU,移除 deploy.resources.reservations 部分

services:
  ollama:
    image: ollama/ollama:latest
    container_name: ollama
    restart: unless-stopped
    
    # Ollama 的环境变量配置(这些变量不在官方文档里明显列出,但对生产很重要)
    environment:
      # 允许来自任意 IP 的连接(默认只允许 localhost,在容器里必须改)
      # 不改这个,其他容器通过 Docker 网络访问 Ollama 时会连接被拒绝
      OLLAMA_HOST: "0.0.0.0"
      
      # 并发处理请求数(默认是 1,即串行处理)
      # 多 GPU 或高显存的机器上可以适当增大,但增大后每个请求的响应时间也会变长
      # 原因:并发推理时 GPU 显存被多个请求分摊,每个 batch 更小,效率下降
      OLLAMA_NUM_PARALLEL: "2"
      
      # 模型在显存中保持加载的时间(默认 5 分钟)
      # 生产环境设长一点,避免频繁的模型卸载/加载(每次加载需要几十秒)
      OLLAMA_KEEP_ALIVE: "30m"
      
      # 每次推理使用的上下文长度(token 数)
      # 越长占用的显存越多,但支持更长的对话历史
      # llama3-8b 推荐 4096,llama3-70b 在 24GB 显存上最大约 4096
      OLLAMA_NUM_CTX: "4096"

    volumes:
      # 模型文件持久化(这是 Ollama 最重要的数据卷)
      # 模型文件很大(几 GB 到几十 GB),必须持久化,否则每次容器重启都要重新下载
      - ollama-models:/root/.ollama
    
    ports:
      # 开发环境暴露到本地,方便直接调用 API 测试
      # 生产环境可以不暴露(让应用通过 Docker 网络访问),或者只绑定内网 IP
      - "127.0.0.1:11434:11434"
    
    healthcheck:
      # Ollama 的健康检查:/api/tags 端点返回已下载的模型列表
      # 注意:Ollama 启动时间较长(首次需要初始化),start_period 要设得足够长
      test: ["CMD", "curl", "-f", "http://localhost:11434/api/tags"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 60s   # Ollama 启动比普通应用慢,给足够时间
    
    # GPU 资源配置(仅在有 NVIDIA GPU 时使用)
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: 1          # 使用 1 张 GPU(如果有多张,可以设 all 使用全部)
              capabilities: [gpu]   # 声明需要 GPU 能力(Swarm 和 Docker Compose v3.8+ 支持)
    
    networks:
      - ai-network

  # Ollama 模型初始化 Job:容器首次启动后自动拉取指定模型
  # 这解决了"Ollama 跑起来了但还没有模型"的问题,让系统启动后立刻可用
  ollama-model-init:
    image: curlimages/curl:latest
    depends_on:
      ollama:
        condition: service_healthy   # 等 Ollama 健康检查通过后再拉取模型
    restart: "no"    # 只运行一次(模型拉取完成后 exit 0,不会重启)
    entrypoint: |
      sh -c "
        MODEL=${OLLAMA_MODEL:-llama3}
        echo '正在检查模型 '$$MODEL' 是否已下载...'
        MODELS=$$(curl -sf http://ollama:11434/api/tags | grep -o '\"name\":\"[^\"]*\"' | grep -o '\"[^\"]*\"$$')
        if echo $$MODELS | grep -q $$MODEL; then
          echo '模型已存在,跳过下载'
        else
          echo '开始下载模型 '$$MODEL'(可能需要较长时间)...'
          curl -sf http://ollama:11434/api/pull -d '{\"name\":\"'$$MODEL'\",\"stream\":false}'
          echo '模型下载完成'
        fi
      "
    networks:
      - ai-network

OLLAMA_HOST: "0.0.0.0" 这个环境变量是 Ollama 容器部署中最容易忘记、导致问题最多的配置。Ollama 默认只监听 127.0.0.1(loopback),在物理机上没问题(其他进程也在同一台机器上),但在容器里,127.0.0.1 指的是容器自身的 loopback,其他容器通过 Docker 网络访问 Ollama 时发现目标是 0.0.0.0 才能接受连接。忘记这个配置,Spring Boot 应用调用 Ollama API 会得到 Connection refused,而在容器内用 curl localhost:11434 却是通的,让你以为 Ollama 有问题,但其实是监听地址的问题。


五、vLLM 高性能推理服务部署

vLLM 的配置比 Ollama 更复杂,因为它的设计更面向生产——需要显式指定模型来源、量化策略、并发参数、显存使用策略。正确配置这些参数,直接决定了推理服务的吞吐量和响应延迟。

# docker-compose.ai.yml 中的 vLLM 配置(替换或并列 Ollama)

services:
  vllm:
    # vLLM 官方镜像,需要 CUDA 12.1+(确认宿主机驱动支持)
    image: vllm/vllm-openai:latest
    container_name: vllm
    restart: unless-stopped
    
    # vLLM 通过命令行参数配置(不是环境变量)
    # 这是 vLLM 和 Ollama 的一个重要区别:Ollama 用环境变量,vLLM 用 CLI 参数
    command: >
      --model /models/Qwen2-7B-Instruct
      --served-model-name qwen2-7b
      --host 0.0.0.0
      --port 8000
      --dtype bfloat16
      --max-model-len 8192
      --gpu-memory-utilization 0.90
      --max-num-seqs 32
      --trust-remote-code
      --tensor-parallel-size 1
    
    # 注意:不要把所有参数堆在一起而不解释它们的含义
    # --model:模型路径(从挂载的卷里读取,或者 HuggingFace Hub 的模型 ID)
    # --dtype bfloat16:使用 bfloat16 精度(比 float32 省一半显存,比 float16 数值更稳定)
    # --max-model-len 8192:最大支持的 token 上下文长度
    # --gpu-memory-utilization 0.90:允许 vLLM 使用 GPU 显存的 90%(留 10% 给系统)
    #   设太高容易 OOM;设太低浪费显存,降低并发能力
    # --max-num-seqs 32:同时处理的最大请求数(并发度)
    #   增大这个值会提升吞吐量,但每个请求的延迟也会增加
    # --tensor-parallel-size 1:使用的 GPU 数量(多 GPU 时设为 GPU 数量)
    
    environment:
      # HuggingFace 镜像站(国内访问 HF Hub 下载模型用)
      HF_ENDPOINT: https://hf-mirror.com
      # 禁用 HuggingFace 遥测(不上报使用数据)
      HF_HUB_DISABLE_IMPLICIT_TOKEN: "1"
    
    volumes:
      # 模型文件从宿主机挂载(事先下载好,避免每次容器启动都要下载)
      # 模型文件下载见下文的说明
      - /data/models:/models:ro   # 只读挂载,防止容器意外修改模型文件
      
      # vLLM 的 KV Cache 可以配置卸载到 CPU 内存(当 GPU 显存不足时)
      # 这会降低性能,但允许更大的 batch 或更长的 context
      # - kv-cache:/tmp/vllm-kv-cache
    
    ports:
      - "127.0.0.1:8000:8000"
    
    healthcheck:
      # vLLM 提供 OpenAI 兼容的 /health 端点
      test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
      interval: 30s
      timeout: 15s    # vLLM 健康检查有时候需要几秒才响应
      retries: 5
      start_period: 120s   # vLLM 加载模型需要较长时间(7B 模型约 30-60 秒)
    
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: 1
              capabilities: [gpu]
    
    networks:
      - ai-network

关于 --gpu-memory-utilization 0.90 这个参数,背后有一个重要的设计权衡值得解释清楚。vLLM 在启动时会根据这个比例预分配 GPU 显存(用于 KV Cache),剩余 10% 留给 CUDA 运行时和其他系统使用。KV Cache 越大,能同时处理的请求越多(吞吐量越高),但如果设太高(如 0.99),系统显存没有余量,容易在加载模型权重时就已经超出显存限制而 OOM。实测中,0.85~0.90 是一个比较稳健的值,在显存充足的情况下能保证高吞吐量,又不会频繁触发显存不足的错误。

# 在宿主机上事先下载模型(不推荐让容器启动时下载,太慢且占用请求时间)

# 方法一:用 HuggingFace CLI 下载(需要 Python 环境)
pip install huggingface_hub
huggingface-cli download Qwen/Qwen2-7B-Instruct --local-dir /data/models/Qwen2-7B-Instruct

# 方法二:用临时容器下载(不需要在宿主机装 Python)
docker run --rm \
  -v /data/models:/models \
  -e HF_ENDPOINT=https://hf-mirror.com \
  python:3.11-slim \
  sh -c "pip install -q huggingface_hub && \
         huggingface-cli download Qwen/Qwen2-7B-Instruct --local-dir /models/Qwen2-7B-Instruct"

# 验证模型文件完整性(确认关键文件存在)
ls /data/models/Qwen2-7B-Instruct/
# 期望看到:config.json、tokenizer.json、model-*.safetensors(可能是多个分片文件)

下载模型时,国内访问 HuggingFace 官网经常超时,HF_ENDPOINT=https://hf-mirror.com 是国内可用的镜像站。模型文件一旦下载完成,通过卷挂载到 vLLM 容器,后续无论容器重启多少次都不需要重新下载。这是和 Ollama 不同的地方——Ollama 把模型存在它自己的卷里,vLLM 需要你自己管理模型文件的存储位置。


六、完整 RAG 应用栈:Compose 把所有 AI 组件串联起来

RAG(Retrieval-Augmented Generation,检索增强生成)是目前企业 AI 应用最常见的架构:把用户的问题先在向量数据库里搜索相关文档,然后把检索到的文档作为上下文送给 LLM,让 LLM 基于这些上下文回答。这个架构让 LLM 能够"知道"你的私有知识库(产品文档、FAQ、历史工单)里的内容,而不需要微调模型。

一套完整的 RAG 应用需要以下组件:LLM 推理服务(Ollama 或 vLLM)、向量数据库(存储文档的向量表示,支持相似度搜索)、Embedding 模型(把文档和用户问题转换成向量)、Spring Boot 应用(协调整个 RAG 流程)。

# docker-compose.rag.yml — 完整 RAG 应用栈

services:

  # ── Ollama:LLM 推理 + Embedding 模型 ──────────────────────────────────
  # Ollama 同时提供 LLM 推理(/api/generate)和 Embedding(/api/embeddings)
  # 在 GPU 资源有限的情况下,用 Ollama 做两件事可以减少基础设施复杂度
  ollama:
    image: ollama/ollama:latest
    container_name: saas-ollama
    restart: unless-stopped
    environment:
      OLLAMA_HOST: "0.0.0.0"
      OLLAMA_NUM_PARALLEL: "2"
      OLLAMA_KEEP_ALIVE: "30m"
    volumes:
      - ollama-models:/root/.ollama
    ports:
      - "127.0.0.1:11434:11434"
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:11434/api/tags"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 60s
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: 1
              capabilities: [gpu]
    networks:
      - ai-network

  # ── Qdrant:向量数据库 ─────────────────────────────────────────────────
  # Qdrant 是目前性能最好的开源向量数据库之一,支持 REST API 和 gRPC
  # 专门为高维向量的相似度搜索优化,内存效率高于 Chroma、Weaviate 等竞品
  qdrant:
    image: qdrant/qdrant:v1.7.4
    container_name: saas-qdrant
    restart: unless-stopped
    
    # Qdrant 的存储目录,必须持久化
    # 向量数据库的数据量通常不大(相比原始文档),但索引文件必须保留
    volumes:
      - qdrant-data:/qdrant/storage
    
    ports:
      - "127.0.0.1:6333:6333"   # REST API 端口
      - "127.0.0.1:6334:6334"   # gRPC 端口(Spring AI 也支持 gRPC,延迟更低)
    
    healthcheck:
      # Qdrant 提供 /healthz 端点
      test: ["CMD", "curl", "-f", "http://localhost:6333/healthz"]
      interval: 15s
      timeout: 5s
      retries: 3
      start_period: 20s
    
    # Qdrant 的内存配置(通过命令行参数)
    # 默认 Qdrant 会尽量把向量索引放在内存里,提升搜索速度
    # 内存有限时,可以配置 on_disk=true,把索引存在磁盘(搜索变慢但不 OOM)
    command: >
      ./qdrant
      --storage.optimizers.memmap_threshold_kb=200000
    
    networks:
      - ai-network

  # ── Spring Boot RAG 应用 ───────────────────────────────────────────────
  saas-rag-app:
    image: harbor.example.com/saas-backend/rag-service:${VERSION:-latest}
    container_name: saas-rag-app
    restart: unless-stopped
    
    environment:
      # LLM 配置:连接 Ollama
      SPRING_AI_OLLAMA_BASE_URL: http://ollama:11434
      SPRING_AI_OLLAMA_CHAT_MODEL: ${CHAT_MODEL:-llama3}
      SPRING_AI_OLLAMA_EMBEDDING_MODEL: ${EMBEDDING_MODEL:-nomic-embed-text}
      
      # 向量数据库配置:连接 Qdrant
      SPRING_AI_VECTORSTORE_QDRANT_HOST: qdrant
      SPRING_AI_VECTORSTORE_QDRANT_PORT: 6334   # 用 gRPC 端口(更高效)
      SPRING_AI_VECTORSTORE_QDRANT_COLLECTION_NAME: saas-knowledge-base
      
      # 应用配置
      SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/saas_demo
      SPRING_PROFILES_ACTIVE: production
      JAVA_OPTS: "-XX:+UseContainerSupport -XX:MaxRAMPercentage=70.0"
    
    ports:
      - "8080:8080"
    
    depends_on:
      ollama:
        condition: service_healthy
      qdrant:
        condition: service_healthy
    
    networks:
      - ai-network
      - saas-backend

networks:
  ai-network:
    driver: bridge
  saas-backend:
    external: true

volumes:
  ollama-models:
    name: saas-ollama-models
  qdrant-data:
    name: saas-qdrant-data

Qdrant 选用 gRPC 端口(6334)而不是 REST 端口(6333)做服务间通信,是一个性能上的考量。gRPC 基于 HTTP/2,使用 Protocol Buffers 序列化,相比 REST + JSON 在高频的向量搜索场景下延迟更低(大约低 30%~50%)。对于 RAG 应用,每次用户提问都要做一次向量搜索,这个延迟差异是可感知的。Spring AI 从 1.0 版本起对 Qdrant 的 gRPC 有原生支持,配置 port: 6334 即可启用。


七、Spring AI 接入私有推理服务的完整代码

配置好了推理服务,下面把 Spring AI 的代码写完整。这里展示的不是"Hello World"级别的示例,而是真实 SaaS 场景里的 RAG 实现:

<!-- pom.xml — Spring AI 依赖(Spring Boot 3.2+) -->
<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-bom</artifactId>
            <version>1.0.0</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

<dependencies>
    <!-- Ollama 推理客户端 -->
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-ollama-spring-boot-starter</artifactId>
    </dependency>
    <!-- Qdrant 向量数据库 -->
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-qdrant-store-spring-boot-starter</artifactId>
    </dependency>
    <!-- PDF 文档解析(用于知识库文档导入) -->
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-pdf-document-reader</artifactId>
    </dependency>
</dependencies>

Spring AI 的依赖管理通过 BOM(Bill of Materials)来统一版本,不需要手动指定每个组件的版本号。这和 Spring Boot 本身的依赖管理方式一致,避免了版本不兼容的问题。如果直接指定每个组件的版本,很容易出现 spring-ai-corespring-ai-ollama 版本不一致导致的 classpath 冲突。

// KnowledgeBaseService.java — 知识库文档导入和向量化
@Service
@Slf4j
public class KnowledgeBaseService {

    // Spring AI 自动配置并注入 VectorStore(连接到 Qdrant)
    @Autowired
    private VectorStore vectorStore;

    // Spring AI 的文档读取器(支持 PDF、Markdown、文本等格式)
    @Autowired
    private TokenTextSplitter textSplitter;

    /**
     * 把文档导入知识库(向量化后存入 Qdrant)。
     *
     * 这个方法设计为幂等的:重复导入同一个文档不会导致数据重复。
     * 实现方式是用文档 ID 作为 Qdrant 的 payload 过滤条件,导入前先删除旧数据。
     *
     * 为什么要分块(chunk)?
     * LLM 的 context 长度有限(通常 4096~8192 token),无法把整份文档一次性塞进去。
     * 把文档分割成小块,每次只检索最相关的几块,是 RAG 的核心设计。
     * 块的大小是一个权衡:太小(< 100 token)失去上下文语义;太大(> 1000 token)占用 context 空间。
     * 对于中文文档,推荐 300~500 token 每块。
     */
    public void importDocument(String tenantId, String docId, Resource docResource) {
        log.info("开始导入文档:tenantId={}, docId={}", tenantId, docId);

        // Step 1:读取文档(支持 PDF、Word、Markdown、纯文本)
        // PagePdfDocumentReader 会把 PDF 的每一页作为一个 Document 对象
        PagePdfDocumentReader reader = new PagePdfDocumentReader(
            docResource,
            PdfDocumentReaderConfig.builder()
                .withPageTopMargin(0)
                .withPageBottomMargin(0)
                .build()
        );
        List<Document> rawDocuments = reader.get();

        // Step 2:分块(把大文档切成合适大小的块)
        // TokenTextSplitter 按 token 数量切割,比按字符数更准确
        List<Document> chunks = textSplitter.apply(rawDocuments);

        // Step 3:为每个块添加元数据(用于后续过滤和溯源)
        // 没有元数据,你就不知道这个向量来自哪个文档,也无法做租户隔离
        chunks.forEach(chunk -> {
            chunk.getMetadata().put("tenantId", tenantId);   // 多租户隔离的关键字段
            chunk.getMetadata().put("docId", docId);
            chunk.getMetadata().put("importedAt", Instant.now().toString());
        });

        // Step 4:先删除这个文档的旧向量(幂等保证)
        // Qdrant 支持按 payload 过滤删除,这是 Chroma 等简单向量库不具备的能力
        vectorStore.delete(
            List.of(
                FilterExpressionBuilder.eq("docId", docId),
                FilterExpressionBuilder.eq("tenantId", tenantId)
            )
        );

        // Step 5:向量化并存入 Qdrant
        // Spring AI 会调用 Ollama 的 Embedding 接口把文本转为向量,再存入 Qdrant
        // 这一步耗时较长(取决于文档大小和 Embedding 模型速度),建议异步执行
        vectorStore.add(chunks);

        log.info("文档导入完成:共 {} 个块,docId={}", chunks.size(), docId);
    }
}
// RagChatService.java — RAG 问答核心逻辑
@Service
@Slf4j
public class RagChatService {

    @Autowired
    private ChatClient chatClient;

    @Autowired
    private VectorStore vectorStore;

    /**
     * 基于知识库的 RAG 问答。
     *
     * 整个流程:用户问题 → Embedding 向量化 → Qdrant 相似度搜索 → 构建 Prompt → LLM 推理 → 返回答案
     *
     * 为什么要设计 tenantId 过滤?
     * Qdrant 里存储了所有租户的文档向量。如果不过滤,A 租户的问题可能检索到 B 租户的文档,
     * 这是严重的数据泄露问题。永远在向量搜索时加上租户过滤条件。
     */
    public RagResponse chat(String userQuestion, String tenantId) {
        // Step 1:在向量数据库里检索和问题最相关的文档块
        // SearchRequest 构建搜索条件:相似度阈值(只返回相关度 > 0.7 的结果)+ 租户过滤
        List<Document> relevantDocs = vectorStore.similaritySearch(
            SearchRequest.query(userQuestion)
                .withTopK(5)                    // 最多返回 5 个最相关的块
                .withSimilarityThreshold(0.7)   // 相似度阈值(0-1,越高要求越严格)
                .withFilterExpression(          // 严格过滤租户数据,不可省略
                    new FilterExpressionBuilder()
                        .eq("tenantId", tenantId)
                        .build()
                )
        );

        // Step 2:如果没有找到相关文档,直接返回(避免 LLM 凭空编造)
        // 让 LLM 基于空上下文回答,往往会产生听起来合理但不准确的答案(幻觉)
        if (relevantDocs.isEmpty()) {
            log.info("未找到相关文档,租户={}, 问题={}", tenantId, userQuestion);
            return RagResponse.builder()
                .answer("抱歉,我在知识库中没有找到与您问题相关的内容。请检查知识库是否已导入相关文档。")
                .sourceDocs(Collections.emptyList())
                .build();
        }

        // Step 3:构建包含检索上下文的 Prompt
        // Prompt 工程是 RAG 质量的关键——格式、指令的措辞直接影响 LLM 的回答质量
        String contextText = relevantDocs.stream()
            .map(doc -> String.format("【文档片段】\n%s\n【来源】%s",
                doc.getContent(),
                doc.getMetadata().getOrDefault("docId", "未知")))
            .collect(Collectors.joining("\n\n---\n\n"));

        String systemPrompt = """
            你是一个专业的企业知识库助手,专门根据提供的文档内容回答用户问题。
            
            回答规则:
            1. 只基于下方【参考文档】中的内容回答,不要添加文档中没有的信息
            2. 如果文档内容不足以完整回答问题,明确说明哪些部分无法从文档中确认
            3. 回答要简洁清晰,适合企业用户阅读
            4. 如果问题包含多个子问题,分点回答
            
            【参考文档】
            %s
            """.formatted(contextText);

        // Step 4:调用 LLM(Ollama 或 vLLM),附带检索上下文
        String answer = chatClient.prompt()
            .system(systemPrompt)
            .user(userQuestion)
            .call()
            .content();

        // Step 5:构建响应,包含答案和信息来源(方便用户核实)
        List<String> sourceDocIds = relevantDocs.stream()
            .map(doc -> doc.getMetadata().getOrDefault("docId", "").toString())
            .distinct()
            .collect(Collectors.toList());

        return RagResponse.builder()
            .answer(answer)
            .sourceDocs(sourceDocIds)
            .retrievedChunks(relevantDocs.size())
            .build();
    }
}

RAG 代码里值得特别说明的一个细节是相似度阈值 0.7。这个值决定了"什么程度的相关才算相关"。设太低(如 0.5),会把大量相关性很弱的文档块混入上下文,让 LLM 的答案变得混乱(因为有很多"干扰信息");设太高(如 0.9),很多实际相关的文档块会被过滤掉,RAG 能回答的问题范围变窄。0.7 是一个经验值,但最好在你的实际数据集上做评测——用一批有标准答案的问题测试不同阈值下的准确率,找到最优值。这是 AI 工程中"超参数调优"的一个典型例子。


八、AI 推理服务的弹性伸缩:GPU 调度的特殊性

常规的 Web 服务弹性伸缩很简单:CPU 使用率高了,HPA 加 Pod;CPU 低了,减 Pod。GPU 推理服务的弹性伸缩复杂得多,因为 GPU 资源有几个特殊约束:

GPU 资源不能超卖。CPU 可以超卖(多个 Pod 共享同一颗 CPU,操作系统做时分复用)。GPU 的显存是物理隔离的——如果你把 nvidia.com/gpu: 1 分配给某个 Pod,这张 GPU 就不能再分配给其他 Pod(直到这个 Pod 释放)。这意味着 GPU 资源的调度精度要求比 CPU 高得多。

模型加载时间导致冷启动慢。K8s HPA 扩容一个新的 GPU Pod,需要时间调度到有空闲 GPU 的节点、拉镜像(如果没缓存)、启动容器、加载模型到显存。整个过程可能需要 2-10 分钟。在突发流量时,等 HPA 扩容完成,流量高峰可能已经过去了——这对实时性要求高的场景不可接受。

鉴于这两个特殊性,AI 推理服务的伸缩策略和普通 Web 服务不同:

# k8s/vllm-deployment.yaml — vLLM 的 K8s 部署配置

apiVersion: apps/v1
kind: Deployment
metadata:
  name: vllm-inference
  namespace: saas-prod
spec:
  # 推理服务通常不能缩到 0(冷启动太慢),也不能横向扩展太多(GPU 很贵)
  # 合理的做法是根据业务流量模式,手动设定一个"基线副本数"
  # 然后通过请求队列(而不是 HPA)来应对短暂的流量峰值
  replicas: 1   # 1 张 GPU 服务,如果有多张 GPU 就增加副本数

  template:
    spec:
      containers:
        - name: vllm
          image: vllm/vllm-openai:latest
          resources:
            limits:
              nvidia.com/gpu: 1    # 申请 1 张 GPU(K8s 的 GPU 调度资源名称)
              memory: "24Gi"       # GPU 显存 + 模型文件的系统内存
              cpu: "8"             # vLLM 的 tokenizer、后处理需要较多 CPU
            requests:
              nvidia.com/gpu: 1    # GPU 资源 requests 必须等于 limits(GPU 不能超卖)
              memory: "20Gi"
              cpu: "4"
          
          # 就绪探针:只有模型加载完成,才开始接收流量
          # 这比 startupProbe + readinessProbe 更简单,对于推理服务足够用
          readinessProbe:
            httpGet:
              path: /health
              port: 8000
            initialDelaySeconds: 120  # vLLM 加载模型需要时间,不能太短
            periodSeconds: 10
            failureThreshold: 30      # 最多等 120 + 30×10 = 420 秒
      
      # 节点亲和性:调度到有 GPU 的节点
      # 如果不配置,K8s 可能把 Pod 调度到没有 GPU 的节点,然后一直 Pending
      affinity:
        nodeAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
            nodeSelectorTerms:
              - matchExpressions:
                  - key: nvidia.com/gpu.present
                    operator: In
                    values: ["true"]

对于 AI 推理服务的弹性策略,更实际的方案往往不是 HPA,而是请求队列 + 批处理

用户的 AI 请求先进入一个消息队列(RabbitMQ 或 Kafka),Spring Boot 应用的消费者从队列里取请求,批量发给 vLLM 处理,结果通过 WebSocket 或 Server-Sent Events 推送给用户。这样即使 vLLM 只有 1 个副本,也能处理一定的并发——队列里的请求等待 vLLM 处理,而不是直接超时。


九、本篇小结与下篇预告

本篇完成了 Docker + AI 大模型推理服务的完整工程实践:NVIDIA Container Toolkit 的安装原理和 GPU 透传机制(以及 CUDA version mismatch 踩坑);Ollama vs vLLM 的选型逻辑(开发测试 vs 生产高吞吐量);Ollama 的生产配置(OLLAMA_HOST=0.0.0.0 是最常被忽略的关键配置);vLLM 的参数精讲(gpu-memory-utilizationmax-num-seqs 的权衡逻辑);完整 RAG 应用栈的 Compose 配置(Ollama + Qdrant + Spring Boot);Spring AI 的 RAG 实现代码(含向量导入的幂等设计、相似度阈值的调优思路);以及 GPU 资源调度的特殊性和推理服务的弹性策略(请求队列比 HPA 更适合 AI 推理场景)。

第 15 篇预告:SaaS 生产运维全景——收官篇。这是本系列的终章,把前 14 篇的所有技术点整合进三个完整的生产场景:灰度发布(金丝雀发布的 K8s + Nginx Ingress 实现,让 5% 的流量先走新版本);混沌工程(用 Chaos Mesh 主动向系统注入故障,验证高可用设计的有效性);以及 AI 智能诊断助手——一个用 Spring AI 构建的容器健康状态诊断系统,把本系列所有监控、日志、指标的知识点,串联成一个用自然语言就能查询容器状态、分析异常原因的完整 AI 应用。


FAQ

Q:Ollama 和 vLLM 可以同时运行在同一张 GPU 上吗?

A:技术上可以,但不推荐。两个进程同时使用同一张 GPU 会产生显存竞争——GPU 显存是物理资源,两个进程的显存需求相加后如果超过 GPU 总显存(比如 24GB),就会 OOM。如果确实需要同时运行多个推理服务(比如一个用于生产,一个用于测试),应该使用多张 GPU,通过 CUDA_VISIBLE_DEVICES 环境变量指定每个进程使用哪张 GPU,实现物理隔离。

Q:RAG 的向量搜索精度不高,怎么优化?

A:几个方向可以尝试:① 换更好的 Embedding 模型(模型质量对 RAG 精度影响最大,bge-m3text-embedding-3-large 的质量明显优于 nomic-embed-text);② 调整分块策略(过大或过小的块都会影响检索精度,可以尝试 Semantic Chunking——按语义边界切割而不是固定 token 数);③ 调整相似度阈值(用评测集找最优值);④ 加入 reranking(用交叉编码器对检索结果重新排序,精度更高但延迟也更高)。RAG 的调优是一个迭代工程,没有一次性的最优解,需要持续评测和改进。

Q:没有 GPU 可以跑 vLLM 吗?

A:vLLM 官方不支持纯 CPU 推理(虽然有社区版的 CPU 实现,但性能很差,7B 模型每秒只能生成 1-2 个 token,实际不可用)。没有 GPU 时,Ollama 的 CPU 推理是更好的选择(速度仍然慢,但至少可以正常工作);或者接入云厂商的托管推理 API(OpenAI API、阿里云百炼、火山引擎等),Spring AI 切换 baseURL 即可,开发代码完全不用改。


如果这篇文章对你有帮助,欢迎点赞收藏,有问题欢迎在评论区留言,我会逐一回复。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

做个文艺程序员

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值