☰
大模型推理可观测性实战:Token消耗与延迟监控方案
2026/10/5 4:21:58 网站建设 项目流程

1. 为什么大模型推理必须做可观测性

1.1 从一次线上事故说起

去年下半年,我负责的一个智能客服系统突然出现大面积超时。用户反馈“回答一半就断了”,运维那边只看到网关 504,日志里干干净净。排查了整整一个下午,最后定位到问题:某个上游业务把单次请求的max_tokens设成了 8192,而我们的推理服务默认上下文窗口只有 4096,模型在生成到第 4000 多个 token 时被硬截断,客户端等不到结束符,连接一直挂着直到超时。

这件事给我最大的教训不是参数配置,而是——我们对每一次推理到底消耗了多少 token、花了多少时间、卡在哪个阶段,几乎一无所知。日志里只有“请求进来”“请求出去”,中间那段黑盒完全靠猜。

大模型推理和传统 Web 接口有个本质区别:它的成本和时间都不是固定的。同样一句“帮我写个周报”,模型可能回 50 个 token,也可能回 2000 个 token;延迟可能 300ms,也可能 30s。你不测量,就永远不知道钱花在哪、慢在哪。这就是大模型可观测性要解决的核心问题:把每一次推理的 token 消耗与延迟拆开、量化、可追踪。

1.2 可观测性到底要观测什么

很多人一提到可观测性就想到“上监控大盘”,其实那是结果,不是起点。对推理服务来说,真正要抓的是三类信号:

  • Token 维度:输入 token 数(prompt tokens)、输出 token 数(completion tokens)、总 token 数、缓存命中 token 数。这是计费和容量规划的直接依据。
  • 延迟维度:首 token 延迟(TTFT,Time To First Token)、单 token 生成间隔(ITL/TPOT)、端到端总延迟(E2E)。这三个指标分别对应“用户觉得卡不卡”“吐字顺不顺”“整体等多久”。
  • 质量与异常维度:截断率、重试次数、空响应率、超时率、错误码分布。

这三类信号里,TTFT 和输出 token 数是重中之重。原因很直接:TTFT 决定了用户的第一印象,输出 token 数决定了你的账单。我见过太多团队只盯着 QPS 和平均延迟,结果平均延迟看着很漂亮,P99 却因为少数长输出请求炸穿,用户体验一塌糊涂。

1.3 适合谁来参考这套方案

这套东西不是只有大厂才需要。只要你满足下面任意一条,就值得认真做:

  • 自建或半自建推理服务(vLLM、LocalAI、Ollama 这类推理引擎);
  • 调用第三方大模型 API,但想搞清楚成本和性能瓶颈;
  • 做多模态或语音接入,对延迟敏感;
  • 团队里有人问过“这个月 token 怎么花了这么多”却答不上来。

下面我会按“设计思路 → 埋点细节 → 落地实现 → 排障”的顺序,把整套方案讲透,代码和配置都能直接抄。

2. 整体设计思路与方案选型

2.1 为什么不能只靠应用层日志

最省事的做法是在业务代码里print一下耗时和 token 数。我早期也这么干过,很快就发现三个致命问题。

第一,埋点位置不统一。有的请求走同步接口,有的走流式,有的经过网关转发,你不可能在每个调用点都手写一遍计时逻辑,漏一个就断链。

第二,流式响应没法简单计时。流式输出是一段一段吐出来的,你在函数入口记个开始时间、出口记个结束时间,只能拿到总延迟,拿不到 TTFT,而 TTFT 恰恰是流式场景最关键的指标。

第三,token 数拿不准。第三方 API 会在响应里返回 usage 字段,但自建推理引擎不一定默认返回;就算返回,流式模式下 usage 往往在最后一个 chunk 才给,中间过程你根本不知道已经生成了多少。

所以正确的思路是:把可观测性做成推理链路上的一个独立层,而不是散落在业务代码里的补丁。它应该像水管上的流量计,串在中间,自动记录,业务代码无感知。

2.2 分层架构:采集、聚合、展示

我最终采用的是三层结构,这也是目前业界比较通用的做法:

层级职责常用组件
采集层在推理请求生命周期内打点,生成原始指标OpenTelemetry SDK、自定义中间件
聚合层接收、存储、计算分位数Prometheus、ClickHouse
展示层大盘、告警、下钻查询Grafana、告警规则

采集层用OpenTelemetry(简称 OTel)是我强烈推荐的。原因很简单:它有一套标准的 Trace 和 Metric 语义约定,尤其是针对大模型场景,社区已经定义了gen_ai.*系列的属性名,比如gen_ai.usage.input_tokens、gen_ai.usage.output_tokens、gen_ai.request.model。你按这套标准埋点,将来换后端、换展示工具都不用重写。

聚合层我选 Prometheus 存指标、ClickHouse 存明细。Prometheus 擅长做聚合和告警,但它的高基数能力弱,你要是把每次请求的 request_id 当标签打进去,分分钟把内存打爆。所以明细数据(每次请求的 token、延迟、模型名)进 ClickHouse,聚合指标进 Prometheus,各司其职。

2.3 关键指标的定义与计算口径

口径不统一,数据就是垃圾。我踩过最典型的坑是:A 同学统计的“延迟”是从网关收到请求算起,B 同学是从模型开始推理算起,两个人对同一个接口报出来的 P99 差了 200ms,开会吵了半天。

所以先把口径钉死。下面这张表是我们团队最终统一的口径,你可以直接拿去用:

指标定义采集点
TTFT从请求发出到收到第一个 token 的时间请求发起前 → 首个 chunk 到达
ITL相邻两个 token 之间的平均间隔首 token 之后到最后一个 token
E2E 延迟从请求发出到收到最后一个 token请求发起前 → 流结束
输入 tokenprompt 经过 tokenizer 后的 token 数请求预处理阶段
输出 token模型实际生成的 token 数生成结束或流结束
截断标记是否因 max_tokens 或上下文限制被截断生成结束判断

注意:TTFT 和 E2E 一定要在同一个采集点测量,否则跨进程时钟漂移会让数据失真。如果请求经过网关,建议在网关侧统一打时间戳,透传给下游。

2.4 采样策略:全量还是抽样

全量采集明细数据,成本会随流量线性上涨。我的经验是分两档:

  • 指标(Metric)全量:token 数、延迟这类数值型指标,聚合后数据量很小,全量采集没压力。
  • 明细(Trace/Log)抽样:每次请求的完整 trace 按 1%~10% 采样,但错误请求和慢请求 100% 采集。这样既控制了存储成本,又保证出问题时一定有现场。

这个策略用 OTel 的 Tail Sampling 处理器很容易实现,后面实操部分会给配置。

3. 核心埋点细节与实操要点

3.1 流式响应下如何准确测量 TTFT

流式是最容易出错的地方。很多人写计时逻辑是这样的:

start = time.time() for chunk in stream: process(chunk) end = time.time() print(end - start) # 只有总延迟

这段代码拿不到 TTFT。正确做法是在循环里判断“这是不是第一个 chunk”:

import time start = time.perf_counter() ttft = None token_count = 0 for chunk in stream: if ttft is None: ttft = time.perf_counter() - start token_count += 1 process(chunk) e2e = time.perf_counter() - start itl = (e2e - ttft) / max(token_count - 1, 1)

这里有几个细节值得说。第一,用time.perf_counter()而不是time.time(),前者是单调时钟,不受系统时间调整影响,测延迟更准。第二,token_count统计的是 chunk 数,但一个 chunk 不一定等于一个 token,有些推理引擎会批量吐 token。要精确统计输出 token,最好以引擎返回的 usage 为准,chunk 计数只作为兜底。

第三,itl的分母用token_count - 1,因为首 token 的时间已经算进 TTFT 了,不能重复计算。这个细节不注意,ITL 会系统性偏小。

3.2 自建推理引擎怎么拿到 token 数

调用第三方 API 时,响应体里通常有usage字段,直接读就行。但自建推理引擎(vLLM、LocalAI、Ollama)情况不一样,得分情况处理。

vLLM在流式模式下,如果开启--enable-usage-stats(不同版本参数名略有差异),最后一个 chunk 会带上 usage。非流式模式下 usage 直接返回。所以我的做法是:流式请求里缓存最后一个 chunk 的 usage,流结束后统一上报。

Ollama的/api/generate接口在最终响应里有prompt_eval_count和eval_count,分别对应输入和输出 token 数。流式模式下同样在最后一个 JSON 对象里。

LocalAI相对麻烦,部分版本不返回 usage。这时候只能自己用 tokenizer 估算。注意,估算和真实值会有偏差,尤其是中文场景,不同 tokenizer 差异能到 10%~20%。所以估算值要打上标记,和真实 usage 区分开,别混在一起做成本核算。

# 以 vLLM 流式为例,缓存最后一个 chunk 的 usage final_usage = None for chunk in stream: data = json.loads(chunk) if "usage" in data and data["usage"]: final_usage = data["usage"] input_tokens = final_usage["prompt_tokens"] if final_usage else estimate_tokens(prompt) output_tokens = final_usage["completion_tokens"] if final_usage else token_count

3.3 用 OTel 语义约定统一属性名

埋点最怕各写各的。OTel 针对生成式 AI 已经有一套语义约定,我挑几个最常用的列出来,建议直接照抄:

属性名含义类型
gen_ai.system模型提供方,如 openai、vllmstring
gen_ai.request.model请求的模型名string
gen_ai.usage.input_tokens输入 token 数int
gen_ai.usage.output_tokens输出 token 数int
gen_ai.response.finish_reasons结束原因,如 stop、lengthstring[]
gen_ai.operation.name操作类型,如 chat、completionstring

finish_reasons这个字段特别有用。当它返回length时,说明输出被max_tokens截断了,这时候你就该警惕:要么调大上限,要么检查是不是有请求在恶意刷长输出。

提示:属性名一旦定下来就别随便改,改了历史数据就对不上。建议在团队内维护一份“埋点字典”,新增字段先评审。

3.4 别把高基数标签打进 Prometheus

这是我见过最多的翻车点。有人图省事,把request_id、user_id、session_id全当标签打进 Prometheus,结果指标基数爆炸,Prometheus 内存飙升最后 OOM。

记住一条铁律:Prometheus 的标签只能是低基数的枚举值,比如模型名、接口类型、状态码、是否流式。像 request_id 这种每次请求都不同的,只能进 ClickHouse 或日志系统,绝不能进 Prometheus。

我一般把标签控制在 5 个以内,每个标签的取值不超过几十个。如果发现某个标签取值上千,立刻拆出去。

4. 完整落地实现与配置

4.1 采集层:一个可复用的推理埋点中间件

下面这段代码是我在实际项目里用的简化版,基于 Python,思路对任何语言都通用。核心是把“计时 + token 统计 + 上报”封装成一个上下文管理器,业务代码只要包一层就行。

import time import json from contextlib import contextmanager from opentelemetry import metrics, trace meter = metrics.get_meter("llm.inference") tracer = trace.get_tracer("llm.inference") ttft_hist = meter.create_histogram( "llm.ttft.ms", unit="ms", description="首 token 延迟" ) e2e_hist = meter.create_histogram( "llm.e2e.ms", unit="ms", description="端到端延迟" ) input_tokens_counter = meter.create_counter( "llm.tokens.input", unit="1", description="输入 token 总数" ) output_tokens_counter = meter.create_counter( "llm.tokens.output", unit="1", description="输出 token 总数" ) @contextmanager def observe_inference(model: str, stream: bool): attrs = {"gen_ai.request.model": model, "stream": str(stream)} start = time.perf_counter() state = {"ttft": None, "output_tokens": 0, "input_tokens": 0} with tracer.start_as_current_span("llm.inference") as span: try: yield state finally: e2e = (time.perf_counter() - start) * 1000 e2e_hist.record(e2e, attrs) if state["ttft"] is not None: ttft_hist.record(state["ttft"] * 1000, attrs) input_tokens_counter.add(state["input_tokens"], attrs) output_tokens_counter.add(state["output_tokens"], attrs) span.set_attribute("gen_ai.usage.input_tokens", state["input_tokens"]) span.set_attribute("gen_ai.usage.output_tokens", state["output_tokens"])

业务侧调用就变成这样,非常干净:

with observe_inference(model="qwen-7b", stream=True) as state: state["input_tokens"] = count_input_tokens(prompt) for chunk in stream: if state["ttft"] is None: state["ttft"] = time.perf_counter() - start_time state["output_tokens"] += 1 yield chunk

这个模式的好处是:无论业务逻辑怎么变,只要包在with里,指标就一定会上报,不会因为某个分支提前 return 而漏掉。

4.2 聚合层:Prometheus 指标暴露与 ClickHouse 明细表

OTel 采集到的指标需要一个出口。我一般用 OTel Collector 做中转,它同时支持把指标推给 Prometheus、把 trace 推给 ClickHouse。Collector 的配置大概长这样:

receivers: otlp: protocols: grpc: endpoint: 0.0.0.0:4317 processors: tail_sampling: decision_wait: 10s policies: - name: errors-and-slow type: and and: and_sub_policy: - name: is-error type: status_code status_code: {status_codes: [ERROR]} - name: is-slow type: latency latency: {threshold_ms: 5000} exporters: prometheus: endpoint: 0.0.0.0:8889 clickhouse: endpoint: tcp://clickhouse:9000 database: llm_obs traces_table_name: inference_spans service: pipelines: metrics: receivers: [otlp] exporters: [prometheus] traces: receivers: [otlp] processors: [tail_sampling] exporters: [clickhouse]

tail_sampling那段是关键:错误请求和超过 5 秒的慢请求全量保留,其余按默认采样率。这样既省钱又不丢现场。

ClickHouse 建表时,把 token、延迟、模型名这些字段单独列出来,方便下钻查询:

CREATE TABLE llm_obs.inference_spans ( ts DateTime64(3), request_id String, model String, stream UInt8, input_tokens UInt32, output_tokens UInt32, ttft_ms Float32, e2e_ms Float32, finish_reason String, status String ) ENGINE = MergeTree() ORDER BY (ts, model);

有了这张表,你想查“今天哪个模型输出 token 最多”“哪个请求 TTFT 超过 3 秒”都是一条 SQL 的事。

4.3 展示层:三个必看的大盘

Grafana 大盘不用花里胡哨,我建议先做三个核心面板,覆盖 90% 的日常需求。

第一个:成本面板。按模型分组,展示每小时输入/输出 token 总量,再乘以单价就是成本。这个面板一挂出来,团队里再也没人问“token 花哪了”。

第二个:延迟面板。TTFT 和 E2E 的 P50/P95/P99 三条线。重点看 P99,它才是用户体验的真实反映。如果 P99 和 P50 差距特别大,说明有长尾请求在拖后腿,通常和长输出或大 prompt 有关。

第三个:异常面板。截断率(finish_reason=length 的占比)、错误率、超时率。截断率突然升高,往往意味着有人在刷长文本,或者 max_tokens 配置被改小了。

提示:告警不要设太多,否则会麻木。我一般只设两条:P99 延迟超过阈值、截断率超过 5%。其余靠人看大盘。

4.4 参数计算:max_tokens 到底该设多少

这是个经常被拍脑袋决定的参数,其实可以算。假设你的业务场景是客服问答,统计下来 95% 的回答在 300 token 以内,那max_tokens设 512 就够,留一点余量。设成 4096 只会让少数异常请求白白烧钱。

再结合上下文窗口算总预算。比如模型窗口 8192,你希望输入最多占 60%,那输入上限就是 4915 token,输出上限就是 3277。这两个数一卡,超长请求在入口就被拦掉,不会拖垮整个服务。

我一般会在网关层加一道校验:input_tokens + max_tokens > context_window就直接拒绝,返回明确错误,而不是让模型跑到一半被截断。这样用户拿到的是清晰的报错,而不是半截回答。

5. 常见问题与排查技巧实录

5.1 排查速查表

下面这张表是我这两年攒下来的,基本覆盖了 80% 的线上问题:

现象可能原因排查方向
TTFT 突然升高请求排队、KV Cache 打满看并发数、GPU 显存、队列长度
E2E 高但 TTFT 正常输出 token 过多看输出 token 分布,检查 max_tokens
截断率升高max_tokens 太小或有人刷长文本看 finish_reason 分布、单请求 token 排行
token 数对不上账估算值混入真实值检查 usage 来源标记
指标缺失埋点分支提前 return检查上下文管理器是否覆盖所有路径
Prometheus 内存高高基数标签检查标签取值数量

5.2 三个我踩过的坑

第一个坑:流式请求的 usage 丢失。早期我在流式循环里只在收到 usage 时上报,结果有些请求因为客户端提前断开,最后一个 chunk 根本没收到,usage 就丢了。后来改成:流结束时无论有没有 usage,都用兜底值上报,并打上estimated=true标记。这样数据不会缺,也不会污染真实值。

第二个坑:时钟漂移导致 TTFT 为负。有次发现 TTFT 出现负数,查了半天是网关和推理服务不在同一台机器,两台机器时钟差了 300ms。解决办法是所有时间戳都在同一进程内采集,跨进程只传相对时间。

第三个坑:采样把慢请求采没了。一开始用固定 1% 采样,结果线上偶发的慢请求全被采掉,出问题时没有现场。后来改成 Tail Sampling,慢请求和错误请求全留,才解决。

5.3 一个容易被忽略的指标:缓存命中率

如果你的推理服务用了前缀缓存(Prefix Caching)或 KV Cache 复用,一定要单独统计缓存命中的 token 数。命中的 token 通常不计费或计费更低,命中率上不去,成本就下不来。我见过一个场景,把系统提示词固定下来后,缓存命中率从 0 涨到 60%,成本直接砍掉一大半。这个指标不测,你永远不知道钱省在哪。

5.4 多模态和语音场景的特殊处理

多模态请求的 token 计算和纯文本不一样。图片会按分辨率折算成一定数量的 token,音频按时长折算。这时候不能只统计文本 token,要把各模态的 token 分开记,否则成本核算会严重失真。

语音接入场景对延迟更敏感,用户对“开口后多久有反应”的容忍度远低于文字。这时候 TTFT 的阈值要单独设,我一般把语音场景的 TTFT 告警线设在 800ms,文字场景设在 2s。用同一套阈值会误报。

6. 后续可以怎么扩展

这套方案跑通之后,往上还能做不少事。比如把 token 消耗和业务指标关联起来,算清楚“每个付费用户贡献了多少 token 成本”,做精细化运营。再比如基于历史延迟数据做容量预测,提前扩容,而不是等打满了才救火。

我个人在实际操作中的体会是:可观测性这件事,最难的从来不是技术,而是坚持统一口径。工具选型可以换,架构可以调,但只要口径乱了,数据就废了。所以先把指标定义写进文档,让所有人对齐,再动手写代码,能省掉后面无数扯皮。

最后分享一个小技巧:每次上线新模型或改推理参数,先跑一批固定测试集,把 TTFT、输出 token、截断率这些指标和基线对比,差异超过 10% 就拦下来查。这个习惯帮我拦下过好几次“参数改错导致成本翻倍”的事故。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询