ARTICLE DETAIL

资讯详情

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

基于OpenTelemetry的LLM调用链追踪与Token成本治理

基于OpenTelemetry的LLM调用链追踪与Token成本治理 做了几年AI应用的可观测性建设我最大的感受是传统监控体系在LLM面前基本失灵。以前我们靠QPS、延迟、错误率就能定位问题但到了大模型时代一次请求的失败可能是模型服务端不可用、可能是Prompt写得不合理、可能是Token用量超标被限流甚至可能是用户认证链路上的“token exchange failed”这类和模型本身毫无关系的故障。正因如此OpenTelemetry的GenAI规范才显得格外重要——它给LLM调用链上的追踪和Token成本治理提供了一套统一的语义框架。这篇文章我会从一次真实的排障经历切入把调用链追踪和Token成本治理这件事讲透包括该埋哪些字段、怎么接入、怎么把数据变成账单以及那些文档里不会告诉你的坑。1. 为什么AI应用比传统服务更需要可观测性从一次token交换事故说起1.1 一次真实的排障经历token exchange failed背后隐藏的链路有次生产环境接到大量投诉用户登录后调用AI助手几乎每隔十几分钟就会出现一批“sign-in could not be completed: token exchange failed: token endpoint returned status 403”的报错。一开始同事觉得是Token失效直接重试、刷新登录态但问题依然断断续续。后来我们翻看监控面板发现App层请求量正常错误率却集中在某个内部认证网关。问题在于这个AI助手并不直接调用大模型而是先经过一个BFF服务做身份校验再通过企业内部网关去请求外部模型服务。报错信息里只暴露了“token exchange failed”但究竟是哪个环节的token谁在发起交换token是访问模型API用的还是登录态用的如果没有调用链追踪你只能靠猜。那时候我们刚接入OpenTelemetry的GenAI instrumentation不久span链路上能看到一次完整请求从BFF到认证网关再到外部模型服务的全貌。顺着trace我们定位到BFF在刷新外部模型API的access_token时refresh_token已经因为用户切换了账号而失效认证网关返回403但BFF没有把这次失败标记为“外部依赖错误”而是当成了普通鉴权失败直接抛给前端。一个小小的上游故障被三层服务加工成了另一副样子。这个案例说明两个问题第一AI应用的服务链路比普通Web服务更长故障容易被包装和漂移第二只有把每一次调用、每一个认证步骤、每一项Token消耗都放在同一张谱系图里才能快速看清故障的本质。1.2 LLM场景的三重不可控性黑盒模型、动态成本与不确定延迟传统服务的可观测性关注的是“我做得好不好”而AI应用还需要回答“模型表现如何”“这次调用花了多少钱”。LLM场景有三个传统监控很难覆盖的特点。第一模型是黑盒。你发出Prompt得到Completion但中间模型经历了什么你不知道。同样的Prompt今天和明天可能输出不同同样的输入可能因为模型端临时负载高而延迟激增。你没法在应用代码里去观测模型内部只能观测API边界上的行为。第二成本与调用直接挂钩。每一次请求都要消耗Token而Token单价取决于模型型号和输入输出类型。传统服务的成本是固定的机器资源AI应用的成本却是动态的用户多问一句、Prompt多复制一段日志成本都会线性上涨。没有计量就谈不上治理。第三延迟分布极不稳定。LLM是流式输出的首个Token的延迟和总延迟天差地别。如果一个请求长时间不返回到底是模型在慢慢生成还是网络挂了只看HTTP总耗时根本分辨不出来。这时候就需要span级别的阶段拆解把“等待模型响应”和“模型实际生成”分隔开。这三个特点决定了AI应用需要一套专门为生成式AI设计的可观测语义而不是把LLM调用当成一个普通HTTP POST就完事。2. GenAI语义约定调用链上应该埋哪些字段2.1 Span里不是只有“耗时”从HTTP状态码到模型语义在OpenTelemetry出现之前各家AI中间件都有自己的埋点方式有的是给调用打日志有的是在数据库表里塞一个JSON。问题是你没法统一查询“所有服务的模型调用一共花了多少Token”因为有的字段叫tokens有的叫usage有的叫prompt_tokens completion_tokens。OpenTelemetry GenAI规范做的事情就是把这些字段标准化。它定义了一套gen_ai.*属性让所有接入方的调用链使用相同的关键字。一个典型的LLM调用span应该携带的信息不再是简单的URL和状态码而是模型名称、操作类型、用量、完成原因等。下面这个表格是我在实际项目中常用的核心字段基于当前语义约定整理属性名含义典型值gen_ai.system模型系统类型openai、anthropic、ollamagen_ai.operation.name操作类型chat、text_embeddinggen_ai.request.model请求的模型名gpt-4o、claude-3-5-sonnetgen_ai.request.temperature采样温度0.2gen_ai.request.max_tokens允许的最大输出Token数2048gen_ai.response.model实际响应的模型名某些网关可能路由到不同模型同上gen_ai.response.finish_reasons结束原因集合stop、length、content_filtergen_ai.usage.input_tokens输入Token数1293gen_ai.usage.output_tokens输出Token数512server.address实际调用的服务地址api.example.com这些属性挂在span上本质上是把一个不可观测的黑盒调用拆解成了可过滤、可聚合、可计费的维度和Metrics。2.2 gen_ai属性全拆解请求参数、响应参数与用量值得多说一句的是GenAI规范把语义属性分成三类请求属性、响应属性和用量属性。请求属性描述“你发出去的东西”。除了模型名、温度这些还包括系统提示词和用户消息的摘要。这里要注意规范里并没有强制要求必须把完整Prompt写入span因为Prompt可能是几百KB的上下文塞进trace里会造成存储爆炸而且可能涉及隐私。我通常在属性里只记录Prompt的截断版本或哈希值用于关联具体请求但不保存全文。响应属性描述“模型吐出来的东西”。完成原因尤其重要——如果finish_reasons是length说明输出因为达到max_tokens被截断了这时候业务方可能拿到不完整的回答但代码层面并不报错。没有这个字段你根本不知道用户看到的是半截文章。用量属性就是Token消耗。这里要强调一个细节不要把total_tokens直接抄在span上规范推荐拆成input_tokens和output_tokens因为不同方向的计费单价完全不同。输出Token通常比输入Token贵好几倍合并统计会让账单完全失真。2.3 从Trace到MetricsToken用量为什么适合做成指标Span是面向“单个请求”的但在成本治理场景里你更常问的问题是过去一小时、某个应用、某个模型一共烧了多少Token这属于聚合查询更适合用Metrics来做。OpenTelemetry也定义了对应的指标约定比如指标名gen_ai.client.token.usage标签上区分token.type为input或output再带上模型名、服务名等维度。你在trace中记录的gen_ai.usage.input_tokens会被转换为指标中的数值增量后端可以通过Rate聚合计算Token消耗速率。我在落地时走了两条路一是通过Collector的SpanMetrics Connector把span自动转成指标适合快速看趋势二是直接把span导出到ClickHouse这类列式数据库写SQL按任意维度聚合。两种方式各有优劣后面第4章我会放一个可用的SQL查询。3. 端到端接入实战给LLM调用插上可观测的“探针”3.1 环境准备与SDK选型先声明我这里讲的不是某个闭源平台而是纯开源的OpenTelemetry生态。你只需要一个OTLP后端比如Jaeger、Tempo、SigNoz或者自建Collector ClickHouse。SDK方面Python生态相对成熟我用的是opentelemetry-sdk配合开源的opentelemetry-instrumentation-openai这套instrumentation基于GenAI语义实现会自动生成带gen_ai.*属性的Span。环境准备主要是安装几个包pip install opentelemetry-sdk \ opentelemetry-exporter-otlp-proto-grpc \ opentelemetry-instrumentation-openai然后初始化TracerProvider并设置OTLP导出地址。下面是一个最小初始化代码from opentelemetry import trace from opentelemetry.sdk.resources import Resource from opentelemetry.sdk.trace import TracerProvider from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter from opentelemetry.sdk.trace.export import BatchSpanProcessor resource Resource.create({ service.name: ai-backend, deployment.environment: prod, }) provider TracerProvider(resourceresource) exporter OTLPSpanExporter(endpointhttp://collector:4317, insecureTrue) provider.add_span_processor(BatchSpanProcessor(exporter)) trace.set_tracer_provider(provider)这一步只是一个通用脚手架真正起作用的是下一步的instrumentation。3.2 用OpenTelemetry Instrumentation自动埋点接入OpenAI服务时只需要一行from opentelemetry.instrumentation.openai import OpenAIInstrumentor OpenAIInstrumentor().instrument()之后你所有的OpenAI Client调用都会自动生成SpanSpan属性自动带上gen_ai.system、gen_ai.request.model、gen_ai.usage.input_tokens、gen_ai.usage.output_tokens。如果模型返回了finish_reason也会被记录。你不需要改任何业务代码这比手动埋点省力得多。这套工具本质上不是魔法它就是在OpenAI SDK的请求出口和响应入口做了API级别的拦截。request开始前创建一个Span把请求参数写进属性拿到响应后再填充用量和响应字段最后结束Span。它的好处是稳定缺点是因为基于通用库做拦截某些私有化部署或自研网关的调用不会被自动覆盖这时候就轮到手动封装出场了。3.3 手动封装Span当你需要更多控制时假如你的代码不走OpenAI官方SDK而是通过自研HTTP客户端直连模型网关那自动instrumentation就失效了。此时最好的做法是参考GenAI语义约定手动创建Span。下面是我项目里的一个简化示例import json from opentelemetry import trace tracer trace.get_tracer(__name__) def call_llm(model: str, prompt: str, max_tokens: int 1024): span tracer.start_span( namefgen_ai.chat.{model}, attributes{ gen_ai.system: custom-gateway, gen_ai.operation.name: chat, gen_ai.request.model: model, gen_ai.request.max_tokens: max_tokens, server.address: gateway.internal:8080, }, ) try: # 实际调用模型网关 response post_to_gateway(modelmodel, promptprompt) usage response[usage] span.set_attribute(gen_ai.usage.input_tokens, usage[input_tokens]) span.set_attribute(gen_ai.usage.output_tokens, usage[output_tokens]) if finish_reasons in response: span.set_attribute( gen_ai.response.finish_reasons, json.dumps(response[finish_reasons]), ) return response except Exception as exc: span.record_exception(exc) span.set_status(trace.Status(trace.StatusCode.ERROR, str(exc))) raise finally: span.end()这段代码的关键在于属性命名要和规范完全一致这样后续查询才能复用同一套指标体系。另一个细节是Span的name我习惯用gen_ai.chat.model因为它符合gen_ai.operation.name的语义而且在Trace列表中一眼就能认出这是模型调用。3.4 把Trace导出到后端最小可用的OTLP链路不管自动还是手动最终Span都要交给Exporter。生产环境推荐走OpenTelemetry Collector因为它可以做批处理、重试、脱敏、采样再转发给后端。开发环境可以直接导出到Jaeger但生产环境千万别裸连Exporter原因后面会说到。一个很简陋但足以跑通的Collector配置长这样receivers: otlp: protocols: grpc: endpoint: 0.0.0.0:4317 processors: batch: timeout: 1s exporters: clickhouse: endpoint: tcp://clickhouse:9000 database: otel ttl: 72h service: pipelines: traces: receivers: [otlp] processors: [batch] exporters: [clickhouse]如果你的后端是Jaeger把exporter换成jaeger即可。我自己最终选择ClickHouse是因为Token成本查询需要写大量聚合SQLClickHouse比Jaeger友好得多。4. Token成本治理从调用链数据到账单与预算4.1 Token用量的采集路径一次请求产生多少数据先算一笔账假设你的AI服务每天有10万次LLM调用每次请求平均输入1000 Token、输出500 Token那么每天产生的Token用量数据就是10万对数字。如果每个Span还附带用户ID、应用名、租户ID等标签数据量并不会大到离谱但足以支撑成本核算。我通常的做法是在创建Span时把业务维度塞进Span的Attribute里。比如用户ID可以通过set_attribute(enduser.id, user_id)记录也可以通过Baggage传递让下游所有Span共享。这一步是成本归因的基础没有用户维度你只能看到“某模型烧了多少Token”看不到“某用户烧了多少Token”治理也就无从谈起。4.2 成本预估模型与SQL聚合示例有了Trace数据成本计算其实就是一个乘法把输入Token和输出Token分别乘以对应单价。为了说明我做一个假设的定价模型实际单价需要根据你的模型供应商调整模型输入价格/百万Token输出价格/百万TokenModel-A2元6元Model-B5元15元在ClickHouse中如果Span以JSON方式存储attributes查询代码大致如下SELECT service_name, JSONExtractString(attributes, gen_ai.request.model) AS model, JSONExtractString(attributes, enduser.id) AS user_id, sum(JSONExtractInt(attributes, gen_ai.usage.input_tokens)) AS input_tokens, sum(JSONExtractInt(attributes, gen_ai.usage.output_tokens)) AS output_tokens, round( input_tokens * 2.0 / 1000000 output_tokens * 6.0 / 1000000, 4 ) AS estimated_cost_yuan FROM otel_spans WHERE span_name LIKE gen_ai% AND toDate(timestamp) today() GROUP BY service_name, model, user_id ORDER BY estimated_cost_yuan DESC这个查询在告警和账单日报里很实用。要注意的是JSONExtractInt在做聚合之前必须把字符串转成数字否则sum会直接拼字符串或者报类型错误。另外如果有些模型供应商返回的usage是null查询结果会是0这时候建议在应用层做一次兜底把null替换为0或者记录一条异常Count避免成本估算出现“零成本”假象。4.3 预算治理三板斧告警、配额与降级缓存数据接进来之后真正麻烦的是怎么让成本“关得上”。我实践下来有三板斧缺一不可。第一是告警。按模型、按服务设置Token消耗速率告警例如Model-A的输出Token在5分钟内超过500万就触发。这里注意要用“速率”而不是“累计值”因为累计值受业务量自然增长影响容易误报。速率告警适合捕捉突发流量或调用死循环。第二是配额。给不同租户或业务线设置每日Token配额超额后返回限流错误或切换低成本模型。配额不但要限制总量更要限制输出Token。输出Token贵得多而且经常因为Prompt设计不佳导致模型车轱辘话不停烧钱速度非常吓人。第三是降级缓存。对于语义变化不敏感的场景比如FAQ问答、摘要生成可以把相同输入和模型的响应缓存起来命中缓存就完全跳过LLM调用Token成本直接归零。这不属于可观测性本身但没有这个手段治理就只剩“看着它烧”。5. 实测中反复踩过的坑流式计数、异步链路与成本噪声5.1 流式输出下的Token统计陷阱接入初期我犯过一个经典的错误用流式返回的chunk数量来估算Token数量结果和账单差了快一倍。原因很简单模型端流式响应是分块传输的每个chunk的文本长度并不等于一个Token而且有些模型在流式模式下根本不在中途返回usage而是在最后额外推一个包含usage信息的chunk。正确的做法有两种。第一种是启用stream_options: {include_usage: true}让模型在响应结束时带上usage再从最后一个事件里读取累计用量。第二种是自己在应用层对最终完成的文本做一次tokenize再来填充Span属性。需要注意的是如果用第二种方式tokenize方法和模型训练时用的编码器必须一致不然数字依然对不上。我建议优先用第一种。如果模型供应商不支持就要在SDK层做“流式结束再补attribute”的钩子确保Span结束时usage已经被写入。否则你会在Trace里看到一个调用耗时很长的Span但usage是0成本归因直接缺失。5.2 异步任务和线程池里丢失的父SpanLLM调用经常发生在异步任务里比如离线批量总结、后台对话生成。Python的线程池、asyncio任务以及Celery worker都会面临上下文传播问题。我在一个批量任务里发现所有LLM调用的Trace都是孤立的没有父Span根本无法关联到原始任务ID。根因通常是初始化TracerProvider之后异步任务没有正确继承当前context。解决办法是使用OpenTelemetry的context.attach和context.detach或者在提交异步任务前把当前Context显式传入。更省心的方案是使用opentelemetry-instrumentation-httpx这类自动传播库它在HTTP层会自动携带traceparent头让下游服务串起来。值得注意的是很多自研SDK并不会传播OpenTelemetry的Context所以如果你看到“半截Trace”别急着怪SDK先查一下你的异步包装器是不是把context丢掉了。5.3 重复与噪声重试、多模态和系统提示词带来的成本误差成本统计最怕“假数据”。第一个大噪声源是重试。我的代码里如果模型返回5xx一般会自动重试两次。如果每次调用都单独生成Span那么一次用户请求在账面上会出现三笔Token消耗但用户实际只成功了一次。我的做法是在重试时复用同一个Span或者在Span上标记attempt属性这样聚合时可以做去重。OpenTelemetry的Span本身没有内置重试语义需要应用层设计。第二个噪声源是多模态输入。图片、音频、视频的Token计算方式和文本不一样有的模型把一帧图算成固定Token有的按分辨率等比计算。GenAI规范里的gen_ai.usage.input_tokens是一个扁平值我建议额外记录gen_ai.request.input_type image成本模型也按不同计价规则处理。否则一张大图可能会被当成几千个文本Token估算误差很大。第三个噪声是系统提示词。很多框架会自动把系统提示词拼在请求里但这部分Token是老老实实扣费的。成本分析时应该单独拉出一个维度统计“系统提示词占总输入Token的比例”。我见过一个项目系统提示词长达3万多字符接近模型上下文窗口的一半用户还没开始聊每次请求就先烧掉一大笔成本。这类问题只有把gen_ai.request.prompt或摘要放进Span才能发现。我在第2章提到过不存全文但在请求属性里记录系统提示词的字符数或哈希值是值得的。5.4 敏感内容脱敏prompt可以进观测后端吗最后必须提醒的是Span里的Prompt和Completion是用户原始输入可能包含个人身份信息、企业敏感数据、甚至密钥。直接把全文塞进可观测后端等于把隐私数据拖进了日志系统一旦后端被误访问就是事故。在实践中我的原则是“能不存就不存”。如果一定要存就做两件事一是截断超过200个字符的部分丢掉二是配置Collector的AttributesProcessor进行脱敏正则匹配邮箱、手机号、API Key等模式并替换为掩码。下面是Collector配置片段processors: attributes/redact: actions: - key: gen_ai.request.prompt action: update pattern: \b[\w\.-][\w\.-]\.\w\b replacement: [EMAIL] - key: gen_ai.completion action: update pattern: (sk-[A-Za-z0-9]{20,}) replacement: [KEY]请注意脱敏要发生在导出和存储之前。最好在应用SDK层就先脱敏Collector层做二次把关。隐私问题不是可选项而是一票否决项。6. 最后聊聊落地经验这几个月做下来我的体会有三点。第一别幻想一套完美的可观测平台能解决所有问题先从一条最简链路开始把一个真实的LLM调用变得可追踪在Trace里看到gen_ai.usage.input_tokens和gen_ai.usage.output_tokens你就成功了一半。第二Token成本治理必须绑定业务维度没有用户、应用、租户这类标签你顶多能预警没法做配额和结算。第三流式、异步、重试、多模态这些坑几乎每个人都会踩一遍与其等踩完再补不如在写Span封装的时候就把usage补全、把context传透、把脱敏做在前面。最后分享一个小技巧利用OpenTelemetry的Baggage在入口服务把user_id和tenant_id放进Baggage下游所有Span通过API自动读取并写到Attribute里。这样即使你后面新增了模型调用服务也不需要每个服务都手动传业务标签成本归因体系会自动延伸过去。可观测性这件事不复杂但它需要你从一次真实故障或一张异常账单开始才能真正体会到它的价值。
返回列表