大模型推理服务的账单和性能问题,往往不是"模型不行",而是"看不见"。我见过太多团队在本地跑得好好的模型,一上生产就出现响应忽快忽慢、Token 消耗远超预算、GPU 利用率忽高忽低的情况,排查起来全靠猜。问题的根源在于:传统 APM 工具是为 HTTP 请求设计的,它能看到"这个接口花了 3 秒",但看不到"这 3 秒里,prefill 占了多少、decode 占了多少、KV Cache 命中率如何、输入输出各消耗了多少 Token"。大模型推理的可观测性,需要一套完全不同的埋点思路和指标体系。
这篇文章围绕"追踪每一次推理的 Token 消耗与延迟"这个核心目标展开,把我在实际项目中搭建推理可观测性体系的完整思路拆开来讲。从指标怎么设计、埋点埋在哪里、数据怎么采集和存储,到怎么用这些数据定位性能瓶颈、控制成本、做容量规划,都会给出可落地的方案和代码示例。不管你是用 vLLM、TGI 还是自己写的推理服务,这套方法论都能直接套用。适合正在做大模型部署、推理优化、成本控制的工程师和运维同学参考。
1. 为什么传统监控在大模型推理场景下会失效
1.1 从一次"诡异"的延迟抖动说起
之前接手过一个线上推理服务,用户反馈"有时候快有时候慢,但监控面板上 P99 延迟看起来还行"。打开传统的接口监控,确实只能看到一个总的请求耗时曲线,平均值 1.2 秒,P99 在 4 秒左右,看起来没有明显异常。但用户的实际体感是:同样的问题,有时候 0.5 秒就回来了,有时候要等七八秒。
后来我们在推理引擎内部加了细粒度埋点才发现问题:这个服务的请求输入长度差异极大,短的几十个 Token,长的能到 8000 多 Token。传统监控把这两种请求混在一起统计,平均值自然被拉平了。真正的问题在于,长输入请求触发了 prefill 阶段的排队,而排队时间没有被单独度量,全部被算进了"总延迟"里。更关键的是,长输入请求的 Token 消耗是短请求的上百倍,但监控里完全看不出来——因为传统 APM 根本不认识 Token 这个维度。
这就是大模型推理监控的第一个核心矛盾:请求的"重量"差异巨大,但传统监控把所有请求当成等价的。一个 50 Token 的请求和一个 8000 Token 的请求,在 HTTP 层面看起来都是"一次请求",但在 GPU 层面,它们的计算量、显存占用、耗时可能差两个数量级。
1.2 大模型推理的三个阶段,各有各的瓶颈
要做好可观测性,首先得理解推理过程到底分几个阶段。一个典型的大模型推理请求,在引擎内部会经历三个阶段:
排队阶段(Queueing):请求到达后,如果 GPU 正在处理其他请求,就需要排队等待。这个阶段的耗时取决于当前并发量、批处理策略(continuous batching 还是 static batching)、以及调度器的实现。vLLM 用的是 continuous batching,新请求可以在当前 batch 的某个序列生成结束后立即插入,所以排队时间通常比 static batching 短,但在高并发下依然会显著增长。
预填充阶段(Prefill):把输入的 prompt 一次性喂给模型,计算出所有输入 Token 的 KV Cache。这个阶段的耗时和输入长度基本成正比,而且计算密集,GPU 利用率会拉满。对于长输入请求,prefill 可能是整个推理过程中最耗时的部分。
解码阶段(Decode):逐个生成输出 Token,每生成一个 Token 都要做一次前向计算。这个阶段是显存带宽密集型的,因为每步都要读取整个 KV Cache。输出越长,decode 阶段耗时越长。
这三个阶段的瓶颈完全不同:排队看调度策略,prefill 看算力,decode 看显存带宽。如果不分开度量,你根本不知道延迟到底卡在哪一环。我见过一个案例,团队一直以为是 GPU 算力不够,疯狂加卡,结果加了卡之后延迟没降多少——因为真正的瓶颈是调度器的排队策略不合理,长请求把短请求堵死了。
1.3 Token 消耗:被忽视的成本黑洞
延迟问题看得见,Token 消耗问题往往是"账单来了才发现"。大模型推理的成本直接和 Token 数量挂钩,但很多团队在服务上线初期根本没有按 Token 维度做统计。
我遇到过最夸张的一个案例:某个内部工具接了大模型 API,做文档摘要。上线一个月后账单暴涨,排查发现是前端有个 bug,在某些情况下会把同一份文档重复提交十几次。因为没有任何 Token 维度的监控,这个问题拖了整整一个月才被发现。
即使是用自建推理服务,Token 消耗也直接关系到 GPU 资源的消耗。输入 Token 决定 prefill 的计算量,输出 Token 决定 decode 的步数,两者共同决定了这个请求占用了多少 GPU 时间。如果你能精确追踪每个请求、每个用户、每个业务线的 Token 消耗,就能做精细化的成本分摊和容量规划。
提示:Token 统计一定要区分 input_tokens 和 output_tokens,因为它们的成本模型完全不同。输入 Token 影响 prefill 耗时,输出 Token 影响 decode 耗时,在计费和优化时不能混为一谈。
2. 推理可观测性的指标体系该怎么设计
2.1 四层指标体系:从请求到 GPU
设计指标体系的核心原则是:每一层都要能回答一个具体的排查问题。我把推理可观测性分成四层,每层解决不同的问题。
| 层级 | 核心指标 | 回答的问题 |
|---|---|---|
| 请求层 | 请求总数、成功率、端到端延迟、TTFT、TPOT | 用户体验怎么样?有没有报错? |
| Token 层 | input_tokens、output_tokens、总 Token 数、Token 吞吐率 | 成本花在哪?效率高不高? |
| 引擎层 | 排队时长、prefill 时长、decode 时长、batch size、KV Cache 命中率 | 延迟卡在哪个阶段? |
| 资源层 | GPU 利用率、显存占用、显存带宽、功耗 | 硬件是不是瓶颈? |
这四层不是孤立的,而是可以下钻关联的。比如请求层发现 P99 延迟升高,下钻到引擎层发现是排队时长增加,再下钻到资源层发现 GPU 显存快满了导致 batch size 上不去。这种逐层下钻的能力,才是可观测性的真正价值。
2.2 TTFT 和 TPOT:大模型推理的两个黄金指标
在请求层,有两个指标比端到端延迟更有诊断价值:TTFT(Time To First Token,首 Token 延迟)和TPOT(Time Per Output Token,每输出 Token 耗时)。
TTFT 衡量的是从请求发出到收到第一个 Token 的时间,它主要反映了排队时长加 prefill 时长。对于流式输出的场景,用户感知到的"响应快不快",很大程度上取决于 TTFT。TTFT 高,说明要么在排队,要么 prefill 太慢(输入太长或算力不足)。
TPOT 衡量的是生成阶段每个 Token 的平均耗时,它主要反映 decode 阶段的效率。TPOT 高,说明显存带宽可能是瓶颈,或者 batch size 太大导致单步计算变慢。对于长文本生成场景,TPOT 直接决定了用户要等多久才能看到完整回答。
这两个指标分开看,能快速定位问题方向。我一般会这样判断:
- TTFT 高但 TPOT 正常:问题在排队或 prefill,检查并发量和输入长度分布。
- TTFT 正常但 TPOT 高:问题在 decode,检查显存带宽和 batch 配置。
- 两个都高:可能是整体资源不足,或者请求分布发生了剧烈变化。
2.3 分位数比平均值重要一百倍
大模型推理的延迟分布是典型的长尾分布,平均值几乎没有参考价值。一个服务可能平均延迟 1 秒,但 P99 是 15 秒——那 1% 的用户体验极差,而平均值完全掩盖了这个问题。
所以指标采集必须记录分位数,至少要有 P50、P90、P95、P99。在 Prometheus 里,用 Histogram 类型来记录延迟分布,查询时用 histogram_quantile 函数计算分位数。分桶的边界要根据实际延迟范围来设置,我一般会用这样的桶:0.05、0.1、0.25、0.5、1、2.5、5、10、30、60 秒,覆盖从快到慢的完整范围。
注意:分位数是有时间窗口的,P99 在低流量时段可能波动很大。建议同时看多个时间窗口(如 5 分钟和 1 小时),避免被瞬时抖动误导。
2.4 按维度切分:让指标能回答"是谁的问题"
光有全局指标还不够,必须能按维度切分。至少要有这几个维度:
- 模型维度:不同模型的延迟和 Token 消耗差异巨大,必须分开统计。
- 用户/租户维度:多租户场景下,要能定位是哪个用户在消耗资源。
- 请求类型维度:对话、摘要、翻译等不同任务的输入输出长度分布完全不同。
- 输入长度分桶:把请求按输入长度分成几档(如 0-128、128-512、512-2048、2048+),分别统计延迟。
这些维度组合起来,才能回答"是哪个模型的哪类请求在什么输入长度下出现了延迟升高"这种具体问题。没有维度切分的监控,就像没有分类的账本,只能看到总数,看不到结构。
3. 埋点实操:在推理引擎的哪些位置打点
3.1 请求入口:记录原始请求信息
埋点的第一站是请求入口,也就是请求刚到达服务、还没进入推理引擎的时候。这里要记录的信息包括:请求 ID、时间戳、模型名称、输入 Token 数(如果能在入口就算出来的话)、用户标识、请求类型等。
输入 Token 数的计算需要注意:不同模型的 tokenizer 不一样,必须在入口处用对应模型的 tokenizer 来算。如果入口处拿不到 tokenizer,也可以先记录原始文本长度,后续在引擎内部再补算精确的 Token 数。但精确的 Token 数对成本统计很重要,建议尽量在入口就算准。
import time import uuid from prometheus_client import Histogram, Counter # 定义指标 REQUEST_LATENCY = Histogram( 'llm_request_latency_seconds', 'End-to-end request latency', ['model', 'request_type'], buckets=[0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60] ) TTFT = Histogram( 'llm_ttft_seconds', 'Time to first token', ['model'], buckets=[0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10] ) TOKEN_COUNTER = Counter( 'llm_tokens_total', 'Total tokens processed', ['model', 'token_type', 'user_id'] # token_type: input/output ) def handle_request(request): request_id = str(uuid.uuid4()) start_time = time.time() # 计算输入 Token 数 input_tokens = count_tokens(request.prompt, request.model) TOKEN_COUNTER.labels( model=request.model, token_type='input', user_id=request.user_id ).inc(input_tokens) # 记录请求元信息,用于后续关联 request_context = { 'request_id': request_id, 'start_time': start_time, 'input_tokens': input_tokens, 'model': request.model, 'user_id': request.user_id } return request_context这段代码的关键点是:请求 ID 要贯穿整个推理过程,后续所有埋点都带上这个 ID,这样才能把一次请求在各个阶段的耗时和 Token 数关联起来。
3.2 引擎内部:拆解排队、prefill、decode
引擎内部的埋点是整个体系的核心,也是最难做的部分,因为需要修改或扩展推理引擎的代码。以 vLLM 为例,它的调度器和执行器内部有明确的阶段划分,可以在这些位置插入埋点。
如果不想改引擎源码,一个折中方案是利用引擎暴露的 metrics 接口。vLLM 本身就暴露了 Prometheus 格式的指标,包括vllm:time_to_first_token_seconds、vllm:time_per_output_token_seconds、vllm:num_requests_running、vllm:num_requests_waiting等。这些指标虽然粒度不如自己埋点细,但胜在开箱即用,适合快速搭建基础监控。
如果要自己埋点,关键是在这几个位置打时间戳:
# 伪代码,展示埋点位置 class InstrumentedScheduler: def schedule(self, request): # 埋点 1:请求进入调度队列 request.queue_start = time.time() metrics.record_queue_start(request.id) def execute_prefill(self, batch): # 埋点 2:prefill 开始 prefill_start = time.time() result = self.model.prefill(batch) # 埋点 3:prefill 结束 prefill_duration = time.time() - prefill_start for req in batch.requests: metrics.record_prefill(req.id, prefill_duration) return result def execute_decode(self, batch): # 埋点 4:每个 decode step step_start = time.time() output = self.model.decode(batch) step_duration = time.time() - step_start for req in batch.requests: metrics.record_decode_step(req.id, step_duration) return output这里有个实操中的坑:prefill 和 decode 在 continuous batching 下是混在一起的。一个 batch 里可能既有正在 prefill 的新请求,又有正在 decode 的老请求。所以不能简单地按 batch 来记录 prefill 和 decode 时长,而要按请求来记录。vLLM 内部会把每个请求的状态标记清楚,埋点时要跟着请求的状态走,而不是跟着 batch 走。
3.3 输出阶段:统计输出 Token 和完成时间
输出阶段的埋点相对简单,主要是统计输出 Token 数和请求完成时间。但有一个细节要注意:流式输出场景下,输出 Token 是逐个产生的,要在每个 Token 产生时记录时间戳,这样才能算出 TPOT。
def stream_output(request_context, output_stream): output_tokens = 0 first_token_time = None token_times = [] for token in output_stream: now = time.time() if first_token_time is None: first_token_time = now # 记录 TTFT TTFT.labels(model=request_context['model']).observe( now - request_context['start_time'] ) token_times.append(now) output_tokens += 1 yield token # 请求完成 end_time = time.time() REQUEST_LATENCY.labels( model=request_context['model'], request_type=request_context.get('request_type', 'unknown') ).observe(end_time - request_context['start_time']) # 统计输出 Token TOKEN_COUNTER.labels( model=request_context['model'], token_type='output', user_id=request_context['user_id'] ).inc(output_tokens) # 计算 TPOT if output_tokens > 1: tpot = (token_times[-1] - first_token_time) / (output_tokens - 1) TPOT.labels(model=request_context['model']).observe(tpot)TPOT 的计算要注意分母是output_tokens - 1,因为第一个 Token 的时间已经算在 TTFT 里了,从第二个 Token 开始才是真正的 decode 间隔。
3.4 埋点的性能开销:别让监控拖慢推理
埋点本身是有开销的,尤其是高频的 decode step 埋点。如果每个 Token 都做一次 Prometheus 的 observe 操作,在高并发下可能成为瓶颈。我实测过,在 QPS 500、平均输出 200 Token 的场景下,如果每个 Token 都同步写 Prometheus,会额外增加 3%-5% 的 CPU 开销。
优化方案有几个:
- 批量聚合:在内存里先聚合,每隔 100ms 或每 100 个 Token 批量 flush 一次。
- 异步写入:把指标写入放到单独的线程或协程里,不阻塞推理主流程。
- 采样:对高频指标做采样,比如每 10 个请求采样 1 个做细粒度埋点,其余只记录粗粒度指标。
提示:埋点开销一定要实测。我的经验是,埋点带来的额外延迟不应超过总延迟的 2%,否则就得不偿失。如果超过,优先考虑采样或异步方案。
4. 数据采集、存储与可视化链路搭建
4.1 Prometheus + Grafana:最省事的起步方案
如果不想自己造轮子,Prometheus + Grafana 是最成熟的方案。推理服务暴露/metrics接口,Prometheus 定时抓取,Grafana 做可视化。这套方案的优势是生态成熟、查询语言强大、告警配置方便。
配置上,关键是设计好指标的 label 维度。label 太多会导致时间序列爆炸,label 太少又没法下钻。我的经验是:模型名、请求类型、用户 ID 这三个 label 是必须的,但用户 ID 如果量很大(比如上万用户),建议做哈希分桶或者只保留 top N 用户,避免时间序列数量失控。
# prometheus.yml 抓取配置 scrape_configs: - job_name: 'llm-inference' scrape_interval: 15s static_configs: - targets: ['inference-service:8000'] metrics_path: '/metrics'Grafana 面板建议至少包含这几个图:请求量和成功率、TTFT 和 TPOT 的分位数曲线、Token 消耗速率(按输入输出分开)、排队请求数、GPU 利用率和显存占用。这几个图放在一起,基本能覆盖 80% 的日常排查场景。
4.2 高基数问题的处理:当用户 ID 成为负担
Prometheus 最大的坑就是高基数(high cardinality)。如果把用户 ID 作为 label,每个用户都会产生一组独立的时间序列,用户一多,Prometheus 的内存和存储就会爆炸。我见过一个案例,因为把 request_id 当成了 label,导致 Prometheus 内存直接打满。
处理高基数有几个策略:
- 用户 ID 分桶:把用户按哈希分成 100 个桶,label 用桶编号而不是原始 ID。
- 分离存储:高频指标(如全局延迟)放 Prometheus,低频但需要精确归因的数据(如每个用户的 Token 消耗)放 ClickHouse 或 TDengine 这类列式数据库。
- 预聚合:在服务端先按维度聚合,只把聚合结果暴露给 Prometheus。
对于 Token 消耗这种需要精确归因到用户的数据,我强烈建议用 ClickHouse 单独存。每次请求完成后,把 request_id、user_id、model、input_tokens、output_tokens、各阶段耗时等字段写一条记录进去。ClickHouse 的写入吞吐和聚合查询性能都很强,适合这种场景。
-- ClickHouse 建表 CREATE TABLE llm_inference_log ( request_id String, user_id String, model String, request_type String, input_tokens UInt32, output_tokens UInt32, queue_duration_ms Float32, prefill_duration_ms Float32, decode_duration_ms Float32, ttft_ms Float32, tpot_ms Float32, total_duration_ms Float32, created_at DateTime DEFAULT now() ) ENGINE = MergeTree() ORDER BY (created_at, model, user_id);有了这张表,就能做各种灵活的聚合查询,比如"查某个用户过去 7 天的 Token 消耗趋势"、"查某个模型在不同输入长度下的平均 TTFT",这些在 Prometheus 里做起来很别扭的查询,在 ClickHouse 里就是一句 SQL。
4.3 链路追踪:把一次请求的完整生命周期串起来
指标能告诉你"哪里慢了",但有时候你需要知道"这一次具体的请求到底经历了什么"。这时候就需要链路追踪(Tracing)。用 OpenTelemetry 给每个请求生成一个 trace,把排队、prefill、decode 各个阶段作为 span 记录下来,就能在 Jaeger 或 Tempo 里看到完整的调用链。
from opentelemetry import trace tracer = trace.get_tracer("llm-inference") def process_request(request): with tracer.start_as_current_span("llm_request") as span: span.set_attribute("model", request.model) span.set_attribute("input_tokens", request.input_tokens) with tracer.start_as_current_span("queue") as queue_span: queue_span.set_attribute("queue_position", get_queue_position()) wait_in_queue() with tracer.start_as_current_span("prefill") as prefill_span: prefill_span.set_attribute("prefill_tokens", request.input_tokens) do_prefill() with tracer.start_as_current_span("decode") as decode_span: output = do_decode() decode_span.set_attribute("output_tokens", len(output)) span.set_attribute("total_tokens", request.input_tokens + len(output))链路追踪的采样率要控制好,全量采集在高 QPS 下开销很大。一般用尾部采样(tail sampling),只保留慢请求和错误请求的完整 trace,正常请求只保留指标。
4.4 告警规则:什么时候该叫人起来
监控的最终目的是告警。但告警规则设计不好,要么天天误报让人麻木,要么真出事了不响。我的经验是,告警要分级别,并且基于"用户可感知的问题"而不是"内部指标异常"。
| 告警级别 | 触发条件 | 响应方式 |
|---|---|---|
| P0 | 成功率低于 95% 持续 2 分钟 | 立即电话 |
| P1 | P99 TTFT 超过 5 秒持续 5 分钟 | 即时消息 |
| P2 | Token 消耗速率超过预算 150% | 工单 |
| P3 | GPU 利用率持续低于 20% | 日报 |
注意 P0 和 P1 的区别:成功率是硬指标,掉了就是用户用不了;TTFT 升高是体验问题,可能还能忍。分级的好处是避免所有问题都触发最高级别告警,导致真正的紧急问题被淹没。
5. 用可观测性数据定位真实性能瓶颈
5.1 案例一:长输入请求拖垮短请求
回到开头那个"延迟忽快忽慢"的案例。加了细粒度埋点后,我们把请求按输入长度分桶,画出了不同桶的 TTFT 曲线。结果非常清晰:输入长度小于 512 的请求,TTFT 稳定在 200ms 左右;输入长度大于 2048 的请求,TTFT 在 3-8 秒之间剧烈波动。
进一步看排队时长,发现长请求的排队时长占了 TTFT 的 80% 以上。原因是 vLLM 的调度器在 batch 快满时,会优先让已经在跑的请求继续 decode,新来的长请求要等更久才能插入。而长请求的 prefill 又特别占时间,一旦开始 prefill,又会阻塞其他请求。
解决方案有两个方向:一是给长请求单独开一个推理实例,做请求隔离;二是调整调度参数,比如限制单个 batch 的最大 prefill Token 数,避免一个长请求独占整个 batch。我们最后选了方案一,因为隔离最彻底,虽然成本高一点,但短请求的 TTFT 直接降到了 150ms 以内,用户体验提升明显。
5.2 案例二:Token 消耗异常增长的排查过程
另一个案例是 Token 消耗突然增长。某天早上,Token 消耗速率曲线突然翘头,比平时高了 3 倍。因为有了按用户维度的 Token 统计,我们很快定位到是某个内部服务在疯狂调用。
下钻到请求类型,发现全是同一个 prompt 模板的请求,输入长度都在 3000 Token 左右。再查这个服务的日志,发现它在一个循环里调用推理接口,而且没有做结果缓存。原来是一个定时任务出了 bug,本该每天跑一次的任务变成了每分钟跑一次。
这个问题的排查过程只花了 15 分钟,完全归功于 Token 维度的监控。如果没有这套体系,可能要等到月底账单出来才发现,损失就大了。
5.3 案例三:GPU 利用率高但吞吐上不去
还有一个反直觉的案例:GPU 利用率显示 95%,看起来已经跑满了,但吞吐量就是上不去。按常理,GPU 利用率高说明算力用满了,吞吐应该也高才对。
细看指标才发现问题:GPU 利用率高,但显存带宽利用率只有 40%,而且 decode 阶段的 TPOT 明显偏高。这说明 GPU 大部分时间在等显存,而不是在算。进一步查 batch size,发现 batch 被限制得很小,因为显存不够——KV Cache 占用了大量显存,留给 batch 的空间不多。
解决方案是启用 KV Cache 量化,把 KV Cache 从 FP16 降到 INT8,显存占用直接减半,batch size 翻倍,吞吐量提升了 60%。这个案例说明,GPU 利用率是个会骗人的指标,必须结合显存带宽、batch size、TPOT 一起看,才能判断真正的瓶颈。
5.4 从指标到行动:建立排查决策树
积累了一定经验后,可以把排查思路固化成决策树,让团队里任何人都能快速定位问题:
- 先看成功率:掉了就是服务问题,查错误日志。
- 成功率正常,看 TTFT:高了查排队和 prefill,看并发量和输入长度分布。
- TTFT 正常,看 TPOT:高了查 decode,看显存带宽和 batch 配置。
- 都正常,看 Token 消耗:异常增长查调用方,异常下降查是否有请求被丢弃。
- 都正常但用户仍反馈慢:查网络和客户端,可能是端到端链路上其他环节的问题。
这棵决策树不是万能的,但能覆盖大部分常见问题,大大缩短排查时间。
6. 成本归因与容量规划:让可观测性产生业务价值
6.1 按业务线分摊 Token 成本
可观测性数据最有价值的应用之一,是把 Token 成本精确分摊到各个业务线。做法很简单:在请求入口记录业务线标识,然后按业务线聚合 Token 消耗,乘以单价,就是每个业务线的推理成本。
这件事的意义在于,它把"技术成本"变成了"业务成本",让业务方有动力去优化自己的调用方式。我见过一个团队,做了成本分摊之后,各个业务线主动来问"怎么减少 Token 消耗",因为他们要为自己的成本负责。优化手段包括:精简 prompt、做结果缓存、调整输出长度限制等,整体成本降了 30% 多。
6.2 用历史数据做容量规划
容量规划的核心问题是:当前资源能支撑多少 QPS,什么时候需要扩容。有了历史指标数据,这个问题就能量化回答。
具体做法是:统计不同负载下的 TTFT 和 TPOT 变化曲线,找到"延迟开始明显上升"的拐点,这个拐点对应的 QPS 就是当前配置的容量上限。然后根据业务增长趋势,预测什么时候会达到这个上限,提前扩容。
# 简化的容量预测逻辑 def predict_capacity(history_data, target_growth_rate): # 找到延迟拐点 qps_points = sorted(history_data, key=lambda x: x['qps']) capacity_limit = None for point in qps_points: if point['p99_ttft'] > 2.0: # TTFT 超过 2 秒视为拐点 capacity_limit = point['qps'] break # 预测达到上限的时间 current_qps = qps_points[-1]['qps'] days_to_limit = 0 while current_qps < capacity_limit: current_qps *= (1 + target_growth_rate / 365) days_to_limit += 1 return capacity_limit, days_to_limit这个逻辑虽然简化,但思路是对的:用延迟拐点定义容量,用增长趋势预测扩容时间。比拍脑袋定"再加两张卡"科学得多。
6.3 识别浪费:那些被忽视的低效调用
可观测性数据还能帮你发现浪费。常见的浪费模式有几种:
- 超长输出:有些请求的输出长度远超实际需要,比如用户只要一句话摘要,模型却生成了 500 字。通过统计输出长度分布,可以设置合理的 max_tokens 限制。
- 重复请求:相同或相似的 prompt 被反复调用,可以通过缓存来避免。统计 prompt 的哈希分布,能发现重复率。
- 低效 prompt:有些 prompt 的输入 Token 很多,但实际有效信息很少,比如塞了大量无关的上下文。通过分析输入 Token 和输出质量的关系,可以优化 prompt 设计。
这些优化不需要改模型,只需要改调用方式,但效果往往立竿见影。我做过一个统计,在一个典型业务里,通过识别和消除这三类浪费,Token 消耗能降低 20%-40%。
6.4 建立成本预算和告警机制
最后一步是把成本控制变成常态化的机制。给每个业务线设置 Token 预算,当消耗速率超过预算时触发告警。预算可以按月设置,也可以按日设置,看业务特点。
告警的阈值设置有讲究:太松了没意义,太紧了天天误报。我的经验是,用过去 30 天的日均消耗作为基线,预算设为基线的 120%,告警阈值设为预算的 90%。这样既能提前预警,又不会因为正常的日常波动而误报。
提示:成本告警一定要区分"增长"和"异常增长"。业务正常增长导致的消耗上升是好事,不应该告警;只有突发的、无对应业务增长的消耗上升才需要告警。可以在告警规则里加入业务指标的关联判断,比如"Token 消耗增长 50% 且订单量没有增长"才触发告警。
7. 落地这套体系时踩过的坑
7.1 埋点位置选错,数据全废
最开始做埋点时,我把 prefill 和 decode 的计时放在了 batch 层面,而不是请求层面。结果数据出来后发现,所有请求的 prefill 时长都一样——因为它们是同一个 batch,共享同一个 prefill 时间。但实际每个请求的 prefill 计算量是不同的,长请求的 prefill 应该更久。
这个坑的教训是:埋点必须跟着请求走,而不是跟着 batch 走。在 continuous batching 下,一个 batch 里的请求状态各不相同,必须按请求维度记录。后来改成在每个请求的状态转换时打时间戳,数据才准确。
7.2 时间戳的精度和时钟问题
另一个坑是时间戳精度。Python 的time.time()精度在毫秒级,对于 TTFT 这种可能只有几十毫秒的指标来说,精度不够。后来改用time.perf_counter(),它是单调时钟,精度到微秒级,而且不受系统时间调整的影响。
还有一个分布式场景下的时钟同步问题。如果推理服务和埋点采集服务在不同机器上,机器之间的时钟偏差会导致数据错乱。解决方案是用请求 ID 关联,而不是依赖时间戳排序;或者用 NTP 做时钟同步,把偏差控制在毫秒级以内。
7.3 指标爆炸导致 Prometheus 崩溃
前面提过高基数问题,这里再强调一次。我踩过的具体坑是:把request_id作为 label 加到了指标里。结果每个请求都产生一组独立的时间序列,Prometheus 内存几小时内就涨到了几十 GB,最后直接 OOM。
修复方案是把request_id从 label 里去掉,只保留在日志和 ClickHouse 里。Prometheus 的 label 只保留低基数的维度:模型名、请求类型、状态码。高基数的归因数据全部走 ClickHouse。
7.4 采样策略不当,关键数据丢失
为了降低开销,我们一度对所有指标做了 10% 采样。结果出问题的时候,发现慢请求的 trace 全都没被采样到——因为慢请求本来就是少数,10% 采样后几乎全丢了。
后来改成尾部采样:正常请求采样 1%,慢请求(P99 以上)和错误请求 100% 采集。这样既控制了开销,又保证了关键数据不丢。尾部采样的实现需要在请求完成后才能决定是否采集,所以要在内存里先缓存 trace 数据,请求结束后再决定是否上报。
7.5 监控面板太多,没人看
最后一个坑是"监控面板泛滥"。一开始大家很兴奋,给每个指标都做了面板,结果几十个面板没人看得过来。真正出问题的时候,反而不知道该看哪个。
后来我们做了精简,只保留三个核心面板:总览面板(成功率、QPS、TTFT/TPOT 分位数、Token 消耗速率)、性能排查面板(排队/prefill/decode 分解、按输入长度分桶的延迟)、成本面板(按业务线和用户的 Token 消耗)。每个面板都有明确的用途,出问题时按决策树依次查看,效率高很多。
监控这件事,少即是多。与其做一百个没人看的面板,不如做三个真正会被用起来的面板。
这套推理可观测性体系从零搭建到稳定运行,大概花了我们两个月时间,中间踩了不少坑,但收益是实打实的:延迟问题的平均排查时间从几小时缩短到十几分钟,Token 成本降低了 30% 多,容量规划从拍脑袋变成了数据驱动。如果你也在做推理服务,建议尽早把这套体系建起来,越早建,省下的排查时间和成本越多。