TL;DR:LLM 应用第一次请求总是慢得离谱------连接池没预热、tokenizer 懒加载、CUDA kernel 第一次编译......这些"冷启动税"叠在一起,生产环境首请求可以轻松超过 10 秒。本文记录我们在一个月内把服务冷启动 P95 从 12.3s 砍到 780ms 的 5 个工程手段,每一个都附实测数据和可落地的代码。
目录
- 为什么 LLM 服务的冷启动比普通服务更痛?
- 手段一:HTTP 连接池预热(最低成本,砍掉 3-4s)
- 手段二:Tokenizer 预加载(懒加载害死人)
- 手段三:Prompt Cache 主动预填充(让第一条真实请求命中缓存)
- 手段四:CUDA Kernel 预编译与 Torch Compile 暖机请求
- 手段五:Readiness Gate 与流量接入时序控制
- 组合效果实测:5 个手段的叠加收益
- 踩坑记录与注意事项
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 上,你的应用层通常用 httpx、aiohttp 或大模型 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)。这个加载包含:
- JSON 反序列化(tokenizer.json 本身)
- Trie 树构建(BPE merge 规则,约 1.3s)
- 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 再归还
httpx 和 aiohttp 的连接只有在 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 重启后缓存丢失,首条请求依然慢。解决方案:
- 把 compile cache 写入镜像(Build 时 bake in)
- 挂载共享存储(NFS/EFS),多 Pod 共享一份 cache
- 或者接受每次重启都 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 层"首次税"的叠加:
- 连接池没预热 → 每次建连 200-1200ms → 启动时主动建满连接
- Tokenizer 懒加载 → 首次 encode 1.6-2s → 模块导入时同步初始化
- Prompt Cache 空 → 系统提示重算 prefill 600-900ms → 启动时发 1-token warm-up 请求
- CUDA Kernel 未编译 → JIT 编译 3-6s → dummy 请求覆盖常见 seq length
- Readiness 控制缺失 → warm-up 期间流量进来打到未就绪服务 → 严格区分
/health和/ready
这 5 个手段都是工程层面的配置和代码问题,不需要换推理框架,也不需要更好的硬件。在我们的生产环境上,叠加后 P95 TTFT 从 12.3s 降到 780ms,Pod 替换期间的用户可感知慢请求数量从峰值 ~200 次/分钟降到几乎为零。
最后一个原则:warm-up 永远不应该阻塞 liveness,但应该阻塞流量接入 。把 /health(liveness)和 /ready(readiness)分开,是这套方案能无感知上线的基础。