ARTICLE DETAIL

资讯详情

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

大模型推理可观测性实战:Token消耗与延迟监控落地指南

大模型推理可观测性实战:Token消耗与延迟监控落地指南 1. 大模型推理可观测性到底在解决什么问题大模型应用上线之后最让人头疼的往往不是模型本身答得好不好而是“钱花在哪了、慢在哪了、什么时候开始不对劲了”。我见过不少团队模型接进业务跑了两周账单突然翻了三倍排查半天才发现是某个上游服务在疯狂重试每次重试都带着完整的上下文重新推理一遍。也见过线上对话突然变慢用户投诉不断最后定位到是某个批次的请求上下文长度暴涨把显存打满导致排队。这些问题的共同点是如果没有一套围绕推理过程的观测体系你根本不知道发生了什么。所谓大模型日志与可观测性说白了就是给每一次推理调用建立一份“体检报告”。这份报告里最核心的两个指标一个是Token 消耗一个是延迟。Token 消耗直接对应成本延迟直接对应体验。把这两个指标按请求、按会话、按模型、按业务线拆开看你才能回答“哪个功能最烧钱”“哪个环节最拖后腿”这类问题。这套东西适合谁如果你是刚把大模型 API 接进产品的后端开发它能帮你快速建立成本意识如果你是负责模型部署的运维或平台工程师它能帮你定位推理引擎的性能瓶颈如果你是技术负责人它能让你在汇报时拿出真实数据而不是拍脑袋。哪怕你只是自己搭了个本地推理服务玩玩加上这层观测也能让你清楚知道每次对话到底花了多少资源。我自己的体会是可观测性这件事越早做越好。等到账单爆炸或者线上雪崩再去补日志往往已经损失了一大批用户信任。下面我就按实际落地的思路把整套方案拆开讲清楚。2. 整体设计思路与关键选型考量2.1 为什么不能只靠云厂商自带的用量面板很多人第一反应是我用的是云上的大模型服务后台不是有用量统计吗问题在于云厂商的面板通常是按账号、按天聚合的粒度太粗。你看到的是“今天总共消耗了 200 万 Token”但你想知道的是“今天下午三点那波慢请求是哪个用户触发的、上下文有多长、用的是哪个模型”。这些细粒度信息厂商面板给不了必须自己在调用链路上埋点。另一个现实问题是很多团队并不是只用一家模型服务。可能主力用一家备用用另一家本地还部署了一个开源模型做兜底。这种情况下统一的可观测层就更有必要了否则你连横向对比都做不到。2.2 埋点位置的选择网关层还是业务层埋点位置决定了你能拿到什么数据。常见做法有两种一种是在 API 网关层统一拦截另一种是在业务代码里逐个调用点埋。我的建议是以网关层为主、业务层为辅。网关层的好处是统一、不易遗漏所有经过网关的请求都能被记录而且可以拿到完整的请求和响应体方便计算 Token。但网关层拿不到业务语义比如这次调用属于哪个功能模块、是哪个用户。所以业务层需要补充传递一些标签比如在请求头里带上x-biz-module、x-user-id这类信息网关记录时一并写入日志。如果你们用的是类似 OpenAI 兼容协议的接口网关层可以直接解析请求体里的messages字段和响应体里的usage字段。usage里通常包含prompt_tokens、completion_tokens、total_tokens这是最准确的 Token 数据来源。如果某些自部署的推理引擎不返回usage那就需要自己用分词器估算这部分后面会细讲。2.3 延迟指标怎么拆才有意义延迟不是一个单一数字。用户感知的“慢”可能来自网络传输、排队等待、模型首 Token 生成、后续 Token 流式输出等多个环节。如果只记录一个总耗时排查问题时你会很痛苦。我习惯把延迟拆成这么几段请求排队时间从请求到达网关到真正被推理引擎接收的时间反映的是并发压力。首 Token 延迟TTFT从推理开始到第一个 Token 返回的时间对流式对话体验影响最大。Token 间延迟TPOT相邻 Token 之间的平均间隔反映的是解码速度。总耗时从请求发出到最后一个 Token 返回的完整时间。把这四个指标分开记录你就能判断问题出在哪。比如 TTFT 正常但 TPOT 很高说明模型解码慢可能是显存带宽瓶颈如果排队时间很长说明并发数超了需要扩容或限流。2.4 日志存储与查询的取舍日志量大的时候存储和查询成本会迅速上升。我的经验是分层存储热数据最近 7 天放在支持全文检索的日志系统里方便快速排查冷数据7 天以上归档到对象存储只保留聚合后的指标。聚合指标可以按小时、按天 rollup这样既能看趋势又不会把存储撑爆。查询方面如果团队已经有 Elasticsearch 或 Loki 这类日志系统直接复用即可。如果没有初期用结构化日志写到文件、再用轻量工具做聚合也能顶一阵。关键是日志格式要统一每条记录都是 JSON字段名固定这样后续换系统也不用改埋点代码。3. 核心细节解析与实操要点3.1 Token 统计的三种方式与准确性对比Token 统计是成本核算的基础但不同来源的数据准确性差异很大。我整理了一个对比表统计方式数据来源准确性适用场景响应体 usage 字段模型服务返回最高支持该字段的云服务或推理引擎分词器本地计算本地 tokenizer较高自部署引擎、需要预估的场景字符数粗略估算请求文本长度低仅用于快速预警不能用于计费优先用usage字段这是模型服务自己算的最准。如果拿不到就用对应模型的分词器本地算。注意不同模型的分词器不一样比如有的用 BPE有的用 SentencePiece混用会导致统计偏差。粗略估算只适合做“今天用量是不是异常”这种预警千万别拿它去跟业务方对账。还有一个坑流式响应下的 Token 统计。流式返回时usage字段可能只在最后一个 chunk 里出现也可能完全不出现。如果完全不出现你只能在流结束后用分词器对完整输出做一次计算。这时候要注意把流式拼接的文本完整保存下来否则算不准。3.2 延迟埋点的时间戳怎么打才准延迟计算的准确性取决于时间戳的精度和一致性。我建议在三个位置打时间戳网关收到请求的瞬间t0推理引擎开始处理的瞬间t1收到第一个 Token 的瞬间t2收到最后一个 Token 的瞬间t3这样 TTFT 就是t2 - t1排队时间是t1 - t0总耗时是t3 - t0。注意所有时间戳要用同一时钟源跨机器的话最好用 NTP 同步否则算出来的延迟可能是负数。提示如果推理引擎不暴露“开始处理”的时间点可以用网关转发请求的时间近似代替虽然有一点误差但排查问题时够用了。3.3 上下文长度对延迟的影响必须单独看同样是一次推理上下文 500 Token 和上下文 8000 Token 的延迟可能差好几倍。如果只统计平均延迟长上下文请求会把短请求的数据淹没你看不出真实分布。我的做法是按上下文长度分桶统计比如 0-1K、1K-4K、4K-16K、16K 以上每个桶单独算 P50、P95、P99 延迟。这样一眼就能看出长上下文是不是瓶颈。Token 消耗同理也要按桶看。很多时候成本超支就是因为某个功能悄悄把上下文拉长了分桶统计能让你第一时间发现异常。3.4 采样与全量记录的平衡全量记录每次推理的详细日志在请求量大的时候会产生海量数据。我的建议是指标全量、明细采样。也就是说Token 数和延迟这类数值指标每次都记录并聚合但完整的请求和响应内容只按一定比例采样保存比如 1% 或 5%。采样比例可以根据流量动态调整流量大时降低比例流量小时提高比例。采样时要注意保留异常请求。比如延迟超过阈值、Token 数异常大的请求应该 100% 记录明细方便事后分析。这个逻辑可以在网关层实现先判断是否异常异常就强制记录正常则按概率采样。4. 实操过程与核心环节实现4.1 搭建一个最小可用的观测网关下面用一个 Python 写的轻量网关示例演示怎么在转发请求的同时记录 Token 和延迟。这个例子假设后端是 OpenAI 兼容接口实际使用时把UPSTREAM_URL换成你的推理服务地址即可。import time import json import uuid import httpx from fastapi import FastAPI, Request from fastapi.responses import StreamingResponse app FastAPI() UPSTREAM_URL http://localhost:8000/v1/chat/completions app.post(/v1/chat/completions) async def proxy(request: Request): body await request.json() request_id str(uuid.uuid4()) t0 time.time() # 记录请求元信息 log_entry { request_id: request_id, model: body.get(model), stream: body.get(stream, False), message_count: len(body.get(messages, [])), biz_module: request.headers.get(x-biz-module, unknown), user_id: request.headers.get(x-user-id, anonymous), } async with httpx.AsyncClient(timeout120) as client: if body.get(stream): # 流式处理 async def stream_generator(): t1 time.time() first_token_time None last_chunk None async with client.stream(POST, UPSTREAM_URL, jsonbody) as resp: async for line in resp.aiter_lines(): if line.startswith(data: ) and line ! data: [DONE]: if first_token_time is None: first_token_time time.time() last_chunk line yield line \n t3 time.time() # 从最后一个 chunk 提取 usage usage {} if last_chunk: try: data json.loads(last_chunk[6:]) usage data.get(usage, {}) except Exception: pass log_entry.update({ queue_time_ms: round((t1 - t0) * 1000, 2), ttft_ms: round((first_token_time - t1) * 1000, 2) if first_token_time else None, total_ms: round((t3 - t0) * 1000, 2), prompt_tokens: usage.get(prompt_tokens), completion_tokens: usage.get(completion_tokens), total_tokens: usage.get(total_tokens), }) write_log(log_entry) return StreamingResponse(stream_generator(), media_typetext/event-stream) else: t1 time.time() resp await client.post(UPSTREAM_URL, jsonbody) t3 time.time() data resp.json() usage data.get(usage, {}) log_entry.update({ queue_time_ms: round((t1 - t0) * 1000, 2), ttft_ms: round((t3 - t1) * 1000, 2), total_ms: round((t3 - t0) * 1000, 2), prompt_tokens: usage.get(prompt_tokens), completion_tokens: usage.get(completion_tokens), total_tokens: usage.get(total_tokens), }) write_log(log_entry) return data def write_log(entry): # 实际使用时替换为写入日志系统或消息队列 print(json.dumps(entry, ensure_asciiFalse))这段代码的核心思路是请求进来先打时间戳转发给上游流式响应时逐行透传并记录首 Token 时间结束后从最后一个 chunk 提取 usage 并写入日志。非流式则直接等响应返回后记录。4.2 用分词器兜底计算 Token如果上游不返回 usage就需要本地计算。以 HuggingFace 的 tokenizer 为例from transformers import AutoTokenizer tokenizer AutoTokenizer.from_pretrained(your-model-path) def count_tokens(messages): # 简单拼接实际应按模型的 chat template 处理 text .join([m[content] for m in messages]) return len(tokenizer.encode(text))注意这里只是演示实际计算时要按模型的 chat template 来拼接否则算出来的数和模型真实消耗会有偏差。不同模型的 template 不一样有的加特殊 token有的不加这个细节会直接影响统计准确性。4.3 延迟分桶统计的实现分桶统计可以在日志写入后由聚合任务完成。下面是一个简单的聚合逻辑def bucket_by_context_length(logs): buckets {0-1K: [], 1K-4K: [], 4K-16K: [], 16K: []} for log in logs: tokens log.get(prompt_tokens) or 0 if tokens 1000: buckets[0-1K].append(log) elif tokens 4000: buckets[1K-4K].append(log) elif tokens 16000: buckets[4K-16K].append(log) else: buckets[16K].append(log) return buckets def percentile(values, p): if not values: return None values sorted(values) idx int(len(values) * p / 100) return values[min(idx, len(values) - 1)]拿到分桶后的数据就可以分别算每个桶的 P50、P95、P99 延迟以及平均 Token 消耗。这样报表出来长上下文的影响一目了然。4.4 告警规则怎么设才不吵人告警设得太敏感天天响大家就麻木了设得太迟钝真出问题又发现不了。我的经验是设两级告警预警P95 延迟超过基线 1.5 倍或小时级 Token 消耗超过预算 80%发到团队频道不强制响应。严重告警P99 延迟超过基线 3 倍或小时级 Token 消耗超过预算 120%或错误率超过 5%直接打电话。基线怎么定上线后先跑一周取正常时段的 P95 作为基线之后每周滚动更新。这样基线会随着业务变化自动调整不会因为业务增长而误报。5. 常见问题与排查技巧实录5.1 Token 数和账单对不上怎么办这是最常见的问题。可能的原因有几个一是统计口径不一致你算的是输入加输出账单可能只算输出二是重试请求被重复计费但你的日志只记了一次三是流式响应的 usage 没拿到用了估算值导致偏差。排查时先确认口径再检查是否有重试逻辑。重试一定要在日志里标记出来比如加一个retry_count字段这样对账时能把重试的消耗单独拎出来看。5.2 延迟突然升高但找不到原因延迟升高通常有几个方向上游模型服务变慢、网络抖动、并发排队、上下文变长。排查顺序建议是先看排队时间如果排队时间长说明并发压力大再看 TTFT如果 TTFT 高但排队正常说明模型侧慢最后看上下文分桶如果长上下文请求占比突然升高那就是业务侧的问题。我遇到过一次延迟飙升最后发现是某个新上线的功能把系统提示词写得太长每次请求都多带了两千 Token。这种问题不看分桶统计根本发现不了。5.3 流式响应下首 Token 时间记录不准流式响应时首 Token 时间应该以第一个包含实际内容的 chunk 为准而不是第一个 SSE 事件。有些服务会先发一个空的 role chunk再发内容 chunk如果把空 chunk 当首 TokenTTFT 会偏小。处理时判断一下 chunk 里是否有实际文本内容。5.4 日志量太大导致存储成本失控前面提过采样这里补充一个技巧对日志字段做裁剪。完整的请求和响应内容很占空间但排查问题时往往只需要看前几百个字符。可以在写入前对内容字段做截断比如只保留前 500 字符超出部分用省略号代替。这样既能保留关键信息又能大幅降低存储量。5.5 常见问题速查表问题现象可能原因排查方向Token 消耗突增上下文变长、重试增多、异常请求看分桶统计、重试计数、异常请求明细延迟升高并发排队、模型变慢、网络抖动看排队时间、TTFT、TPOT 分段数据首 Token 时间偏小空 chunk 被误判为首 Token检查 chunk 内容是否为空日志存储暴涨全量记录未采样、字段未裁剪调整采样率、截断长文本字段告警频繁误报基线未随业务更新、阈值过严滚动更新基线、调整阈值注意排查问题时优先看聚合指标定位到异常时间段后再去翻明细日志。反过来做效率极低。6. 我踩过的坑和几条实用建议第一个坑是时间戳精度。早期我用秒级时间戳结果很多请求的总耗时算出来是 0因为太快了。后来统一改成毫秒级问题解决。如果你的服务延迟本身就在几十毫秒级别秒级时间戳完全不够用。第二个坑是流式响应的日志写入时机。一开始我在流结束后才写日志结果如果客户端提前断开连接日志就丢了。后来改成在生成器里用 try-finally 包裹确保无论正常结束还是异常断开都能写入日志。第三个坑是多模型混用时的 Token 统计。不同模型的分词器不一样用同一个分词器算所有模型的 Token 会不准。后来我给每个模型配置了对应的分词器统计才准确。最后分享一个我觉得很实用的小技巧在日志里加一个trace_id从网关一直透传到业务代码和下游服务。这样排查问题时你可以用这一个 ID 把整条链路的日志串起来不用在多个系统之间来回跳。这个习惯一旦养成排查效率会提升一大截。这套观测体系我前后迭代了三四版从最开始只记总耗时和总 Token到现在分桶、分段、采样、告警一应俱全。每次迭代都是被实际问题逼出来的。如果你刚开始做不用一步到位先把 Token 和总延迟记起来跑一段时间有了数据再逐步细化。数据本身会告诉你下一步该补什么。
返回列表