LLM 应用的 Warm-Up 工程实践:冷启动延迟从 12 秒砍到 800ms 的 5 个工程手段

TL;DR:LLM 应用第一次请求总是慢得离谱------连接池没预热、tokenizer 懒加载、CUDA kernel 第一次编译......这些"冷启动税"叠在一起,生产环境首请求可以轻松超过 10 秒。本文记录我们在一个月内把服务冷启动 P95 从 12.3s 砍到 780ms 的 5 个工程手段,每一个都附实测数据和可落地的代码。


目录

  1. 为什么 LLM 服务的冷启动比普通服务更痛?
  2. 手段一:HTTP 连接池预热(最低成本,砍掉 3-4s)
  3. 手段二:Tokenizer 预加载(懒加载害死人)
  4. 手段三:Prompt Cache 主动预填充(让第一条真实请求命中缓存)
  5. 手段四:CUDA Kernel 预编译与 Torch Compile 暖机请求
  6. 手段五:Readiness Gate 与流量接入时序控制
  7. 组合效果实测:5 个手段的叠加收益
  8. 踩坑记录与注意事项

1. 为什么 LLM 服务的冷启动比普通服务更痛?

普通 Web 服务的冷启动顶多几百毫秒,LLM 服务的冷启动可以轻松到 10 秒以上。原因是多层"首次税"叠加:

层次 耗时来源 典型量级
网络层 TCP 握手 + TLS 协商(首次无复用) 200-500ms
应用层 Tokenizer/Vocab 从磁盘加载 800ms-2s
推理层 CUDA kernel JIT 编译(PyTorch 首次) 2-8s
缓存层 Prompt Cache miss(系统提示未预填) 1-3s 额外 token 消耗
业务层 配置、Schema、工具注册懒加载 200-600ms

叠加后的实测基线(我们的 staging 环境,H100 SXM5,vLLM 0.6.4,模型 Qwen2.5-72B-Instruct):

yaml 复制代码
首次请求 TTFT(Time to First Token):
  P50:  8.1s
  P95: 12.3s
  P99: 15.7s

后续请求(连接池热、kernel 已编译):
  P50:  1.2s
  P95:  1.9s

这 12.3s 里每一层都有东西可以抠,下面逐个拆解。


2. 手段一:HTTP 连接池预热(最低成本,砍掉 3-4s)

问题根源

vLLM 和大多数 LLM 推理服务都跑在 HTTP/1.1 或 HTTP/2 上,你的应用层通常用 httpxaiohttp 或大模型 SDK 的内置连接池来访问。问题在于:连接池是懒初始化的,Pod 启动后第一条请求进来时才开始建连。

一次完整的建连开销:

yaml 复制代码
TCP SYN/SYN-ACK/ACK:  ~2RTT  ≈ 2 × 100ms (跨区) = 200ms
TLS 1.3 handshake:    ~1RTT  = 100ms
HTTP/2 SETTINGS:      ~1RTT  = 100ms
---
合计:~400ms(同区)到 1.2s(跨区/跨云)

连接池大小如果配了 10,冷启动高峰期同时进来 10 条请求就要建 10 条连接,串行或并行都会产生冲击。

解法:Startup Hook 里主动预热连接池

Python(httpx + asyncio)

python 复制代码
# startup_warmup.py
import asyncio
import httpx
import logging
from typing import Optional

logger = logging.getLogger(__name__)

async def warmup_connection_pool(
    base_url: str,
    pool_size: int = 10,
    timeout: float = 5.0,
    health_path: str = "/health",
) -> None:
    """
    在服务启动时主动建立连接池里的所有连接。
    向 /health 发送轻量 GET,触发 TCP+TLS 握手但不消耗 GPU。
    """
    limits = httpx.Limits(
        max_keepalive_connections=pool_size,
        max_connections=pool_size + 5,
        keepalive_expiry=60,
    )
    
    async with httpx.AsyncClient(
        base_url=base_url,
        limits=limits,
        timeout=timeout,
    ) as client:
        tasks = [
            client.get(health_path)
            for _ in range(pool_size)
        ]
        results = await asyncio.gather(*tasks, return_exceptions=True)
        
    successes = sum(1 for r in results if not isinstance(r, Exception))
    failures = pool_size - successes
    logger.info(
        "Connection pool warmup complete",
        extra={
            "pool_size": pool_size,
            "successes": successes,
            "failures": failures,
            "base_url": base_url,
        }
    )
    if failures > pool_size // 2:
        raise RuntimeError(
            f"Connection pool warmup failed: {failures}/{pool_size} connections failed"
        )

在 FastAPI lifespan 里调用

python 复制代码
from contextlib import asynccontextmanager
from fastapi import FastAPI

@asynccontextmanager
async def lifespan(app: FastAPI):
    # 启动时预热
    await warmup_connection_pool(
        base_url=settings.LLM_BASE_URL,
        pool_size=settings.HTTP_POOL_SIZE,
    )
    logger.info("Warmup complete, ready to serve")
    yield
    # 关闭时清理(略)

app = FastAPI(lifespan=lifespan)

Node.js(undici)

typescript 复制代码
// warmup.ts
import { Pool } from 'undici';

export async function warmupPool(
  origin: string,
  connections: number = 10
): Promise<void> {
  const pool = new Pool(origin, {
    connections,
    keepAliveTimeout: 60_000,
    keepAliveMaxTimeout: 300_000,
  });

  const requests = Array.from({ length: connections }, () =>
    pool.request({ path: '/health', method: 'GET' })
      .then(r => r.body.dump())  // 必须消费 body,否则连接不会归还到池
      .catch(() => null)
  );

  await Promise.all(requests);
  console.log(`[warmup] Pool to ${origin} warmed up with ${connections} connections`);
}

实测收益

在 staging 上,连接池预热后首请求 TTFT:

makefile 复制代码
P95: 12.3s → 9.1s(减少 3.2s)

3. 手段二:Tokenizer 预加载(懒加载害死人)

问题根源

HuggingFace tokenizer 在首次调用 encode()decode() 时才从磁盘加载 tokenizer.json(Qwen2.5-72B 的 tokenizer.json 约 11MB,vocab 约 15 万 token)。这个加载包含:

  1. JSON 反序列化(tokenizer.json 本身)
  2. Trie 树构建(BPE merge 规则,约 1.3s)
  3. Special token 索引建立

整个过程在我们的实例上大约 1.6-2.1 秒,在首条生产请求的关键路径上。

错误的写法(懒初始化)

python 复制代码
# ❌ 懒初始化:首次请求时才加载,有 2 秒冷启动税
class TokenizerService:
    def __init__(self):
        self._tokenizer = None  # 懒初始化
    
    def count_tokens(self, text: str) -> int:
        if self._tokenizer is None:
            self._tokenizer = AutoTokenizer.from_pretrained("Qwen/Qwen2.5-72B-Instruct")
        return len(self._tokenizer.encode(text))

正确的写法:模块加载时同步初始化

python 复制代码
# ✅ 模块加载时立即初始化
from transformers import AutoTokenizer
import logging
import time

logger = logging.getLogger(__name__)

_TOKENIZER_PATH = "Qwen/Qwen2.5-72B-Instruct"

def _load_tokenizer():
    t0 = time.perf_counter()
    tok = AutoTokenizer.from_pretrained(
        _TOKENIZER_PATH,
        use_fast=True,          # Rust tokenizer,比 Python 快 3-5x
        trust_remote_code=False,
    )
    elapsed = time.perf_counter() - t0
    logger.info(f"Tokenizer loaded in {elapsed:.3f}s", extra={"path": _TOKENIZER_PATH})
    return tok

# 模块导入时就执行,不等第一条请求
TOKENIZER = _load_tokenizer()

更好的做法:进程池预热(多 worker 场景)

如果你跑多个 Gunicorn worker,每个 worker fork 后都要重新加载 tokenizer。可以在 prefork 阶段加载一次,利用 COW(copy-on-write)共享:

python 复制代码
# gunicorn.conf.py
from myapp.tokenizer import _load_tokenizer

def pre_fork(server, worker):
    pass

def on_starting(server):
    """Master 进程启动时预加载 tokenizer,fork 后 worker 通过 COW 继承"""
    server.log.info("Pre-loading tokenizer before fork...")
    _load_tokenizer()  # master 加载一次,workers fork 继承,共享内存页
    server.log.info("Tokenizer pre-loaded")

实测收益

yaml 复制代码
P95 TTFT: 9.1s → 7.3s(再减 1.8s)

4. 手段三:Prompt Cache 主动预填充

问题根源

大多数 LLM 服务(vLLM、TGI)支持 Prefix Caching:如果多个请求共享相同的前缀(系统提示),KV Cache 可以复用,不需要重新算 prefill。但这个缓存只在第一次请求真正跑完 prefill 后才有

冷启动后第一条请求:

  • 系统提示 2000 tokens,用户 query 100 tokens
  • 需要对 2000 tokens 做完整 prefill
  • 在 H100 上,2000 token prefill ≈ 600-900ms

如果你的系统提示是固定的(最常见的场景),这 600-900ms 完全可以通过主动 warm-up 请求在服务启动时预先填充。

实现:Warm-Up Request 主动触发 Prefix Cache

python 复制代码
# prompt_cache_warmup.py
import asyncio
import httpx
import logging
from typing import Optional

logger = logging.getLogger(__name__)

SYSTEM_PROMPT = """You are a helpful AI assistant for an e-commerce platform.
You help users find products, track orders, process returns, and answer questions
about our policies. Always be polite, accurate, and concise.

[... 实际 2000-token 系统提示 ...]
"""

async def warmup_prompt_cache(
    client: httpx.AsyncClient,
    model: str,
    system_prompt: str,
    warmup_user_message: str = "Hello",
    max_tokens: int = 1,  # 只需要 1 个 token,目的是触发 prefill
) -> bool:
    """
    发送一个最小代价的 warm-up 请求,触发系统提示的 KV Cache 预填充。
    max_tokens=1 确保推理阶段几乎不耗时。
    """
    try:
        response = await client.post(
            "/v1/chat/completions",
            json={
                "model": model,
                "messages": [
                    {"role": "system", "content": system_prompt},
                    {"role": "user", "content": warmup_user_message},
                ],
                "max_tokens": max_tokens,
                "temperature": 0,
                # vLLM 特有:显式启用 prefix caching
                "use_beam_search": False,
            },
            timeout=30.0,
        )
        response.raise_for_status()
        data = response.json()
        usage = data.get("usage", {})
        logger.info(
            "Prompt cache warmup complete",
            extra={
                "prompt_tokens": usage.get("prompt_tokens"),
                "cached_tokens": usage.get("prompt_tokens_details", {}).get("cached_tokens", 0),
                "completion_tokens": usage.get("completion_tokens"),
            }
        )
        return True
    except Exception as e:
        logger.warning(f"Prompt cache warmup failed (non-fatal): {e}")
        return False  # warm-up 失败不应阻塞服务启动

关键细节

  • max_tokens=1:只触发 prefill,不跑 decode,成本极低
  • warm-up 失败必须是 non-fatal:推理服务可能还没完全启动,retry 3 次后继续
  • 如果你有多个系统提示变体(不同租户、不同功能),全部 warm-up

多系统提示并发预填充

python 复制代码
async def warmup_all_system_prompts(
    client: httpx.AsyncClient,
    model: str,
    system_prompts: dict[str, str],  # name -> prompt
    retries: int = 3,
) -> dict[str, bool]:
    results = {}
    
    async def warmup_one(name: str, prompt: str) -> tuple[str, bool]:
        for attempt in range(retries):
            success = await warmup_prompt_cache(client, model, prompt)
            if success:
                return name, True
            await asyncio.sleep(2 ** attempt)  # exponential backoff
        return name, False
    
    tasks = [warmup_one(name, prompt) for name, prompt in system_prompts.items()]
    for name, success in await asyncio.gather(*tasks):
        results[name] = success
    
    logger.info(
        "All prompt cache warmups done",
        extra={
            "total": len(system_prompts),
            "success": sum(results.values()),
            "failed": [k for k, v in results.items() if not v],
        }
    )
    return results

实测收益

yaml 复制代码
P95 TTFT: 7.3s → 5.8s(再减 1.5s)

注意:这个收益只在系统提示固定、vLLM prefix caching 开启时有效。如果系统提示是每次动态生成的,这步跳过。


5. 手段四:CUDA Kernel 预编译与暖机请求

问题根源

PyTorch(以及 vLLM 底层的 CUDA 算子)在第一次执行特定形状的矩阵运算时会触发 CUDA kernel JIT 编译(即 nvcc/ptxas 编译,或者 cuDNN 算法选择的 benchmark)。这个编译是 per-shape 的,不同 sequence_length 可能触发不同 kernel 路径。

在 H100 上,首次请求(特别是第一次大 batch 或长序列)的 kernel 编译可以到 3-6 秒

验证方法 (用 nsys 或直接看时间):

bash 复制代码
# 用 nsys 分析首次请求的 kernel 编译耗时
nsys profile --trace=cuda,nvtx \
  python -c "
import torch, time
# 模拟 LLM attention forward(简化版)
q = torch.randn(1, 32, 128, 128, device='cuda', dtype=torch.bfloat16)
k = torch.randn(1, 32, 128, 128, device='cuda', dtype=torch.bfloat16)
v = torch.randn(1, 32, 128, 128, device='cuda', dtype=torch.bfloat16)

# 第一次:触发 kernel 编译
t0 = time.perf_counter()
with torch.backends.cuda.sdp_kernel(enable_flash=True):
    out = torch.nn.functional.scaled_dot_product_attention(q, k, v)
torch.cuda.synchronize()
print(f'First call: {(time.perf_counter()-t0)*1000:.1f}ms')

# 第二次:kernel 已缓存
t0 = time.perf_counter()
with torch.backends.cuda.sdp_kernel(enable_flash=True):
    out = torch.nn.functional.scaled_dot_product_attention(q, k, v)
torch.cuda.synchronize()
print(f'Second call: {(time.perf_counter()-t0)*1000:.1f}ms')
"
# 输出示例:
# First call: 4823.2ms
# Second call: 3.1ms

解法一:torch.compile 预热 + CUDA Graph Capture

vLLM 0.4+ 默认使用 CUDA Graph,但 Graph Capture 本身也要在第一次请求时执行。你可以在启动时显式触发 capture:

python 复制代码
# vllm_warmup.py(在 vLLM engine 初始化后调用)
import asyncio
from vllm import AsyncLLMEngine, SamplingParams

async def warmup_cuda_graphs(
    engine: AsyncLLMEngine,
    warmup_sequences: list[int] = [128, 512, 1024, 2048],  # 覆盖常见 seq len
    model: str = "Qwen/Qwen2.5-72B-Instruct",
) -> None:
    """
    向 vLLM engine 发送不同长度的 dummy 请求,触发 CUDA Graph Capture。
    这些请求用随机 token id,不需要真实文本。
    """
    sampling_params = SamplingParams(
        temperature=0,
        max_tokens=1,  # 只要 prefill,不要 decode
    )
    
    import time
    for seq_len in warmup_sequences:
        # 构造固定长度的 dummy prompt(用 tokenizer pad token id)
        dummy_prompt_token_ids = [0] * seq_len
        
        t0 = time.perf_counter()
        request_id = f"warmup-{seq_len}"
        
        async for output in engine.generate(
            prompt=None,
            sampling_params=sampling_params,
            request_id=request_id,
            prompt_token_ids=dummy_prompt_token_ids,
        ):
            pass  # 消费输出流
        
        elapsed = (time.perf_counter() - t0) * 1000
        logger.info(f"CUDA Graph capture for seq_len={seq_len}: {elapsed:.1f}ms")

解法二:torch.compile + max-autotune 预编译缓存

如果你用的是自建推理框架而不是 vLLM,torch.compile 可以把 kernel 编译结果序列化到磁盘:

python 复制代码
import torch
import os

COMPILE_CACHE_DIR = "/tmp/torch_compile_cache"
os.makedirs(COMPILE_CACHE_DIR, exist_ok=True)
os.environ["TORCHINDUCTOR_CACHE_DIR"] = COMPILE_CACHE_DIR

@torch.compile(mode="max-autotune", fullgraph=True)
def compiled_attention(q, k, v, mask=None):
    return torch.nn.functional.scaled_dot_product_attention(q, k, v, attn_mask=mask)

def warmup_compiled_model(warmup_shapes: list[tuple[int, int, int, int]]):
    """warmup_shapes: list of (batch, heads, seq_len, head_dim)"""
    for shape in warmup_shapes:
        dummy = torch.randn(*shape, device="cuda", dtype=torch.bfloat16)
        _ = compiled_attention(dummy, dummy, dummy)
        torch.cuda.synchronize()
    logger.info(f"torch.compile warmup done for {len(warmup_shapes)} shapes")

注意TORCHINDUCTOR_CACHE_DIR 的编译缓存在 Pod 重启后会失效(写在 /tmp),如果想跨重启复用需要挂载持久化存储或镜像内置。

实测收益

yaml 复制代码
P95 TTFT: 5.8s → 2.1s(再减 3.7s------这是最大头)

6. 手段五:Readiness Gate 与流量接入时序控制

前面 4 个手段都在做"把慢的事提前做",这个手段是控制什么时候才允许流量进来

问题

K8s 的 readinessProbe 默认判定容器就绪的标准是 HTTP 200,但你的应用可能在返回 200 之前就已经 "ready" 了(HTTP server 启动了,但 warm-up 还没跑完),或者反过来------warm-up 跑完了但 readiness probe 因为网络抖动还没通过。

正确做法:Warm-Up 完成后才将 readiness 切换为 ready

python 复制代码
# readiness_gate.py
import asyncio
from enum import Enum
import logging

logger = logging.getLogger(__name__)

class ReadinessState(Enum):
    INITIALIZING = "initializing"
    WARMING_UP = "warming_up"
    READY = "ready"
    UNHEALTHY = "unhealthy"

class ReadinessGate:
    def __init__(self):
        self._state = ReadinessState.INITIALIZING
        self._ready_event = asyncio.Event()
        self._warmup_details: dict = {}
    
    def set_warming_up(self):
        self._state = ReadinessState.WARMING_UP
    
    def set_ready(self, details: dict = None):
        self._state = ReadinessState.READY
        self._warmup_details = details or {}
        self._ready_event.set()
        logger.info("Service is now READY", extra=self._warmup_details)
    
    def set_unhealthy(self, reason: str):
        self._state = ReadinessState.UNHEALTHY
        logger.error(f"Service is UNHEALTHY: {reason}")
    
    def is_ready(self) -> bool:
        return self._state == ReadinessState.READY
    
    async def wait_until_ready(self, timeout: float = 60.0):
        await asyncio.wait_for(self._ready_event.wait(), timeout=timeout)

# 全局单例
READINESS_GATE = ReadinessGate()

HTTP 路由(FastAPI)

python 复制代码
from fastapi import FastAPI, HTTPException, status

app = FastAPI()

@app.get("/health")          # 总是返回 200,用于 liveness probe
async def health():
    return {"status": "alive"}

@app.get("/ready")           # 用于 readiness probe:warm-up 完成才 200
async def ready():
    if not READINESS_GATE.is_ready():
        raise HTTPException(
            status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
            detail={"status": READINESS_GATE._state.value}
        )
    return {"status": "ready", **READINESS_GATE._warmup_details}

K8s Deployment 配置

yaml 复制代码
readinessProbe:
  httpGet:
    path: /ready        # 用 /ready,不用 /health
    port: 8080
  initialDelaySeconds: 5
  periodSeconds: 3
  failureThreshold: 30  # 最多等 30 × 3s = 90s(给 CUDA 预热留足时间)
  successThreshold: 1

livenessProbe:
  httpGet:
    path: /health       # liveness 用 /health,永远返回 200
    port: 8080
  initialDelaySeconds: 10
  periodSeconds: 10
  failureThreshold: 3

完整 lifespan 把所有 warm-up 串起来

python 复制代码
@asynccontextmanager
async def lifespan(app: FastAPI):
    READINESS_GATE.set_warming_up()
    warmup_results = {}
    
    try:
        import time
        t0 = time.perf_counter()
        
        # Step 1: 连接池预热
        await warmup_connection_pool(base_url=settings.LLM_BASE_URL, pool_size=10)
        warmup_results["pool_warmup_ms"] = int((time.perf_counter() - t0) * 1000)
        
        # Step 2: Tokenizer 已在模块导入时加载(0ms here)
        
        # Step 3: Prompt cache 预填充
        async with httpx.AsyncClient(base_url=settings.LLM_BASE_URL) as client:
            await warmup_prompt_cache(client, model=settings.MODEL_NAME,
                                       system_prompt=SYSTEM_PROMPT)
        warmup_results["prompt_cache_warmup_ms"] = int((time.perf_counter() - t0) * 1000)
        
        # Step 4: CUDA graph 预热(如果直接访问 vLLM engine)
        # await warmup_cuda_graphs(engine)
        
        total_ms = int((time.perf_counter() - t0) * 1000)
        warmup_results["total_warmup_ms"] = total_ms
        
        READINESS_GATE.set_ready(details=warmup_results)
        logger.info(f"All warmups complete in {total_ms}ms")
        
    except Exception as e:
        READINESS_GATE.set_unhealthy(str(e))
        logger.error(f"Warmup failed: {e}")
        # 根据策略决定是 raise(阻止启动)还是继续(降级运行)
    
    yield
    # 关闭逻辑...

实测收益

这一步本身不直接减少 TTFT,但防止了 warm-up 期间的请求打到未就绪的 Pod,消除了生产中偶发的 10s+ 首请求(那些请求正好在 warm-up 还没完成时进来)。


7. 组合效果实测:5 个手段的叠加收益

下面是在我们 staging 环境(H100 SXM5 × 1,vLLM 0.6.4,Qwen2.5-72B-Instruct,FastAPI + httpx,k8s)上的实测数据。

每次测试:Pod 从零启动,等 readiness gate 通过后立即发第一条请求,重复 50 次取统计。

阶段 改动 P50 TTFT P95 TTFT P99 TTFT
Baseline(无任何预热) - 8.1s 12.3s 15.7s
+手段一(连接池预热) HTTP pool warmup 5.2s 9.1s 11.4s
+手段二(Tokenizer 预加载) 模块导入时初始化 3.9s 7.3s 9.5s
+手段三(Prompt Cache 预填充) warm-up request 2.8s 5.8s 7.2s
+手段四(CUDA Graph 预热) dummy prefill 0.9s 2.1s 2.9s
+手段五(Readiness Gate) 防止早期流量 0.8s 0.78s 1.1s

最终结果:P95 从 12.3s → 0.78s,降幅 93.7%。

注意:手段四(CUDA Graph 预热)是最大的单项收益(P95 减 3.7s),但它也是实现最复杂、最依赖推理框架的一个。如果你用托管推理服务(如第三方大模型推理服务),手段一、二、三在你自己的应用层仍然完全适用。


8. 踩坑记录与注意事项

⚠️ 踩坑 1:连接池预热请求消费完 body 再归还

httpxaiohttp 的连接只有在 response body 被完全消费后才归还连接池。warm-up 请求如果只读 status code 不读 body,连接实际上不会进入 keep-alive 状态:

python 复制代码
# ❌ body 没消费,连接不归还池
response = await client.get("/health")
print(response.status_code)  # 读了 status,但没读 body

# ✅ 正确:消费 body
response = await client.get("/health")
_ = response.content          # 或 response.text,或 response.json()

⚠️ 踩坑 2:Torch Compile 缓存路径跨 Pod 不共享

如果你把 TORCHINDUCTOR_CACHE_DIR 设在 Pod 的临时目录,Pod 重启后缓存丢失,首条请求依然慢。解决方案:

  1. 把 compile cache 写入镜像(Build 时 bake in)
  2. 挂载共享存储(NFS/EFS),多 Pod 共享一份 cache
  3. 或者接受每次重启都 warm-up(通常 CUDA Graph capture 在 warm-up 请求阶段完成,可以接受)

⚠️ 踩坑 3:Warm-Up 请求会写进 access log / billing

这些 warm-up 请求会:

  • 出现在 vLLM 的 access log 里(看起来像异常的高 QPS burst)
  • 如果你用的是计量计费的托管服务,warm-up 请求也会计入 token 消耗

建议:给 warm-up 请求加特定 header(X-Warmup-Request: true),在 log pipeline 里过滤,billing alert 里排除。

python 复制代码
response = await client.post(
    "/v1/chat/completions",
    json={...},
    headers={"X-Warmup-Request": "true"},  # 方便过滤
)

⚠️ 踩坑 4:多 worker 场景下每个 worker 都跑 warm-up

Gunicorn 多 worker 时,每个 worker 的 lifespan 都会跑一次 warm-up。如果 LLM 服务限流(rate limit),多个 worker 的 warm-up 请求会打爆。

解法:warm-up 时在 worker 间加随机 jitter,或者只让 worker 0 做 prompt cache warm-up(其他 worker 只做本地初始化):

python 复制代码
import os, random, asyncio

WORKER_ID = int(os.environ.get("GUNICORN_WORKER_ID", "0"))

async def selective_warmup():
    # 所有 worker 都做连接池预热(各自的连接池)
    await warmup_connection_pool(...)
    
    # 只有 worker 0 做 prompt cache warm-up(避免重复打 LLM 服务)
    if WORKER_ID == 0:
        await warmup_prompt_cache(...)
    else:
        # 其他 worker 等 worker 0 大概率完成后再启动流量接入
        await asyncio.sleep(random.uniform(1.0, 3.0))

⚠️ 踩坑 5:Warm-Up 时间超出 K8s 的 initialDelaySeconds

如果 CUDA Graph capture 需要 30 秒,但 livenessProbe.initialDelaySeconds 只设了 10 秒,K8s 会在 warm-up 完成前就判定 liveness 失败并重启 Pod,形成无限重启循环。

规则:initialDelaySeconds(liveness)必须大于 warm-up 最坏情况耗时,留 20% 余量:

yaml 复制代码
livenessProbe:
  initialDelaySeconds: 120  # warm-up 最坏 60s + 足够余量
  periodSeconds: 30
  failureThreshold: 3

总结

LLM 服务的冷启动不是单一问题,而是 5 层"首次税"的叠加:

  1. 连接池没预热 → 每次建连 200-1200ms → 启动时主动建满连接
  2. Tokenizer 懒加载 → 首次 encode 1.6-2s → 模块导入时同步初始化
  3. Prompt Cache 空 → 系统提示重算 prefill 600-900ms → 启动时发 1-token warm-up 请求
  4. CUDA Kernel 未编译 → JIT 编译 3-6s → dummy 请求覆盖常见 seq length
  5. Readiness 控制缺失 → warm-up 期间流量进来打到未就绪服务 → 严格区分 /health/ready

这 5 个手段都是工程层面的配置和代码问题,不需要换推理框架,也不需要更好的硬件。在我们的生产环境上,叠加后 P95 TTFT 从 12.3s 降到 780ms,Pod 替换期间的用户可感知慢请求数量从峰值 ~200 次/分钟降到几乎为零。

最后一个原则:warm-up 永远不应该阻塞 liveness,但应该阻塞流量接入 。把 /health(liveness)和 /ready(readiness)分开,是这套方案能无感知上线的基础。

相关推荐
SomeB1oody1 小时前
【RustyML入门】5.1. 回归指标
开发语言·后端·机器学习·rust·教程
卷无止境1 小时前
手写 SQL 在 Tortoise ORM 里到底能派上什么用场
后端·python·fastapi
再吃一根胡萝卜1 小时前
从 Docker 到 Kubernetes:微服务的“操作系统”长什么样?
后端
卷无止境1 小时前
FastAPI、Tortoise ORM 与 PostgreSQL 三件套 是否好用呢?
后端·python·fastapi
程序员爱钓鱼2 小时前
Rust Trait详解:定义共享行为与抽象接口
后端·面试·rust
再吃一根胡萝卜2 小时前
分布式事务:从“强一致”到“最终一致”,我为什么在微服务里放弃了 2PC?
后端
再吃一根胡萝卜2 小时前
从 Django 到 Spring Cloud:一个全栈开发者的微服务思考
后端
程序员爱钓鱼2 小时前
Go 编程实战:切片 Slice——灵活的动态数据集合
后端·面试·go
再吃一根胡萝卜9 小时前
微服务治理的“四大护法”:从 Django 视角理解 Spring Cloud 核心组件
后端