Skip to content
Published on

LLMOps 平台构建指南:模型部署、监控与 A/B 测试实战架构

分享
Authors
LLMOps Platform

引言

随着 LLM(大语言模型)在生产环境中快速普及,出现了许多传统 MLOps 无法单独解决的新运维课题。相比模型训练,提示词工程变得更为核心;相比定量指标,生成质量评估变得更加重要;以 token 为单位的成本管理,以及用于防止幻觉的护栏,也成为了必需品。

据 Gartner 预测,到 2026 年为止,企业生成式 AI 部署中将有 50% 以上因运维不成熟而失败。这并非 LLM 技术本身的问题,而是源于缺乏能够稳定运维 LLM 的平台架构。

本文将讲解 LLMOps 平台的整体架构。从基于 vLLM/TGI 的模型服务、token 使用量监控、提示词版本管理、A/B 测试框架、NeMo Guardrails 集成,到成本优化,我们将用代码构建生产环境 LLM 运维所需的一切。

LLMOps 与 MLOps:区别何在

如果说传统 MLOps 专注于将「训练-部署-监控」流水线自动化,那么 LLMOps 则出发自根本不同的范式。MLOps 是为可重复的预测而生的系统,LLMOps 则是为概率性生成而生的系统。

分类MLOpsLLMOps
核心活动模型训练/再训练提示词工程/微调
成本结构以训练成本为中心以推理成本为中心(按 token 计费)
评估方式Accuracy, F1, RMSEBLEU, ROUGE, LLM-as-Judge
数据流水线特征存储, ETLRAG、向量数据库、分块流水线
版本管理模型产物提示词模板 + 模型 + 参数
监控数据漂移、性能指标token 使用量、延迟、质量、幻觉
部署周期按周/月再训练提示词变更可按分钟级进行
安全机制输入校验护栏、内容过滤、PII 检测

LLMOps 的架构比传统 MLOps 需要更多组件。应用网关位于模型服务器之前,负责编排提示词路由、向量数据库检索、工具调用与缓存层。

用户请求
┌─────────────────────────────────────────┐
Application Gateway│  ┌──────────┐ ┌────────┐ ┌───────────┐ │
│  │ 提示词    │ │ 向量DB │ │ 护栏      │ │
│  │ 路由器    │  (RAG)  │ │ 引擎      │ │
│  └──────────┘ └────────┘ └───────────┘ │
│  ┌──────────┐ ┌────────┐ ┌───────────┐ │
│  │ 缓存     │ │ A/B    │ │ token     │ │
│  │ 层       │ │ 路由器 │ │ 计量      │ │
│  └──────────┘ └────────┘ └───────────┘ │
└─────────────────┬───────────────────────┘
    ┌─────────────┼─────────────┐
    ▼             ▼             ▼
┌────────┐  ┌────────┐  ┌────────────┐
│ vLLM   │  │  TGI   │  │ TensorRT-Server │  │ Server │  │ LLM Server└────────┘  └────────┘  └────────────┘

模型服务架构

LLM 服务框架比较

对 LLM 服务专用主流框架的比较如下。

框架核心技术GPU 要求吞吐量延迟模型兼容性运维难度
vLLMPagedAttentionCUDA GPU中等全部 HuggingFace
TGIFlash AttentionCUDA GPU低 (v3)全部 HuggingFace
TensorRT-LLMCUDA 图优化仅限 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 使用量成正比,因此需要一套系统化的成本优化策略。

成本削减策略

  1. 语义缓存:基于向量相似度,对相似问题的响应进行缓存,避免重复推理。通常能带来 20~40% 的成本节省效果。

  2. 提示词压缩:去除不必要的 token,只传递核心信息以减少输入 token。使用 LLMLingua 之类的工具,可以将提示词压缩 50% 以上。

  3. 模型路由:根据查询难度,自动在轻量模型(7B)与大型模型(70B)之间路由。用轻量模型处理简单问题,可节省 80% 以上的成本。

  4. 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 测试结果的统计显著性之后,再决定是否全量部署
  • 每周复查质量指标(相关性、准确性、有用性)仪表盘
  • 生成月度成本报告,追踪实际支出与预算的对比
  • 收集用户反馈数据,用于改进提示词
  • 分析护栏日志,将新发现的风险模式补充进策略中

参考资料