1. 为什么大模型推理必须做可观测性
大模型上线之后,最让人头疼的往往不是模型本身答得好不好,而是"钱花在哪了、慢在哪了、什么时候开始抖的"。我见过太多团队,模型部署完就扔在那跑,直到月底账单出来才发现 Token 消耗比预期翻了三倍,或者用户投诉响应越来越慢,却完全不知道是哪个环节拖了后腿。大模型日志与可观测性这件事,本质上就是给推理链路装上一双眼睛,让每一次推理的 Token 消耗和延迟都变得可追踪、可归因、可优化。
先说清楚这套东西到底解决什么问题。一次大模型推理请求,从用户发出到拿到结果,中间会经过网关、鉴权、路由、推理引擎、显存调度、流式输出等多个环节。每个环节都可能成为瓶颈:可能是输入 prompt 太长导致 prefill 阶段耗时飙升,可能是并发太高导致排队等待,也可能是输出 token 太多把 decode 阶段拖成了长尾。如果没有细粒度的日志和指标,你只能看到一个笼统的"总耗时 8 秒",根本没法定位问题。而可观测性要做的,就是把这个 8 秒拆开,告诉你 prefill 花了多少、decode 花了多少、排队等了多久、每个 token 的平均生成速度是多少。
这套方案适合谁?我认为三类人最需要:一是负责大模型部署和运维的工程师,你们要保证服务稳定和成本可控;二是做 AI 应用开发的同学,你们需要知道自己的 prompt 设计到底费不费钱;三是技术负责人,你们要向上汇报成本和质量,没有数据支撑根本说不清楚。哪怕你现在只是用免费大模型 API 做个小 demo,养成记录 Token 和延迟的习惯,等业务量上来之后会感谢现在的自己。
我个人的经验是,可观测性不是等出问题了才补的救火工具,而是应该从第一天就设计进去的基础设施。下面我会把整套思路、关键指标、实操步骤和踩过的坑都摊开讲,尽量让不同基础的人都能照着落地。
2. 核心指标体系与数据模型设计
2.1 一次推理请求到底该记哪些字段
很多人做日志就是简单打一行"请求成功,耗时 3.2s",这种日志在排查问题时几乎没用。真正有价值的推理日志,需要把一次请求拆成结构化的字段。我通常会把字段分成四类:请求标识类、输入特征类、性能指标类、结果状态类。
请求标识类包括 trace_id、request_id、user_id、session_id、模型名称和版本。trace_id 用来串联整条链路,request_id 定位单次请求,user_id 和 session_id 用于分析用户维度的用量和成本。模型名称和版本必须记,因为不同版本的行为可能完全不同,尤其是做过大模型微调之后,版本管理混乱是灾难。
输入特征类是最容易被忽略但极其重要的部分。你需要记录 input_tokens(输入 Token 数)、output_tokens(输出 Token 数)、total_tokens、prompt 模板 ID、是否流式、最大生成长度设置、temperature 等采样参数。为什么要记 prompt 模板 ID?因为同一个业务可能有多套 prompt,不同模板的 Token 消耗差异巨大,不记下来你根本不知道是哪套模板在烧钱。
性能指标类是核心。我建议至少记录以下几个时间点:请求到达时间、排队开始时间、排队结束时间、prefill 开始时间、prefill 结束时间、首 Token 时间(TTFT)、最后一个 Token 时间、请求结束时间。由此可以推导出排队延迟、prefill 耗时、decode 耗时、端到端延迟、Token 生成速率(tokens per second)。这些指标后面我会详细讲怎么算。
结果状态类包括 HTTP 状态码、是否命中缓存、错误类型、重试次数、finish_reason(是正常结束还是被 max_tokens 截断)。finish_reason 特别关键,如果大量请求都是 length 结束,说明你的 max_tokens 设置不合理,用户拿到的答案是被硬截断的。
2.2 关键指标的计算口径与陷阱
指标定义不清楚,数据就是垃圾。我踩过最大的坑就是 TTFT 的口径不统一。TTFT(Time To First Token)严格来说是从请求发出到收到第一个 Token 的时间,但在流式场景下,很多框架把它算成了从 prefill 开始到第一个 Token 输出,这就把排队时间漏掉了。如果你的服务有排队,这个差异可能有好几秒。
Token 生成速率也有讲究。常见算法是 output_tokens 除以 decode 耗时,但 decode 耗时的起止点必须明确。我一般用(最后 Token 时间 - 首 Token 时间)作为 decode 窗口,这样算出来的速率更接近用户实际感受到的"吐字速度"。如果直接用总耗时算,会把 prefill 和排队都算进去,速率被严重低估。
还有一个隐蔽的陷阱是 Token 计数本身。不同模型用的 tokenizer 不一样,同一个中文句子在不同模型下的 Token 数可能差 30% 以上。所以 Token 统计必须用对应模型的 tokenizer 来算,不能拿一个通用估算公式糊弄。我见过有人用"字符数除以 1.5"来估 Token,结果成本核算偏差巨大。
| 指标名称 | 计算方式 | 常见陷阱 |
|---|---|---|
| 端到端延迟 | 请求结束时间 - 请求到达时间 | 未区分流式和非流式 |
| TTFT | 首 Token 时间 - 请求到达时间 | 漏算排队时间 |
| 排队延迟 | 排队结束 - 排队开始 | 无队列时该值为 0,需区分 |
| Prefill 耗时 | prefill 结束 - prefill 开始 | 与排队时间混淆 |
| Decode 速率 | output_tokens / (末Token - 首Token) | 用总耗时导致低估 |
| Token 成本 | (input + output) × 单价 | tokenizer 不匹配 |
2.3 数据模型:从原始日志到可聚合指标
原始日志和指标是两回事。原始日志用于单次请求的精确回溯,指标用于聚合分析和告警。我的做法是双写:每次请求既落一条结构化日志(JSON 格式,方便检索),同时把关键数值打点到指标系统(如 Prometheus 的 histogram 和 counter)。
日志的存储要考虑成本和检索效率。全量日志保留 7 到 15 天足够排查问题,更久的历史数据做降采样聚合即可。指标则要长期保留,因为成本趋势和性能基线需要按周、按月对比。这里有个经验:延迟指标一定要用直方图而不是平均值。平均值会掩盖长尾,而大模型服务的用户体验恰恰由 P95、P99 决定。一个平均 2 秒但 P99 达到 30 秒的服务,用户感知就是"经常卡死"。
3. 埋点实操:从请求入口到推理引擎
3.1 网关层埋点:抓住第一手时间戳
埋点的第一站是网关或 API 入口。这一层要记录请求到达的绝对时间戳,并生成 trace_id 往下透传。为什么强调绝对时间戳?因为跨服务的时间差分析必须基于统一时钟,如果各服务用自己的相对时间,链路拼接就会错乱。
网关层还要做一件事:在请求头里注入 trace 上下文。我通常用 W3C Trace Context 标准,把 traceparent 头透传到下游。这样无论中间经过多少个服务,都能用同一个 trace_id 串起来。如果你用的是自研框架,至少保证 trace_id 通过上下文对象一路传递,别在中途丢了。
这一层能拿到的指标包括:请求速率(QPS)、入口排队情况、鉴权耗时、限流触发次数。鉴权耗时经常被忽略,但在高并发下,如果鉴权走了远程调用,它可能成为隐藏的延迟来源。我遇到过 token 校验每次都查数据库,结果单次鉴权就要 50ms,占了总延迟的十分之一。
3.2 推理引擎层埋点:prefill 与 decode 分离
这是最核心的一层。现代推理引擎(如 vLLM 这类)内部会把推理分成 prefill 和 decode 两个阶段,埋点必须把这两个阶段分开计时。prefill 阶段处理整个输入 prompt,计算量随输入长度增长;decode 阶段逐个生成 Token,耗时随输出长度线性增长。两者优化手段完全不同,混在一起就没法针对性优化。
具体怎么埋?如果引擎暴露了回调或钩子,优先用官方接口。以常见的推理框架为例,通常可以在请求进入调度队列、prefill 完成、每个 Token 生成、请求结束这几个节点注册回调。如果没有现成钩子,退而求其次可以在引擎外层包一层,但这样拿不到 prefill 和 decode 的分界点,只能拿到 TTFT 作为近似。
这里有个实操细节:流式输出场景下,每个 Token 的生成时间都要记录。这听起来开销很大,但正是这些细粒度数据让你能画出 Token 生成的时间分布,发现"越到后面越慢"这类问题。我一般会记录首 Token、中间采样点(比如每 10 个 Token 记一次)、末 Token 的时间,既控制开销又保留趋势。
import time import json class InferenceTracer: def __init__(self, trace_id, request_id): self.trace_id = trace_id self.request_id = request_id self.marks = {} self.token_times = [] def mark(self, name): self.marks[name] = time.time() def on_token(self, token_index): # 采样记录,避免每个 token 都存 if token_index == 0 or token_index % 10 == 0: self.token_times.append((token_index, time.time())) def build_log(self, input_tokens, output_tokens, finish_reason): arrival = self.marks.get("arrival") first_token = self.marks.get("first_token") last_token = self.marks.get("last_token") end = self.marks.get("end") ttft = (first_token - arrival) if first_token and arrival else None decode_window = (last_token - first_token) if last_token and first_token else None tps = output_tokens / decode_window if decode_window and decode_window > 0 else None return { "trace_id": self.trace_id, "request_id": self.request_id, "input_tokens": input_tokens, "output_tokens": output_tokens, "total_tokens": input_tokens + output_tokens, "ttft_ms": round(ttft * 1000, 2) if ttft else None, "e2e_ms": round((end - arrival) * 1000, 2) if end and arrival else None, "tokens_per_sec": round(tps, 2) if tps else None, "finish_reason": finish_reason, "token_timeline": self.token_times, }上面这段代码是一个简化的埋点骨架,核心思路是把时间戳打点和方法调用解耦,最后统一计算派生指标。实际项目中我会把它接到日志管道里,异步落盘,避免阻塞推理主流程。
3.3 输出层埋点:别漏了后处理和网络传输
很多人埋点到推理引擎结束就停了,其实从引擎吐出最后一个 Token 到用户真正收到,中间还有后处理(比如敏感词过滤、格式化)、序列化、网络传输。这些环节在正常情况下耗时很短,但一旦出问题就是灾难。我遇到过响应体太大导致序列化耗时 2 秒的情况,如果不埋点根本发现不了。
输出层要记录:后处理耗时、响应体大小、网络写出耗时。响应体大小这个指标很有用,它能帮你发现"为什么这个请求特别慢"——有时候就是返回内容太大,传输本身成了瓶颈。
4. 延迟归因与 Token 成本核算实战
4.1 把端到端延迟拆成可优化的几块
有了埋点数据,下一步就是归因。我习惯把端到端延迟拆成五块:网络传输、排队等待、prefill、decode、后处理。每一块都有对应的优化手段。
排队等待高,说明并发超过了引擎处理能力,要么扩容,要么做请求优先级调度。prefill 耗时长,通常是输入 prompt 太长,可以考虑做 prompt 压缩、缓存公共前缀(prefix caching)。decode 慢,可能是输出太长或者显存带宽受限,可以调整 max_tokens、用更激进的量化。后处理慢,一般是业务逻辑问题,需要单独优化。
我做过一个真实案例:某服务 P99 延迟 25 秒,拆解后发现排队占了 18 秒,prefill 和 decode 加起来才 5 秒。问题根本不在模型,而在并发调度。把队列改成带优先级的,并且对超长请求单独限流,P99 直接降到 8 秒。如果只看总延迟,你可能会去优化模型,那就南辕北辙了。
4.2 Token 成本核算:从单次到用户维度
Token 成本核算的关键是分层聚合。单次请求的 Token 数只是基础,真正有价值的是按用户、按业务线、按模型版本聚合。我一般会建三张视图:请求明细、用户日汇总、业务线月汇总。
用户维度能发现"大户",有些用户可能占了 40% 的 Token 消耗,需要单独沟通或限流。业务线维度能指导资源分配,哪个业务 ROI 高就多给配额。模型版本维度能评估微调效果,如果微调后 Token 消耗反而上升,说明微调可能引入了啰嗦的输出风格。
这里有个核算技巧:输入 Token 和输出 Token 要分开计价。大多数模型的输出单价远高于输入,有的差 3 到 4 倍。如果混在一起算平均单价,成本预估会严重失真。我见过有人用统一单价核算,结果实际账单比预估高了 60%,就是因为输出占比被低估了。
| 聚合维度 | 用途 | 关注指标 |
|---|---|---|
| 单次请求 | 问题回溯 | 全字段明细 |
| 用户日汇总 | 大户识别、限流 | 总 Token、请求数、P95 延迟 |
| 业务线月汇总 | 资源分配 | 成本、增长率、ROI |
| 模型版本 | 微调评估 | Token 均值、输出长度分布 |
| Prompt 模板 | 模板优化 | 平均 Token、截断率 |
4.3 用滑动窗口做实时异常检测
延迟和 Token 消耗都是时变指标,用固定阈值告警会频繁误报。我的做法是用滑动窗口计算基线和波动范围,超出动态阈值才告警。比如用过去 30 分钟的 P95 延迟作为基线,当前窗口超过基线 1.5 倍就触发告警。
滑动窗口的窗口大小要按业务节奏调。高频服务用 5 分钟窗口,低频服务用 1 小时窗口。窗口太小会抖动,太大则反应迟钝。我一般会同时维护短窗口(5 分钟,抓突发)和长窗口(1 小时,看趋势),两个都异常才升级告警级别,减少噪音。
Token 消耗的异常检测更微妙,因为它的波动本来就大。我的经验是看"单位请求的平均 Token 数"而不是总量,因为总量受请求数影响。如果平均 Token 数突然上升,可能是某类请求的 prompt 变长了,或者模型开始输出啰嗦内容,这两种情况都值得排查。
5. 常见问题与排查技巧实录
5.1 日志里 Token 数和账单对不上怎么办
这是最高频的问题。原因通常有三个:一是 tokenizer 不一致,你本地统计用的 tokenizer 和实际推理用的不是同一个;二是流式场景下被截断的请求,你统计了完整输出但实际只生成了部分;三是重试请求被重复计费但日志只记了一次。
排查顺序我建议这样:先确认 tokenizer 版本和模型是否匹配,再检查是否有请求在流式输出中途断开,最后核对重试逻辑。我踩过的坑是重试没打标记,导致同一逻辑请求在日志里出现两次,一次成功一次失败,但账单只算成功那次,看起来就像"日志多算了"。
5.2 延迟指标忽高忽低,怎么定位抖动源
延迟抖动通常来自资源竞争。先看是不是有定时任务在抢资源,比如日志轮转、模型热加载、批量任务。再看是不是有慢请求拖累了整体,用 P99 和 P50 的比值判断,比值大说明长尾严重。
我常用的一个技巧是给请求打上"是否命中缓存"的标签。如果命中缓存的请求延迟稳定,未命中的抖动大,那问题就在缓存策略上。另一个技巧是按输入长度分桶看延迟,如果只有长输入桶抖动,那就是 prefill 阶段的问题。
5.3 流式输出下 TTFT 统计不准
流式场景下 TTFT 统计不准,多半是因为框架把"第一个 Token 生成"和"第一个 Token 发出"混为一谈。中间可能还有缓冲、批处理合并等操作。解决办法是在真正写出的那一刻打点,而不是在生成的那一刻。
还有一个坑是某些框架会做"首包合并",把前几个 Token 攒一起发,导致 TTFT 看起来很短但用户实际感知的等待更长。这种情况要结合 token_timeline 看,如果第一个采样点就跳到了第 5 个 Token,说明有合并。
| 问题现象 | 可能原因 | 排查方向 |
|---|---|---|
| Token 数与账单不符 | tokenizer 不一致/截断/重试 | 核对 tokenizer、检查流式断开 |
| 延迟抖动大 | 资源竞争/长尾请求 | 查定时任务、看 P99/P50 比值 |
| TTFT 偏小 | 首包合并 | 看 token_timeline 采样点 |
| 成本超预期 | 输出占比高 | 分开统计输入输出 Token |
| 告警频繁误报 | 固定阈值 | 改滑动窗口动态阈值 |
5.4 几个我踩过的坑和独家技巧
第一个坑:日志同步写导致推理变慢。早期我图省事,每次请求结束同步写日志文件,结果高并发下 IO 成了瓶颈。后来改成异步队列 + 批量落盘,推理延迟立刻降下来。日志再重要也不能阻塞主流程。
第二个坑:trace_id 在异步任务里丢失。大模型推理经常涉及异步回调,如果上下文没传好,trace_id 就断了。我的做法是用上下文变量(contextvars)显式传递,别依赖全局变量。
第三个技巧:给慢请求单独采样全量日志。正常请求只记聚合指标,超过阈值的慢请求记全量明细。这样既控制存储成本,又保证问题请求有足够信息排查。
第四个技巧:定期做延迟基线回归。每周跑一次标准测试集,记录基线延迟和 Token 数。一旦基线漂移,说明环境或模型有变化,能提前发现问题。这个习惯帮我抓到过好几次模型版本被意外更新的情况。
6. 从可观测性到持续优化
可观测性做扎实之后,优化就有了方向。我通常按"先降本、再提速、后提质"的顺序推进。降本靠 Token 核算找出浪费点,比如精简 prompt、限制 max_tokens、对重复请求做缓存。提速靠延迟归因定位瓶颈,该扩容扩容,该调参调参。提质则要结合业务反馈,看哪些请求虽然快但质量差,可能需要调整采样参数或换模型。
这套体系不是一次搭完就完事,而是随着业务演进不断调整指标和阈值。我现在的习惯是每月回顾一次指标口径,看看有没有新的业务场景需要补充埋点。大模型这个领域变化太快,今天够用的观测维度,明天可能就不够了。保持指标体系的弹性,比一次性设计完美更重要。
最后分享一个小心得:可观测性的价值不在于数据多,而在于数据能回答具体问题。每次加指标前先问自己"这个数据能帮我做什么决策",答不上来的指标就别加,否则只会淹没真正有用的信号。