ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

大模型推理可观测性实战:Token消耗与延迟监控方案

大模型推理可观测性实战:Token消耗与延迟监控方案 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 延迟TTFTTime 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 之后到最后一个 tokenE2E 延迟从请求发出到收到最后一个 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_count3.3 用 OTel 语义约定统一属性名埋点最怕各写各的。OTel 针对生成式 AI 已经有一套语义约定我挑几个最常用的列出来建议直接照抄属性名含义类型gen_ai.system模型提供方如 openai、vllmstringgen_ai.request.model请求的模型名stringgen_ai.usage.input_tokens输入 token 数intgen_ai.usage.output_tokens输出 token 数intgen_ai.response.finish_reasons结束原因如 stop、lengthstring[]gen_ai.operation.name操作类型如 chat、completionstringfinish_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, unitms, description首 token 延迟 ) e2e_hist meter.create_histogram( llm.e2e.ms, unitms, description端到端延迟 ) input_tokens_counter meter.create_counter( llm.tokens.input, unit1, description输入 token 总数 ) output_tokens_counter meter.create_counter( llm.tokens.output, unit1, 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(modelqwen-7b, streamTrue) 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_reasonlength 的占比、错误率、超时率。截断率突然升高往往意味着有人在刷长文本或者 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都用兜底值上报并打上estimatedtrue标记。这样数据不会缺也不会污染真实值。第二个坑时钟漂移导致 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% 就拦下来查。这个习惯帮我拦下过好几次“参数改错导致成本翻倍”的事故。
返回列表