1. 为什么 AI 应用需要一套专属的可观测性方案
1.1 从传统 APM 到 GenAI 可观测性的范式转移
做过微服务监控的同学对调用链追踪这套东西应该不陌生。一个 HTTP 请求从网关进来,经过鉴权、业务逻辑、缓存、数据库,最后返回结果,每个环节的耗时、状态码、异常堆栈都能被完整记录下来。这套方法论在过去十年里已经非常成熟,OpenTelemetry 也基本成了事实标准。
但当你把监控对象换成一个大语言模型应用时,会发现传统那套东西突然不够用了。原因很简单:传统 APM 关注的是“请求有没有成功、耗时多少、有没有报错”,而 AI 应用真正烧钱和出问题的地方,藏在语义层面。一次对话请求可能 HTTP 200 返回得漂漂亮亮,但背后消耗了 8000 个输入 token、生成了 2000 个输出 token,成本是普通接口的几十倍;用户可能连续追问了五轮,每一轮都把历史上下文重新塞进去,token 用量呈平方级增长;模型可能因为提示词里某个措辞触发了异常长的推理链,导致响应时间从 2 秒飙到 30 秒。
这些问题的共同点是:它们在传统指标里几乎不可见。你看到的是“接口正常”,但账单在悄悄爆炸。这就是 GenAI 可观测性要解决的核心矛盾——把语义层的消耗和行为,翻译成可度量、可追踪、可归因的工程指标。
OpenTelemetry 社区在 2024 年前后开始推进 GenAI 语义约定(Semantic Conventions for GenAI),目的就是给这类应用定义一套统一的埋点规范。它规定了 span 该怎么命名、属性该怎么填、token 用量该放在哪个字段、模型参数该怎么记录。有了这套规范,不同厂商的 SDK、不同的模型服务、不同的编排框架才能被同一套后端接住,做统一分析。
1.2 这套方案到底解决谁的痛点
我把实际会遇到这个问题的角色分成三类,你可以对号入座。
第一类是应用开发者。你写了一个基于大模型的客服机器人或者代码助手,上线之后老板问你“这个月 API 花了多少钱、哪个功能最费钱、有没有异常调用”,你一脸茫然,因为你的日志里只有请求和响应,没有成本维度。你需要的是在代码里埋点,把每次调用的模型名、token 数、耗时、会话 ID 都记录下来。
第二类是平台/SRE 工程师。你负责维护公司内部的 AI 网关或者模型路由层,多个团队共用你的服务。你需要知道哪个团队、哪个应用、哪个用户消耗了多少资源,出了故障要能快速定位是模型侧的问题还是调用方的问题。你需要的是端到端的调用链,把网关、编排层、模型调用串起来。
第三类是成本负责人或者技术管理者。你不写代码,但你要为 AI 预算负责。你需要一张能按天、按模型、按业务线拆分的成本报表,还需要在用量异常时收到告警。你需要的是后端分析能力和可视化面板。
这三类人的需求层层递进,但底层依赖的是同一套埋点数据。所以正确的做法不是各做各的,而是从一开始就按 OpenTelemetry GenAI 规范来埋点,让数据一次采集、多方复用。
1.3 核心概念先对齐:Trace、Span 与 GenAI 属性
在往下讲实操之前,有必要把几个关键概念用大白话过一遍,不然后面看属性名会晕。
Trace(追踪)是一次完整请求的生命周期。比如用户问了一句“帮我写个快排”,从请求进来到答案返回,整个过程就是一个 trace。它有一个全局唯一的 trace_id。
Span(跨度)是 trace 里的一个环节。一次 AI 调用通常至少有三个 span:一个是应用层的入口 span,一个是编排/检索 span(如果你用了 RAG),一个是真正的模型调用 span。span 之间有父子关系,串起来就是调用链。
GenAI 语义约定是 OpenTelemetry 给 AI 相关 span 定义的一套属性命名规则。比如模型调用 span 的名字建议用{operation} {model}的格式,像chat gpt-4o;token 用量放在gen_ai.usage.input_tokens和gen_ai.usage.output_tokens里;模型名称放在gen_ai.request.model。这些名字看着啰嗦,但好处是标准化——任何支持这套规范的后端都能直接识别,不用你写一堆解析规则。
理解了这三个概念,后面的埋点和分析就有了共同的坐标系。
2. OpenTelemetry GenAI 规范的核心字段拆解
2.1 模型调用 Span 的必备属性清单
规范里定义的属性不少,但真正在日常分析中高频用到的其实就那么十几个。我把它们按用途分成四组,你埋点的时候照着这张表填基本就够了。
| 属性名 | 类型 | 用途 | 是否必填 |
|---|---|---|---|
gen_ai.system | string | 供应商标识,如 openai、anthropic | 必填 |
gen_ai.request.model | string | 请求的模型名 | 必填 |
gen_ai.response.model | string | 实际响应的模型名 | 建议 |
gen_ai.operation.name | string | 操作类型,chat/text_completion/embeddings | 必填 |
gen_ai.usage.input_tokens | int | 输入 token 数 | 必填 |
gen_ai.usage.output_tokens | int | 输出 token 数 | 必填 |
gen_ai.request.temperature | double | 温度参数 | 建议 |
gen_ai.request.max_tokens | int | 最大生成 token | 建议 |
gen_ai.response.finish_reasons | string[] | 结束原因,stop/length/content_filter | 建议 |
gen_ai.conversation.id | string | 会话 ID,用于多轮归因 | 强烈建议 |
这里我要特别强调gen_ai.conversation.id。规范早期版本里没有这个字段,很多团队埋点时就漏了,结果后面想做多轮对话的成本分析时发现数据串不起来。多轮对话是 token 消耗的大头,因为每一轮都要把历史消息重新发一遍,如果你不记录会话 ID,就无法回答“这个用户这一整段对话总共花了多少钱”这种问题。我的建议是,只要你的应用支持多轮,这个字段一定要填,哪怕规范里标的是可选。
另一个容易踩坑的是gen_ai.usage.input_tokens和output_tokens的取值来源。有些 SDK 返回的 usage 字段里,input_tokens 是包含缓存命中的部分的,有些则分开算。如果你同时用了提示词缓存,一定要看清楚供应商文档,否则成本计算会偏差很大。我后面会专门讲这个问题。
2.2 Span 命名与层级设计:别让调用链变成一团乱麻
Span 的命名看起来是小事,但它直接决定了你在后端能不能快速筛选和聚合。规范建议模型调用的 span 名用{operation} {model}格式,比如chat gpt-4o、embeddings text-embedding-3-small。这样你在追踪系统里按名字一搜,就能把所有同类调用捞出来。
层级设计上,我推荐一个三层结构,实测下来最清晰:
- 第一层:应用入口 span,名字用业务语义,比如
handle_user_query、generate_code_suggestion。这一层记录用户 ID、会话 ID、业务场景标签。 - 第二层:编排 span,如果你有 RAG 检索、工具调用、多步推理,每个步骤一个 span。这一层记录检索命中的文档数、工具调用次数。
- 第三层:模型调用 span,按规范命名,记录所有 GenAI 属性。
为什么要分三层而不是把所有信息塞进一个 span?因为分析需求是分层的。看整体成本时你聚合第一层,看检索效率时你聚合第二层,看模型选型是否合理时你聚合第三层。如果全塞一起,属性会爆炸,查询也会变慢。
注意:span 的父子关系一定要正确设置。我见过有团队因为异步调用没传 context,导致模型 span 变成了根 span,调用链直接断掉,排查了半天才发现是 context 没透传。
2.3 Token 计量的三个陷阱
Token 计量是成本治理的地基,但这里面的坑比想象中多。我总结了三个最常见的。
陷阱一:把字符数当 token 数。有些人图省事,用字符串长度除以 4 来估算 token,这在英文里勉强能用,中文直接崩盘。中文一个汉字通常对应 1 到 2 个 token,具体取决于分词器。正确做法是直接用供应商返回的 usage 字段,或者用对应的 tokenizer 库精确计算。
陷阱二:忽略系统提示词的消耗。很多应用的系统提示词写得很长,几百上千 token,每次调用都要带上。如果你只统计用户输入,会严重低估成本。埋点时要确保 input_tokens 包含完整的请求内容。
陷阱三:缓存命中与未命中混为一谈。现在主流供应商都支持提示词缓存,缓存命中的 token 价格可能只有未命中的十分之一。如果你的 usage 字段里不区分这两者,成本报表就会失真。建议在 span 里额外加一个自定义属性,比如app.cache_hit_tokens,把缓存部分单独记出来。
这三个陷阱我在不同项目里都踩过,尤其是第三个,等到月底对账发现报表和实际账单差了一大截,回头查才发现是缓存没区分。
3. 从零搭建调用链追踪的实操流程
3.1 环境准备与 SDK 选型
动手之前先把工具链定下来。核心就三样:OpenTelemetry SDK、一个追踪后端、一个模型调用库。
SDK 方面,Python 用opentelemetry-sdk加opentelemetry-exporter-otlp,Node.js 用@opentelemetry/sdk-node。版本上建议用 1.20 以上的,因为 GenAI 语义约定在早期版本里支持不完整。安装命令很直接:
pip install opentelemetry-sdk opentelemetry-exporter-otlp-proto-http pip install opentelemetry-instrumentation追踪后端的选择取决于你的部署环境。自建的话 Jaeger 或者 Grafana Tempo 都行,云上的话各家都有 OTLP 兼容的服务。选型的核心标准是能不能按属性做聚合查询,因为成本分析需要 group by 模型名、会话 ID 这些维度。有些轻量后端只支持按 trace_id 查,做不了聚合,那就不适合。
模型调用库这块,如果你用官方 SDK,很多已经内置了 OpenTelemetry 埋点,比如某些版本的 OpenAI SDK 可以通过环境变量开启。但内置埋点往往不够灵活,属性可能不全,我的建议是自己包一层,在包装函数里手动创建 span 并填充属性,这样可控性最强。
3.2 初始化 TracerProvider 的关键配置
初始化这块有几个参数必须配对,配错了要么数据发不出去,要么性能受影响。
from opentelemetry import trace from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import BatchSpanProcessor from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter from opentelemetry.sdk.resources import Resource resource = Resource.create({ "service.name": "ai-assistant", "service.version": "1.2.0", "deployment.environment": "production", }) provider = TracerProvider(resource=resource) exporter = OTLPSpanExporter(endpoint="http://your-collector:4318/v1/traces") processor = BatchSpanProcessor( exporter, max_queue_size=2048, schedule_delay_millis=5000, max_export_batch_size=512, ) provider.add_span_processor(processor) trace.set_tracer_provider(provider)这里service.name一定要填,它是后端区分不同应用的依据。BatchSpanProcessor的几个参数值得说一下:max_queue_size是队列容量,AI 应用在高并发下 span 产生速度很快,队列太小会丢数据;schedule_delay_millis是批量发送间隔,设太短会增加网络开销,设太长会影响实时性,5000 毫秒是个平衡点;max_export_batch_size是单批发送量,别超过后端接收上限。
提示:如果你的应用是短生命周期的(比如 Serverless 函数),BatchSpanProcessor 可能来不及发送就退出了,这种情况要用 SimpleSpanProcessor 或者在函数结束前手动 flush。
3.3 给模型调用包一层带埋点的封装
这是整个方案的核心环节。我以一个通用的 chat 调用为例,展示怎么把 span 和属性填进去。
import time from opentelemetry import trace tracer = trace.get_tracer("genai.instrumentation") def traced_chat_completion(client, model, messages, conversation_id=None, **kwargs): span_name = f"chat {model}" with tracer.start_as_current_span(span_name) as span: span.set_attribute("gen_ai.system", "openai") span.set_attribute("gen_ai.operation.name", "chat") span.set_attribute("gen_ai.request.model", model) if conversation_id: span.set_attribute("gen_ai.conversation.id", conversation_id) if "temperature" in kwargs: span.set_attribute("gen_ai.request.temperature", kwargs["temperature"]) if "max_tokens" in kwargs: span.set_attribute("gen_ai.request.max_tokens", kwargs["max_tokens"]) start = time.time() try: response = client.chat.completions.create( model=model, messages=messages, **kwargs ) elapsed = time.time() - start span.set_attribute("gen_ai.response.model", response.model) span.set_attribute("gen_ai.usage.input_tokens", response.usage.prompt_tokens) span.set_attribute("gen_ai.usage.output_tokens", response.usage.completion_tokens) span.set_attribute("gen_ai.response.finish_reasons", [c.finish_reason for c in response.choices]) span.set_attribute("app.latency_ms", int(elapsed * 1000)) return response except Exception as e: span.record_exception(e) span.set_status(trace.Status(trace.StatusCode.ERROR, str(e))) raise这段代码有几个设计意图值得说明。第一,span 名用chat {model}格式,符合规范,方便聚合。第二,所有属性都在调用前后填充,确保异常时也能记录到部分信息。第三,record_exception会把异常堆栈记进 span,排查时不用再去翻日志。第四,我额外加了一个app.latency_ms自定义属性,因为有些后端的 span duration 精度不够,自己记一份更可靠。
如果你用的是流式响应,情况会复杂一些。流式调用下 usage 字段往往在最后一个 chunk 才返回,你需要累积所有 chunk 后再填属性。而且流式的首 token 延迟和总延迟是两个不同指标,建议分别记录,首 token 延迟用app.ttft_ms,总延迟用app.latency_ms。
3.4 多轮对话的会话串联
多轮对话的成本分析是重点也是难点。核心思路是给同一个会话的所有 span 打上相同的gen_ai.conversation.id。
会话 ID 的生成时机很关键。我的做法是在用户第一次发起对话时生成一个 UUID,存在会话上下文里,后续每一轮都复用。如果你用的是无状态的 HTTP 接口,可以把会话 ID 放在请求头或者请求体里,由前端维护。
有了会话 ID 之后,你就能在后端做这样的查询:按 conversation_id 聚合,算出这段对话的总 token 消耗、总轮数、平均每轮消耗。这个数据非常有用,因为多轮对话的 token 增长往往是非线性的——第二轮带一轮历史,第三轮带两轮历史,到第十轮时输入 token 可能是第一轮的十倍。如果你发现某些会话的 token 曲线异常陡峭,说明用户可能在问需要长上下文的问题,或者你的历史裁剪策略有问题。
注意:会话 ID 不要用可预测的递增数字,也不要把用户隐私信息编进去。用随机 UUID 最稳妥,既唯一又不泄露信息。
4. Token 成本治理的分析与优化实战
4.1 成本归因模型:从 span 数据到账单
采集到数据只是第一步,真正产生价值的是把 span 数据转化成成本数字。这里需要一个归因模型,核心公式很简单:
单次调用成本 = 输入 token 数 × 输入单价 + 输出 token 数 × 输出单价
但实际落地时,单价不是固定的。不同模型单价不同,同一模型在不同时间段可能有不同价格,缓存命中的 token 单价又不一样。所以你需要维护一张价格表,并且这张表要能随供应商调价而更新。
| 模型 | 输入单价(每百万 token) | 输出单价(每百万 token) | 缓存命中输入单价 |
|---|---|---|---|
| 模型 A | 15 | 60 | 1.5 |
| 模型 B | 3 | 15 | 0.3 |
| 模型 C | 0.5 | 1.5 | 0.05 |
有了价格表,你就可以在后端写一个聚合查询,按天、按模型、按业务线算出成本。我建议至少做三个维度的报表:按时间(看趋势)、按模型(看选型是否合理)、按业务场景(看哪个功能最费钱)。
这里有个实操细节:价格表不要硬编码在代码里,做成配置或者数据库表,方便随时更新。我见过有团队把价格写死在代码里,供应商调价后忘了改,导致成本报表连续几个月都是错的。
4.2 用数据驱动模型选型优化
成本治理最有价值的产出,是帮你做出更聪明的模型选型决策。举几个我实际遇到过的场景。
场景一:简单任务用了贵模型。通过按业务场景聚合,我发现某个“意图分类”功能一直在用最贵的模型,但它的输入输出都很短,任务也很简单。换成便宜模型后,准确率几乎没降,成本直接降了 90%。这种优化如果没有数据支撑,你根本不知道从哪里下手。
场景二:输出 token 失控。有个功能的输出 token 数波动特别大,有时候几百,有时候几千。查了 span 发现是提示词里没有限制输出长度,模型有时候会“话痨”。加上 max_tokens 限制后,成本变得可预测了。
场景三:缓存没生效。系统提示词很长,理论上应该能命中缓存,但数据显示缓存命中率很低。排查发现是提示词里有个时间戳,每次都变,导致缓存永远失效。把时间戳挪到用户消息里之后,缓存命中率上去了,成本降了一大截。
这三个场景的共同点是:问题在传统监控里完全看不见,只有把 token 数据按维度拆开才能发现。这也是为什么我反复强调埋点要全、维度要细。
4.3 实时告警与异常检测
成本治理不能只做事后分析,还要有实时告警。我建议设置三类告警规则。
第一类是单次调用异常。比如单次调用的 token 数超过某个阈值(比如 50000),或者单次成本超过某个金额。这类告警能帮你抓住提示词注入、死循环之类的极端情况。
第二类是速率异常。比如某个应用在 5 分钟内的 token 消耗速率突然翻了好几倍。这可能是流量突增,也可能是代码 bug 导致的重复调用。
第三类是日/周预算告警。设定一个预算上限,当累计消耗达到 80% 时预警,达到 100% 时告警。这类告警适合给管理者看。
告警规则的阈值怎么定?我的经验是先用一周的数据跑出基线,然后按基线的 2 到 3 倍设阈值。设太紧会天天误报,设太松又起不到作用。基线本身也要定期更新,因为业务在增长。
提示:告警要带上足够的上下文,比如 trace_id、会话 ID、模型名,这样收到告警的人能直接跳转到对应的调用链去排查,不用大海捞针。
4.4 提示词与上下文的成本优化技巧
除了选型和告警,日常开发中还有很多能省钱的技巧,这些技巧配合可观测性数据使用效果最好。
技巧一:动态历史裁剪。多轮对话不要无脑带上全部历史。可以根据 token 预算动态裁剪,比如保留最近 N 轮,或者保留最近 M 个 token 的历史。裁剪策略的效果可以通过会话维度的 token 曲线来验证。
技巧二:提示词压缩。系统提示词能精简就精简。我见过一个系统提示词写了 2000 多 token,里面一半是重复的说明。精简到 800 token 后,每次调用省了一大半输入成本。
技巧三:分级模型路由。简单问题走便宜模型,复杂问题走贵模型。可以用一个轻量分类器先判断问题难度,再决定路由。这个分类器本身的成本很低,但能省下大量模型调用费用。
技巧四:批量处理。如果业务允许,把多个独立请求合并成一次调用,能显著降低开销。比如一次要处理 10 条文本,与其调 10 次,不如拼成一个 batch 调一次。
这些技巧单独看都不复杂,但组合起来,配合可观测性数据持续验证效果,成本能降到一个很健康的水平。我在一个项目里把这几个技巧都用上,月度成本从最初的数字降到了三分之一左右,而且用户体验没有明显下降。
5. 常见问题排查与避坑实录
5.1 数据采集阶段的典型故障
问题一:span 发不出去,后端看不到数据。排查顺序是这样的:先确认 exporter 的 endpoint 对不对,OTLP 的 HTTP 端口通常是 4318,gRPC 是 4317,别搞混;再确认网络通不通,用 curl 测一下;然后看 SDK 有没有报错日志,BatchSpanProcessor 发送失败时会有 warning;最后检查 resource 配置,有些后端要求 service.name 必填,缺了会拒收。
问题二:调用链断裂,模型 span 变成根 span。这几乎都是 context 没透传导致的。异步调用、线程池、消息队列这些场景特别容易出问题。解决办法是在跨边界的地方手动传递 context,比如用trace.set_span_in_context把父 span 的 context 传下去。
问题三:token 数对不上。先确认你取的是哪个字段,不同 SDK 的字段名可能不一样。再确认是否包含了系统提示词。如果用了缓存,确认缓存部分有没有单独统计。最后拿一次调用去供应商的控制台对一下,看数字是否一致。
5.2 成本分析阶段的常见困惑
困惑一:报表数字和账单对不上。差异通常来自几个地方:一是价格表没更新,二是缓存 token 没区分,三是有些调用没埋点(比如测试环境的调用混进了生产数据),四是供应商的计费粒度和你的统计粒度不一致。建议先做一次全量对账,把差异来源一个个排除。
困惑二:某个模型成本突然飙升。先看是调用量涨了还是单价涨了。调用量涨的话,按应用和会话拆开看是哪个业务线的问题。单价涨的话,可能是供应商调价,也可能是你从缓存命中变成了未命中。
困惑三:优化之后成本没降。这种情况往往是优化点选错了。比如你优化了输出 token,但输入 token 才是大头。这时候要回到数据,看清楚成本构成,再决定优化方向。
5.3 一份可直接抄的排查速查表
| 现象 | 可能原因 | 排查动作 |
|---|---|---|
| 后端无数据 | endpoint 错误/网络不通 | 检查端口、curl 测试 |
| 调用链断裂 | context 未透传 | 检查异步边界 |
| token 数偏低 | 未含系统提示词 | 核对请求内容 |
| 成本报表偏高 | 价格表过期 | 更新价格配置 |
| 缓存命中率低 | 提示词含变量 | 检查提示词模板 |
| 告警频繁误报 | 阈值设置过紧 | 重设基线 |
| 流式调用无 usage | chunk 未累积 | 累积后统一填属性 |
这张表是我从多次踩坑中总结出来的,基本覆盖了 80% 的常见问题。遇到新问题时,先对照这张表过一遍,能省不少时间。
5.4 几个容易被忽视的细节
最后分享几个细节,都是我在实际项目里踩过或者见别人踩过的。
细节一:采样策略要慎重。全量采集成本高,但采样又会丢数据。我的建议是模型调用 span 全量采集,因为它是成本分析的核心;其他辅助 span 可以按比例采样。如果实在要采样,至少保证错误请求和异常高消耗请求被完整采集。
细节二:属性值不要放敏感信息。提示词和响应内容可能包含用户隐私,不要直接塞进 span 属性。如果确实需要记录,做脱敏处理或者只记录长度和哈希值。
细节三:span 数量要控制。一次请求产生几十个 span 会让后端压力很大。只对关键环节埋点,细枝末节的东西用日志记录就够了。
细节四:定期清理历史数据。追踪数据量增长很快,如果不做保留策略,存储成本会失控。一般保留 30 到 90 天的明细,更早的数据做聚合后归档。
细节五:埋点代码要测试。我见过埋点代码本身有 bug,导致属性填错,分析出来的结论全是错的。埋点上线前一定要验证数据正确性,拿几次调用去核对。
这套方案从埋点到分析到优化,我在几个不同规模的项目里都跑通过。核心体会是:可观测性不是装个 SDK 就完事,而是要围绕业务问题去设计数据维度。你埋什么点,决定了你能回答什么问题。如果一开始没想清楚要分析什么,埋出来的数据往往用不上。所以动手之前,先想清楚你的成本归因要细到什么程度、你的告警要覆盖哪些场景,再倒推需要哪些属性。这个顺序反了,后面返工的成本会很高。