1. 大模型推理可观测性到底在解决什么问题
1.1 从一次线上故障说起
去年冬天我帮一个团队排查他们内部问答机器人的问题。用户反馈很简单:最近回答变慢了,有时候等十几秒才出结果,偶尔还会直接超时。团队第一反应是“模型太大,GPU不够”,准备申请加卡。我让他们先把每次推理的耗时和Token消耗打出来看看,结果发现一个很有意思的现象:P99延迟飙到了14秒,但GPU利用率只有40%出头。真正的问题出在请求排队和上下文拼接上——有些用户的历史对话被无限制地塞进prompt,单次输入Token从平均800涨到了6000多,推理引擎的批处理队列被这些超长请求堵死,后面的短请求只能干等。
这件事让我更加确信一个判断:大模型应用上线之后,最容易被忽视、却最能决定用户体验的,不是模型本身的能力,而是你有没有把每一次推理的Token消耗和延迟看清楚。这就是大模型可观测性要解决的核心问题。
所谓可观测性,落到大模型推理这个场景里,说白了就是三件事:每一次推理花了多少Token、花了多长时间、这些数字在什么维度上发生了变化。听起来简单,但真正做起来,从埋点位置的选择、指标口径的定义,到采样策略、存储成本、告警阈值,每一步都有坑。我见过太多团队把精力全砸在模型选型和微调上,上线后却连“哪个接口最费Token”都答不上来,出了问题只能靠猜。
这篇文章适合三类人看:正在做大模型应用开发、需要给推理链路加监控的工程师;负责大模型服务稳定性、需要定位延迟和成本问题的运维同学;以及做AI产品、想搞清楚“钱到底花在哪”的负责人。我会把整套思路、埋点方法、指标设计、排查技巧都摊开讲,尽量让你看完就能照着搭一套。
1.2 为什么传统APM在大模型场景下不够用
很多团队第一反应是:我们已经有APM了,直接接进去不就行了?我试过,结论是传统APM能覆盖一部分,但远远不够。
传统APM擅长的是HTTP请求级别的监控——接口耗时、错误率、QPS。但大模型推理的耗时结构和普通接口完全不同。一次推理的延迟可以拆成好几段:请求排队等待、prompt预处理(Tokenization)、首Token生成时间(TTFT)、后续Token的逐字生成时间、以及后处理。传统APM只能告诉你“这个接口花了8秒”,但没法告诉你这8秒里有多少是排队、多少是生成、首Token等了多久。而恰恰是这些细分指标,决定了你该优化哪里。
Token消耗更是传统APM的盲区。普通接口的“成本”基本是固定的,一次请求消耗多少CPU、多少内存,波动不大。但大模型推理的成本和输入输出长度强相关,同样一个接口,输入100个Token和输入5000个Token,成本能差几十倍。你不把Token单独拎出来统计,成本核算就是一笔糊涂账。
还有一个关键差异是流式输出。大模型应用普遍用SSE或WebSocket做流式返回,用户看到的是字一个个蹦出来。这种情况下,“请求结束”和“用户看到完整回答”是两个时间点,传统APM按请求结束时间算延迟,会严重低估用户的真实等待感受。你必须单独记录首Token时间和Token生成速率,才能反映真实体验。
所以我的建议是:传统APM继续用,负责接口级别的健康度;大模型推理的可观测性单独做一层,专注Token和延迟的细粒度追踪。两层配合,才能既看到宏观又看到微观。
2. 核心指标设计:追踪什么、怎么定义口径
2.1 Token维度的四个核心指标
Token是大模型推理的成本单位,也是很多性能问题的根源。我一般会盯四个指标:
- 输入Token数(prompt_tokens):每次请求送进模型的Token总量。这个数字直接决定预处理耗时和显存占用。我见过最夸张的案例,一个客服机器人因为把整个知识库塞进system prompt,单次输入稳定在12000 Token以上,成本高得离谱,后来改成检索增强,输入直接降到1500以内。
- 输出Token数(completion_tokens):模型生成的Token总量。这个决定了生成阶段的耗时,也是计费的主要部分。
- 总Token数(total_tokens):输入加输出,成本核算的基础。
- Token生成速率(tokens_per_second):输出Token数除以生成耗时。这个指标能反映推理引擎的真实吞吐能力,也是判断“是模型慢还是排队慢”的关键。
这里有个口径问题必须提前定清楚:Token数到底按谁的计数算?不同模型的分词器(tokenizer)切出来的Token数是不一样的。同一个中文句子,有的模型切成20个Token,有的切成35个。如果你用A模型的分词器去统计B模型的消耗,数字会对不上。我的做法是:统计口径以实际调用的推理引擎返回的usage字段为准,不要自己用tiktoken之类的库去估算,除非引擎不返回。自己估算只能用于预算,不能用于对账。
2.2 延迟维度的五个关键分段
延迟这块,我强烈建议不要只记一个总耗时,而是拆成五段:
| 指标 | 含义 | 为什么重要 |
|---|---|---|
| 排队时间(queue_time) | 请求进入队列到开始处理 | 判断是否GPU资源不足或批处理积压 |
| 预处理时间(prefill_time) | prompt Tokenization和KV Cache构建 | 输入Token多时这段会明显变长 |
| 首Token时间(TTFT) | 从开始处理到第一个Token输出 | 用户感知“卡不卡”的核心指标 |
| 生成时间(decode_time) | 首Token到最后一个Token | 反映逐字生成速度 |
| 总耗时(total_latency) | 端到端完整时间 | 对外SLA的统计口径 |
这五段里,TTFT是最容易被忽视但最重要的。用户对延迟的感知不是线性的,等第一个字出来之前的那段时间,焦虑感最强。实测下来,TTFT控制在1秒以内,用户基本无感;超过3秒,就会觉得“这AI是不是死了”;超过8秒,大概率直接关页面。而生成阶段哪怕每秒只出10个字,只要字在动,用户耐心就会好很多。
所以做告警的时候,我会给TTFT单独设一条线,而不是只看总耗时。总耗时可能因为输出很长而偏高,但TTFT高才是真正的体验杀手。
2.3 成本与效率的衍生指标
有了基础指标,可以再算几个衍生指标,用于横向对比和优化决策:
- 单次请求平均成本:总Token数乘以单价,按模型、按接口、按用户维度聚合。
- Token有效利用率:输出Token数除以总Token数。这个比值太低,说明输入里塞了太多没用的上下文,有优化空间。
- 缓存命中率:如果用了KV Cache或prompt缓存,命中率直接决定成本和延迟。命中率高,预处理时间能砍掉一大半。
- 每美元Token产出:成本除以有效输出Token,衡量“钱花得值不值”。
这些衍生指标不用一开始就全上,但单次请求平均成本和Token有效利用率这两个,我建议从第一天就统计。它们能帮你快速发现“哪个功能在烧钱”。
3. 埋点实操:在推理链路的哪些位置下钩子
3.1 埋点位置的选择逻辑
埋点位置决定了你能拿到什么数据。我的原则是:在推理引擎的入口和出口各埋一个点,在应用层再埋一个点,三个点交叉验证。
- 应用层入口:请求刚到达你的服务,还没进推理引擎。这里记录请求ID、用户ID、接口名、原始prompt长度、时间戳。这个点的作用是拿到“用户视角”的起点。
- 推理引擎入口:请求真正提交给推理引擎(比如vLLM、TGI、LocalAI等)的那一刻。这里记录排队开始时间、实际送入的prompt Token数。
- 推理引擎出口:引擎返回结果。这里拿到usage字段(输入输出Token数)、首Token时间、生成结束时间。这是最权威的数据源。
- 应用层出口:结果返回给用户。这里记录端到端总耗时,和引擎出口的数据对比,差值就是你的应用层开销(序列化、网络传输等)。
三个点的时间戳一减,就能把延迟拆得清清楚楚。我一般会在日志里用统一的trace_id串起来,方便后续关联查询。
3.2 用OpenTelemetry做标准化埋点
如果你的团队已经在用OpenTelemetry(OTel),那太好了,直接复用它做埋点,不用另起炉灶。OTel的Span模型天然适合表达“一次推理”这种有开始有结束、还带属性的操作。
下面是一个Python示例,展示怎么在调用推理接口时打Span:
from opentelemetry import trace from opentelemetry.trace import Status, StatusCode import time tracer = trace.get_tracer("llm.inference") def call_llm(prompt, model_name, user_id): with tracer.start_as_current_span("llm.inference") as span: span.set_attribute("llm.model", model_name) span.set_attribute("llm.user_id", user_id) span.set_attribute("llm.prompt_length", len(prompt)) start = time.time() first_token_time = None output_tokens = 0 try: # 假设这里是流式调用 for chunk in stream_inference(prompt, model_name): if first_token_time is None: first_token_time = time.time() span.set_attribute("llm.ttft_ms", (first_token_time - start) * 1000) output_tokens += 1 end = time.time() span.set_attribute("llm.output_tokens", output_tokens) span.set_attribute("llm.total_latency_ms", (end - start) * 1000) span.set_attribute("llm.tokens_per_second", output_tokens / (end - first_token_time)) span.set_status(Status(StatusCode.OK)) except Exception as e: span.set_status(Status(StatusCode.ERROR, str(e))) span.record_exception(e) raise这段代码的关键点:TTFT在收到第一个chunk时就记录,不要等全部结束。很多团队图省事,等整个流式返回结束才统一算,那样TTFT就丢了。另外,llm.prompt_length这里用的是字符数,实际应该用引擎返回的prompt_tokens,我这里只是示意。
3.3 日志字段设计:一次推理该记哪些信息
Span适合做指标聚合,但排查具体问题时,还是得靠结构化日志。我设计日志字段的习惯是:能唯一标识一次请求的、能定位问题的、能用于聚合的,三类字段都要有。
下面是我常用的一套字段,用JSON格式输出,方便后续用日志系统检索:
{ "trace_id": "a1b2c3d4-...", "request_id": "req-20240115-001", "user_id": "u_12345", "session_id": "s_67890", "model_name": "qwen-7b-chat", "endpoint": "/v1/chat/completions", "prompt_tokens": 1523, "completion_tokens": 287, "total_tokens": 1810, "queue_time_ms": 45, "prefill_time_ms": 320, "ttft_ms": 680, "decode_time_ms": 2100, "total_latency_ms": 3145, "tokens_per_second": 136.7, "cache_hit": true, "status": "success", "timestamp": "2024-01-15T10:23:45.123Z" }这套字段里,trace_id和request_id用于关联,prompt_tokens和completion_tokens用于成本核算,几个时间字段用于延迟分析,cache_hit用于评估缓存效果。字段不要贪多,但上面这些是底线。
注意:日志里绝对不要记录完整的prompt和输出内容,尤其是涉及用户隐私的场景。只记长度和统计值就够了。如果确实需要采样记录内容用于调试,一定要做脱敏,并且设置很短的保留期。
4. 数据采集与存储:别让可观测性本身变成负担
4.1 采样策略:全量还是抽样
大模型推理的日志量可能非常大。一个中等规模的应用,每天几十万次推理,如果每次都打完整日志,存储成本很快就上来了。所以采样策略必须提前想清楚。
我的做法是分层采样:
- 指标(Metrics)全量聚合:Token数、延迟这些数值型指标,用计数器(Counter)和直方图(Histogram)全量聚合,不存原始日志。聚合后的数据量很小,成本可控。
- 日志(Logs)按需采样:正常请求按1%到5%采样存详细日志;错误请求、超时请求、Token异常高的请求,100%全存。
- 链路(Traces)头部采样:用OTel的头部采样,对慢请求和错误请求提高采样率。
这样既能保证问题可排查,又不会让存储爆炸。我见过一个团队不做采样,三个月日志存了20TB,光存储费用就够买好几张卡了。
4.2 存储选型:时序库加日志库的组合
指标和日志的查询模式不一样,存储也要分开:
- 指标数据:用Prometheus或VictoriaMetrics这类时序数据库。它们对Counter和Histogram的支持好,聚合查询快,适合做看板和告警。
- 日志数据:用Elasticsearch、Loki或ClickHouse。Loki轻量、和Prometheus生态配合好;ClickHouse查询快、压缩率高,适合日志量大的场景。
- 链路数据:用Jaeger或Tempo,和OTel无缝对接。
如果团队规模不大,不想维护太多组件,我推荐Prometheus + Loki + Grafana这套组合,部署简单,社区成熟,够用很久。等数据量真的上来了,再考虑换ClickHouse。
4.3 成本控制:可观测性不能比推理还贵
这里有个很现实的矛盾:可观测性本身也要消耗资源。如果监控系统的成本超过了它帮你省下的钱,那就本末倒置了。
几个控制成本的经验:
- 指标聚合粒度不要太细:按分钟聚合就够了,没必要按秒。按秒聚合数据量会大60倍,但排查问题时分钟级完全够用。
- 日志保留期分级:详细日志保留7天,聚合指标保留90天,成本报表保留1年。过期自动清理。
- 避免高基数标签:不要把user_id、request_id这种高基数字段做成指标标签,否则时序库会被撑爆。这些字段放日志里,指标里只用model_name、endpoint这种低基数维度。
我踩过一次坑:早期把user_id做成了Prometheus的label,结果几万个用户直接把时序库打挂了。后来改成只在日志里记user_id,指标里按用户分群聚合,问题就解决了。
5. 可视化与告警:让数据真正发挥作用
5.1 看板设计:三块看板覆盖不同视角
数据存下来不是目的,能看懂才有用。我一般会做三块看板:
第一块:成本看板。核心是Token消耗和费用。按模型、按接口、按天聚合,展示总Token数、总成本、单次请求平均成本、Token有效利用率。这块看板给产品和负责人看,回答“钱花在哪了”。
第二块:性能看板。核心是延迟。展示TTFT的P50/P95/P99、总耗时的P50/P95/P99、Token生成速率、排队时间。这块给工程师和运维看,回答“慢在哪了”。
第三块:健康度看板。核心是错误率和异常。展示请求成功率、超时率、错误码分布、缓存命中率。这块给值班同学看,回答“现在有没有问题”。
三块看板不用做得很花哨,Grafana拉几个图就够。关键是指标口径要统一,别成本看板和性能看板对同一个请求的Token数算出来不一样,那就乱套了。
5.2 告警阈值怎么定:从基线到动态
告警阈值定太松,问题漏报;定太紧,天天误报,最后没人看。我的经验是先跑两周基线,再定阈值。
具体做法:新服务上线后,先只采集不告警,观察两周。看TTFT的P95大概在什么范围,总耗时的P99是多少,Token数的分布如何。然后按基线的1.5到2倍设阈值。比如基线TTFT P95是800毫秒,那告警线设在1.5秒左右。
几个我必设的告警:
- TTFT P95超过阈值持续5分钟:说明用户体验在恶化,可能是排队积压或模型异常。
- 错误率超过1%持续3分钟:说明服务有问题,需要立即介入。
- 单次请求Token数超过阈值:说明有异常长的输入,可能是prompt拼接出了问题,或者有人在滥用。
- Token生成速率骤降:说明推理引擎可能降频或资源争抢。
阈值不要设太多,五六个核心的就够。告警太多等于没有告警。
5.3 从指标到根因:一个排查实例
光有告警不够,还得能快速定位根因。我拿一个真实案例走一遍。
某天下午,告警响了:TTFT P95从800毫秒涨到了3.2秒。值班同学先看性能看板,发现总耗时也涨了,但Token生成速率正常。这说明问题不在生成阶段,而在生成之前。
接着看排队时间,发现queue_time从平均50毫秒涨到了1.8秒。排队时间涨,说明请求在引擎入口积压了。再看请求量,QPS没有明显变化。那为什么排队变长?
这时候去看Token分布,发现prompt_tokens的P99从2000涨到了8000。有少量超长请求混进来了。超长请求的预处理时间长,占着引擎的批处理槽位,把后面的短请求堵住了。
根因找到了:某个上游功能改了prompt拼接逻辑,把历史对话全量塞进去了。修复方式很简单,加个滑动窗口,只保留最近N轮对话。改完TTFT立刻回落。
这个排查过程之所以能这么快,就是因为延迟被拆成了分段,Token被单独统计了。如果只有一个总耗时,你根本不知道问题出在排队还是生成,只能瞎猜。
6. 常见问题与避坑指南
6.1 流式场景下延迟统计的坑
流式输出下,最容易犯的错是用请求结束时间算延迟。用户看到第一个字的时间,和请求真正结束的时间,可能差好几秒。你按结束时间算,TTFT就丢了,用户体验的恶化你根本发现不了。
正确做法是:在收到第一个chunk时记录TTFT,在收到最后一个chunk时记录总耗时,两个都存。看板上前者看体验,后者看资源占用。
还有一个坑是客户端和服务端时间不一致。如果你的TTFT是在客户端测的,服务端也在测,两边对不上。我的建议是以服务端为准,客户端的数据只做参考。因为服务端能排除网络波动,数据更稳定。
6.2 Token计数不一致怎么排查
前面提过,不同分词器切出来的Token数不一样。如果你发现日志里的Token数和账单对不上,按这个顺序排查:
- 确认统计来源:是引擎返回的usage,还是自己用分词器估的?以引擎返回为准。
- 确认模型版本:同一个模型的不同版本,分词器可能变过。日志里要记模型版本号。
- 确认是否包含特殊Token:有些引擎的usage不包含system prompt的Token,有些包含。看引擎文档确认。
- 确认是否有重试:一次请求如果重试了,Token会重复计算。日志里要标记重试次数。
我遇到过一次对账差异,最后发现是引擎在prompt超长时自动截断了,但usage返回的是截断前的Token数。这种细节只能靠仔细核对引擎文档和实际返回。
6.3 高并发下的数据丢失问题
高并发时,如果日志是同步写的,会拖慢推理主流程。我见过一个服务,因为日志写磁盘太慢,QPS一高就超时。
解决办法是异步写日志:推理主流程只把日志对象丢进内存队列,后台线程慢慢消费写盘。队列满了就丢弃低优先级日志(比如正常请求的采样日志),保证错误日志不丢。
用Python的话,可以用queue.Queue加一个后台消费线程,或者直接用logging.handlers.QueueHandler。关键是主流程不能阻塞在日志上。
6.4 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| TTFT高但生成速率正常 | 排队积压或预处理慢 | 看queue_time和prompt_tokens分布 |
| 总耗时高但TTFT正常 | 输出Token太多 | 看completion_tokens分布,考虑限制max_tokens |
| Token数对不上账单 | 统计口径不一致 | 核对引擎usage字段和分词器版本 |
| 日志量暴涨 | 采样策略失效或异常请求多 | 检查采样配置,看是否有异常长prompt |
| 告警频繁误报 | 阈值太紧或基线未校准 | 重新跑基线,放宽阈值 |
| 缓存命中率低 | 缓存key设计不合理 | 检查缓存key是否包含易变字段 |
7. 我踩过的几个真实坑
7.1 别在推理主线程里做重活
早期我图方便,在推理返回后直接在主线程里算指标、拼日志、发到远端。结果QPS一上200,延迟就抖得厉害。后来把指标聚合和日志发送全改成异步,主线程只负责把数据丢进队列,延迟立刻稳了。
这个坑的本质是:可观测性代码不能影响被观测的业务。观测是旁路,不是主路。任何在主流程里做的统计、序列化、网络发送,都要评估它的耗时。超过1毫秒的,一律异步化。
7.2 指标标签的基数陷阱
前面提过user_id的坑,这里再强调一次。Prometheus这类时序库,每个唯一的标签组合就是一条时间线。你把user_id做成标签,一万个用户就是一万条时间线,内存直接爆。
正确的做法是:指标标签只用低基数维度,比如model_name(几个到几十个)、endpoint(几个到几十个)、status(成功/失败)。高基数维度(user_id、session_id、request_id)只放日志和链路里,需要按用户分析时,从日志里聚合。
7.3 采样率设太高等于没采样
有段时间我把采样率设成50%,想着数据全一点好排查。结果存储成本翻了好几倍,查询也变慢,真正出问题时在海量日志里找半天。后来降到5%,配合错误请求全采,反而更好用。
采样这件事,关键不是采多少,而是采得巧。正常请求采一点点做基线,异常请求全采做排查,这个组合比均匀高采样有效得多。
7.4 告警要有人看,否则就是噪音
我见过太多团队,告警配了一堆,但没人看,或者看了也不处理。时间一长,告警就成了背景噪音,真出问题时反而被忽略。
我的建议是:告警数量控制在个位数,每条告警都要有明确的处理人 and 处理动作。配一条告警前先问自己:这条响了,我具体要做什么?如果答不上来,就别配。宁可少配几条,也要保证每条都有用。
8. 后续可以怎么扩展
这套基础的可观测性搭起来之后,还有几个方向可以继续深挖。
第一个方向是A/B实验的可观测性。如果你在对比不同模型、不同prompt策略的效果,可以把实验分组做成指标标签,直接在看板上对比不同组的Token消耗、延迟和成本。这样选型决策就有数据支撑,不用拍脑袋。
第二个方向是异常检测。现在阈值是人工设的,未来可以用历史数据做基线,自动检测异常。比如用滑动窗口算移动平均,偏离超过几个标准差就告警。这样能适应流量的自然波动,减少误报。
第三个方向是成本归因。把Token消耗按业务线、按功能模块、按用户分群拆开,做成成本报表。这样哪个功能在烧钱一目了然,优化起来有的放矢。
第四个方向是和微调打通。微调后的模型,Token分布和延迟特性都会变。把微调版本号做成标签,对比微调前后的指标,能直观看到微调带来的收益和代价。
这些扩展不用一次做完,先把基础的Token和延迟追踪跑通,再按需加。可观测性这东西,够用比全面重要,能解决你当前最痛的问题就是好方案。
最后分享一个我自己的习惯:每次上线新功能,我都会先问一句“这个功能的Token消耗和延迟,我能看到吗?”如果看不到,就先别上,把埋点补上再说。这个习惯帮我省了很多事后排查的麻烦。