
引言
随着 LLM(大语言模型)在生产环境中快速普及,出现了许多传统 MLOps 无法单独解决的新运维课题。相比模型训练,提示词工程变得更为核心;相比定量指标,生成质量评估变得更加重要;以 token 为单位的成本管理,以及用于防止幻觉的护栏,也成为了必需品。
据 Gartner 预测,到 2026 年为止,企业生成式 AI 部署中将有 50% 以上因运维不成熟而失败。这并非 LLM 技术本身的问题,而是源于缺乏能够稳定运维 LLM 的平台架构。
本文将讲解 LLMOps 平台的整体架构。从基于 vLLM/TGI 的模型服务、token 使用量监控、提示词版本管理、A/B 测试框架、NeMo Guardrails 集成,到成本优化,我们将用代码构建生产环境 LLM 运维所需的一切。
LLMOps 与 MLOps:区别何在
如果说传统 MLOps 专注于将「训练-部署-监控」流水线自动化,那么 LLMOps 则出发自根本不同的范式。MLOps 是为可重复的预测而生的系统,LLMOps 则是为概率性生成而生的系统。
| 分类 | MLOps | LLMOps |
|---|---|---|
| 核心活动 | 模型训练/再训练 | 提示词工程/微调 |
| 成本结构 | 以训练成本为中心 | 以推理成本为中心(按 token 计费) |
| 评估方式 | Accuracy, F1, RMSE | BLEU, ROUGE, LLM-as-Judge |
| 数据流水线 | 特征存储, ETL | RAG、向量数据库、分块流水线 |
| 版本管理 | 模型产物 | 提示词模板 + 模型 + 参数 |
| 监控 | 数据漂移、性能指标 | token 使用量、延迟、质量、幻觉 |
| 部署周期 | 按周/月再训练 | 提示词变更可按分钟级进行 |
| 安全机制 | 输入校验 | 护栏、内容过滤、PII 检测 |
LLMOps 的架构比传统 MLOps 需要更多组件。应用网关位于模型服务器之前,负责编排提示词路由、向量数据库检索、工具调用与缓存层。
用户请求
│
▼
┌─────────────────────────────────────────┐
│ Application Gateway │
│ ┌──────────┐ ┌────────┐ ┌───────────┐ │
│ │ 提示词 │ │ 向量DB │ │ 护栏 │ │
│ │ 路由器 │ │ (RAG) │ │ 引擎 │ │
│ └──────────┘ └────────┘ └───────────┘ │
│ ┌──────────┐ ┌────────┐ ┌───────────┐ │
│ │ 缓存 │ │ A/B │ │ token │ │
│ │ 层 │ │ 路由器 │ │ 计量 │ │
│ └──────────┘ └────────┘ └───────────┘ │
└─────────────────┬───────────────────────┘
│
┌─────────────┼─────────────┐
▼ ▼ ▼
┌────────┐ ┌────────┐ ┌────────────┐
│ vLLM │ │ TGI │ │ TensorRT- │
│ Server │ │ Server │ │ LLM Server │
└────────┘ └────────┘ └────────────┘
模型服务架构
LLM 服务框架比较
对 LLM 服务专用主流框架的比较如下。
| 框架 | 核心技术 | GPU 要求 | 吞吐量 | 延迟 | 模型兼容性 | 运维难度 |
|---|---|---|---|---|---|---|
| vLLM | PagedAttention | CUDA GPU | 高 | 中等 | 全部 HuggingFace | 低 |
| TGI | Flash Attention | CUDA GPU | 高 | 低 (v3) | 全部 HuggingFace | 低 |
| TensorRT-LLM | CUDA 图优化 | 仅限 NVIDIA | 最高 | 最低 | 需要转换 | 高 |
| Triton + vLLM | 集成流水线 | CUDA GPU | 高 | 中等 | 多模型 | 中等 |
vLLM 通过 PagedAttention 像虚拟内存一样以分页单位管理 KV 缓存,将 GPU 内存碎片降到最低。同样的显存下能处理更多并发请求,在混合长度负载下比 TGI 高出 10~30% 的吞吐量。
TGI v3 在长提示词(200K+ token)处理中展现出比 vLLM 快至多 13 倍的性能,与 Hugging Face 生态系统的紧密集成是其优势。
TensorRT-LLM 在 H100 硬件上通过 CUDA 图优化与算子融合,实现了比 vLLM/TGI 高 20~40% 的原始吞吐量,但需要经过模型转换流程且依赖 NVIDIA 硬件。
vLLM 部署配置
在 Kubernetes 环境中部署 vLLM 的实战配置如下。
apiVersion: apps/v1
kind: Deployment
metadata:
name: vllm-llama3-70b
namespace: llm-serving
spec:
replicas: 2
selector:
matchLabels:
app: vllm-llama3-70b
template:
metadata:
labels:
app: vllm-llama3-70b
spec:
containers:
- name: vllm
image: vllm/vllm-openai:v0.7.3
args:
- '--model'
- 'meta-llama/Llama-3.3-70B-Instruct'
- '--tensor-parallel-size'
- '4'
- '--max-model-len'
- '8192'
- '--gpu-memory-utilization'
- '0.90'
- '--enable-chunked-prefill'
- '--max-num-batched-tokens'
- '32768'
- '--port'
- '8000'
ports:
- containerPort: 8000
resources:
limits:
nvidia.com/gpu: 4
requests:
nvidia.com/gpu: 4
memory: '64Gi'
cpu: '16'
env:
- name: HUGGING_FACE_HUB_TOKEN
valueFrom:
secretKeyRef:
name: hf-secret
key: token
readinessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 120
periodSeconds: 10
livenessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 180
periodSeconds: 30
---
apiVersion: v1
kind: Service
metadata:
name: vllm-llama3-70b-svc
namespace: llm-serving
spec:
selector:
app: vllm-llama3-70b
ports:
- port: 8000
targetPort: 8000
type: ClusterIP
核心参数说明:
tensor-parallel-size: 4:将 70B 模型分布到 4 个 GPU 上进行推理。gpu-memory-utilization: 0.90:将 90% 的 GPU 内存分配给 KV 缓存,最大化并发请求数。enable-chunked-prefill:将预填充与解码交叉进行,缩短 TTFT(Time To First Token)。max-num-batched-tokens: 32768:每批次最大 token 数,用于平衡吞吐量与延迟。
监控策略
与传统 ML 监控不同,LLM 监控需要同时追踪三个维度:性能(Performance)、成本(Cost)、质量(Quality)。
核心指标体系
性能指标 成本指标 质量指标
├── TTFT (Time To ├── 输入 token 数 ├── 响应相关性得分
│ First Token) ├── 输出 token 数 ├── 幻觉比例
├── TPOT (Time Per ├── 单次请求成本 ├── 护栏违规率
│ Output Token) ├── 各模型成本比较 ├── 用户反馈
├── 总生成时长 │ │ (thumbs up/down)
├── 请求吞吐量 (RPS) ├── 缓存命中率 └── LLM-as-Judge 得分
├── GPU 利用率 └── 日/月成本趋势
└── 队列等待时间
Prometheus 指标采集实现
以下是使用 Prometheus 采集 vLLM 暴露的指标、并添加自定义业务指标的 Python 中间件示例。
import time
import tiktoken
from prometheus_client import (
Counter, Histogram, Gauge, start_http_server
)
from functools import wraps
# 性能指标
REQUEST_LATENCY = Histogram(
"llm_request_latency_seconds",
"LLM 请求延迟",
["model", "endpoint"],
buckets=[0.1, 0.5, 1.0, 2.0, 5.0, 10.0, 30.0, 60.0],
)
TTFT_LATENCY = Histogram(
"llm_ttft_seconds",
"Time To First Token",
["model"],
buckets=[0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0],
)
# 成本指标
TOKEN_COUNTER = Counter(
"llm_tokens_total",
"总 token 使用量",
["model", "direction"], # direction: input/output
)
REQUEST_COST = Counter(
"llm_request_cost_dollars",
"单次请求成本 (USD)",
["model"],
)
# 质量指标
QUALITY_SCORE = Histogram(
"llm_quality_score",
"LLM 响应质量得分",
["model", "evaluator"],
buckets=[0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0],
)
GUARDRAIL_VIOLATIONS = Counter(
"llm_guardrail_violations_total",
"护栏违规次数",
["model", "violation_type"],
)
# 各模型 token 单价 (USD per 1K tokens)
PRICING = {
"llama-3.3-70b": {"input": 0.00059, "output": 0.00079},
"gpt-4o": {"input": 0.0025, "output": 0.01},
"claude-sonnet": {"input": 0.003, "output": 0.015},
}
class LLMMetricsCollector:
def __init__(self, model_name: str):
self.model_name = model_name
self.encoder = tiktoken.get_encoding("cl100k_base")
def record_request(self, prompt: str, response: str,
latency: float, ttft: float):
input_tokens = len(self.encoder.encode(prompt))
output_tokens = len(self.encoder.encode(response))
# 记录性能
REQUEST_LATENCY.labels(
model=self.model_name, endpoint="/v1/chat/completions"
).observe(latency)
TTFT_LATENCY.labels(model=self.model_name).observe(ttft)
# 记录 token 使用量
TOKEN_COUNTER.labels(
model=self.model_name, direction="input"
).inc(input_tokens)
TOKEN_COUNTER.labels(
model=self.model_name, direction="output"
).inc(output_tokens)
# 计算并记录成本
pricing = PRICING.get(self.model_name, PRICING["llama-3.3-70b"])
cost = (
input_tokens / 1000 * pricing["input"]
+ output_tokens / 1000 * pricing["output"]
)
REQUEST_COST.labels(model=self.model_name).inc(cost)
def record_quality(self, score: float, evaluator: str = "auto"):
QUALITY_SCORE.labels(
model=self.model_name, evaluator=evaluator
).observe(score)
def record_guardrail_violation(self, violation_type: str):
GUARDRAIL_VIOLATIONS.labels(
model=self.model_name, violation_type=violation_type
).inc()
if __name__ == "__main__":
start_http_server(9090)
collector = LLMMetricsCollector("llama-3.3-70b")
Grafana 仪表盘关键面板
以下是基于 Prometheus 指标构建 Grafana 仪表盘所需的核心 PromQL 查询。
# P99 延迟(5 分钟窗口)
histogram_quantile(0.99, rate(llm_request_latency_seconds_bucket[5m]))
# 每分钟 token 消耗量
rate(llm_tokens_total[1m])
# 每小时成本趋势
rate(llm_request_cost_dollars[1h]) * 3600
# 护栏违规率
rate(llm_guardrail_violations_total[5m]) / rate(llm_request_latency_seconds_count[5m])
# 平均质量得分
histogram_quantile(0.5, rate(llm_quality_score_bucket[1h]))
提示词版本管理
在 LLMOps 中,提示词是对应传统 MLOps 中模型产物的核心资产。提示词模板、模型版本、生成参数(temperature、top_p 等)必须一同进行版本管理,才能实现可复现的部署与精细的回滚。
import json
import hashlib
from datetime import datetime
from dataclasses import dataclass, field, asdict
from typing import Optional
@dataclass
class PromptVersion:
name: str
template: str
model: str
temperature: float = 0.7
top_p: float = 0.9
max_tokens: int = 2048
system_prompt: str = ""
version: str = ""
created_at: str = field(
default_factory=lambda: datetime.utcnow().isoformat()
)
def __post_init__(self):
if not self.version:
content = f"{self.template}{self.model}{self.temperature}"
self.version = hashlib.sha256(
content.encode()
).hexdigest()[:8]
def to_dict(self) -> dict:
return asdict(self)
class PromptRegistry:
"""提示词版本管理注册表"""
def __init__(self, storage_backend="redis"):
self.storage_backend = storage_backend
self.prompts: dict[str, list[PromptVersion]] = {}
def register(self, prompt: PromptVersion) -> str:
if prompt.name not in self.prompts:
self.prompts[prompt.name] = []
self.prompts[prompt.name].append(prompt)
return prompt.version
def get_latest(self, name: str) -> Optional[PromptVersion]:
versions = self.prompts.get(name, [])
return versions[-1] if versions else None
def get_version(self, name: str, version: str
) -> Optional[PromptVersion]:
versions = self.prompts.get(name, [])
for v in versions:
if v.version == version:
return v
return None
def rollback(self, name: str, version: str) -> bool:
target = self.get_version(name, version)
if target:
self.prompts[name].append(
PromptVersion(
name=target.name,
template=target.template,
model=target.model,
temperature=target.temperature,
top_p=target.top_p,
max_tokens=target.max_tokens,
system_prompt=target.system_prompt,
)
)
return True
return False
# 使用示例
registry = PromptRegistry()
v1 = PromptVersion(
name="customer-support",
template="请友好地回答客户咨询。\n\n咨询:{query}",
model="llama-3.3-70b",
temperature=0.3,
system_prompt="你是一名专业的客服代表。",
)
registry.register(v1)
A/B 测试框架
LLM 的 A/B 测试与传统 web A/B 测试有着根本区别。它需要的不是点击率这类简单指标,而是生成质量这种多维度评估;由于概率性输出特性,同一输入可能得到不同响应,因此需要更多样本量。
A/B 测试路由器实现
import random
import hashlib
from dataclasses import dataclass
from typing import Any
@dataclass
class ABVariant:
name: str
prompt_version: str
model: str
weight: float # 流量比例 (0.0 ~ 1.0)
parameters: dict = None
class LLMABRouter:
"""LLM A/B 测试流量路由器"""
def __init__(self, experiment_name: str):
self.experiment_name = experiment_name
self.variants: list[ABVariant] = []
def add_variant(self, variant: ABVariant):
self.variants.append(variant)
def route(self, user_id: str) -> ABVariant:
"""基于用户 ID 的确定性路由(同一用户始终分配到同一 variant)"""
hash_input = f"{self.experiment_name}:{user_id}"
hash_value = int(
hashlib.md5(hash_input.encode()).hexdigest(), 16
)
normalized = (hash_value % 10000) / 10000.0
cumulative = 0.0
for variant in self.variants:
cumulative += variant.weight
if normalized < cumulative:
return variant
return self.variants[-1]
def validate_weights(self) -> bool:
total = sum(v.weight for v in self.variants)
return abs(total - 1.0) < 0.001
# 实验配置示例
experiment = LLMABRouter("customer-support-v2-test")
experiment.add_variant(ABVariant(
name="control",
prompt_version="v1-abc123",
model="llama-3.3-70b",
weight=0.7,
parameters={"temperature": 0.3},
))
experiment.add_variant(ABVariant(
name="treatment",
prompt_version="v2-def456",
model="llama-3.3-70b",
weight=0.3,
parameters={"temperature": 0.5},
))
# 按用户路由
variant = experiment.route(user_id="user-12345")
print(f"Assigned variant: {variant.name}")
统计显著性判定
以下是判定 LLM A/B 测试结果统计显著性的核心逻辑。
import numpy as np
from scipy import stats
def calculate_ab_significance(
control_scores: list[float],
treatment_scores: list[float],
alpha: float = 0.05,
min_samples: int = 100,
) -> dict:
"""判定 A/B 测试结果的统计显著性"""
if (len(control_scores) < min_samples
or len(treatment_scores) < min_samples):
return {
"status": "insufficient_samples",
"control_n": len(control_scores),
"treatment_n": len(treatment_scores),
"min_required": min_samples,
}
control_mean = np.mean(control_scores)
treatment_mean = np.mean(treatment_scores)
lift = (treatment_mean - control_mean) / control_mean
# Welch's t-test(无需等方差假设)
t_stat, p_value = stats.ttest_ind(
control_scores, treatment_scores, equal_var=False
)
# 效应量 (Cohen's d)
pooled_std = np.sqrt(
(np.std(control_scores) ** 2 + np.std(treatment_scores) ** 2)
/ 2
)
cohens_d = (
(treatment_mean - control_mean) / pooled_std
if pooled_std > 0 else 0
)
return {
"status": "significant" if p_value < alpha else "not_significant",
"control_mean": round(control_mean, 4),
"treatment_mean": round(treatment_mean, 4),
"lift": round(lift * 100, 2),
"p_value": round(p_value, 6),
"cohens_d": round(cohens_d, 4),
"recommendation": (
"DEPLOY treatment"
if p_value < alpha and lift > 0
else "KEEP control"
),
}
护栏集成
在生产环境 LLM 中,护栏不是可选项而是必需品。借助 NVIDIA NeMo Guardrails,可以声明式地配置输入输出过滤、防止话题偏离、PII 检测与幻觉检查。
NeMo Guardrails 配置
# config.yml - NeMo Guardrails 配置
models:
- type: main
engine: vllm
parameters:
base_url: 'http://vllm-llama3-70b-svc:8000/v1'
model_name: 'meta-llama/Llama-3.3-70B-Instruct'
rails:
input:
flows:
- self check input # 输入有害性检测
- check jailbreak # 检测越狱尝试
- mask pii # PII 脱敏
output:
flows:
- self check output # 输出有害性检测
- check hallucination # 幻觉检测
- check topic relevance # 主题相关性检查
config:
enable_multi_step_generation: true
lowest_temperature: 0.1
enable_rails_exceptions: true
instructions:
- type: general
content: |
请务必遵守以下准则:
1. 未经确认的事实须明确标注为推测
2. 禁止在响应中包含个人信息
3. 医疗/法律/金融建议须推荐咨询专业人士
4. 禁止生成暴力或有害内容
sample_conversation: |
user "你好,我需要帮助。"
express greeting
bot express greeting and offer help
"你好!有什么可以帮您?"
护栏中间件集成
from nemoguardrails import RailsConfig, LLMRails
class GuardrailMiddleware:
"""LLM 护栏中间件"""
def __init__(self, config_path: str):
config = RailsConfig.from_path(config_path)
self.rails = LLMRails(config)
async def process(self, user_message: str,
context: dict = None) -> dict:
try:
response = await self.rails.generate_async(
messages=[{"role": "user", "content": user_message}]
)
return {
"status": "success",
"response": response["content"],
"guardrail_actions": response.get(
"log", {}
).get("activated_rails", []),
}
except Exception as e:
return {
"status": "blocked",
"reason": str(e),
"response": "无法处理您的请求。"
"请换一个问题重试。",
}
成本优化
LLM 运维成本与 token 使用量成正比,因此需要一套系统化的成本优化策略。
成本削减策略
-
语义缓存:基于向量相似度,对相似问题的响应进行缓存,避免重复推理。通常能带来 20~40% 的成本节省效果。
-
提示词压缩:去除不必要的 token,只传递核心信息以减少输入 token。使用 LLMLingua 之类的工具,可以将提示词压缩 50% 以上。
-
模型路由:根据查询难度,自动在轻量模型(7B)与大型模型(70B)之间路由。用轻量模型处理简单问题,可节省 80% 以上的成本。
-
KV 缓存优化:利用 vLLM 的 prefix caching 功能,复用系统提示词或公共上下文的 KV 缓存。
# 模型路由示例:根据查询复杂度自动路由
class ModelRouter:
def __init__(self):
self.complexity_threshold = 0.6
self.models = {
"simple": {
"name": "llama-3.2-8b",
"endpoint": "http://vllm-8b:8000/v1",
"cost_per_1k": 0.00010,
},
"complex": {
"name": "llama-3.3-70b",
"endpoint": "http://vllm-70b:8000/v1",
"cost_per_1k": 0.00079,
},
}
def classify_complexity(self, query: str) -> float:
"""将查询复杂度评估为 0~1 之间的分数"""
indicators = [
len(query) > 500, # 长问题
"比较" in query, # 比较分析请求
"分析" in query, # 分析请求
"代码" in query, # 代码生成
query.count("?") > 2, # 多重问题
]
return sum(indicators) / len(indicators)
def route(self, query: str) -> dict:
complexity = self.classify_complexity(query)
if complexity >= self.complexity_threshold:
return self.models["complex"]
return self.models["simple"]
故障案例与经验教训
案例 1:模型服务 OOM(内存不足)
将 70B 模型部署在 4x A100 80GB 上,但由于将 max-model-len 设置为 32768,并发请求增加时发生了 OOM。
原因:KV 缓存的内存消耗与序列长度成正比。在 32K 长度下同时涌入 50 个请求时,仅 KV 缓存就需要 300GB+ 内存。
解决方案:将 max-model-len 减小到 8192,把 gpu-memory-utilization 设为 0.90,并将长输入路由到单独的实例上。
案例 2:提示词回归(Prompt Regression)
将客服提示词修改得「更亲切」之后,技术支持准确率下降了 30%。
原因:提示词变更没有经过 A/B 测试就立即部署到了全部流量。强调「亲切感」带来的副作用是抑制了准确技术术语的使用。
解决方案:制定政策规定,所有提示词变更必须先以 10% 流量进行金丝雀部署,并需通过准确性、相关性、有用性这三项质量指标之后才能进行全量部署。
案例 3:A/B 测试统计错误
仅用 500 份样本就结束了 A/B 测试并决定部署 treatment,但之后其表现却低于 control。
原因:LLM 的概率性特性带来了较大方差,500 份样本的统计检验力(power)不足。此外,也没有考虑到周末/工作日流量模式的差异。
解决方案:将最小样本量设定为 1000 份以上,实验运行至少 1 周以抵消不同时段的影响。同时结合基于 Cohen's d 的效应量一并确认。
运维检查清单
部署前
- 确认模型服务框架的 healthcheck 端点是否正常响应
- 确认 GPU 内存使用率是否未超过 95%
- 确认提示词版本是否已在注册表中登记
- 确认护栏配置是否反映了最新策略
- 确认回滚提示词版本是否已明确指定
部署中
- 金丝雀流量比例从 10% 开始,逐步递增
- 实时监控 TTFT、TPOT、总延迟这三项性能指标
- 确认护栏违规率相较基线没有骤增
- 确认 token 使用量与成本是否在预算范围内
部署后
- 确认 A/B 测试结果的统计显著性之后,再决定是否全量部署
- 每周复查质量指标(相关性、准确性、有用性)仪表盘
- 生成月度成本报告,追踪实际支出与预算的对比
- 收集用户反馈数据,用于改进提示词
- 分析护栏日志,将新发现的风险模式补充进策略中
参考资料
- vLLM vs TensorRT-LLM vs HF TGI vs LMDeploy - 生产环境 LLM 推理深度技术对比
- LLMOps vs MLOps: Key Differences and Architecture - Codebridge
- LLM Monitoring: Quality, Cost, Latency, and Drift in Production - LangWatch
- A/B Testing of LLM Prompts - Langfuse
- NVIDIA NeMo Guardrails - GitHub
- LLM Observability and Monitoring - Langfuse
- A/B Testing LLM Prompts: A Practical Guide - Braintrust
현재 단락 (1/507)
随着 LLM(大语言模型)在生产环境中快速普及,出现了许多传统 MLOps 无法单独解决的新运维课题。相比模型训练,提示词工程变得更为核心;相比定量指标,生成质量评估变得更加重要;以 token 为单...