Docker 部署大模型教程

68次阅读
没有评论

共计 13614 个字符,预计需要花费 35 分钟才能阅读完成。


教程一 · 最小可用版(两条路线)

路线 A:GPU 生产级(vLLM + OpenAI 兼容 API)

适合:有 NVIDIA GPU 的机器,需要高吞吐与低延迟。

0. 前置

  • Docker 已安装;NVIDIA 驱动已装好(nvidia-smi 有输出)。
  • 安装 nvidia-container-toolkit 后,Docker 才能访问 GPU。

检查 GPU 可用性

docker run --rm --gpus all nvidia/cuda:12.1.1-base-ubuntu22.04 nvidia-smi

1. 一条命令跑起来(以 Llama 3.1 8B Instruct 为例)

# 可选:如果你有 HF Token(私有或 gated 模型会用到)export HUGGING_FACE_HUB_TOKEN= 你的 token

# 挂载缓存目录,首次拉权重更快,重启不重复下载
mkdir -p $HOME/.cache/huggingface

docker run -d --name vllm \
  --gpus all \
  -p 8000:8000 \
  -e HUGGING_FACE_HUB_TOKEN=$HUGGING_FACE_HUB_TOKEN \
  -v $HOME/.cache/huggingface:/root/.cache/huggingface \
  vllm/vllm-openai:latest \
  --model meta-llama/Meta-Llama-3.1-8B-Instruct \
  --dtype bfloat16 \
  --max-model-len 8192 \
  --gpu-memory-utilization 0.90

小贴士:
--gpu-memory-utilization 控制显存占用上限;--max-model-len 是上下文长度;没有 bfloat16 就用 --dtype float16

2. 健康检查与快速验证

# 看容器日志(拉权重会花点时间)docker logs -f vllm

# 健康探针(服务正常会有 JSON 返回)curl -s localhost:8000/health

3. 调用方式(OpenAI 兼容)

cURL

curl -s http://localhost:8000/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{"model":"meta-llama/Meta-Llama-3.1-8B-Instruct","messages": [{"role":"user","content":" 用一句话解释量子纠缠 "}],"temperature": 0.7
  }'

Python(openai 官方 SDK,改 base_url 即可)

from openai import OpenAI
client = OpenAI(base_url="http://localhost:8000/v1", api_key="EMPTY")
resp = client.chat.completions.create(
    model="meta-llama/Meta-Llama-3.1-8B-Instruct",
    messages=[{"role": "user", "content": "用一句话解释量子纠缠"}],
    temperature=0.7,
)
print(resp.choices[0].message.content)

4. 常用参数速查

  • --tensor-parallel-size N:多卡并行(N=GPU 数)。
  • --max-num-seqs:并发生成序列上限,提升吞吐。
  • --enforce-eager:调试更直观,极限性能可去掉。
  • 显存不够? 优先:更小模型 / 量化权重(AWQ/GPTQ 版本)/ 降低 --max-model-len

路线 B:CPU/ 入门最省心(Ollama)

适合:无 GPU 或想先无痛试跑。接口不是 100% OpenAI 兼容,但足够易用。

1. 启动服务

docker run -d --name ollama -p 11434:11434 ollama/ollama

2. 拉一个轻量模型并试跑

# 进入容器内拉权重
docker exec -it ollama bash -lc "ollama pull llama3.2:3b"

# 生成一条
curl -s http://localhost:11434/api/generate -d '{"model":"llama3.2:3b","prompt":" 用一句话解释量子纠缠 "}'

想要“接近”OpenAI 调用体验,可以在应用里做一层轻量适配,或选择路线 A 的 vLLM。


进阶(选做)

A. Docker Compose(单机示例)

不同 Compose 版本对 GPU 声明支持差异较大,最稳是 命令行加 --gpus all。若你已确认 Compose 支持 gpus,可参考下例。

# docker-compose.yml
services:
  vllm:
    image: vllm/vllm-openai:latest
    container_name: vllm
    ports:
      - "8000:8000"
    environment:
      - HUGGING_FACE_HUB_TOKEN=${HUGGING_FACE_HUB_TOKEN:-}
    volumes:
      - ~/.cache/huggingface:/root/.cache/huggingface
    # 仅在你的 Compose 支持时启用:deploy:
      resources:
        reservations:
          devices:
            - capabilities: ["gpu"]
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
      interval: 10s
      timeout: 3s
      retries: 10

运行:

export HUGGING_FACE_HUB_TOKEN= 你的 token # 可选
docker compose up -d

B. 反向代理与鉴权

  • 只在内网开放 8000,对外用 Nginx/Traefik 做鉴权(基本认证 / 签名网关)。
  • 给应用层配置“伪 API Key”校验,避免裸接口外泄。

C. 观测与运维

  • docker logs -f vllm 看服务日志。
  • vLLM 暴露的 /metrics(若镜像 / 版本支持)可被 Prometheus 抓取,结合 Grafana 做面板。
  • 压测:vegeta/wrk 用并发对 POST /v1/chat/completions 打压,观察 QPS 与 Token/s。

D. 常见报错排查

  • 拉权重失败:确认模型是否需要许可证 /Token;网络是否能访问 Hugging Face;挂载缓存目录权限是否正确。
  • 显存不足 OOM:换小模型;降低 --max-model-len;使用量化;或多卡 --tensor-parallel-size
  • GPU 不可见nvidia-smi 正常?nvidia-container-toolkit 已安装?docker run 是否带了 --gpus all
  • 延迟高:调低 temperaturetop_p;开启更高并发前先观察 --max-num-seqs 的影响。

教程二:RAG 与多模型编排(含 Docker Compose)


一、原理小抄(把黑盒拧开看一眼)

**RAG(Retrieval-Augmented Generation,检索增强生成)** 的核心是分两步:

  1. 先找知识(从你自己的语料 / 文档里检索相关内容);
  2. 再让大模型回答(把检索出来的片段塞进提示词,限制模型“胡编”)。

把条件概率写得直白点:
$$[
\text{Answer} = \mathrm{LLM}\big(\text{Question},\underbrace{\mathrm{TopK}(\text{Search}(\text{Question}))}_{\text{你的知识}}\big)
]$$

想要它靠谱,四个关键点:

  • 切块(Chunking):文本按语义或结构切片(段落、标题、代码块),别把一整本 PDF 塞成一个向量。
  • 嵌入(Embedding):把文本变成向量;中文 / 多语种优先用 M3/BGE/Jina 这类强检索模型。
  • 检索(Vector Search):Top-K 召回 +(可选)交叉编码器重排(cross-encoder rerank)提纯相关性。
  • 提示构造(Prompting):把片段拼成「给模型看的开放书」,控制长度、排序、来源标注与“不会就说不知道”。

** 多模型编排(Orchestration)** 做的事更像“分诊台”:

  • 路由(Routing):根据问题特征挑最合适的模型(如:代码问题走 Code LLM,数学走 Math LLM,其它走通用 LLM)。
  • 级联(Cascade):先用小模型尝试;不确定或答不全再升级到大模型(省钱提速)。
  • 工具化(Tools):把“检索、计算器、表格解析”等当可调用工具,模型按需调用。

二、平台组件图(最小闭环)

[ 客户端 / 你的应用]
        |
        v
   [RAG 后端编排 API]  <--(A) 嵌入 / 检索 / 重排 / 路由 / 拼 Prompt
     |       |   \
     |       |    \--(B) 生成模型 API(通用 / 代码等,OpenAI 兼容)
     |       \
     |        \--(C) 嵌入服务(TEI /embed)
     |
     \--(D) 向量数据库(Qdrant)
  • (C) 用 Hugging Face Text Embeddings Inference (TEI) 跑嵌入模型(开箱即用 REST)。
  • (D) 用 Qdrant 存向量与元数据,支持相似度搜索。
  • (B) 用 vLLM + OpenAI 兼容 暴露聊天 / 补全接口(生成答案)。
  • (A) 自研一个轻量 FastAPI 服务,把检索、重排、拼 Prompt、路由整合到一个统一接口。

三、Docker Compose 一把梭

目录结构(建议):

rag-stack/
├─ docker-compose.yml
├─ .env                        # 放可配参数(HF_TOKEN 等)├─ data/                       # 你的原始文档(pdf/txt/md/...)└─ rag-api/
   ├─ Dockerfile
   ├─ requirements.txt
   └─ app.py                   # 编排后端(FastAPI)

1) .env(示例,可按需修改)

# vLLM 主模型(通用问答)GEN_MODEL_ID=meta-llama/Meta-Llama-3.1-8B-Instruct
GEN_PORT=8000

#(可选)代码模型,使用 Compose profiles 控制启用
CODE_MODEL_ID=Qwen/Qwen2.5-Coder-7B-Instruct
CODE_PORT=8002

# TEI 嵌入服务
EMBED_MODEL_ID=BAAI/bge-m3
TEI_PORT=8080

# Qdrant
QDRANT_PORT=6333
QDRANT_COLLECTION=rag_chunks

#(如需下载 gated 模型)Hugging Face Token
HUGGING_FACE_HUB_TOKEN=

# RAG API
RAG_API_PORT=8001

2) docker-compose.yml

version: "3.9"
services:
  qdrant:
    image: qdrant/qdrant:latest
    container_name: qdrant
    ports: ["${QDRANT_PORT}:6333"]
    volumes:
      - qdrant_storage:/qdrant/storage
    restart: unless-stopped

  tei-embeddings:
    image: ghcr.io/huggingface/text-embeddings-inference:cpu-latest
    # 如有闲置 GPU,可换: ...:latest 并加上 --gpus all
    container_name: tei-embeddings
    environment:
      - MODEL_ID=${EMBED_MODEL_ID}
      - HF_TOKEN=${HUGGING_FACE_HUB_TOKEN}
    ports: ["${TEI_PORT}:80"]
    restart: unless-stopped

  vllm-main:
    image: vllm/vllm-openai:latest
    container_name: vllm-main
    ports: ["${GEN_PORT}:8000"]
    environment:
      - HUGGING_FACE_HUB_TOKEN=${HUGGING_FACE_HUB_TOKEN}
    volumes:
      - ~/.cache/huggingface:/root/.cache/huggingface
    command:
      - --model
      - ${GEN_MODEL_ID}
      - --max-model-len
      - "8192"
      - --gpu-memory-utilization
      - "0.90"
    deploy:
      resources:
        reservations:
          devices:
            - capabilities: ["gpu"]
    restart: unless-stopped

  vllm-code:
    image: vllm/vllm-openai:latest
    container_name: vllm-code
    ports: ["${CODE_PORT}:8000"]
    environment:
      - HUGGING_FACE_HUB_TOKEN=${HUGGING_FACE_HUB_TOKEN}
    volumes:
      - ~/.cache/huggingface:/root/.cache/huggingface
    command:
      - --model
      - ${CODE_MODEL_ID}
      - --max-model-len
      - "8192"
      - --gpu-memory-utilization
      - "0.80"
    deploy:
      resources:
        reservations:
          devices:
            - capabilities: ["gpu"]
    restart: unless-stopped
    profiles: ["code"]   # 默认不启,用 `--profile code` 开

  rag-api:
    build: ./rag-api
    container_name: rag-api
    ports: ["${RAG_API_PORT}:8001"]
    environment:
      - TEI_URL=http://tei-embeddings:80
      - QDRANT_URL=http://qdrant:6333
      - QDRANT_COLLECTION=${QDRANT_COLLECTION}
      - GEN_URL=http://vllm-main:8000
      - CODE_URL=http://vllm-code:8000
    volumes:
      - ./data:/app/data:ro
    depends_on:
      - qdrant
      - tei-embeddings
      - vllm-main
    restart: unless-stopped
volumes:
  qdrant_storage:

单卡机器显存不够同时跑两个 vLLM?很正常。先只开 vllm-main(不带 --profile code),等扩容或换更小的 code 模型再启。


四、RAG 编排后端(FastAPI)

1) rag-api/requirements.txt

fastapi
uvicorn[standard]
httpx
pydantic
qdrant-client
sentence-transformers
pypdf
python-multipart

2) rag-api/Dockerfile

FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8001"]

3) rag-api/app.py(精简但能跑的编排逻辑)

import os, glob, json, re
from typing import List, Optional
from fastapi import FastAPI, UploadFile, File, Form
from pydantic import BaseModel
import httpx
from qdrant_client import QdrantClient
from qdrant_client.http.models import Distance, VectorParams, PointStruct
from sentence_transformers import CrossEncoder
from pypdf import PdfReader

TEI_URL = os.getenv("TEI_URL", "http://localhost:8080")
QDRANT_URL = os.getenv("QDRANT_URL", "http://localhost:6333")
QDRANT_COLLECTION = os.getenv("QDRANT_COLLECTION", "rag_chunks")
GEN_URL = os.getenv("GEN_URL", "http://localhost:8000")
CODE_URL = os.getenv("CODE_URL", "")
DATA_DIR = "/app/data"

app = FastAPI(title="RAG Orchestrator")

# ----- utils -----
async def tei_embed(texts: List[str]) -> List[List[float]]:
    async with httpx.AsyncClient(timeout=120) as client:
        r = await client.post(f"{TEI_URL}/embed", json={"inputs": texts})
        r.raise_for_status()
        return r.json()["embeddings"] if isinstance(r.json(), dict) else r.json()

def read_any(path: str) -> str:
    if path.lower().endswith(".pdf"):
        reader = PdfReader(path)
        return "\n".join([p.extract_text() or "" for p in reader.pages])
    with open(path, "r", encoding="utf-8", errors="ignore") as f:
        return f.read()

def split_chunks(text: str, chunk_size=1200, overlap=150) -> List[str]:
    # 简单但实用:按双换行切,再做拼接与重叠
    paras = [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()]
    out, buf = [], ""
    for p in paras:
        if len(buf) + len(p) + 1 <= chunk_size:
            buf = (buf + "\n" + p).strip()
        else:
            if buf: out.append(buf)
            # 重叠:保留末尾 overlap 字符
            buf = (buf[-overlap:] + "\n" + p)[-chunk_size:]
    if buf: out.append(buf)
    # 兜底:超长段再切
    final = []
    for c in out:
        if len(c) <= chunk_size:
            final.append(c)
        else:
            for i in range(0, len(c), chunk_size - overlap):
                final.append(c[i:i+chunk_size])
    return final

# ----- Qdrant init -----
qdrant = QdrantClient(url=QDRANT_URL)
async def ensure_collection(dim: int):
    collections = qdrant.get_collections().collections
    names = {c.name for c in collections}
    if QDRANT_COLLECTION not in names:
        qdrant.create_collection(
            collection_name=QDRANT_COLLECTION,
            vectors_config=VectorParams(size=dim, distance=Distance.COSINE)
        )

# ----- Cross-encoder reranker (惰性加载,CPU 可跑) -----
_reranker = None
def get_reranker():
    global _reranker
    if _reranker is None:
        # 多语种强相关重排器
        _reranker = CrossEncoder("BAAI/bge-reranker-v2-m3")
    return _reranker

# ----- ingest APIs -----
class IngestReq(BaseModel):
    paths: Optional[List[str]] = None
    chunk_size: int = 1200
    overlap: int = 150
    namespace: str = "default"

@app.post("/ingest")
async def ingest(req: IngestReq):
    files = req.paths or sorted(glob.glob(f"{DATA_DIR}/**/*.pdf", recursive=True)
                                + glob.glob(f"{DATA_DIR}/**/*.txt", recursive=True)
                                + glob.glob(f"{DATA_DIR}/**/*.md", recursive=True))
    if not files:
        return {"status": "no_files", "hint": "把文档放到 ./data/ 再试"}

    # 用首个片段获取维度 -> 创建 / 确保 collection
    sample_text = "你好,向量维度探测"
    dim = len((await tei_embed([sample_text]))[0])
    await ensure_collection(dim)

    points = []
    pid = 0
    for path in files:
        txt = read_any(path)
        chunks = split_chunks(txt, req.chunk_size, req.overlap)
        embeds = await tei_embed(chunks)
        for i, (c, v) in enumerate(zip(chunks, embeds)):
            points.append(PointStruct(
                id=pid,
                vector=v,
                payload={
                    "text": c,
                    "source": path.replace("/app/data/", ""),"chunk_id": i,"namespace": req.namespace
                }
            ))
            pid += 1
        # 分批写入,避免一次性过大
        if len(points) >= 1024:
            qdrant.upsert(collection_name=QDRANT_COLLECTION, points=points)
            points = []
    if points:
        qdrant.upsert(collection_name=QDRANT_COLLECTION, points=points)

    return {"status": "ok", "files": files, "points": pid}

# ----- chat APIs -----
class ChatReq(BaseModel):
    query: str
    top_k: int = 24
    use_rerank: bool = True
    namespace: str = "default"
    model_pref: str = "auto"  # auto | primary | code

def pick_model(query: str, pref: str) -> str:
    # 简单路由:代码 / 报错 / 函数关键词 -> code 模型
    code_keywords = ["报错", "stack trace", "traceback", "error", "异常",
                     "函数", "类", "接口", "API", "实现", "bug", "python", "java", "go", "rust"]
    if pref == "primary": return GEN_URL
    if pref == "code" and CODE_URL: return CODE_URL
    if any(k.lower() in query.lower() for k in code_keywords) and CODE_URL:
        return CODE_URL
    return GEN_URL

def build_prompt(query: str, contexts: List[dict]) -> List[dict]:
    context_block = "\n\n".join([f"[{i+1}] 来源: {c['payload']['source']}#{c['payload']['chunk_id']}\n{c['payload']['text']}"
         for i, c in enumerate(contexts)]
    )
    sys = (
        "你是严谨的检索增强助手。仅使用提供的【上下文】回答;"
        "若上下文不足以作答,请明确说明“根据现有资料无法确定”。"
        "回答末尾给出引用片段编号。"
    )
    user = f"问题:{query}\n\n【上下文】\n{context_block}\n\n 请基于以上内容作答。"
    return [{"role": "system", "content": sys}, {"role": "user", "content": user}]

async def openai_chat(base_url: str, messages: List[dict], temperature=0.2, max_tokens=512) -> str:
    async with httpx.AsyncClient(timeout=120) as client:
        r = await client.post(f"{base_url}/v1/chat/completions",
            json={"model": "placeholder", "messages": messages, "temperature": temperature, "max_tokens": max_tokens}
        )
        r.raise_for_status()
        data = r.json()
        return data["choices"][0]["message"]["content"]

@app.post("/chat")
async def chat(req: ChatReq):
    # 1) 向量检索
    q_vec = (await tei_embed([req.query]))[0]
    hits = qdrant.search(
        collection_name=QDRANT_COLLECTION,
        query_vector=q_vec,
        limit=max(req.top_k, 8),
        query_filter={"must": [{"key": "namespace", "match": {"value": req.namespace}}]}
    )
    points = [h.dict() for h in hits]

    # 2) 可选:交叉编码器重排(提纯 Top-K)if req.use_rerank and points:
        reranker = get_reranker()
        pairs = [(req.query, p["payload"]["text"]) for p in points]
        scores = reranker.predict(pairs, batch_size=32, convert_to_numpy=True)
        ranked = sorted(zip(points, scores), key=lambda x: float(x[1]), reverse=True)
        points = [p for p, _ in ranked[: min(12, req.top_k)]]
    else:
        points = points[: min(12, req.top_k)]

    # 3) 选择模型 & 构造消息
    model_url = pick_model(req.query, req.model_pref)
    messages = build_prompt(req.query, points)

    # 4) 生成
    answer = await openai_chat(model_url, messages)
    return {"answer": answer, "citations": [
        {"idx": i+1, "source": p["payload"]["source"], "chunk_id": p["payload"]["chunk_id"]}
        for i, p in enumerate(points)
    ]}

五、启动与验证

1) 启动服务

# 仅通用模型
docker compose up -d

# 若你要启用代码模型(且显存允许)docker compose --profile code up -d

2) 准备测试语料

把你的 PDF/TXT/Markdown 丢进 ./data/。也可以先放几篇 Markdown 试跑。

3) 构建索引(向量化并入库)

curl -X POST "http://localhost:${RAG_API_PORT}/ingest" \
  -H "Content-Type: application/json" \
  -d '{"namespace":"demo","chunk_size":1200,"overlap":150}'

返回里有 points 代表写入的切块数量。

4) 提问(RAG 生效)

curl -X POST "http://localhost:${RAG_API_PORT}/chat" \
  -H "Content-Type: application/json" \
  -d '{"query":" 用要点说明文档中关于数据脱敏流程 ","namespace":"demo"}'

你会得到:

  • answer:基于检索结果生成的答案;
  • citations:每条引用包含 sourcechunk_id,方便追溯。

5) 代码类问题路由(若启用 code 模型)

curl -X POST "http://localhost:${RAG_API_PORT}/chat" \
  -H "Content-Type: application/json" \
  -d '{"query":" 用 Python 写个分页游标示例,并解释复杂度 ","namespace":"demo","model_pref":"auto"}'

命中简单关键词路由则会走 vllm-code;也可以显式指定:
"model_pref":"code""primary"


六、实践要领与可选增强

1) 切块策略

  • 结构优先:优先按标题 / 小节拆分;代码块整体保留。
  • 长度权衡:模型上下文贵,块太大浪费、太小丢语义。一般 600–1500 字符 + 10–20% 重叠。
  • 去噪:移除目录、水印、页眉页脚;保留有用表格(必要时抽取为 Markdown 表)。

2) 检索与重排

  • 召回 Top-K 比“拿前 5 条”更保险;再用交叉编码器 重排成 Top-N(例如 40 → 8)。
  • 多字段权重:标题 / 小节名可拼入 chunk 文本前部,微妙地提高相关性。
  • 命名空间(namespace):按业务线或数据域隔离,避免串味。

3) 提示构造(Prompting)

  • 明确 “不在上下文就别编”
  • 引用编号 + 来源,鼓励可审计回答。
  • 控制拼接顺序(最相关在前)与总长度(别超过主模型上下文 70–80%)。

4) 多模型编排

  • 路由规则:关键词(代码 / 数学 / 表格)、长度阈值、正则(是否包含代码围栏),都能做一层轻量路由。
  • 级联:小模型先答;若包含“不确定 / 无法确定 / 和资料不符”关键短语,则二次升级到大模型。
  • 成本控制:统计每问 token 花费与延迟,给路由器一个“代价上限”。

5) 观测与评测

  • 日志:记录 query、召回的 chunk id、rerank 分数、最终 prompt 长度、用的是哪个模型。
  • 离线评测:构造一批带标注的问答数据(问题、应命中片段、参考答案),评对齐率与 BLEU/ROUGE/F1。
  • 在线 A/B:渐进启 / 停某些策略(如改 chunk_size 或是否启用重排)。

6) 安全与权限

  • 只读挂载 ./data:/app/data:ro,避免后端误写源文件。
  • 鉴权:对外只暴露 rag-api,其余容器走内网;rag-api 层做 API Key 或网关签名。
  • 合规:把隐私字段在切块前做脱敏(可在 split_chunks 前先跑正则替换)。

七、常见问题速修

  • 索引建不起来 / 向量维度不对:本教程在首次嵌入时动态探测维度,再建 Qdrant collection,避免模型换了维度不匹配。
  • 检索命中但答案“发散”:增大 Top-K、启用重排、缩短每块长度;提示里再强调“只用上下文”。
  • 速度慢:把嵌入模型换小(如 bge-small 系列)、降低 Top-K、减少重排条数;生成模型可先上 7B/8B。
  • 显存爆:缩短 --max-model-len、换更小模型、或单机只开一个 vLLM 服务。
  • 中文效果差:优先中文 / 多语种嵌入模型(如 BGE-M3 / bge-small-zh / jina-zh),不要用只擅长英文的嵌入。

八、立刻做的三件事

  1. 把你的文档丢进 ./data/,跑 /ingest
  2. /chat 对比:开 / 关重排、调 top_k、换不同模型。
  3. 逐步加“路由 + 级联”,记录每次实验的延迟与命中率,形成你自己的 RAG“技战术”手册。

正文完
 0
一诺
版权声明:本站原创文章,由 一诺 于2025-10-01发表,共计13614字。
转载说明:除特殊说明外本站文章皆由CC-4.0协议发布,转载请注明出处。
评论(没有评论)
验证码