graph LR
subgraph "一次推理请求的时间线"
A[t=0\n请求发出] --> B[t₁\n到达服务器]
B --> C[t₂\n开始前向计算\n首token生成]
C --> D[t₃\n最后一个token]
D --> E[t₄\n客户端收到完整响应]
end
B -.->|网络延迟| A
C -.->|"TTFT (Time To First Token)"| A
D -.->|"E2E Latency"| A
D -.->|"TPOT × output_tokens"| C
第12章 推理系统可观测性
导入:你看不见的东西,就无法优化
“推理服务上线了,线上一切正常。”——这句话有多大的可信度?
如果你没有监控 TTFT,你怎么知道用户等了多久才看到第一个字?如果你没有追踪 token 吞吐,你怎么知道 GPU 是满载还是空转?如果你没有统计单位 token 成本,你怎么知道这个月的推理账单是否合理?
推理系统的可观测性不是为了”好看”——它是为了回答三个问题:用户满意吗?系统健康吗?成本可控吗?
本章覆盖推理系统可观测性的核心维度:性能指标、全链路追踪、质量监控和成本归因。
12.1 关键指标
推理系统有一组区别于传统 Web 服务的特有指标。不理解这些指标,就无法判断系统是否健康。
核心指标定义
| 指标 | 全称 | 含义 | 目标值 |
|---|---|---|---|
| TTFT | Time To First Token | 从请求到首 token 的时间 | < 500ms |
| TPOT | Time Per Output Token | 生成每个 output token 的平均时间 | < 50ms |
| E2E Latency | End-to-End Latency | 从请求到完整响应的总时间 | 取决于输出长度 |
| Throughput | Tokens/sec | 每秒生成的 token 数(系统级) | 越高越好 |
| Goodput | Tokens/sec(满足 SLA 的) | 满足延迟 SLA 的有效吞吐 | 接近 Throughput |
TTFT:用户感知最强的指标
TTFT 决定了用户是否觉得”快”。它包括: - 网络延迟 - 请求排队等待时间 - Prefill 时间(处理输入 prompt 的时间)
TTFT 与输入长度正相关——输入越长,prefill 计算越久,首 token 就越慢。
# vLLM 已经内置了 Prometheus 指标导出
# 启动时添加 --disable-log-stats 关闭控制台统计,用 Prometheus 替代
# 主要指标(vLLM 自动导出到 /metrics):
# vllm:time_to_first_token_seconds (Histogram)
# vllm:time_per_output_token_seconds (Histogram)
# vllm:request_latency_seconds (Histogram)
# vllm:num_requests_running (Gauge)
# vllm:num_requests_waiting (Gauge)
# vllm:gpu_cache_usage_perc (Gauge)
# vllm:gpu_prefix_cache_queries_total (Counter)
# vllm:gpu_prefix_cache_hits_total (Counter)TPOT:生成速度
TPOT 是用户感受到的”打字速度”。一个 token 大约 0.6 个英文单词,所以 50ms/token ≈ 12 words/s。人类阅读速度约 200-300 wpm ≈ 3-5 words/s,所以只要 TPOT 在 50ms 以内,用户会觉得”流畅”。
Goodput:真正重要的指标
Throughput 看总量,Goodput 只算满足 SLA 的请求。一个系统可能 throughput 很高(大量 batch 同时跑),但如果 P99 延迟超标,Goodput 可能很低。
# Goodput 计算
# 定义:在 SLA(如 P99 < 2s)内完成的请求数 / 总时间
from prometheus_client import Counter, Histogram
# 自定义 Goodput 指标
sla_compliant_requests = Counter(
'inference_sla_compliant_requests_total',
'Requests completed within SLA',
['model', 'slo']
)
sla_violations = Counter(
'inference_sla_violations_total',
'Requests that violated SLA',
['model', 'reason'] # reason: timeout, error, high_latency
)
# 在请求处理中
async def handle_request(request):
start_time = time.time()
try:
result = await model.generate(request)
elapsed = time.time() - start_time
if elapsed < request.sla_threshold:
sla_compliant_requests.labels(
model=request.model,
slo=f"{request.sla_threshold}s"
).inc()
else:
sla_violations.labels(
model=request.model,
reason="high_latency"
).inc()
return result
except Exception:
sla_violations.labels(model=request.model, reason="error").inc()
raiseGoodput 是推理服务的北极星指标。Throughput 高但 Goodput 低意味着系统在”做无用功”——忙于计算但用户不满意。设定清晰的 SLA(如 TTFT P99 < 500ms,E2E P99 < 5s),然后用 Goodput 来衡量达标率。
Grafana 面板配置
# 推荐的 Grafana Dashboard 面板布局
# 基于 vLLM + Prometheus 的指标
panels:
# Row 1: 实时延迟
- title: "TTFT (P50/P90/P99)"
query: |
histogram_quantile(0.50, rate(vllm:time_to_first_token_seconds_bucket[5m]))
histogram_quantile(0.90, rate(vllm:time_to_first_token_seconds_bucket[5m]))
histogram_quantile(0.99, rate(vllm:time_to_first_token_seconds_bucket[5m]))
- title: "TPOT (P50/P90/P99)"
query: |
histogram_quantile(0.50, rate(vllm:time_per_output_token_seconds_bucket[5m]))
histogram_quantile(0.99, rate(vllm:time_per_output_token_seconds_bucket[5m]))
# Row 2: 吞吐
- title: "Output Throughput (tokens/s)"
query: |
sum(rate(vllm:request_success_total{additional_metrics=["output_tokens"]}[5m]))
# Row 3: 队列状态
- title: "Running / Waiting Requests"
query: |
vllm:num_requests_running
vllm:num_requests_waiting
# Row 4: GPU 内存
- title: "KV Cache Usage %"
query: |
vllm:gpu_cache_usage_perc12.2 全链路性能 Profiling
当延迟指标异常时,你需要知道慢在哪里。全链路 Profiling 能把一个推理请求的时间拆解到每个阶段。
请求时间拆解
gantt
title 一次推理请求的时间构成
dateFormat X
axisFormat %s
section 网络
客户端到网关 :a, 0, 15
section 网关
鉴权 + 路由 :b, 15, 20
section 排队
等待 GPU 槽位 :crit, c, 20, 80
section Prefill
处理输入 prompt :d, 80, 200
section Decode
生成 tokens :e, 200, 500
section 网络
返回客户端 :f, 500, 510
上图中,排队等待(60ms)和 Prefill(120ms)往往是最大的优化空间。
使用 OpenTelemetry 追踪
# 推理服务的分布式追踪
from opentelemetry import trace
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
tracer = trace.get_tracer(__name__)
app = FastAPI()
FastAPIInstrumentor.instrument_app(app)
@app.post("/v1/chat/completions")
async def chat_completions(request: Request):
body = await request.json()
with tracer.start_as_current_span("inference_request") as span:
span.set_attribute("model.name", body.get("model"))
span.set_attribute("input.tokens", count_tokens(body.get("messages")))
# 细分各阶段
with tracer.start_as_current_span("queue_wait"):
slot = await acquire_gpu_slot()
with tracer.start_as_current_span("prefill"):
pass # vLLM 内部已处理
with tracer.start_as_current_span("generate"):
result = await model.generate(body)
with tracer.start_as_current_span("response_serialize"):
response = format_response(result)
span.set_attribute("output.tokens", result.usage.completion_tokens)
return responseGPU 级别 Profiling
有时瓶颈不在推理框架,而在 GPU 本身。NVIDIA Nsight Systems 可以做 kernel 级别的性能分析。
# 使用 Nsight Systems 分析推理过程
nsys profile \
--trace=cuda,nvtx \
--output=inference_profile \
python inference_benchmark.py
# 生成报告
nsys stats inference_profile.nsys-rep
nsys-ui inference_profile.nsys-rep # GUI 查看常见瓶颈定位: 1. TTFT 高 → 检查 prefill 时间和队列深度 2. TPOT 高 → 检查 GPU 利用率和 batch 大小 3. 偶发延迟飙升 → 检查是否有 GC/内存整理/框架内部同步 4. 吞吐低于预期 → 检查 KV Cache 利用率和 batch 填充率
12.3 异常请求与模型幻觉监控
推理服务的”健康”不只是性能指标——输出质量也是可观测性的重要维度。
需要监控的异常输出
| 异常类型 | 表现 | 检测方法 |
|---|---|---|
| 幻觉 | 编造不存在的事实 | 与知识库比对 / LLM-as-Judge |
| 重复 | 同一句话反复出现 | n-gram 重复率 |
| 截断 | 输出在中间停止 | 检查 finish_reason |
| 拒答 | 无正当理由拒绝回答 | 关键词 + 分类器 |
| 有害内容 | 歧视/暴力/违法 | 安全分类器 |
| 格式错误 | JSON 解析失败 | 结构化校验 |
# 输出质量监控(简化实现)
import re
from collections import Counter
class OutputQualityMonitor:
def __init__(self):
self.stats = Counter()
def check(self, output: str, finish_reason: str) -> dict:
issues = []
# 1. 检查是否被截断
if finish_reason == "length":
issues.append("truncated")
self.stats["truncated"] += 1
# 2. 检查重复(n-gram)
words = output.split()
if len(words) > 20:
bigrams = [tuple(words[i:i+2]) for i in range(len(words)-1)]
repeat_rate = sum(1 for b, c in Counter(bigrams).items() if c > 3) / len(bigrams)
if repeat_rate > 0.1: # 超过 10% 的 bigram 重复 3 次以上
issues.append("repetition")
self.stats["repetition"] += 1
# 3. 检查 JSON 格式(如果期望 JSON 输出)
if output.strip().startswith("{"):
try:
import json
json.loads(output)
except json.JSONDecodeError:
issues.append("malformed_json")
self.stats["malformed_json"] += 1
# 4. 检查空输出
if len(output.strip()) < 5:
issues.append("empty_output")
self.stats["empty_output"] += 1
return {"issues": issues, "output_length": len(output)}
# 使用
monitor = OutputQualityMonitor()
result = monitor.check(generated_text, finish_reason="stop")
if result["issues"]:
send_alert(f"Output quality issue: {result['issues']}")幻觉检测:LLM-as-Judge
对于关键场景(如医疗、法律),可以用另一个 LLM 来评估输出的事实准确性:
# LLM-as-Judge 幻觉检测(成本较高,适合采样执行)
import random
JUDGE_PROMPT = """你是一个事实核查员。判断以下回答中是否存在事实错误。
问题:{question}
回答:{answer}
参考知识:{retrieved_context}
判断标准:
- 如果回答中的信息可以在参考知识中找到支持,则"正确"
- 如果回答包含参考知识中没有的具体声明,则"幻觉"
- 如果回答含糊或不够具体,则"模糊"
输出 JSON:{{"verdict": "correct|hallucination|vague", "issues": ["具体问题描述"]}}
"""
async def check_hallucination(question, answer, context, judge_model):
# 只对 5% 的请求做幻觉检测(成本控制)
if random.random() > 0.05:
return None
prompt = JUDGE_PROMPT.format(
question=question, answer=answer, retrieved_context=context
)
result = await judge_model.generate(prompt, temperature=0.0)
return parse_judge_result(result)幻觉检测的成本:LLM-as-Judge 每次检测本身就是一次推理调用,成本不可忽略。建议只对采样数据(如 1-5% 的请求)执行,并使用较小的模型(如 8B)作为 judge,而不是用 70B+ 模型。
12.4 成本归因与单位 token 成本
推理是要花钱的。知道钱花在哪里,是成本优化的前提。
成本模型
# 推理服务的单位成本计算
class InferenceCostModel:
"""计算每个请求、每个 token 的实际成本"""
def __init__(self, gpu_hourly_cost: float, num_gpus: int):
self.gpu_hourly_cost = gpu_hourly_cost # 每张 GPU 每小时成本
self.num_gpus = num_gpus # 推理使用的 GPU 数量
# 总硬件成本/小时
self.hourly_cost = gpu_hourly_cost * num_gpus
def cost_per_token(self, total_tokens: int, hours: float) -> float:
"""计算单位 token 成本"""
total_cost = self.hourly_cost * hours
return total_cost / total_tokens if total_tokens > 0 else float('inf')
def cost_per_request(self, avg_input_tokens: int, avg_output_tokens: int,
requests_per_hour: float) -> float:
"""计算每个请求的平均成本"""
total_tokens_per_hour = (avg_input_tokens + avg_output_tokens) * requests_per_hour
cost_per_token = self.hourly_cost / total_tokens_per_hour
cost_per_request = cost_per_token * (avg_input_tokens + avg_output_tokens)
return cost_per_request
# 示例:Llama-3-70B 推理成本
# 硬件:4 x A100 80GB,每张 $2.5/h(按需价格)
# 吞吐:约 5000 output tokens/s(经过优化后)
cost_model = InferenceCostModel(gpu_hourly_cost=2.5, num_gpus=4)
hourly_cost = cost_model.hourly_cost # $10/h
tokens_per_hour = 5000 * 3600 # 18,000,000 tokens/h
cost_per_million_tokens = cost_model.hourly_cost / tokens_per_hour * 1e6 # $0.56/M tokens
print(f"每小时硬件成本: ${hourly_cost}")
print(f"每百万 token 成本: ${cost_per_million_tokens:.2f}")
print(f"作为对比,GPT-4o API: ~$5/M tokens (input)")按团队/用户归因
# 基于请求标签的成本归因
from collections import defaultdict
from prometheus_client import Counter as PromCounter
# 带标签的 token 计数器
token_counter = PromCounter(
'inference_tokens_total',
'Total tokens processed',
['team', 'model', 'direction'] # direction: input/output
)
# 在请求处理中记录
async def handle_request(request):
team = request.headers.get("X-Team", "unknown")
model = request.model
input_tokens = count_tokens(request.messages)
token_counter.labels(team=team, model=model, direction="input").inc(input_tokens)
result = await model.generate(request)
output_tokens = result.usage.completion_tokens
token_counter.labels(team=team, model=model, direction="output").inc(output_tokens)
return result
# 每天生成本报告
# PromQL 查询:
# sum by (team) (rate(inference_tokens_total[24h]))
# → 每个团队的 token 使用量-- 用 Grafana / Superset 展示成本看板
-- 每日成本明细
WITH daily_usage AS (
SELECT
date(timestamp) as date,
team,
model,
SUM(CASE WHEN direction = 'input' THEN tokens ELSE 0 END) as input_tokens,
SUM(CASE WHEN direction = 'output' THEN tokens ELSE 0 END) as output_tokens,
COUNT(DISTINCT request_id) as request_count
FROM inference_logs
WHERE date(timestamp) = CURRENT_DATE - 1
GROUP BY 1, 2, 3
)
SELECT
date,
team,
model,
input_tokens,
output_tokens,
request_count,
(input_tokens + output_tokens) * 0.00000056 as cost_usd -- 假设 $0.56/M tokens
FROM daily_usage
ORDER BY cost_usd DESC;成本优化的杠杆: 1. 量化:INT8/FP8 直接减半计算成本(~50% 节省) 2. Prefix Caching:减少 prefill 计算(~20-30% 节省,取决于共享前缀比例) 3. 合理选模型:8B 能做好的事不要用 70B(~10x 成本差异) 4. 自动扩缩容:低谷时缩容到最小副本(~30-50% 节省,取决于流量波动) 5. Spot Instance:非实时场景使用竞价实例(~60-70% 节省)
小结
推理可观测性的四层模型:
| 层次 | 回答什么问题 | 核心指标 |
|---|---|---|
| 性能 | 用户满意吗? | TTFT, TPOT, E2E, Goodput |
| 系统 | 系统健康吗? | GPU 利用率, KV Cache 使用率, 队列深度 |
| 质量 | 输出正确吗? | 幻觉率, 重复率, 截断率, 格式错误率 |
| 成本 | 花了多少? | 每 M token 成本, 每团队日成本, GPU 利用效率 |
可观测性不是”装个监控就完了”。它的价值在于形成闭环:测量 → 分析 → 优化 → 再测量。当你能清楚地看到 TTFT 80% 的时间花在 prefill 上,你就知道该启用 Prefix Caching;当你看到某个团队的 token 使用量占了 50% 但产出价值很低,你就知道该调整配额。
推理服务上线只是开始。持续的可观测和优化,才是 AI Infra 工程师的日常。
延伸阅读
- vLLM Prometheus Metrics 文档:https://docs.vllm.ai/en/latest/serving/metrics.html
- “Goodput: A Metric for Evaluating LLM Serving Performance” — Zhang et al., 2024
- OpenTelemetry 推理追踪指南:https://opentelemetry.io/docs/demo/
- NVIDIA Nsight Systems:https://docs.nvidia.com/nsight-systems/
- “Prompt-Based Hallucination Detection in LLMs” — Liu et al., 2024
- Chip Huyen, AI Engineering Chapter 11 — “Inference Optimization”