1. 大模型推理可观测性到底在解决什么问题
1.1 从一次线上告警说起
去年下半年,我负责维护的一个内部推理服务突然收到告警:P99 延迟从 1.8 秒飙到 11 秒,但 GPU 利用率、显存占用、请求 QPS 三个指标全都正常。运维同学第一反应是网络抖动,查了半小时没结果。最后把单次请求的输入 Token 数和输出 Token 数打出来对比,才发现问题:某个上游业务把系统提示词从 200 Token 悄悄改到了 3000 Token,导致 prefill 阶段计算量暴涨,而 decode 阶段因为输出很短,整体 QPS 没变化,GPU 利用率自然看不出异常。
这件事让我彻底意识到一个问题:传统服务的可观测性三件套(Metrics、Logging、Tracing)直接套到大模型推理上是不够用的。CPU 密集型服务的瓶颈在指令数,而大模型推理的瓶颈在 Token——输入多少 Token、输出多少 Token、每个 Token 花多少毫秒、KV Cache 命中多少,这些才是真正决定成本和延迟的变量。
所谓大模型日志与可观测性,说白了就是给每一次推理请求建立一份"体检报告":这次请求吃了多少 Token、首 Token 延迟(TTFT)多少、每 Token 输出间隔(TPOT)多少、总耗时多少、命中了多少缓存、花了多少钱。没有这份报告,你既不知道钱花在哪,也不知道延迟卡在哪。
1.2 谁需要这套东西
这套东西不是只有大厂才需要。我梳理了一下,下面几类角色都绕不开:
- 推理服务开发者:要定位延迟毛刺、优化吞吐,必须知道时间花在 prefill 还是 decode。
- 平台/运维同学:要按业务线分摊 GPU 成本,必须按 Token 用量计费。
- 算法/微调同学:要评估微调后模型是否"变啰嗦"了,必须对比微调前后的输出 Token 分布。
- 业务方:要知道一次对话大概烧多少钱,才能决定要不要上大模型。
我见过太多团队,模型部署起来了,接口也能调通,但一问"你们一次请求平均多少 Token、P99 延迟多少",没人答得上来。这种状态下做容量规划和成本控制,基本靠拍脑袋。
1.3 核心指标先定义清楚
在动手之前,必须把指标口径统一,否则后面数据全是乱的。我按重要性排了个序:
| 指标 | 英文/缩写 | 含义 | 为什么重要 |
|---|---|---|---|
| 首 Token 延迟 | TTFT | 从请求发出到第一个 Token 返回的时间 | 直接决定用户"卡不卡"的体感 |
| 单 Token 输出间隔 | TPOT / ITL | decode 阶段平均每个 Token 的生成时间 | 决定输出"流不流畅" |
| 输入 Token 数 | prompt_tokens | 本次请求的输入长度 | 决定 prefill 计算量和成本 |
| 输出 Token 数 | completion_tokens | 本次生成的输出长度 | 决定 decode 总时长和成本 |
| 总 Token 数 | total_tokens | 输入+输出 | 计费基准 |
| 端到端延迟 | E2E Latency | 整个请求的总耗时 | 用户感知的总时间 |
| 吞吐 | Throughput | 每秒处理的 Token 数/请求数 | 决定要几张卡 |
| KV Cache 命中率 | cache hit rate | 前缀缓存复用比例 | 直接影响 TTFT 和成本 |
提示:TTFT 和 TPOT 一定要分开统计。很多团队只统计端到端延迟,结果优化时完全不知道该动 prefill 还是 decode,这是最常见的坑。
2. 日志埋点方案怎么设计才不返工
2.1 埋点位置的选择逻辑
埋点位置决定了你能拿到什么数据。我一般分三层埋:
第一层:网关层(Gateway)。在请求进入推理服务之前记录请求 ID、用户 ID、业务线、时间戳、原始 prompt 长度。这一层的好处是能拿到"用户视角"的延迟,包括排队时间。很多团队漏掉排队时间,导致明明服务内部很快,用户却觉得很慢。
第二层:推理引擎层。这是核心。以 vLLM 为例,它本身提供了丰富的 metrics,通过/metrics端点暴露 Prometheus 格式的数据。关键指标包括vllm:time_to_first_token_seconds、vllm:time_per_output_token_seconds、vllm:num_requests_running、vllm:gpu_cache_usage_perc等。如果你用的是别的推理引擎,思路一样,找它暴露的 metrics 端点。
第三层:应用层。在业务代码里记录这次请求的业务语义,比如是哪个功能模块、用户是否点了"重新生成"、是否命中了缓存。这一层是纯业务数据,推理引擎给不了。
三层数据靠一个全局唯一的request_id串起来。这个 ID 必须在网关层生成,然后一路透传到推理引擎和业务层。
2.2 为什么不用纯日志而要用指标+日志组合
我踩过一个坑:一开始所有数据都往日志里打,结果一天几百万条日志,ES 集群直接扛不住,查询还慢。后来改成"指标走 Prometheus,明细走日志"的组合:
- 高频、聚合类数据(QPS、延迟分位数、Token 总量)走 Prometheus,采样成本低,聚合查询快。
- 低频、明细类数据(单次请求的完整 Token 明细、异常请求的完整上下文)走日志,用于事后排查。
这个分工的逻辑是:你不可能对每一次请求都做全量明细存储,成本扛不住;但聚合指标又无法回答"这次异常请求到底发生了什么"。所以两者必须配合。
2.3 采样策略:全量还是抽样
这里有个反直觉的结论:Token 用量和延迟的聚合指标必须全量统计,但明细日志可以抽样。
原因很简单:Token 用量是计费依据,少统计一次就少收一次钱,必须全量。而明细日志主要用于排查,抽样 10% 已经足够发现规律性问题。我的做法是:
- 聚合指标:100% 全量上报。
- 明细日志:正常请求采样 5%~10%,异常请求(延迟超阈值、报错)100% 全量记录。
这样既控制了存储成本,又保证了排查时不会"恰好没采到"。
3. 核心指标采集的实操细节
3.1 TTFT 和 TPOT 到底怎么算
这两个指标看着简单,实际算起来有讲究。TTFT 是"请求发出到第一个 Token 到达",但"请求发出"这个时间点从哪算?从网关收到请求算,还是从推理引擎开始计算算?
我的建议是两个都算,分别叫"用户侧 TTFT"和"引擎侧 TTFT"。两者之差就是排队和调度时间。如果用户侧 TTFT 远大于引擎侧,说明瓶颈在排队,该扩容了;如果两者接近但都很大,说明瓶颈在 prefill 计算,该优化模型或加算力了。
TPOT 的计算公式是:
TPOT = (E2E Latency - TTFT) / (completion_tokens - 1)注意分母是completion_tokens - 1,因为第一个 Token 的时间已经算在 TTFT 里了。这个细节很多人搞错,导致 TPOT 偏小。
3.2 用代码把埋点串起来
下面是我实际项目里用的一段 Python 埋点代码,基于 OpenAI 兼容接口,思路通用:
import time import uuid from prometheus_client import Histogram, Counter TTFT_HIST = Histogram('llm_ttft_seconds', 'Time to first token', buckets=[0.1, 0.25, 0.5, 1, 2, 5, 10]) TPOT_HIST = Histogram('llm_tpot_seconds', 'Time per output token', buckets=[0.005, 0.01, 0.02, 0.05, 0.1, 0.5]) TOKEN_COUNTER = Counter('llm_tokens_total', 'Total tokens', ['type', 'biz']) def call_llm(prompt, biz_line): request_id = str(uuid.uuid4()) start = time.perf_counter() first_token_time = None output_tokens = 0 # 流式调用,逐 Token 计时 for chunk in stream_chat(prompt): if first_token_time is None: first_token_time = time.perf_counter() TTFT_HIST.observe(first_token_time - start) output_tokens += 1 end = time.perf_counter() if output_tokens > 1: tpot = (end - first_token_time) / (output_tokens - 1) TPOT_HIST.observe(tpot) TOKEN_COUNTER.labels(type='completion', biz=biz_line).inc(output_tokens) return request_id这段代码的关键点:用time.perf_counter()而不是time.time(),因为前者是单调时钟,不受系统时间调整影响;流式调用才能精确拿到 TTFT,非流式调用只能拿到总时间。
3.3 输入 Token 数怎么准确获取
输入 Token 数最好直接用推理引擎返回的usage.prompt_tokens,不要自己用 tokenizer 估算。原因有两个:一是不同模型的 tokenizer 不一样,自己算容易错;二是推理引擎返回的是实际参与计算的 Token 数,包含了 chat template 拼接后的系统提示词,这才是真实成本。
如果你用的是 vLLM,它会在响应的usage字段里返回准确的prompt_tokens和completion_tokens。如果引擎不返回,那就只能自己用对应模型的 tokenizer 算,但要记得把 chat template 也算进去。
注意:很多团队统计 Token 时只算用户输入的文本,漏掉了系统提示词和对话历史。一个多轮对话场景,系统提示词加历史可能有几千 Token,漏算会导致成本统计严重偏低。
4. 延迟归因:把毛刺拆到具体环节
4.1 延迟到底花在哪几个阶段
一次推理请求的延迟可以拆成这几段:
- 网络传输:请求从客户端到网关的时间。
- 排队等待:请求在推理引擎队列里等 GPU 的时间。
- Prefill 阶段:处理输入 Token,生成 KV Cache。
- Decode 阶段:逐个生成输出 Token。
- 回传:Token 流式返回客户端的时间。
大部分团队只统计了 3+4,忽略了 1、2、5。但实际排查中,排队时间(第 2 段)经常是延迟毛刺的元凶——当并发请求超过 GPU 处理能力时,请求会在队列里堆积,TTFT 暴涨。
4.2 用滑动窗口做延迟平滑
延迟数据天然抖动大,直接看瞬时值会被噪声干扰。我一般用滑动窗口做平滑,窗口大小取 1 分钟或 5 分钟。这里涉及一个"滑动窗口滤波器"的思路:对窗口内的延迟数据取分位数(P50、P95、P99),而不是平均值。
为什么不用平均值?因为平均值会被极端值拉偏,而且掩盖了长尾问题。用户体感差往往是因为 P99 高,而不是平均值高。一个服务平均延迟 500ms 但 P99 是 10 秒,用户体验就是"时不时卡一下",平均值完全反映不出来。
4.3 一个真实的延迟归因案例
回到开头那个案例,我最后是怎么定位的?把延迟按阶段拆开后发现:
| 阶段 | 正常请求 | 异常请求 |
|---|---|---|
| 排队 | 20ms | 25ms |
| Prefill | 180ms | 8500ms |
| Decode | 1200ms | 1300ms |
| 回传 | 50ms | 55ms |
Prefill 从 180ms 涨到 8500ms,其他阶段几乎没变。这就锁定了问题在输入 Token 数。再对比输入 Token 分布,发现异常请求的输入 Token 从平均 300 涨到了 3200。根因就是上游改了系统提示词。
如果没有分阶段埋点,只看端到端延迟,你永远不知道是哪个环节出了问题。
5. 成本核算与 Token 计费落地
5.1 按 Token 计费的实现思路
Token 计费的核心是"谁用了多少 Token"。实现上分两步:
第一步:采集。每次请求记录user_id、biz_line、prompt_tokens、completion_tokens。这些数据在推理引擎层就能拿到。
第二步:聚合。按用户、按业务线、按天做聚合,算出总 Token 数,再乘以单价。
这里有个细节:输入和输出的单价通常不一样(输出更贵,因为 decode 更耗算力)。所以计费时要分开算:
cost = prompt_tokens * input_price + completion_tokens * output_price5.2 缓存命中怎么算钱
现在很多推理引擎支持前缀缓存(Prefix Caching),命中的部分不需要重新计算 prefill,成本更低。但计费时要不要给用户打折?我的做法是:按实际计算量计费,缓存命中的部分按折扣价。
具体实现上,vLLM 会返回num_cached_tokens之类的字段,用prompt_tokens - cached_tokens作为实际计费的输入 Token 数。这样既公平,也能激励业务方复用相同的前缀(比如固定的系统提示词)。
5.3 成本异常告警
光统计不够,还要能告警。我设了几条规则:
- 单次请求 Token 数超过阈值(比如 8000)触发告警,防止有人误传超长文本。
- 单业务线日 Token 用量环比增长超过 50% 触发告警,防止异常调用。
- 单用户日 Token 用量超过配额触发限流。
这几条规则帮我拦下过好几次事故,最典型的一次是某个测试脚本忘了加循环退出条件,一晚上烧了几百万 Token。
6. 常见问题与排查技巧实录
6.1 排查速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| TTFT 高但 TPOT 正常 | 输入 Token 太多或排队严重 | 看 prompt_tokens 分布和队列长度 |
| TTFT 正常但 TPOT 高 | decode 阶段算力不足或 batch 太大 | 看 GPU 利用率和 batch size |
| 端到端延迟高但引擎指标正常 | 网络回传慢或客户端处理慢 | 看网关到客户端的耗时 |
| Token 统计和引擎对不上 | 漏算系统提示词或 chat template | 对比 usage 字段和自算值 |
| 延迟毛刺周期性出现 | 定时任务抢占资源或缓存失效 | 对齐毛刺时间和任务调度时间 |
| 缓存命中率突然下降 | 前缀被改动或缓存被清 | 检查系统提示词是否变更 |
6.2 几个我踩过的坑
坑一:用平均值看延迟。前面说过,平均值会掩盖长尾。我现在的习惯是 P50、P95、P99 一起看,任何一个异常都要查。
坑二:Token 统计口径不统一。网关层算一次、引擎层算一次、业务层又算一次,三个数对不上。后来统一规定:以推理引擎返回的 usage 为准,其他层只做透传,不做二次计算。
坑三:日志里打了完整 prompt。这既是隐私风险,也是存储灾难。后来改成只打 prompt 的哈希值和长度,需要排查时再根据 request_id 去受控的地方取原文。
坑四:忘了统计失败请求。失败的请求也消耗了资源(比如 prefill 算完了但 decode 报错),如果不统计,成本会漏算。现在我把失败请求也纳入 Token 统计,只是标记状态为 failed。
6.3 一个容易被忽略的指标:队列等待时间
很多推理引擎的 metrics 里没有直接暴露"排队时间",但你可以用当前时间 - 请求入队时间来算。这个指标在扩容决策上非常关键:如果排队时间持续大于 TTFT,说明瓶颈在并发能力,加卡比优化模型更有效。
我一般会设一条规则:排队时间 P95 超过 500ms 就触发扩容评估。这条规则比看 GPU 利用率靠谱得多,因为 GPU 利用率高不代表排队严重(可能 batch 打得好),利用率低也不代表不排队(可能调度有问题)。
7. 可视化看板怎么搭才有用
7.1 看板分层原则
看板不是指标越多越好,我一般分三层:
- 总览层:QPS、P99 延迟、Token 总量、成本。给管理层看,一眼知道健康度。
- 诊断层:TTFT/TPOT 分位数、排队时间、缓存命中率、各业务线 Token 分布。给开发和运维看,用于定位问题。
- 明细层:单请求的完整链路追踪。给排查具体问题时用。
三层之间可以下钻:总览发现异常,点进诊断层看哪个环节,再点进明细层看具体请求。
7.2 告警阈值怎么定
阈值不能拍脑袋,要基于历史数据。我的做法是:先跑两周收集基线,然后按 P99 的 1.5 倍设告警阈值。比如历史 P99 延迟是 2 秒,那告警阈值设 3 秒。这样既能捕捉异常,又不会因为正常波动频繁误报。
另外,告警要分级:P99 超过 1.5 倍发 warning,超过 3 倍发 critical。不同级别走不同的通知渠道,避免告警疲劳。
8. 我个人的几点实操体会
这套可观测性体系我从零搭到能用,前后迭代了三四个月。最大的体会是:不要一上来就追求大而全。我一开始想做一个覆盖所有指标的完美系统,结果拖了很久没上线。后来改成"先上 TTFT、TPOT、Token 数三个核心指标,能跑起来再逐步加",两周就上线了,后面再慢慢补。
第二个体会是:指标口径一定要在团队内对齐。我们曾经因为"延迟从哪算起"这个问题争论了一周,最后发现大家说的根本不是一回事。现在我们的做法是,所有指标定义写进文档,新人入职第一件事就是读这份文档。
第三个体会是:可观测性本身也有成本。埋点、存储、查询都要花钱。所以要有取舍,高频聚合指标全量,明细日志抽样,异常请求全量。这个平衡点需要根据业务量慢慢调。
最后一个建议:如果你现在还没做任何 Token 和延迟统计,别想着一步到位。先在最外层加一个简单的计时和 Token 计数,把数据跑起来,哪怕只是打到日志里。有了数据,你才知道下一步该优化什么。没有数据的时候,所有的优化都是猜。