OpenTelemetry GenAI 规范实战:LLM 应用可观测性与 Token 成本治理
1. 为什么 GenAI 应用必须补上可观测性这一课过去两年我经手过不少大模型应用项目从最早的简单问答机器人到后来的多智能体协作系统、RAG 检索增强管线再到最近帮几个团队做成本优化。说实话真正让我睡不着觉的从来不是模型效果调不上去而是线上跑着跑着突然发现账单翻了三倍却完全不知道钱花在哪个环节。这种失控感做过 LLM 应用的人应该都懂。传统微服务那套可观测性体系——指标、日志、链路追踪——在大模型场景下几乎失效。原因很简单一次用户请求背后可能触发五六次模型调用每次调用的输入输出都是自然语言Token 消耗和响应延迟跟输入长度强相关而且流式输出让一次请求的边界变得模糊。你拿 Prometheus 去抓 QPS 和 P99 延迟抓到的只是 HTTP 层的表象根本看不到这次对话到底烧了多少 Token、哪个 Prompt 模板最费钱、哪次工具调用导致了重试风暴。这就是OpenTelemetry GenAI 规范要解决的问题。它把大模型调用抽象成标准化的 Span 语义约定让模型调用、Token 用量、工具调用、向量检索这些 GenAI 特有的操作都能像普通 HTTP 请求一样被追踪、被聚合、被分析。配合调用链追踪和Token 成本治理你能做到精确到每一次模型调用的成本归因。这篇文章适合三类人看一是正在做 LLM 应用、被成本问题困扰的后端和平台工程师二是负责 AI 基础设施、需要搭建可观测体系的 SRE 和 DevOps三是对OpenTelemetry有一定了解、想把它扩展到 GenAI 场景的技术负责人。我会从规范设计思路讲到落地实操包括埋点、采集、存储、看板、成本归因的完整链路也会分享几个我踩过的坑。2. OpenTelemetry GenAI 规范到底定义了什么2.1 从通用语义约定到 GenAI 专用 SpanOpenTelemetry 的语义约定Semantic Conventions本质上是一套字段命名标准。比如一个 HTTP 请求的 Span它的属性里有http.method、http.status_code、http.url这些固定字段这样不同语言、不同框架采集上来的数据才能被同一套后端系统理解。GenAI 规范做的事情是一样的只不过它定义的是大模型调用相关的字段。核心的 Span 类型有这么几类LLM 调用 Span对应一次模型推理请求属性里包含模型名称、请求参数temperature、max_tokens 等、输入输出 Token 数、完成原因stop、length、tool_calls。工具调用 Span对应 Agent 调用外部工具或函数的过程记录工具名称、参数、返回结果。向量检索 Span对应 RAG 场景下的向量库查询记录查询文本、返回文档数、相似度阈值。Agent 编排 Span对应多步推理的顶层编排作为父 Span 串联起下面所有子调用。关键属性字段我列个表这些是实际埋点时最常用的属性名类型说明是否必填gen_ai.systemstring模型提供方标识如 openai、anthropic必填gen_ai.request.modelstring请求的模型名称必填gen_ai.response.modelstring实际响应的模型版本建议gen_ai.usage.input_tokensint输入 Token 数必填gen_ai.usage.output_tokensint输出 Token 数必填gen_ai.request.temperaturedouble采样温度建议gen_ai.request.max_tokensint最大输出 Token建议gen_ai.response.finish_reasonsstring[]结束原因列表建议gen_ai.operation.namestring操作类型如 chat、embeddings必填注意gen_ai.system和gen_ai.request.model这两个字段是成本归因的基石如果埋点的时候漏了或者写错了后面所有成本分析都是空中楼阁。我见过有团队把模型名硬编码成 gpt 这种模糊值结果账单对不上排查了两天才发现是埋点问题。2.2 为什么不用自定义字段而要用规范有朋友可能会问我自己定义几个字段不就行了为什么要遵循 OpenTelemetry 规范这个问题我早期也纠结过。答案是生态兼容性。你自定义的字段只有你自己的采集器和看板认识。但如果你用了gen_ai.usage.input_tokens这个标准字段那么任何支持 OpenTelemetry 的后端——无论是自建的 Jaeger、Tempo还是商业化的观测平台——都能直接识别并展示。更重要的是社区里已经有人基于这套规范做了现成的看板和告警规则你直接导入就能用省下大量造轮子的时间。另一个隐性好处是跨语言一致性。你的 Python 服务用 OpenAI SDKGo 服务用自研的推理网关Java 服务调的是内部模型平台。如果大家都遵循同一套语义约定那么采集上来的数据可以统一分析不会出现Python 服务的 Token 字段叫 input_tokensGo 服务叫 prompt_tokens这种混乱。2.3 调用链追踪在 GenAI 场景的特殊性传统调用链追踪的核心假设是一次请求的耗时和资源消耗相对稳定Span 之间的父子关系清晰。但 GenAI 场景打破了这个假设。首先是流式输出。用户看到第一个 Token 的时间TTFT和整个响应完成的时间Total Latency是两个完全不同的指标前者影响体验后者影响成本。规范里建议把这两个时间都记录下来但很多埋点实现只记了总耗时。其次是Token 与耗时的非线性关系。一次模型调用可能输入 100 Token 输出 2000 Token耗时 30 秒另一次输入 5000 Token 输出 50 Token耗时 3 秒。你不能简单地用耗时来推断成本必须单独记录 Token 数。最后是重试和降级。模型调用失败后自动重试、主模型超时后降级到备用模型这些操作在调用链上会产生多个 Span。如果不做标记你会看到一次用户请求对应三次模型调用但不知道哪两次是重试。规范里建议用 Span 的 status 和 event 来标记重试实际落地时我建议额外加一个自定义属性gen_ai.retry.attempt来明确次数。3. 埋点实操从 SDK 到自研网关的完整方案3.1 用官方 Instrumentation 快速起步如果你用的是 OpenAI 或 Anthropic 的官方 SDK最省事的方案是用 OpenTelemetry 的自动埋点库。Python 生态里有opentelemetry-instrumentation-openai它通过 monkey patch 的方式拦截 SDK 的调用自动生成符合 GenAI 规范的 Span。安装和初始化大概是这样from opentelemetry import trace from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import BatchSpanProcessor from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter from opentelemetry.instrumentation.openai import OpenAIInstrumentor # 初始化 TracerProvider provider TracerProvider() processor BatchSpanProcessor(OTLPSpanExporter(endpointhttp://localhost:4317)) provider.add_span_processor(processor) trace.set_tracer_provider(provider) # 启用 OpenAI 自动埋点 OpenAIInstrumentor().instrument()这样你原有的client.chat.completions.create()调用不需要改任何代码就会自动产生 Span。实测下来输入输出 Token 数、模型名称、结束原因这些字段都能正确采集。但自动埋点有几个明显的局限一是它只能覆盖官方 SDK如果你用的是自研的 HTTP 客户端或者第三方封装库就失效了二是它无法感知业务语义比如你不知道这次调用属于哪个用户、哪个会话、哪个功能模块三是流式调用的 TTFT 采集往往不准确。所以生产环境我一般建议自动埋点打底关键路径手动补充。3.2 手动埋点把业务上下文注入 Span手动埋点的核心思路是在调用模型之前创建一个 Span把业务上下文作为属性写进去调用完成后补充 Token 用量和响应信息。下面是一个封装好的辅助函数from opentelemetry import trace tracer trace.get_tracer(__name__) def traced_llm_call(user_id, session_id, feature, messages, modelgpt-4o): with tracer.start_as_current_span(gen_ai.chat) as span: # 业务上下文 span.set_attribute(app.user_id, user_id) span.set_attribute(app.session_id, session_id) span.set_attribute(app.feature, feature) # GenAI 规范字段 span.set_attribute(gen_ai.system, openai) span.set_attribute(gen_ai.request.model, model) span.set_attribute(gen_ai.operation.name, chat) try: response client.chat.completions.create( modelmodel, messagesmessages, ) usage response.usage span.set_attribute(gen_ai.usage.input_tokens, usage.prompt_tokens) span.set_attribute(gen_ai.usage.output_tokens, usage.completion_tokens) span.set_attribute(gen_ai.response.model, response.model) span.set_attribute(gen_ai.response.finish_reasons, [c.finish_reason for c in response.choices]) return response except Exception as e: span.record_exception(e) span.set_status(trace.Status(trace.StatusCode.ERROR, str(e))) raise这里有个细节值得说app.feature这个自定义属性是我强烈建议加的。它标记了这次调用属于哪个业务功能比如智能客服、文档摘要、代码补全。有了它你才能做按功能维度的成本归因——月底一看账单发现代码补全功能烧了 60% 的预算但只服务了 5% 的用户这种洞察是纯技术指标给不了的。3.3 自研推理网关的埋点策略很多团队会在模型 API 前面加一层自研网关做鉴权、限流、路由、缓存。这层网关其实是埋点的最佳位置因为所有模型调用都从这里过埋一次就能全覆盖。网关埋点的关键点是透传 Trace Context。上游服务在 HTTP Header 里带上traceparent网关解析出来后作为父 Span 的上下文这样整条链路才能串起来。如果网关不支持 W3C Trace Context 标准链路就会断在这里。网关层还需要处理一个棘手问题流式响应的 Token 统计。非流式调用可以直接从响应的 usage 字段拿 Token 数但流式调用SSE的 usage 往往在最后一个 chunk 才返回有些提供方甚至不返回。这时候只能自己估算用 tiktoken 之类的库对输入做精确计数对输出做累积计数。import tiktoken def count_tokens(text, modelgpt-4o): encoding tiktoken.encoding_for_model(model) return len(encoding.encode(text)) # 流式场景下累积输出 output_tokens 0 for chunk in stream: if chunk.choices[0].delta.content: output_tokens count_tokens(chunk.choices[0].delta.content, model)提示tiktoken 的计数和提供方实际计费可能有 1% 到 3% 的偏差因为不同模型的 tokenizer 细节不完全公开。做成本核算时建议留 5% 的缓冲别把预算卡得太死。3.4 采样策略别让观测数据压垮存储全量采集 Trace 数据在 GenAI 场景下成本极高。一次多轮对话可能产生几十个 Span每个 Span 的输入输出如果都完整记录单条 Trace 可能几百 KB。日活一万的应用一天就能产生几十 GB 的 Trace 数据。我的建议是分层采样错误和慢请求全采status 为 error 的、耗时超过阈值的100% 采集这些是排查问题的关键。正常请求按比例采比如 10% 采样率用于统计分析和成本趋势。高价值用户全采付费用户、VIP 用户的请求全采方便做个性化分析。OpenTelemetry 支持基于属性的采样器可以自定义规则。另外Span 属性里的输入输出内容Prompt 和 Completion建议做脱敏或截断既省存储又合规。4. 数据落地采集、存储与成本计算4.1 Collector 配置要点OpenTelemetry Collector 是数据管道的核心负责接收、处理、导出 Trace 数据。GenAI 场景下有几个配置需要特别注意。首先是批处理。模型调用的 Span 往往比较大如果一条一条发网络开销和 Collector 压力都很大。BatchSpanProcessor 的默认配置是 512 个 Span 或 5 秒触发一次GenAI 场景建议调大 batch size 到 1024超时调到 10 秒。其次是属性过滤。Prompt 和 Completion 的完整内容如果不需要可以在 Collector 里直接丢弃只保留 Token 数和元数据。这样能省下大量存储。processors: batch: send_batch_size: 1024 timeout: 10s attributes: actions: - key: gen_ai.prompt action: delete - key: gen_ai.completion action: delete最后是多后端导出。我一般会同时导出到两个地方一个是 Tempo 或 Jaeger用于链路查询和问题排查另一个是 ClickHouse 或 Doris用于成本分析和聚合查询。前者擅长按 Trace ID 查单条链路后者擅长按时间范围做聚合统计各司其职。4.2 用 ClickHouse 做 Token 成本分析Trace 数据落到 ClickHouse 后成本分析就变成了 SQL 查询。建表的时候把关键字段抽出来做列CREATE TABLE genai_spans ( trace_id String, span_id String, parent_span_id String, timestamp DateTime64(3), model String, system String, feature String, user_id String, input_tokens UInt32, output_tokens UInt32, duration_ms UInt32, status String ) ENGINE MergeTree() ORDER BY (timestamp, feature, model);有了这张表各种成本分析都能跑。比如按功能维度统计每日 Token 消耗SELECT toDate(timestamp) AS day, feature, sum(input_tokens) AS total_input, sum(output_tokens) AS total_output, count() AS call_count FROM genai_spans WHERE timestamp today() - 7 GROUP BY day, feature ORDER BY day DESC, total_output DESC;再比如找出最费钱的用户SELECT user_id, sum(input_tokens output_tokens) AS total_tokens, count() AS call_count FROM genai_spans WHERE timestamp today() - 30 GROUP BY user_id ORDER BY total_tokens DESC LIMIT 20;4.3 成本计算模型把 Token 换算成钱Token 数本身不是钱要乘以单价才是。不同模型、不同提供方的单价差异巨大而且经常调整。我的做法是维护一张价格表定期更新然后在查询时做 JOIN。模型输入单价每百万 Token输出单价每百万 Tokengpt-4o2.5 美元10 美元gpt-4o-mini0.15 美元0.6 美元claude-3.5-sonnet3 美元15 美元国产某模型1 元2 元注意价格表一定要带生效时间字段。模型提供方调价是常事如果你用当前价格去算历史账单结果肯定对不上。我吃过这个亏后来改成价格表带effective_from和effective_to查询时按 Span 的时间戳匹配对应价格。成本计算的 SQL 大概长这样SELECT s.feature, sum(s.input_tokens / 1000000.0 * p.input_price) AS input_cost, sum(s.output_tokens / 1000000.0 * p.output_price) AS output_cost FROM genai_spans s JOIN model_pricing p ON s.model p.model AND s.timestamp p.effective_from AND (p.effective_to IS NULL OR s.timestamp p.effective_to) WHERE s.timestamp today() - 30 GROUP BY s.feature ORDER BY input_cost output_cost DESC;4.4 缓存命中率的追踪成本治理里最容易被忽视的是缓存。很多请求的 Prompt 是重复的如果命中缓存就不用调模型直接省下全部 Token 成本。但缓存命中率如果不追踪你根本不知道缓存策略有没有生效。我的做法是在网关层加一个 Span 属性app.cache_hit命中缓存的请求标记为 trueToken 数记为 0。这样在成本分析时就能算出如果没缓存会花多少钱和实际花了多少钱的对比量化缓存的价值。if cache_hit: span.set_attribute(app.cache_hit, True) span.set_attribute(gen_ai.usage.input_tokens, 0) span.set_attribute(gen_ai.usage.output_tokens, 0) span.set_attribute(app.saved_tokens, estimated_tokens)5. 看板与告警让成本问题主动暴露5.1 核心看板设计看板不是图表越多越好关键是要能回答几个核心问题今天花了多少钱、钱花在哪个功能上、哪个模型最贵、有没有异常。我一般会设计四个面板。第一个是成本总览。展示今日、本周、本月的总成本以及同比环比变化。这个面板放在最上面一眼就能看到趋势。第二个是功能维度拆解。用堆叠柱状图展示各功能的成本占比配合折线图展示趋势。这个面板能帮你发现某个功能成本突然飙升这类问题。第三个是模型维度拆解。展示各模型的调用次数、Token 消耗、平均单次成本。这个面板能帮你做模型选型决策——比如发现某个场景用 mini 模型效果差不多但成本只有十分之一就可以考虑切换。第四个是异常检测。展示单次调用 Token 数的分布标记出超过 P99 的异常调用。有些 Prompt 注入攻击或者死循环会导致单次调用消耗几十万 Token这个面板能帮你及时发现。5.2 告警规则配置告警的核心是在成本失控之前发出信号而不是月底看账单才发现。我配置的告警规则有这么几条日成本超阈值当日累计成本超过预算的 80% 时告警给团队留出调整时间。单次调用 Token 异常单次调用 Token 数超过 50000 时告警可能是 Prompt 注入或死循环。错误率突增模型调用错误率 5 分钟内超过 10% 时告警可能是提供方故障或 API Key 失效。P99 延迟突增模型调用 P99 延迟超过基线 2 倍时告警可能是提供方限流或网络问题。告警规则用 PromQL 或者 ClickHouse 的告警查询都能实现。关键是要设置合理的静默期避免同一个问题反复告警造成告警疲劳。5.3 从告警到归因的闭环告警只是起点真正的价值在于快速归因。收到日成本超阈值告警后你需要能在几分钟内定位到是哪个功能、哪个用户、哪个模型导致的。我的做法是在告警消息里直接带上下钻链接点击后跳转到预置的查询页面自动带上告警时间范围。这样从收到告警到定位问题通常不超过 5 分钟。另外我建议把成本告警和发布系统打通。如果某次发布后成本突然飙升能自动关联到对应的代码变更排查效率会高很多。6. 踩坑记录与常见问题排查6.1 Token 数对不上的几种情况这是最常见的问题自己统计的 Token 数和提供方账单上的对不上。原因通常有这几种。流式调用的 usage 缺失。有些提供方的流式响应不返回 usage 字段或者只在最后一个 chunk 返回。如果你的代码没处理这种情况就会漏统计。解决方案是手动用 tokenizer 计数或者开启提供方的stream_options: {include_usage: true}参数如果支持。重试导致的重复计数。模型调用失败后自动重试如果重试的 Span 没有正确标记会被当成两次独立调用。解决方案是给重试的 Span 加gen_ai.retry.attempt属性分析时去重。多模态输入的 Token 计算。图片、音频输入的 Token 计算规则和纯文本不同很多 tokenizer 不支持。这种情况只能依赖提供方返回的 usage自己算不准。系统提示词的遗漏。有些 SDK 会把 system message 单独处理如果你的计数逻辑只统计了 user message就会漏掉系统提示词的 Token。6.2 链路断裂的排查思路调用链追踪最怕的就是链路断裂——上游有 Span下游有 Span但中间连不起来。排查思路是这样的。首先检查Trace Context 透传。上游服务发请求时HTTP Header 里有没有traceparent网关有没有正确解析并传递如果中间有异步消息队列消息的 metadata 里有没有带上 Trace Context其次检查Span 的父子关系。手动埋点时子 Span 必须用start_as_current_span或者显式指定 parent context否则会变成独立的根 Span。我见过有团队在异步任务里创建 Span 时忘了传 context结果所有异步调用的 Span 都成了孤立的根节点。最后检查采样决策的一致性。如果上游采样器决定不采下游却决定采链路就会断。解决方案是使用 ParentBased 采样器让下游跟随上游的采样决策。6.3 常见问题速查表问题现象可能原因排查方法解决方案Token 数与账单不符流式 usage 缺失检查流式响应最后一个 chunk手动计数或开启 include_usage链路断裂Trace Context 未透传检查 HTTP Header确保网关透传 traceparentSpan 数量暴增循环调用未限制查看单 Trace 的 Span 数加循环次数上限和熔断成本分析为空模型名属性缺失检查 gen_ai.request.model补全埋点字段看板数据延迟Collector 批处理过大检查 batch 配置调小 batch size 或超时告警误报频繁阈值设置过严分析历史数据分布用 P95 而非固定值做阈值6.4 几个我踩过的坑坑一把 Prompt 完整内容写进 Span 属性。早期我觉得这样方便排查结果存储成本爆炸而且有合规风险。后来改成只存 Prompt 的哈希值和长度需要看内容时再去日志系统查。坑二用模型名做成本归因但没考虑版本。gpt-4o和gpt-4o-2024-08-06是两个不同的模型价格可能不同。埋点时要用gen_ai.response.model而不是gen_ai.request.model来做成本计算因为前者是实际使用的版本。坑三忽略了 Embedding 调用的成本。RAG 场景下文档入库时要调 Embedding 模型查询时也要调。这些调用的 Token 消耗往往被忽视但量大起来成本很可观。埋点时要单独标记gen_ai.operation.name embeddings。坑四没有区分输入和输出 Token 的单价。输出 Token 通常比输入贵 3 到 5 倍如果只统计总 Token 数成本估算会严重偏差。必须分开统计。7. 成本治理的进阶玩法7.1 按会话维度的成本归因单次调用的成本分析只能看到点按会话维度聚合才能看到面。一个用户的一次完整对话可能包含十几轮交互每轮又触发多次模型调用和工具调用。把这些 Span 按app.session_id聚合就能算出这次对话总共花了多少钱。这个数据有几个用途一是识别高成本会话分析是不是 Prompt 设计有问题二是做用户分层把成本高的用户单独分析三是做产品决策比如发现某个功能平均每次会话成本 5 元但用户付费只有 1 元那这个功能就需要优化或调整定价。7.2 模型路由的成本优化有了详细的成本数据就可以做智能模型路由。简单请求走便宜的小模型复杂请求走贵的大模型。路由策略可以基于请求特征输入长度、历史轮次、任务类型或者基于实时反馈小模型置信度低时升级到大模型。路由的效果需要用数据验证对比路由前后的成本变化和效果指标用户满意度、任务完成率。如果成本降了 50% 但满意度也降了 30%那这个路由策略就不划算。7.3 Prompt 优化的量化评估Prompt 工程的效果往往靠感觉但有了 Token 数据就能量化。比如把系统提示词从 500 Token 精简到 200 Token单次调用省 300 输入 Token按日调用 10 万次算一天就省 3000 万 Token。这个数字摆出来优化 Prompt 的动力就足了。我一般会维护一个Prompt 版本对比表记录每个版本的 Token 消耗、效果指标、成本。这样每次优化都有数据支撑而不是拍脑袋。8. 我个人的一些实践体会这套体系我在三个项目里落地过最大的体会是可观测性不是一次性工程而是持续迭代的过程。一开始不用追求大而全先把最核心的模型调用 Span 埋上把 Token 数采准把成本看板搭起来。有了基础数据之后再逐步补充工具调用、向量检索、缓存命中的追踪。另一个体会是成本治理要和业务指标挂钩。单纯看今天花了多少钱没有意义要看每元成本带来了多少用户价值。我习惯把成本和 DAU、会话数、付费转化率放在一起看这样才能判断成本是健康增长还是失控。最后说个实操小技巧给每个环境打上独立的标签。开发、测试、预发、生产环境的 Trace 数据要能区分开否则开发环境调试产生的大量调用会污染生产环境的成本统计。用deployment.environment这个标准属性就能解决简单但容易被忽略。这套方案目前在我负责的系统里稳定运行了大半年成本归因的准确率能到 95% 以上异常成本的发现时间从月底看账单缩短到分钟级告警。如果你也在做 LLM 应用强烈建议尽早把可观测性补上越晚补历史数据的缺失就越难弥补。