最近在帮团队做 AI 应用的可观测性治理,发现 OpenTelemetry 的 GenAI 语义规范真是来得及时:调用链追踪能还原每一次模型调用的上下文,Token 成本治理则直接关系到一个功能是赚钱还是亏钱。这篇文章不是概念科普,是我把规范原理、落地代码、踩坑实录一次讲清楚的经验帖,适合正在接大模型、准备做成本管控的研发和运维同学读。
1. 为什么 AI 应用比传统应用更需要可观测性
先说一个我最近高频遇到的场景:线上服务 QPS 正常、错误率不高,老板却拿着账单来问"这个月光调用大模型就花了十几万,钱花哪了?"研发一脸无辜——代码里就那几个接口,日志也没报错。问题就出在,传统可观测性体系根本回答不了"钱花哪了"这个问题。
1.1 大模型应用的三笔"糊涂账"
大模型应用和普通 Web 服务有个本质区别:普通服务关心的是 CPU、内存、延迟,而大模型服务多了三个变量——不确定性、不可见性、不可控成本。
不确定性好理解,同一个 prompt 你问十次,十次回答可能都不太一样。传统排查思路是"复现问题",到了 AI 场景基本失效,因为问题本身可能是概率性的。不可见性更麻烦,模型内部怎么推理、为什么这么回答,对调用方来说就是黑盒,你只能从输入输出和少量元数据里找线索。成本问题则是最现实的痛点,普通接口的边际成本趋近于零,而大模型每次调用都要按 Token 付费,一个 Agent 任务内部可能调十几次模型,中间任何一次结果不理想,钱就白花了。
打个不严谨的比方:传统 API 像是雇小时工,按固定工时结算;大模型 API 像是请顾问,按说的每一个字付费。你总得知道这个顾问说了多少字、在哪些话题上说了多少字、哪次对话是最贵的。
1.2 传统监控指标在 AI 场景下的短板
Red 指标(Rate、Errors、Duration)和 USE 指标(Utilization、Saturation、Errors)是传统可观测性的两大支柱,但它们对 AI 应用有三个明显盲区。
第一个盲区是没有 Token 维度。请求量再大,你也不知道每次请求烧了多少 Token,更不知道 prompt 和 completion 各占多少。第二个盲区是链路不完整。一个智能客服的背后往往是"意图识别 → 检索知识库 → 调用大模型 → 工具查询订单 → 汇总回答"这一串动作,传统监控只会告诉你总耗时和状态码,中间哪个环节贵、哪个环节慢,全被折叠了。第三个盲区是缺少业务语义。我见过很多团队排查问题时,日志、指标、链路三套系统各查各的,费了半天劲才拼出一个完整现场。
说到底,AI 应用需要的是"过程可观测",而不只是"结果可观测"。这正好是 OpenTelemetry GenAI 语义规范想解决的事。
2. OpenTelemetry GenAI 规范:给 AI 调用一份"标准病历"
OpenTelemetry 是当前可观测性领域事实上的标准协议,之前主要覆盖 HTTP、数据库、消息队列这些通用组件。GenAI 语义规范就是在这个框架里为生成式 AI 场景新增的一套标准化描述,目的很直接:让不同厂商的模型调用、不同团队的埋点代码,最终产出的 Trace 数据长得一样、字段统一、能直接对比。
2.1 语义约定解决的三个问题
没有规范之前,各家 AI 应用的埋点基本是"各写各的"。有人把模型名写在model字段,有人写在llm_model,还有人干脆写在日志里;Token 统计更是五花八门。数据格式不统一,意味着跨团队、跨服务做成本汇总时,光是清洗字段就要写一堆胶水代码。
GenAI 语义约定通过三件事解决这个混乱:第一,统一 span 命名和属性名,规定哪些信息必须记录、以什么 key 记录;第二,明确 span 的父子关系,让多轮调用、工具调用能串成完整链路;第三,把 Token 用量定义为标准属性,让成本计算可以直接基于 Trace 数据完成,不用再额外翻账单。
注意:语义约定(Semantic Conventions)是规范,不是具体的 SDK 实现。你可以用官方 OpenTelemetry SDK 自己埋点,也可以用 OpenLLMetry 这类社区封装的库,只要最终输出的属性名符合规范,后端统一消费即可。
2.2 核心 Span 与属性字段速查
GenAI 规范里,最常用的是 LLM 调用对应的 client span,我把生产环境里真正用得上的字段整理了一张速查表。
| 属性名 | 含义 | 示例值 |
|---|---|---|
gen_ai.operation.name | 操作类型 | chat、complete、embed |
gen_ai.provider.name | 服务商 | openai、anthropic、azure |
gen_ai.request.model | 请求的模型名 | gpt-4o-mini |
gen_ai.request.max_tokens | 最大输出 Token 数 | 2048 |
gen_ai.response.id | 服务端返回的响应 ID | chatcmpl-xxxx |
gen_ai.response.finish_reasons | 结束原因 | stop、length、tool_calls |
gen_ai.usage.input_tokens | 输入的 Token 数 | 1280 |
gen_ai.usage.output_tokens | 输出的 Token 数 | 512 |
这里面我特别想强调gen_ai.response.finish_reasons。它看着不起眼,实际上最能反映成本浪费:如果大量请求是length结束,说明 max_tokens 设小了,输出被截断,用户得多问几轮,Token 自然就上去了;如果是tool_calls结束,说明模型在反复调用工具,需要重点看工具链路是不是陷入了循环。
2.3 Token 字段:成本治理的地基
为什么专门说 Token 字段是"地基"?因为 Token 是计费的直接依据,没有标准化的 Token 统计,谈成本治理就是空中楼阁。
各家模型服务的计费逻辑不完全一致,但大体都分三块:输入 Token(prompt 部分)、输出 Token(completion 部分)、缓存 Token(命中 prompt 缓存的部分)。输入和输出单价通常差好几倍,所以规范里单独拆开记录,而不是混成一个总数,就是为了让成本分摊更精确。这个细节后面算账时会非常有用。
另外提醒一句,在流式输出场景下,usage 数据是分多次返回的。我见过不少团队在流式回调里逐次累加 Token,结果统计出来的量比实际账单翻了一倍——正确做法是取最后一次响应里带上的 usage 汇总值。这个坑我放到后面"常见问题"里细说。
3. 调用链追踪:把一次智能回答拆成一条完整链路
调用链追踪的价值,一句话概括:给一次用户请求建立完整的时空档案。用户看到的是最终回复,你通过 Trace 看到的是这个回复背后发生的所有事。
3.1 一条 RAG 请求的调用链到底长什么样
拿最常见的 RAG(检索增强生成)场景举例,一次"帮我查一下上周的订单状态"的请求,链路大概长这样:网关收到请求后,先有一个编排层 span,接着是向量检索 span(从知识库找出相关片段),然后是 LLM 调用 span(把检索结果拼进 prompt 一起发给模型),模型如果决定调用工具,还会挂上工具执行 span,最后生成回复。
这些 span 不是平行的,是一层层嵌套的父子关系。父 span 记录整个请求的生命周期,子 span 记录每个子步骤的耗时和元数据。任何一个环节变慢、失败或者烧了太多 Token,都能在 Trace 里定位到具体是哪一跳。
有了这套链路,排查问题就不再靠猜。用户说"回答得不对",你先看检索环节召回的内容是不是本身就偏了;用户说"好慢",你看延迟花在生成上还是工具调用上;老板说"太贵",你看哪一跳的输入 Token 异常膨胀——很可能是检索结果没有做裁剪,把几万字的知识库片段全塞进 prompt 了。
3.2 从 Trace 里能挖出哪些有效情报
除了排查故障,合理的 Trace 数据还能回答很多业务问题。
第一是模型行为对比。你在灰度新模型时,可以在同一链路里对比新旧模型的 Token 消耗和响应质量。第二是参数调优依据。同一个功能,temperature 设成 0.7 和 0.2,Token 消耗和重试率差多少,用数据说话。第三是异常模式识别。Agent 场景最常见的异常是"工具循环"——模型反复调用同一个工具,拿不到想要的结果就一直重试。这种问题在日志里很难发现,但在 Trace 里一眼就能看到多子 span 的重复模式,几十次循环就是几十倍的 Token 成本。
我还习惯给每个入口 span 加上enduser.id、feature.name这类业务属性。这样不仅能看到技术层面的运行情况,还能按用户、按功能模块做成本分摊——到底是大客户把额度吃掉了,还是某个新功能上线后成本暴增,一查便知。
3.3 生产环境埋点的取舍与采样策略
埋点不是越多越好,尤其是 prompt 内容。很多团队一上来就把完整的 prompt 塞进 span,没过几天就发现两个问题:一是存储成本暴涨,二是敏感信息失控。用户问题里可能带姓名、订单号、健康信息,这些进了链路系统就是妥妥的数据合规风险。
我的建议是:默认不记录完整 prompt,只记录 Token 数和必要元数据;需要诊断时再按需开启完整记录,并配合脱敏策略。采样策略上,我习惯用"错误全采、慢请求高概率采样、普通请求低比例采样"的混合方案。OpenTelemetry 的 Collector 里配置也很简单,比如下面的片段就是让正常请求按 10% 采样,错误请求全量上报:
processors: probabilistic_sampler: sampling_percentage: 10 tail_sampling: decision: - name: errors-full span_conditions: - span_status: status_code: ERROR提示:采样率不是越高越好。先看后端存储容量再定比例,一开始 10% 完全够用,后续根据诊断需求逐步上调。
4. Token 成本治理:从用量数据到预算闭环
如果说调用链追踪是"看得清",成本治理就是"管得住"。有了标准化的 Token 数据,成本治理就不再是月末看账单的被动行为,而是一个可以实时监控、按需告警的闭环。
4.1 模型账单的底层计算逻辑
要治理成本,先得懂计价模型。虽然各家模型价格差异很大,但逻辑是通用的:总费用 = 输入 Token 数 × 输入单价 + 输出 Token 数 × 输出单价。这里的单价通常按"每百万 Token"计价。
举一个演示用参考价格(不同时期价格会调整,逻辑不变):输入 $0.15/百万、输出 $0.60/百万的模型,一次消耗了 4 万输入 Token、2 千输出 Token 的调用,成本大约是40000/1000000 × 0.15 + 2000/1000000 × 0.60 = 0.006 + 0.0012 = 0.0072 美元。单次看着便宜,但一天几百万次调用,成本就非常可观了。
除了单价差异,还有几个隐藏成本点:第一是重试,一次超时或限流触发的重试,等于把同样的 Token 消耗算了两遍;第二是系统提示词膨胀,团队迭代产品时不断往 system prompt 里加内容,单次成本看起来没什么,但全部流量累积起来就是一笔大钱;第三是缓存利用不足,很多服务支持 prompt 缓存,命中的部分价格更低,但代码里如果不做 prompt 结构化,缓存基本命中不了。
4.2 把 Trace 里的 Token 数据聚合成成本报表
规范里已经把 Token 数记录在 span 属性上了,成本聚合其实就是一层算术。最简单的做法是写一个成本计算函数,按模型单价把 Token 转成金额:
# 以每 100 万 Token 计价的参考价格表,实际以合同价为准 PRICING = { "gpt-4o-mini": {"input": 0.15, "output": 0.60}, "gpt-4o": {"input": 2.50, "output": 10.00}, } def span_cost(attributes): model = attributes.get("gen_ai.request.model") if model not in PRICING: return 0.0 input_tokens = attributes.get("gen_ai.usage.input_tokens", 0) output_tokens = attributes.get("gen_ai.usage.output_tokens", 0) return ( input_tokens / 1_000_000 * PRICING[model]["input"] + output_tokens / 1_000_000 * PRICING[model]["output"] )把这个函数接到 Trace 数据的消费端,就可以按时间、按服务、按功能模块、按用户维度做聚合并展示到仪表盘上。我实际落地时最常看的三张报表分别是:
- 按接口维度的单位成本:哪个接口单次成本最高,哪个接口调用量最大,一对比就能找到"降本优先级"。
- 按模型版本的成本趋势:灰度新模型后成本是升了还是降了,五分钟内就能看到,不用等月底账单。
- 按业务方/用户维度的用量 Top N:大客户是不是在滥用、某个免费功能是不是被脚本刷量,这类问题靠 Token 数据一眼就能发现。
4.3 预算告警与用量异常排查
成本报表只能告诉你"发生了什么",真正要控制,还得靠预算告警。我建议至少配三档:日预算 70% 提醒、日预算 100% 告警、单请求成本异常告警(比如单次请求成本超过预设阈值)。
告警的触发逻辑可以基于指标来实现。OpenTelemetry 的 span 数据通过 Collector 可以转成 metrics,比如按分钟聚合每个服务的总 Token 消耗量,再配合告警规则引擎,就能实现接近实时的成本监控。我遇到过一个很典型的例子:新版本上线后单次请求成本直接涨了 3 倍,原因就是 prompt 里多拼接了一段没用的历史会话记录。如果没有按版本维度的成本监控,这种问题很容易在线上跑好几个星期才被发现。
另外,成本治理还有一个容易忽略的环节:在上游做"预算硬限制"。有些模型服务支持设置每月的消费上限,建议在测试环境确认好用后,给生产环境的 key 设置一个略高于预期的上限,作为最后的兜底防线。真正的精细化治理,还是要靠代码层面的 prompt 优化和缓存策略。
4.4 顺手解决 Token 生命周期问题:续签与失效
在调研 Token 成本治理的过程中,有个绕不开的技术细节:调用模型服务本身需要鉴权,而Token 的有效期管理如果没做好,会造成大量无谓的报错和重试,间接推高成本。
实际生产里最常见的几类问题:access token 过期后没有实现自动续签,导致请求全部走一遍"发现过期 → 重新换取 → 重试"的流程;JWT 类 Token 在分布式环境下时钟不同步,明明没过期却被判定为失效;还有多服务共用同一个 token 导致刷新时互相覆盖。这些问题的排查思路我放在后面第六部分的速查表里,这里先给一个通用原则:所有 Token 换取和刷新逻辑必须集中收敛到一个模块里,做好原子性和重试,别让每个业务服务自己去跟身份端点打交道。
5. 实操记录:给 Python 应用接入 GenAI 可观测性
理论讲完,来点能直接抄作业的内容。我以 Python 后端为例,记录一次完整的接入过程。
5.1 架构选型:SDK、Collector 与后端
先明确一个架构分层:应用内埋点用 OpenTelemetry SDK,数据先发给 Collector 做统一处理,再由 Collector 转存到后端。中间加一层 Collector 非常重要,它可以做采样控制、敏感字段过滤、把 Trace 转成 Metrics,还能做格式转换。如果让应用直接连后端,后续改采样策略或者加数据清洗逻辑,就得改代码重新发布,太不灵活。
后端选型就看团队现有设施。有 Prometheus 和 Grafana 的,用 Grafana Tempo 或者 Jaeger 都行;我这次用的是 Jaeger,部署简单,做链路检索够用。依赖库方面,参考版本如下:
pip install opentelemetry-api==1.28.0 pip install opentelemetry-sdk==1.28.0 pip install opentelemetry-exporter-otlp==1.28.05.2 埋点接入的三种方式
第一种是全自动埋点,适合想快速见效的场景。OpenTelemetry 提供了opentelemetry-instrument命令,它可以自动接入常用框架和 HTTP 客户端的调用链,不需要改业务代码:
opentelemetry-instrument \ --traces_exporter otlp \ --metrics_exporter otlp \ --service_name ai-order-assistant \ python app.py第二种是半自动埋点,针对 LLM 调用做精细化打点。这里用到一个很实用的技巧:把模型调用封一个统一入口,在入口里创建 span 并按规范记录属性。这样即使没有现成的 SDK 支持某个模型服务,你也能手动对齐规范:
from opentelemetry import trace tracer = trace.get_tracer("genai.instrumentation") def chat_completion(client, model, messages, max_tokens=2048): with tracer.start_as_current_span("chat completion") as span: span.set_attribute("gen_ai.operation.name", "chat") span.set_attribute("gen_ai.provider.name", "openai") span.set_attribute("gen_ai.request.model", model) span.set_attribute("gen_ai.request.max_tokens", max_tokens) response = client.chat.completions.create( model=model, messages=messages, max_tokens=max_tokens, ) span.set_attribute("gen_ai.response.id", response.id) span.set_attribute("gen_ai.response.finish_reasons", [c.finish_reason for c in response.choices]) span.set_attribute("gen_ai.usage.input_tokens", response.usage.prompt_tokens) span.set_attribute("gen_ai.usage.output_tokens", response.usage.completion_tokens) return response第三种是手动埋点补充业务上下文。在 LLM span 之上,再包一层业务 span,记录用户标识和功能名称。这一步很关键,因为有了业务维度,后面才能做成本分摊:
from opentelemetry import trace tracer = trace.get_tracer("business.instrumentation") def handle_user_query(user_id, feature_name, query): with tracer.start_as_current_span("handle.query") as span: span.set_attribute("enduser.id", user_id) span.set_attribute("feature.name", feature_name) # 内部调用 chat_completion(),会自动挂在当前 span 下面 return chat_completion(client, "gpt-4o-mini", build_messages(query))5.3 端到端验证:从请求到 Trace 与指标
接入完成后,我习惯用一条真实请求做端到端验证。步骤很简单:启动应用和 Jaeger,发一个测试请求,然后到 Jaeger 页面按 service name 搜最近十分钟的 Trace,点开看 span 树。
正常情况你会看到类似这样的结构:顶层业务 span 下面挂了 LLM 调用 span,span 属性里带模型名、Token 数、finish reason。如果你用的后端支持转指标,还可以把gen_ai.usage.input_tokens和gen_ai.usage.output_tokens聚合出 Token 消耗曲线。
验证阶段最容易发现的问题是span 上下文没传透。如果你是异步任务或者通过消息队列触发的调用,子线程里创建的 span 可能找不到父 span。解决办法是在任务投递或出队时手动注入上下文,比如用tracecontext把当前上下文序列化到消息头里,消费端再取出来恢复。
6. 常见问题与排查技巧实录
这一部分是我觉得最值得收藏的内容,全是实践中踩过的坑。
6.1 Token 与鉴权类报错速查表
做完接入和上线,一大类高频问题就来了——各种 Token 相关的鉴权报错。我整理了这段时间排查过的问题,做成速查表供参考。
| 报错特征 | 常见原因 | 排查与解决方向 |
|---|---|---|
| token endpoint 返回 403(提示区域不可用) | 出口 IP 所在区域不在服务商支持范围内 | 确认服务出口地域,核对服务商的区域可用性政策,排查网关或云厂商地域节点配置 |
| token exchange failed: error sending request | 网络抖动、身份端点短暂不可用或 TLS 证书问题 | 检查到身份端点的连通性与证书有效性,加入重试与熔断 |
| sign-in could not be completed | OAuth 流程中 scope 或凭据配置不一致 | 核对 client_id、client_secret、回调地址、scope 权限是否匹配 |
| access token could not be refreshed | refresh token 过期或刷新策略未生效 | 实现自动重新登录流程,统一管理 refresh token 的存储与轮换 |
| 有 token 仍提示无权限 | 角色映射或权限策略未同步 | 检查 RBAC 角色映射,确认新权限策略是否已生效 |
这里单独说一下最容易被误解的那个:403 提示区域不支持时,先从基础配置查起,别急着怀疑代码。很多团队把服务部署在某个云厂商节点,但身份提供商白名单里没加该节点的 IP 段,或者服务商的区域能力列表更新了但配置没同步。这种问题通常不是程序逻辑能解决的,需要运维和账号体系两边一起排查。
6.2 追踪链路断链的五个原因
"埋了点但看不到完整链路"是我被问得最多的问题。总结下来,断链的原因基本逃不出这五类:
- 异步执行丢了上下文:线程池、asyncio task 里没有显式传递 context,子任务里的 span 变成孤儿。
- 消息中间件没传播 span:往 Kafka、RabbitMQ 投递消息时没把
traceparent写进消息头。 - 采样率不一致:上游服务采样率低、下游服务采样率高,导致下游有子 span 但上游没有父 span。
- 服务名配置错误:同一服务在不同环境用了不同 service name,跨环境串联时对不上。
- Collector 过滤规则写错:想过滤掉健康检查的流量,结果正则把正常流量也过滤了。
排查断链问题,我的习惯是先看"子 span 有没有、只是没连上",还是"子 span 压根没产生"。前者去查传播链路,后者去查埋点逻辑和采样配置,方向对了很快就能定位。
6.3 实测踩坑:Token 用量统计对不上账单
最后分享一个我们真实发生的案例:仪表盘上算出来的月成本,和模型服务商账单差了将近 20%。查了很久,发现是三个原因叠加。
第一个原因是流式响应的重复累加。前面提过,流式场景下 usage 会在多次 chunk 里出现,累加逻辑写错了就成倍虚高。第二个原因是重试请求没去重。我们内部 SDK 自带了重试逻辑,埋点代码把每次重试都记成独立 span,成本聚合时自然重复计算。第三个原因是缓存 Token 计入方式不对。实际账单里缓存命中的 Token 有单独的折扣价,直接按输入 Token 的全价算,成本自然被高估。
这三个问题的共同教训是:埋点采集的 Token 是"原始消耗",不是"最终计费"。做成本报表时,一定要把重试去重、缓存 Token 折扣、流式汇总这几个规则先理清楚,否则数据看着很精确,实际跟账单对不上,老板一比对就露馅。
7. 分阶段落地建议
聊了这么多,如果你正准备在自己团队推进这套体系,我给一个务实的路线图。第一阶段,先接一条最核心的链路,只做自动埋点,把 Trace 跑通,这个阶段的目标是"能看到"。第二阶段,把 LLM 调用打点做细,对齐 GenAI 语义规范,把 Token 数据采上来,这个阶段的目标是"能算账"。第三阶段,再上 Collector 的采样策略、成本聚合报表和告警,形成治理闭环。
每个阶段之间留足够的时间观察数据质量。我见过不少团队一上来就追求大而全,结果数据不可信,最后整个可观测性项目被打入冷宫。倒不如先让一条链路的成本算得准、查得清,让团队真的靠它解决过几次问题,再逐步扩展。
我个人在实际操作中的体会是:可观测性建设最怕的不是技术难,而是"做了没人用"。只要坚持"数据必须能回答一个具体问题"这个原则,一步步把 Trace 和 Token 数据用起来,它产生的价值会远超你的预期。后面有机会,我再把成本聚合报表的具体实现和告警规则配置展开写一篇,这次的内容先到这里。