1. 大模型推理可观测性到底在解决什么问题
1.1 从一次线上告警说起
上个月凌晨两点,我被一条告警叫醒:某个对外提供的大模型推理接口 P99 延迟从 1.8 秒飙到了 11 秒,但 CPU、GPU、内存三项基础指标全都正常。运维同事第一反应是“网络抖动”,业务同事怀疑“上游流量突增”,而我盯着监控面板看了十分钟,发现真正的问题藏在一个没人关注的维度里——输出 Token 数。那段时间有一批用户开始用长文本摘要任务,单次请求的输出 Token 从平均 120 涨到了 900 多,而我们的推理引擎还在用固定 batch 策略,导致显存碎片化严重,调度队列越堆越长。
这件事让我彻底意识到:传统的那套 CPU/内存/网络监控,在大模型推理场景下几乎是失灵的。你看到的是资源利用率不高,但用户体感是“卡得没法用”;你看到的是 QPS 稳定,但成本账单在悄悄翻倍。原因很简单——大模型推理的核心成本单元和性能单元,不是“请求数”,而是Token;它的核心体验指标,不是“响应时间”,而是首 Token 延迟(TTFT)和单 Token 输出延迟(TPOT)。
这篇内容我想聊的就是:怎么给大模型推理做一套真正有用的可观测性体系,把每一次推理的 Token 消耗和延迟都追踪清楚。适合正在做大模型部署、推理引擎调优、AI 应用后端的工程师,也适合刚接触LLM 服务化、想搞清楚“钱花在哪、慢在哪”的同学。不管你是用 vLLM、LocalAI、Ollama 还是自研推理服务,这套思路都能直接套用。
1.2 为什么“请求级监控”不够用
先讲清楚一个认知差。传统 Web 服务的监控粒度是“请求”:一个请求进来,耗时 200ms,成功或失败,完事。但大模型推理完全不是这个模型。同一个接口,A 用户问“今天天气怎么样”,输出 15 个 Token,耗时 0.6 秒;B 用户让它写一篇 2000 字的产品文案,输出 1800 个 Token,耗时 40 秒。这两个请求在“请求级监控”里长得一模一样——都是 1 次调用、1 次成功。但它们的资源消耗差了上百倍,用户体验也天差地别。
所以可观测性必须下沉到Token 粒度。具体来说,一次推理要能拆出这几个关键量:
| 指标 | 含义 | 为什么重要 |
|---|---|---|
| 输入 Token 数(prompt tokens) | 用户提示词被分词后的长度 | 决定 prefill 阶段的计算量,直接影响 TTFT |
| 输出 Token 数(completion tokens) | 模型生成的 Token 总数 | 决定 decode 阶段总耗时和计费成本 |
| 首 Token 延迟 TTFT | 从请求发出到第一个 Token 返回 | 用户感知“快不快”的第一印象 |
| 单 Token 输出延迟 TPOT | 后续每个 Token 的平均生成间隔 | 决定“打字机”是否流畅 |
| 总延迟 | 整个请求端到端耗时 | 计费、SLA、容量规划的基础 |
| 队列等待时间 | 请求在调度队列里排队的时间 | 区分“引擎慢”还是“排队慢” |
把这六个量采集全,你才能回答那些真正要命的问题:为什么这个用户的请求特别慢?是 prompt 太长导致 prefill 卡住,还是输出太长导致 decode 拖尾?为什么这个月的成本涨了 30%?是调用量涨了,还是平均输出 Token 涨了?为什么高峰期延迟抖动?是 GPU 打满了,还是调度队列积压了?
1.3 可观测性的三层结构
我在实际项目里把大模型可观测性分成三层,从下往上依次是:
第一层是资源层,也就是 GPU 利用率、显存占用、KV Cache 使用率、CPU、网络。这一层解决“机器累不累”的问题,是基础但不是核心。
第二层是推理层,也就是上面那张表里的 Token 和延迟指标。这一层解决“引擎快不快、贵不贵”的问题,是整套体系的核心。
第三层是业务层,比如每个租户/用户的 Token 消耗、每个业务场景的平均输出长度、成本分摊。这一层解决“钱花在谁身上、哪个功能最烧钱”的问题。
很多团队只做了第一层,所以永远在“加机器”和“降延迟”之间反复横跳,却找不到根因。真正有效的做法是三层打通:资源层的异常能关联到推理层的具体请求,推理层的成本能归因到业务层的具体租户。
2. 核心指标定义与埋点设计
2.1 指标口径必须先对齐,否则全是废数据
我踩过最大的坑,就是指标口径不统一。前端统计的“响应时间”是从用户点击按钮开始算,后端统计的“推理延迟”是从收到 HTTP 请求开始算,中间差了网络传输、网关转发、鉴权校验一大截。结果两边对不上账,排查问题时互相甩锅。
所以第一步,必须把每个指标的时间戳打点位置定义死。我一般要求至少打这几个时间戳:
t0:客户端发起请求(前端埋点)t1:网关收到请求t2:推理服务收到请求,进入调度队列t3:引擎开始 prefill(拿到 GPU 资源)t4:第一个 Token 生成t5:最后一个 Token 生成t6:响应完整返回客户端
有了这七个点,就能算出:网络耗时 = t1 - t0,网关耗时 = t2 - t1,排队耗时 = t3 - t2,prefill 耗时 = t4 - t3,decode 耗时 = t5 - t4,回传耗时 = t6 - t5。哪个环节慢,一目了然。
注意:时间戳一定要用统一时钟源。分布式环境下不同机器的本地时钟可能有几十毫秒偏差,建议用 NTP 同步,或者干脆在网关层统一打点,避免跨机时钟误差污染数据。
2.2 Token 计数:别信估算,要信分词器
Token 数怎么来?很多人图省事,用“字符数除以 4”这种经验公式估算。这在英文场景下勉强能用,但中文、代码、特殊符号一多,误差能到 50% 以上。我见过一个团队按估算值做成本核算,月底对账发现实际账单比预估高了 40%,就是因为中文 Token 密度远高于英文。
正确做法是用模型自己的分词器(tokenizer)来数。主流推理引擎在返回结果时都会带上usage字段,比如:
{ "usage": { "prompt_tokens": 128, "completion_tokens": 512, "total_tokens": 640 } }这个字段是引擎内部真实统计的,最准。如果引擎不返回(有些自研引擎会漏),那就得在服务层自己调 tokenizer 算一遍。以 HuggingFace 的 tokenizer 为例:
from transformers import AutoTokenizer tokenizer = AutoTokenizer.from_pretrained("your-model-path") def count_tokens(text: str) -> int: return len(tokenizer.encode(text, add_special_tokens=False)) prompt_tokens = count_tokens(prompt) completion_tokens = count_tokens(generated_text)这里有个细节:add_special_tokens参数要跟实际推理时保持一致。有些模型会在 prompt 前后自动加 BOS/EOS,如果你统计时没加,就会少算几个 Token,长期累积下来对账会有偏差。
2.3 流式场景下的延迟采集
现在大部分大模型应用都是流式输出(streaming),Token 是一个一个吐出来的。这种场景下延迟采集要特别处理,不能等整个响应结束才算总时间。
我的做法是在流式回调里记录每个 chunk 的到达时间:
import time first_token_time = None token_times = [] def on_token(token: str): global first_token_time now = time.perf_counter() if first_token_time is None: first_token_time = now token_times.append(now) # 推理结束后计算 ttft = first_token_time - request_start_time if len(token_times) > 1: tpot = (token_times[-1] - token_times[0]) / (len(token_times) - 1) else: tpot = 0这里用time.perf_counter()而不是time.time(),因为前者是单调时钟,不受系统时间调整影响,测间隔更准。TPOT 的计算要注意分母是len(token_times) - 1,因为第一个 Token 的时间已经算进 TTFT 了,不能重复计算。
实操心得:流式场景下,如果某个 chunk 特别大(比如一次返回 10 个 Token),TPOT 会被拉低,看起来“很快”,但用户体感是“一顿一顿的”。所以除了平均 TPOT,我还建议记录TPOT 的 P95 和最大值,抖动比平均值更能反映体验问题。
2.4 埋点不能拖慢推理
这是很多人忽略的一点:可观测性本身也是有开销的。如果你在每个 Token 生成时都同步写一次数据库,那推理性能会被拖垮。我见过一个项目,加了详细埋点后 QPS 直接掉了 30%,就是因为埋点写库是同步阻塞的。
正确姿势是异步 + 批量。埋点数据先写进内存队列,由独立线程批量刷到后端:
import queue import threading metrics_queue = queue.Queue(maxsize=10000) def metrics_worker(): batch = [] while True: try: item = metrics_queue.get(timeout=1) batch.append(item) if len(batch) >= 100: flush_to_backend(batch) batch = [] except queue.Empty: if batch: flush_to_backend(batch) batch = [] threading.Thread(target=metrics_worker, daemon=True).start()队列要设上限,满了就丢弃并计数(丢弃本身也是个指标,说明埋点压力过大)。刷盘批量大小 100 左右比较合适,太小频繁 IO,太大延迟高。
3. 从零搭建一套可观测性链路
3.1 技术选型:Prometheus + Grafana 是基本盘
指标采集和展示,我强烈建议用Prometheus + Grafana这套组合。原因很实在:生态成熟、社区案例多、和 Kubernetes 集成好、查询语言 PromQL 足够灵活。大模型推理的指标大多是时序数据(随时间变化的延迟、Token 数),正好是 Prometheus 的强项。
具体分工是:
- Prometheus负责拉取和存储指标,做告警规则
- Grafana负责可视化,做大盘和报表
- 推理服务暴露一个
/metrics接口,用 Prometheus 客户端库埋点
Python 服务用prometheus_client:
from prometheus_client import Histogram, Counter, start_http_server TTFT = Histogram( 'llm_ttft_seconds', 'Time to first token', buckets=[0.1, 0.25, 0.5, 1, 2, 5, 10] ) TOKENS = Counter( 'llm_tokens_total', 'Total tokens processed', ['type', 'model', 'tenant'] ) start_http_server(8000)Histogram的 buckets 要按实际延迟分布来设。大模型 TTFT 通常在 0.1 到几秒之间,所以我在 0.1、0.25、0.5、1、2、5、10 秒设了桶。桶设得太粗,P99 算不准;设得太细,存储成本高。经验是覆盖 P1 到 P99.9 的范围,中间均匀分布。
3.2 标签设计:决定你能查多细
Prometheus 的标签(label)设计是门学问。标签加得好,你能按任意维度下钻;加得烂,要么查不出来,要么基数爆炸把 Prometheus 撑死。
我的标签设计原则是:高频查询的维度做标签,低频的做日志。大模型场景下,这几个标签是必须的:
model:模型名称,区分不同模型的成本tenant:租户/业务方,做成本分摊type:prompt 还是 completion,区分输入输出status:成功/失败/超时
但要注意标签基数(cardinality)。如果你把request_id做成标签,那每个请求都是一个独立时间序列,几万请求就能把 Prometheus 打爆。request_id这种唯一标识,应该放日志里,不放指标标签。
踩坑记录:我曾经把
user_id做成标签,结果一个 C 端产品有几十万用户,Prometheus 内存直接爆了。后来改成只对 Top 100 的大客户做标签,其余归到other,问题才解决。
3.3 日志与 Trace 的配合
指标告诉你“哪里不对”,日志和 Trace 告诉你“为什么不对”。三者要能互相跳转。
我的做法是:每个请求生成一个trace_id,这个 ID 同时出现在指标标签(作为 exemplar)、结构化日志、以及分布式 Trace 里。当 Grafana 上看到某个时间点 TTFT 飙升,点开 exemplar 就能跳到对应的 Trace,看到这次请求的完整链路和日志。
结构化日志用 JSON 格式,方便检索:
import logging import json logger = logging.getLogger("llm") logger.info(json.dumps({ "trace_id": trace_id, "model": model_name, "prompt_tokens": prompt_tokens, "completion_tokens": completion_tokens, "ttft_ms": ttft_ms, "tpot_ms": tpot_ms, "total_ms": total_ms, "status": "success" }))这样在日志系统里可以直接按completion_tokens > 1000这种条件筛选,快速定位长输出请求。
3.4 采样策略:全量还是抽样
高 QPS 场景下,全量采集日志和 Trace 成本很高。我的策略是指标全量、日志抽样、Trace 按需:
- 指标(Prometheus)数据量小,全量采集,保证统计准确
- 日志按比例抽样,比如 10%,但错误日志和慢请求(超过阈值)100% 采集
- Trace 默认关闭,需要排查时对特定租户或特定时间段开启
这样既保证了统计指标的准确性,又控制了存储成本。慢请求全采是关键,因为排查问题时你最需要的就是那些“异常样本”。
4. 延迟与 Token 的联合分析方法
4.1 用 TTFT 和 TPOT 定位瓶颈
拿到 TTFT 和 TPOT 两个指标后,就能做二维定位了。我总结了一个判断表:
| TTFT | TPOT | 可能原因 | 优化方向 |
|---|---|---|---|
| 高 | 正常 | prompt 太长,prefill 慢 | 压缩 prompt、开启 prefix caching |
| 正常 | 高 | decode 慢,显存带宽瓶颈 | 量化、张量并行、换更快的 GPU |
| 高 | 高 | 整体资源不足或排队严重 | 扩容、优化调度、限流 |
| 正常 | 正常 | 网络或网关问题 | 查网关、查回传链路 |
这个表在实际排查中非常管用。比如前面提到的凌晨告警,我一查发现 TTFT 正常但 TPOT 从 30ms 涨到了 90ms,立刻判断是 decode 阶段的问题,最后定位到是长输出请求把 KV Cache 撑满,导致显存换页。
4.2 排队时间:被忽视的隐形杀手
很多团队只看“推理耗时”,忽略了“排队耗时”。但在高并发场景下,排队时间往往才是延迟的大头。
举个例子:你的引擎单次推理只要 2 秒,但同时来了 50 个请求,GPU 一次只能处理 8 个,那剩下 42 个就得排队。第 50 个请求的排队时间可能长达 12 秒,端到端延迟 14 秒,但引擎自己“觉得”只花了 2 秒。
所以必须把排队时间单独采集。在调度层记录请求入队和出队的时间戳:
enqueue_time = time.perf_counter() # ... 等待调度 ... dequeue_time = time.perf_counter() queue_wait = dequeue_time - enqueue_time排队时间的 P99 是容量规划的核心依据。如果排队时间经常超过 TTFT 本身,说明你的并发容量不够,该扩容了。
4.3 滑动窗口统计:别被瞬时值骗了
延迟指标波动很大,看瞬时值容易被误导。我一般用滑动窗口做平滑统计。Prometheus 的rate和histogram_quantile本身就是基于时间窗口的,比如:
# 过去 5 分钟的 P95 TTFT histogram_quantile(0.95, rate(llm_ttft_seconds_bucket[5m])) # 过去 5 分钟的平均 TPOT rate(llm_tpot_seconds_sum[5m]) / rate(llm_tpot_seconds_count[5m])窗口大小的选择有讲究:窗口太小(如 1 分钟),曲线抖动大,看不出趋势;窗口太大(如 1 小时),反应迟钝,告警不及时。我的经验是告警用 5 分钟窗口,趋势分析用 1 小时窗口,两个都看。
注意:
histogram_quantile算出来的是近似值,桶的边界越密越准。如果发现 P95 和 P99 差距异常大,可能是桶设置不合理,需要调整。
4.4 Token 消耗的成本归因
Token 不只是性能指标,更是成本指标。按 Token 计费的场景下,每个租户、每个功能消耗了多少 Token,直接对应真金白银。
我一般会做一个成本归因看板,按租户和模型维度聚合:
# 某租户过去 24 小时的 completion token 总量 sum(increase(llm_tokens_total{type="completion", tenant="tenant_a"}[24h]))再乘以单价,就是成本。这样能快速发现“哪个租户最烧钱”“哪个功能 Token 消耗异常”。我遇到过一个小功能,因为 prompt 里塞了一大段没用的系统提示,导致每次请求的 prompt token 多了 800 个,一个月多花了好几万。这种问题,没有 Token 级别的归因根本发现不了。
5. 常见问题与排查技巧实录
5.1 指标对不上账怎么办
现象:前端统计的延迟和后端统计的对不上,差了几百毫秒甚至几秒。
排查思路:先确认时间戳打点位置是否一致。最常见的原因是前端从“用户点击”开始算,后端从“收到请求”开始算,中间的网络和网关耗时没算进去。解决办法是统一口径,或者干脆在网关层打一个“统一起点”,前后端都以这个为准。
另一个常见原因是时钟不同步。分布式环境下,如果各机器时钟有偏差,跨机计算的时间差就是错的。用 NTP 同步,或者所有时间戳都在同一台机器上打。
5.2 Token 数统计偏差
现象:自己统计的 Token 数和账单对不上。
排查思路:第一,确认是否用了模型自己的 tokenizer,而不是估算公式。第二,确认add_special_tokens参数是否和推理时一致。第三,如果是流式输出,确认是否把每个 chunk 的 Token 都算进去了,有没有漏算最后一个 chunk。第四,多模态场景下,图像 Token 的计算方式可能和文本不同,要单独处理。
5.3 埋点导致性能下降
现象:加了可观测性之后,推理 QPS 下降,延迟上升。
排查思路:检查埋点是否同步阻塞。所有埋点写入都应该是异步的,用内存队列缓冲,独立线程刷盘。另外检查指标标签基数是否过大,标签太多会导致 Prometheus 拉取和存储压力大。还有,Histogram 的桶不要设太多,几十个桶就够了。
5.4 延迟抖动大,找不到规律
现象:延迟时高时低,没有明显规律。
排查思路:先看是不是排队导致的。把排队时间和推理时间分开看,如果排队时间抖动大,说明并发调度有问题。再看是不是长输出请求拖尾,把 completion token 数和延迟做散点图,如果高 Token 请求延迟明显更高,说明需要针对长输出做优化(比如限制最大输出长度、开启 chunked prefill)。最后看 GPU 是否有其他任务抢占,显存是否频繁换页。
5.5 常见问题速查表
| 问题 | 可能原因 | 快速验证方法 |
|---|---|---|
| TTFT 高 | prompt 太长 / prefix cache 未命中 | 看 prompt token 分布 |
| TPOT 高 | 显存带宽瓶颈 / batch 太大 | 看 GPU 显存带宽利用率 |
| 总延迟高但引擎快 | 排队严重 | 看队列等待时间 |
| 成本异常上涨 | 输出 Token 变多 | 看 completion token 趋势 |
| 指标缺失 | 埋点被采样丢弃 | 看队列丢弃计数 |
| 延迟抖动 | 长输出请求拖尾 | 做 Token-延迟散点图 |
5.6 几个我踩过的坑
第一个坑是只监控平均值。平均值会掩盖长尾问题,一个 P99 延迟 10 秒的系统,平均值可能只有 1 秒,看起来很美,但 1% 的用户在骂娘。一定要看 P95、P99。
第二个坑是告警阈值拍脑袋。延迟告警设多少合适?不能拍脑袋,要基于历史数据的 P99 来定,比如设成历史 P99 的 1.5 倍。而且要用滑动窗口,避免瞬时抖动误报。
第三个坑是忽略冷启动。模型刚加载完的第一批请求,延迟会明显偏高(显存预热、CUDA 编译等)。如果这部分数据混进统计,会污染指标。我的做法是启动后先跑一批预热请求,预热完成前的数据单独标记,不计入正式统计。
第四个坑是多模型共用一套指标。不同模型的延迟基线完全不同,混在一起看会互相干扰。一定要用model标签区分,分别设阈值。
6. 落地建议与扩展方向
6.1 从小处着手,别一上来就搞大而全
我见过太多团队一上来就想搞一套“全链路可观测平台”,结果做了三个月还没上线。我的建议是先做最小可用版本:先把 TTFT、TPOT、Token 数这三个核心指标采集起来,用 Grafana 出一个大盘,能看趋势、能下钻。这三样东西加起来,一天就能搞定,但已经能解决 80% 的问题。
等这套跑顺了,再逐步加排队时间、成本归因、Trace 关联。可观测性是迭代出来的,不是设计出来的。
6.2 把可观测性接进 CI/CD
更进一步,可以把延迟和 Token 指标接进 CI/CD 流程。每次模型更新或引擎升级,自动跑一批基准测试,对比新旧版本的 TTFT、TPOT、Token 效率。如果新版本延迟劣化超过 10%,直接卡住发布。这样能把性能回归挡在上线之前,而不是等用户投诉了才发现。
6.3 面向未来的扩展
随着推理引擎越来越复杂(多模态、MoE、投机解码),可观测性的维度也会越来越多。比如 MoE 模型要监控专家激活分布,投机解码要监控接受率。但核心思路不变:把每次推理拆成可度量的小单元,追踪每个单元的成本和延迟。这套方法论,从最早的 Transformer 到现在,一直适用。
我在实际项目里最大的体会是:可观测性不是“锦上添花”,而是“雪中送炭”。当你半夜被叫醒排查问题,当老板问你为什么成本涨了,当用户投诉“太慢了”,一套好的可观测性体系能让你在十分钟内给出答案,而不是花一整天去猜。Token 和延迟这两个维度,就是大模型推理的“任督二脉”,打通了,很多问题自然就清晰了。
最后分享一个小技巧:给每个请求的日志里加上completion_tokens / total_ms这个比值,也就是“每秒输出 Token 数”。这个值在正常情况下应该稳定在一个区间,一旦明显下降,说明引擎效率出了问题,比单纯看延迟更敏感。这个指标我用了两年,屡试不爽。