1. 为什么 AI 应用的可观测性突然成了刚需
过去两年我一直在做 LLM 应用落地,从最早的"套壳对话"到现在的多智能体编排、RAG 检索增强、工具调用链,踩过的坑比写过的代码还多。最让我头疼的不是模型效果不好,而是出了问题根本不知道问题出在哪。用户反馈"回答很慢",你去看日志,只有一行request completed in 8.3s,这 8.3 秒里到底是检索慢、模型排队慢、还是工具调用超时?完全抓瞎。更别提月底财务拿着账单来问"这个月 Token 用量为什么翻了三倍",你连哪个功能、哪个用户、哪次调用烧的钱都说不清楚。
这就是AI 应用可观测性要解决的核心问题。传统微服务那套可观测性体系(Metrics、Logs、Traces 三件套)是为确定性系统设计的:一次 HTTP 请求进来,经过几个服务,每个服务耗时多少、有没有报错,链路清清楚楚。但 LLM 应用不一样,它的"一次调用"内部包含了非确定性的推理过程:Prompt 有多长、模型生成了多少 Token、有没有触发工具调用、检索召回了哪些文档、重试了几次——这些信息传统 APM 工具根本采集不到,因为它们不认识gen_ai.*这些语义。
OpenTelemetry GenAI 规范就是在这个背景下被推出来的。它本质上是一套语义约定(Semantic Conventions),规定了 LLM 调用应该用哪些属性名来记录:比如gen_ai.system标记模型厂商、gen_ai.request.model标记请求的模型名、gen_ai.usage.input_tokens和gen_ai.usage.output_tokens分别记录输入输出 Token 数。有了这套统一标准,你的调用链追踪数据就能跨厂商、跨框架地打通,Token 成本治理也就有了数据基础。
这篇文章适合三类人看:一是正在做 LLM 应用但还没上可观测性的开发者,二是被 Token 账单折磨想搞清楚钱花在哪的团队负责人,三是想了解 OpenTelemetry 在 AI 场景下怎么落地可观测性的架构师。我会从设计思路讲到实操细节,把调用链追踪和 Token 成本治理这两件事掰开揉碎讲清楚,代码和配置都能直接抄。
2. 整体设计思路:为什么选 OpenTelemetry 而不是自建埋点
2.1 自建埋点的三个死穴
我最早的做法很朴素:在每个 LLM 调用前后打时间戳,把 Prompt、Completion、Token 数写进自己的日志表。跑了一个月就发现三个问题。
第一个是上下文丢失。一次用户请求可能触发多次 LLM 调用(比如先做意图识别,再检索,再生成,再校验),我在每个调用点独立打日志,但日志之间没有关联 ID,事后想把它们串成一条完整链路,得靠时间戳去猜,误差几百毫秒就串错了。第二个是跨框架不兼容。我用了 LangChain 做编排,又用了 OpenAI SDK 直接调模型,还接了一个自研的向量检索服务,三套东西的埋点格式各写各的,最后数据汇总时字段对不上,input_tokens有的叫prompt_tokens有的叫input_token_count,清洗数据比写业务还累。第三个是采样和性能开销。全量记录 Prompt 内容,日志量爆炸,磁盘一周就满了;改成采样又怕漏掉关键错误请求。
2.2 OpenTelemetry 的解法:标准化 + 上下文传播
OpenTelemetry 的核心价值在于两点。一是统一的语义约定,GenAI 规范把 LLM 调用的关键属性都定义好了,你只要按规范填,不同框架、不同厂商的数据天然就能对齐。二是分布式上下文传播,每个 Span 都有trace_id和span_id,父 Span 调用子 Span 时通过 Context 传递,天然形成树状调用链,不需要你自己维护关联 ID。
具体到 AI 应用,我推荐的架构是这样的:应用层用 OpenTelemetry SDK 埋点,LLM 调用、向量检索、工具调用各自生成 Span;SDK 通过 OTLP 协议把数据发给 Collector;Collector 做统一处理(脱敏、采样、属性补全)后分发给后端,Trace 数据进 Jaeger 或 Tempo,Metrics 数据进 Prometheus,需要做成本分析的 Token 数据单独落一份到 ClickHouse 或 PostgreSQL。
提示:不要一上来就追求全链路全量采集。LLM 应用的 Span 数据里 Prompt 和 Completion 内容体积很大,全量存储成本极高。建议 Trace 只存元数据(模型名、Token 数、耗时、状态),Prompt 内容按需采样或脱敏后单独存储。
2.3 调用链追踪与 Token 治理的关系
很多人把这两件事分开做,我觉得是错的。Token 成本治理的前提是能定位到成本来源,而定位成本来源靠的就是调用链。举个例子,你发现某天 Token 用量暴涨,如果只有总量数据,你只能猜;但如果有调用链,你可以下钻到具体是哪个接口、哪个用户、哪次会话导致的。可能是某个用户的 Prompt 里塞了一整本书,也可能是检索环节召回了几十个文档全塞进了上下文,还可能是重试逻辑写错了导致同一请求调了五次模型。这些问题的根因,只有调用链能告诉你。
所以我的设计思路是:以 Trace 为骨架,把 Token 数据作为 Span 的属性挂上去。这样你在看调用链的时候,每个 LLM Span 上直接就能看到这次调用消耗了多少输入 Token、多少输出 Token,按模型单价一乘就是成本。整条 Trace 的 Token 总量一加,就是这次用户请求的总成本。这个设计让成本治理从"事后算账"变成了"实时可见"。
3. 核心细节解析:GenAI 语义约定到底规定了什么
3.1 关键属性字段逐个拆解
OpenTelemetry GenAI 规范目前还在演进中,但核心字段已经比较稳定了。我把实际落地中最常用的字段整理成表,这些字段你埋点时必须填对,否则后端分析时对不上。
| 属性名 | 类型 | 说明 | 是否必填 |
|---|---|---|---|
gen_ai.system | string | 模型厂商,如openai、anthropic、dashscope | 必填 |
gen_ai.request.model | string | 请求的模型名,如gpt-4o、qwen-max | 必填 |
gen_ai.response.model | string | 实际响应的模型名,可能和请求不同 | 建议填 |
gen_ai.operation.name | string | 操作类型,如chat、embeddings、text_completion | 必填 |
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 | 建议填 |
这里有个坑我要特别提醒:gen_ai.usage.input_tokens和output_tokens这两个字段,不同厂商的 SDK 返回的字段名不一样。OpenAI 返回的是prompt_tokens和completion_tokens,Anthropic 返回的是input_tokens和output_tokens,国内一些厂商返回的是prompt_tokens和completion_tokens。你在埋点的时候必须做一层归一化,统一映射到 GenAI 规范的字段名上,否则后端聚合时会漏数据。
3.2 Span 的层级结构怎么设计
调用链的层级结构直接决定了你排查问题的效率。我见过有人把所有 LLM 调用都平铺在一层,结果一次请求触发十次模型调用,链路图上一排并列的 Span,根本看不出调用顺序和嵌套关系。正确的做法是按业务逻辑分层。
我的分层方案是这样的:最外层是一个http.serverSpan,代表用户请求进来;下一层是业务编排 Span,比如agent.run或chain.invoke;再下一层是具体的操作 Span,包括gen_ai.chat(LLM 调用)、vector.search(向量检索)、tool.execute(工具调用)。这样你在链路图上能清楚看到:用户请求触发了 Agent 编排,Agent 先做了一次向量检索,然后调了一次 LLM 生成回答,回答里又触发了一次工具调用,工具返回后又调了一次 LLM 做总结。每一层的耗时和 Token 消耗一目了然。
注意:Span 的命名要遵循规范。LLM 调用的 Span 名建议用
{operation} {model}格式,比如chat gpt-4o,这样在链路列表里一眼就能看出是哪个模型的操作。不要用llm_call_1、llm_call_2这种无意义命名。
3.3 Token 成本怎么在 Span 上体现
Token 数本身不是成本,乘以单价才是。但单价是会变的,而且不同模型、不同厂商、不同时间段单价都不一样。我的做法是在 Span 上只记录 Token 数,成本计算放到后端做。后端维护一张模型单价表,按gen_ai.response.model和调用时间匹配单价,实时计算出每次调用的成本。
这样做的好处是单价调整时不用改埋点代码,只改后端配置就行。而且可以做更复杂的成本分析,比如区分输入 Token 和输出 Token 的单价(通常输出 Token 贵好几倍),区分缓存命中的 Token(有些厂商对缓存输入有折扣)。如果你把成本直接算在 Span 上,这些灵活性就没了。
模型单价表的结构大概长这样:
CREATE TABLE model_pricing ( model_name VARCHAR(64), provider VARCHAR(32), input_price_per_1k DECIMAL(10, 6), output_price_per_1k DECIMAL(10, 6), cached_input_price_per_1k DECIMAL(10, 6), effective_from DATE, effective_to DATE );查询时按调用时间落在effective_from和effective_to之间来匹配,这样历史数据的成本不会因为单价调整而失真。
4. 实操过程:从零搭建可观测性链路
4.1 环境准备与依赖安装
我以 Python 技术栈为例,因为大部分 LLM 应用都是 Python 写的。需要装的包有这几个:opentelemetry-api、opentelemetry-sdk、opentelemetry-exporter-otlp,如果要做自动埋点还需要opentelemetry-instrumentation系列。LLM 相关的自动埋点,OpenTelemetry 社区有opentelemetry-instrumentation-openai这样的包,但覆盖度还不够全,我建议核心链路手动埋点,辅助链路用自动埋点。
pip install opentelemetry-api opentelemetry-sdk opentelemetry-exporter-otlp pip install opentelemetry-instrumentation-requests opentelemetry-instrumentation-fastapiCollector 我推荐用官方的otelcol-contrib镜像,它内置了各种 processor 和 exporter,不用自己编译。部署方式用 Docker Compose 最省事,生产环境再考虑 K8s。
4.2 SDK 初始化与 Tracer 配置
SDK 初始化这块有几个关键配置。Resource用来标记服务身份,service.name必填,否则后端分不清数据来自哪个服务。Sampler决定采样策略,我建议用ParentBased(TraceIdRatioBased(0.1)),即根 Span 按 10% 采样,子 Span 跟随父 Span 的采样决定,这样能保证一条链路要么全采要么全不采,不会出现断链。SpanProcessor用BatchSpanProcessor,它异步批量发送,对业务性能影响小。
from opentelemetry import trace from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import BatchSpanProcessor from opentelemetry.sdk.resources import Resource from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter from opentelemetry.sdk.trace.sampling import ParentBased, TraceIdRatioBased resource = Resource.create({ "service.name": "llm-gateway", "service.version": "1.2.0", "deployment.environment": "production" }) provider = TracerProvider( resource=resource, sampler=ParentBased(TraceIdRatioBased(0.1)) ) exporter = OTLPSpanExporter(endpoint="http://otel-collector:4317", insecure=True) provider.add_span_processor(BatchSpanProcessor(exporter)) trace.set_tracer_provider(provider) tracer = trace.get_tracer("llm.gateway", "1.2.0")提示:
BatchSpanProcessor的默认队列大小是 2048,如果 QPS 很高可能会丢数据。可以调大max_queue_size和max_export_batch_size,但要注意内存占用。我一般设成队列 8192、批量 1024、导出间隔 5 秒。
4.3 LLM 调用埋点的完整实现
这是最核心的部分。我封装了一个traced_llm_call函数,把 LLM 调用包起来,自动创建 Span、填充 GenAI 属性、记录 Token 数和异常。这个函数可以直接抄。
import time from opentelemetry import trace from opentelemetry.trace import Status, StatusCode tracer = trace.get_tracer("llm.gateway") def traced_llm_call(client, model, messages, **kwargs): with tracer.start_as_current_span( name=f"chat {model}", kind=trace.SpanKind.CLIENT ) 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 "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"]) # 记录 Prompt 长度(不记录内容,避免体积过大) span.set_attribute("gen_ai.request.message_count", len(messages)) start = time.time() try: response = client.chat.completions.create( model=model, messages=messages, **kwargs ) duration = 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", [response.choices[0].finish_reason]) span.set_attribute("llm.duration_ms", int(duration * 1000)) # 计算吞吐量,用于性能分析 total_tokens = response.usage.prompt_tokens + response.usage.completion_tokens span.set_attribute("llm.tokens_per_second", round(total_tokens / duration, 2)) return response except Exception as e: span.set_status(Status(StatusCode.ERROR, str(e))) span.record_exception(e) raise这段代码有几个设计考量。不记录 Prompt 内容是因为体积太大,而且可能包含敏感信息,需要的话单独走脱敏存储。记录 message_count是为了分析上下文长度对成本和延迟的影响。记录 tokens_per_second是为了监控模型服务的吞吐性能,如果某个时段明显下降,可能是厂商侧限流了。
4.4 向量检索和工具调用的埋点
LLM 应用的成本不只是模型调用,向量检索和工具调用也是大头。向量检索的 Span 要记录检索耗时、召回文档数、相似度阈值。工具调用的 Span 要记录工具名、参数、执行耗时、是否成功。
def traced_vector_search(collection, query, top_k=5): with tracer.start_as_current_span(name="vector.search") as span: span.set_attribute("vector.collection", collection) span.set_attribute("vector.top_k", top_k) span.set_attribute("vector.query_length", len(query)) start = time.time() results = collection.query(query_texts=[query], n_results=top_k) duration = time.time() - start span.set_attribute("vector.result_count", len(results["ids"][0])) span.set_attribute("vector.duration_ms", int(duration * 1000)) # 记录召回文档的总字符数,用于估算塞进上下文的 Token 量 total_chars = sum(len(doc) for doc in results["documents"][0]) span.set_attribute("vector.retrieved_chars", total_chars) return results这里vector.retrieved_chars这个属性很关键。检索召回的文档最终会拼进 Prompt,字符数除以 4 大概就是 Token 数(英文场景),中文场景除以 1.5 左右。有了这个数据,你就能分析"检索环节贡献了多少 Token 成本",如果发现某次请求检索召回了大量文档导致输入 Token 暴涨,就可以优化 top_k 或加相似度过滤。
4.5 Collector 配置与数据分发
Collector 的配置决定了数据怎么处理、往哪发。我的配置分三块:receivers 接收 OTLP 数据,processors 做处理,exporters 分发到后端。
receivers: otlp: protocols: grpc: endpoint: 0.0.0.0:4317 http: endpoint: 0.0.0.0:4318 processors: batch: timeout: 5s send_batch_size: 1024 attributes: actions: # 删除可能包含敏感信息的属性 - key: gen_ai.prompt action: delete - key: gen_ai.completion action: delete tail_sampling: decision_wait: 10s policies: # 错误请求全采 - name: errors type: status_code status_code: {status_codes: [ERROR]} # 慢请求全采 - name: slow type: latency latency: {threshold_ms: 5000} # 其余按 10% 采样 - name: baseline type: probabilistic probabilistic: {sampling_percentage: 10} exporters: otlp/jaeger: endpoint: jaeger:4317 tls: insecure: true prometheus: endpoint: 0.0.0.0:8889 clickhouse: endpoint: tcp://clickhouse:9000 database: otel traces_table_name: otel_traces service: pipelines: traces: receivers: [otlp] processors: [attributes, tail_sampling, batch] exporters: [otlp/jaeger, clickhouse] metrics: receivers: [otlp] processors: [batch] exporters: [prometheus]tail_sampling这个 processor 是精髓。它等整条 Trace 的所有 Span 都到齐了再做采样决策,所以能实现"错误请求全采、慢请求全采、正常请求按比例采"的策略。这样你既不会漏掉问题请求,又能控制存储成本。attributesprocessor 删除 Prompt 和 Completion 属性,防止敏感内容进后端。
5. Token 成本治理的实战打法
5.1 成本归因:从总量到明细
有了调用链数据,成本归因就简单了。我在 ClickHouse 里建了一张物化视图,按小时、按模型、按接口维度聚合 Token 用量和成本。
CREATE MATERIALIZED VIEW token_cost_hourly ENGINE = SummingMergeTree() ORDER BY (hour, model, endpoint) AS SELECT toStartOfHour(timestamp) AS hour, SpanAttributes['gen_ai.response.model'] AS model, SpanAttributes['http.route'] AS endpoint, sum(toInt64(SpanAttributes['gen_ai.usage.input_tokens'])) AS input_tokens, sum(toInt64(SpanAttributes['gen_ai.usage.output_tokens'])) AS output_tokens, count() AS call_count FROM otel_traces WHERE SpanName LIKE 'chat %' GROUP BY hour, model, endpoint;有了这张视图,你可以快速回答几个关键问题:哪个接口最烧钱?哪个模型用量最大?成本趋势是涨还是跌?我实际用下来,最常见的成本异常是某个接口的输入 Token 突然暴涨,通常是 Prompt 模板改了、检索召回变多了、或者用户输入变长了。
5.2 异常检测:怎么发现 Token 用量异常
光看总量不够,要能自动发现异常。我设了几个告警规则。单次调用输入 Token 超过阈值(比如 10000),可能是 Prompt 拼接出了问题。单用户小时 Token 用量超过阈值,可能是滥用或死循环。同一 Trace 内 LLM 调用次数超过阈值(比如 10 次),可能是 Agent 编排逻辑有 bug 导致无限重试。
这些规则都可以在 Collector 或后端用查询实现。我更喜欢在后端做,因为可以结合历史数据做动态基线。比如某接口过去 7 天平均每小时消耗 50 万 Token,今天突然变成 200 万,就触发告警。
5.3 优化手段:从数据到行动
发现异常只是第一步,关键是怎么优化。我总结了几种常见的成本优化手段,都建立在可观测性数据之上。
Prompt 压缩:分析发现输入 Token 里有多少是系统提示词、多少是检索文档、多少是历史对话。如果系统提示词占比过高,考虑精简;如果检索文档占比高,考虑优化 top_k 或做文档摘要。
缓存复用:分析相同或相似的 Prompt 出现频率,如果重复率高,可以上 Prompt 缓存。有些厂商对缓存输入 Token 有折扣,能省不少钱。
模型降级:分析不同模型的调用场景,简单任务用便宜模型,复杂任务才用贵模型。比如意图识别用gpt-4o-mini就够了,没必要用gpt-4o。
输出长度控制:分析finish_reasons,如果大量请求是因为length结束的,说明max_tokens设太大了,模型生成了很多无用内容。适当调小max_tokens能直接省钱。
注意:优化前一定要有基线数据,优化后再对比,否则你不知道优化有没有效果。我见过有人凭感觉改 Prompt,结果成本没降反而涨了,因为没有数据支撑。
6. 常见问题与排查技巧实录
6.1 调用链断链怎么办
断链是最常见的问题,表现为链路图里某些 Span 没有父节点,或者本该连在一起的 Span 分成了两条链路。根因通常是上下文没有正确传播。比如你在异步任务里调 LLM,但没把 Context 传进去,新 Span 就成了根 Span。
排查方法:先看断链的 Span 是不是在异步代码里。如果是,检查有没有用context.attach()和context.detach()手动传播上下文。如果是跨进程调用(比如通过消息队列),需要在消息头里带上traceparent,消费端解析后恢复上下文。
6.2 Token 数对不上怎么办
有时候 Span 上记录的 Token 数和厂商账单对不上,差个百分之几。原因有几个:一是流式响应,流式模式下 Token 数是在最后一个 chunk 才返回的,如果你在流结束前就结束了 Span,就采集不到。二是重试请求,失败的请求可能也计费了,但你的 Span 只记录了成功的。三是厂商侧的统计口径,有些厂商把系统提示词的 Token 也算进去,有些不算。
解决办法:流式响应要在流结束后再结束 Span;重试逻辑要单独建 Span 记录;定期和厂商账单对账,发现系统性偏差就调整统计口径。
6.3 采样导致数据缺失怎么办
采样是为了控制成本,但采样率设太低会导致问题请求被漏掉。我的经验是用 tail_sampling 而不是 head_sampling,并且给错误和慢请求设全采策略。这样正常请求可以低采样率,问题请求一个不漏。
如果发现某些关键请求被采样掉了,可以临时调高采样率,或者给特定用户、特定接口设单独的采样策略。OpenTelemetry 的 tail_sampling 支持按属性匹配,很灵活。
6.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 | 解决方案 |
|---|---|---|---|
| 链路图 Span 缺失 | 采样率过低 | 检查 Sampler 配置 | 调高采样率或改 tail_sampling |
| Token 数为 0 | 流式响应未结束就关 Span | 检查流式处理逻辑 | 流结束后再结束 Span |
| 成本计算偏差大 | 单价表未更新 | 对比厂商最新定价 | 更新 model_pricing 表 |
| Collector 丢数据 | 队列满 | 看 Collector 日志 | 调大队列或加 Collector 实例 |
| 埋点影响性能 | 同步导出 | 检查 SpanProcessor | 改用 BatchSpanProcessor |
6.5 几个我踩过的坑
第一个坑是在 Span 上记录完整 Prompt。一开始图方便,把 Prompt 内容直接塞进 Span 属性,结果 Collector 内存暴涨,因为一条 Trace 可能几百 KB。后来改成只记录长度和哈希值,需要内容时按哈希去单独的存储查。
第二个坑是忘了给 Span 设 Kind。LLM 调用应该设成CLIENT,表示是客户端发起的调用。如果不设,默认是INTERNAL,在链路图上显示不出来跨服务的关系。
第三个坑是异常没记录。LLM 调用失败时抛异常,如果没调span.record_exception(),Span 状态还是 OK,后端就发现不了错误。一定要在 except 块里设Status(StatusCode.ERROR)并记录异常。
第四个坑是属性值类型不对。OpenTelemetry 对属性值类型有要求,int 就是 int,不能传字符串。我有次把 Token 数传成了字符串,后端聚合时全变成 0,排查了半天才发现是类型问题。
7. 我个人的一些实践体会
这套可观测性体系我前后迭代了三个版本,从最早的纯日志,到自建埋点,再到现在的 OpenTelemetry 标准方案。最大的感受是标准化带来的复利效应。一开始投入时间学 GenAI 语义约定、配 Collector、调采样策略,看起来比直接打日志麻烦,但一旦跑通,后面接新模型、新框架、新后端都是即插即用,不用重复造轮子。
另一个体会是可观测性要和成本治理绑定做。单纯做调用链追踪,你只能看到"慢"和"错";加上 Token 数据,你才能看到"贵"。而成本往往是 LLM 应用最敏感的指标,老板不一定关心 P99 延迟,但一定关心这个月账单。把成本数据可视化出来,让每个接口、每个功能、每个用户的成本都透明,优化才有方向。
最后分享一个小技巧:给 Span 加业务标签。除了 GenAI 规范要求的属性,我还会加一些业务维度的标签,比如user.tier(用户等级)、feature.name(功能名)、tenant.id(租户 ID)。这样分析成本时能按业务维度下钻,比如"免费用户贡献了多少成本"、"哪个功能最烧钱"。这些标签在规范里没有,但对实际运营非常有用。
这套方案后续还可以扩展的方向包括:把 Token 成本和业务指标(比如转化率、留存率)关联分析,算出每个功能的"成本效益比";做实时成本预算控制,当某用户或某接口接近预算上限时自动降级或限流;把调用链数据和用户反馈关联,分析"慢请求"和"差评"的相关性。这些都是建立在扎实的可观测性数据之上的,数据质量决定了分析的上限。