本地 embedding 服务在 batch 请求下崩溃

本地 embedding 服务处理单条请求正常,批量请求时 OOM 崩溃或返回 500 错误。从 batch size 限制、显存管理、序列长度截断三个维度定位并修复问题。

在配备 RTX 3070 / 8 GB 显存的机器上,使用 Ollama 0.4 运行 nomic-embed-text 做 RAG 索引时,单条 embedding 请求响应正常,但用 Python 循环发送 100 条文本的批量请求时服务崩溃,重启后再次批量请求 10 条时也崩溃,只有每次发 1-2 条才稳定。这类问题的根本原因几乎都是 batch 处理时显存累积超过限制,而不是 embedding 模型本身的 bug。

常见原因

1. 客户端同时发送过多并发请求,显存累积 OOM

Python 代码使用 asyncioThreadPoolExecutor 并发发送多个 embedding 请求,服务端同时处理多个 batch,显存中同时存在多个 activation buffer,迅速触发 OOM。

怎么判断nvidia-smi dmon -d 1 监控显存,观察崩溃前是否出现显存骤增到 100%;或者把并发数从 10 降到 1,看是否能稳定运行更多请求。

2. 单个 batch 中有超长文本导致 sequence length 爆炸

nomic-embed-text 最大输入长度是 8192 token,BAAI/bge 系列是 512 token。若批量文本中有一条超长文档(如未分割的 PDF 页面),该条文本会使 attention 矩阵尺寸平方级增长,显存需求远超预期。

怎么判断:对 batch 中每条文本计算 token 数,找出是否有超过模型最大长度的条目;ollama run nomic-embed-text 会自动截断,但某些直接调用的方式不截断。

3. Ollama embedding 接口不支持真正的 batch,内部串行处理导致超时

Ollama 的 /api/embed 接口在一次请求中接受 input 数组,但内部实现在某些版本中是逐条处理而非真正的 batch forward。大数组会导致请求超时,客户端超时重试又加剧服务端负载。

怎么判断:发送 100 条文本的单次 POST 请求,查看响应时间是否等于单条 × 100(串行)而非单条(batch)。若是串行,客户端应设置更长的超时时间,而不是增大 batch size。

4. vLLM / llama.cpp 的 embedding 模式下 max_batch_size 默认值过大

使用 python -m vllm.entrypoints.openai.api_server --task embedding 时,默认 --max-num-seqs 可能设置为 256,允许 256 条序列同时在 GPU 上处理,8 GB 显存完全不够。

怎么判断:查看 vLLM 启动参数中 --max-num-seqs 的值;在 llama.cpp 服务端模式下检查 --ubatch-size(微批次大小,默认 512)。

5. 服务端没有做序列长度截断,超长文本导致内存分配失败

直接通过 llama.cpp embedding 模式(-e flag)处理原始文本时,若未设置 --ctx-size--batch-size,默认值可能对长文本分配过大的 buffer 导致 OOM。

怎么判断:查看 llama.cpp 的 --ctx-size 设置(embedding 模式下通常设为 512-2048 就够),若是默认 4096 或更大,对 8 GB 显存的批量处理压力明显。

6. Python 客户端内存泄漏导致系统 OOM 而非 GPU OOM

多次批量请求后,Python 进程的内存持续增长(embedding 向量数组未被 GC 释放),系统 RAM 耗尽触发 OOM Killer 终止 embedding 服务进程。

怎么判断watch -n1 'free -h' 监控系统内存,崩溃前观察是否 available 内存持续减少到接近 0。

最短修复路径

Step 1:控制客户端并发度

import asyncio
import httpx
from asyncio import Semaphore

async def embed_with_limit(texts: list[str], max_concurrent: int = 4):
    """限制并发避免 GPU OOM"""
    semaphore = Semaphore(max_concurrent)
    
    async def embed_one(text: str):
        async with semaphore:
            async with httpx.AsyncClient(timeout=60) as client:
                resp = await client.post(
                    "http://localhost:11434/api/embeddings",
                    json={"model": "nomic-embed-text", "prompt": text}
                )
                return resp.json()["embedding"]
    
    tasks = [embed_one(t) for t in texts]
    return await asyncio.gather(*tasks)

# 使用示例:100 条文本,最多 4 个并发
embeddings = asyncio.run(embed_with_limit(texts, max_concurrent=4))

Step 2:在发送前截断超长文本

from transformers import AutoTokenizer

tokenizer = AutoTokenizer.from_pretrained("nomic-ai/nomic-embed-text-v1")
MAX_TOKENS = 512  # nomic-embed-text 实际有效窗口

def truncate_text(text: str, max_tokens: int = MAX_TOKENS) -> str:
    tokens = tokenizer.encode(text, add_special_tokens=True)
    if len(tokens) > max_tokens:
        tokens = tokens[:max_tokens]
        return tokenizer.decode(tokens, skip_special_tokens=True)
    return text

# 批量截断
texts = [truncate_text(t) for t in raw_texts]

Step 3:vLLM embedding 模式限制批次大小

python -m vllm.entrypoints.openai.api_server \
  --model BAAI/bge-large-zh-v1.5 \
  --task embedding \
  --max-num-seqs 32 \
  --max-model-len 512 \
  --gpu-memory-utilization 0.85 \
  --host 0.0.0.0 \
  --port 8001

Step 4:llama.cpp embedding 服务配置

# 启动 llama.cpp embedding 服务器
./llama-server \
  -m bge-large-zh-v1.5-Q8_0.gguf \
  --embedding \
  --ctx-size 512 \
  --batch-size 64 \
  --ubatch-size 32 \
  -ngl 35 \
  --host 0.0.0.0 \
  --port 8002

Step 5:监控 GPU 显存使用趋势

# 在批量请求时同步监控显存
nvidia-smi dmon -s mu -d 2 -o T > /tmp/gpu_monitor.log &
MONITOR_PID=$!

# 运行批量 embedding
python your_batch_embed.py

kill $MONITOR_PID
# 查看峰值显存
grep -v "^#" /tmp/gpu_monitor.log | awk '{print $3}' | sort -n | tail -5

预防建议

  • 将批量 embedding 的并发数限制在 GPU 显存 / 单条 embedding 预估显存占用 × 安全系数 0.7 以内。
  • 在 embedding 流程的入口处统一做文本截断,确保没有超长文本进入服务端。
  • nvidia-smi dmon 监控批量 embedding 任务全程,记录峰值显存,确定安全的 batch_size 上限。
  • RAG 索引任务应在独立进程中运行,避免与推理服务争用同一 GPU。
  • 对于 512 token 以内的 embedding 模型(BGE / E5 系列),文本超过限制时应切分后分别嵌入再取均值,而不是直接截断。
  • 在 Python 批量处理循环中显式调用 del embeddings_list; gc.collect() 释放内存,防止 RAM OOM。
  • 生产环境的 embedding 服务与推理服务使用不同 GPU,彻底隔离资源竞争。

常见问答 (FAQ)

Q: 同样的 batch 在 A100 上没问题,在 RTX 3070 上崩溃,是模型质量问题吗? A: 不是模型质量问题,是显存容量问题。A100 80 GB vs RTX 3070 8 GB 相差 10 倍,batch 可用显存完全不同。解决方案是等比缩小 batch size 和并发数,而不是换模型。

Q: Ollama 的 /api/embed 接口支持数组输入吗? A: Ollama 0.4+ 支持 input 数组,但实际是否真正 batch 处理取决于版本。安全起见,对大规模嵌入任务建议用循环发送单条请求并限制并发,比依赖 batch 接口更可控。

Q: 嵌入任务可以完全在 CPU 上跑吗? A: 可以,CPU 推理没有显存限制,只受系统 RAM 约束。速度会慢 10-30 倍,但对于离线 RAG 索引构建完全可以接受。在 llama.cpp 中去掉 -ngl 参数或设为 0 即可强制 CPU。

Q: 不同批次的 embedding 向量可以直接比较相似度吗? A: 可以,同一模型产出的向量在相同的嵌入空间内,批次之间不影响相似度计算。但不同模型(即使名称相似)的向量不能混用。

相关阅读

标签: #local-llm #ollama #排查