LLM 应用的 Token 级可观测性:从 Trace 采集到 Cost Attribution 的工程落地

引子:凌晨 3 点的账单告警

上个月的一个周二凌晨,我们的 LLM 应用收到了费用异常告警------过去 24 小时的 API 调用成本比上周同期高了 47%。

值班同学打开 Grafana,看到了熟悉的"总 Token 消耗量"曲线确实有一个陡峭的上升。但当他试图回答"是谁用的""用了什么模型""是哪个功能触发的"这三个问题时,发现手头的监控只能看到聚合数字,没有任何一条 trace 能把一次调用的 input tokens、output tokens、model name、user id、feature flag 串起来。

最终花了 4 个小时翻日志、对时间戳、手动关联才定位到一个新上线的 RAG 功能在处理长文档时没有做 chunk size 限制,导致单次请求的 input tokens 飙到了 120K。

如果我们有 Token 级的可观测性,这个排查应该在 5 分钟内完成。

这篇文章就是关于如何建设这套能力的。不是讲"为什么要监控"的道理,也不是讲"怎么省钱"的策略,而是聚焦一个具体的工程问题:如何让你的 LLM 应用具备 Token 粒度的 Trace 能力和 Cost Attribution 能力

一、Token 级可观测性到底在解决什么问题

在传统的 Web 应用中,可观测性的基本单位是 Request:一次 HTTP 请求的延迟、状态码、错误信息。但在 LLM 应用中,这个粒度不够了。

一次 LLM 调用的核心度量单位是 Token。Token 数量直接决定了:

  • 成本:几乎所有 LLM API 都按 token 计费,且 input/output 价格不同
  • 延迟:output token 数量直接影响 TTFT(Time To First Token)和总生成时间
  • 质量:context window 的使用率影响模型的注意力分配
  • 配额:rate limit 通常以 TPM(Tokens Per Minute)为单位

所以我们需要一个新的观测维度:每一次 LLM 调用消耗了多少 token、属于谁、为了什么目的、花了多少钱

这就是 Token 级可观测性的定义。它不是传统 APM 的简单扩展,而是 LLM 应用特有的工程需求。

与传统 APM 的关键差异

维度 传统 Web APM LLM Token Observability
基本度量单位 Request / Response Token (input + output)
成本关联 间接(CPU/内存) 直接(token × price)
流式处理 罕见 常态(SSE streaming)
内容记录 URL/Header Prompt/Completion(涉及隐私)
多模型路由 不涉及 核心场景
缓存语义 HTTP Cache Prompt Caching / Context Caching

理解了这些差异,你就知道为什么直接把 Datadog/Prometheus 的传统指标套用到 LLM 应用上会水土不服。

二、OpenTelemetry GenAI Semantic Conventions:标准化的基石

2025 年底,OpenTelemetry 的 GenAI Semantic Conventions 从 experimental 升级到 stable。这意味着我们终于有了一个厂商无关的标准来描述 LLM 调用的 trace 数据。

核心 Span 属性

一次 LLM Chat Completion 调用的标准 span 包含以下关键属性:

python 复制代码
# OpenTelemetry GenAI Semantic Conventions (stable)
span.set_attributes({
    # 系统标识
    "gen_ai.system": "dashscope",        # 或 deepseek, zhipu, etc.
    "gen_ai.request.model": "qwen-max",
    
    # Token 用量(核心!)
    "gen_ai.usage.input_tokens": 1250,
    "gen_ai.usage.output_tokens": 380,
    
    # 请求参数
    "gen_ai.request.temperature": 0.7,
    "gen_ai.request.max_tokens": 1024,
    
    # 响应元数据
    "gen_ai.response.finish_reasons": ["stop"],
    "gen_ai.response.id": "chatcmpl-abc123",
})

这几个 gen_ai.usage.* 属性就是 Token 级可观测性的数据基础。有了它们,我们就可以在 Jaeger/Grafana/Langfuse 中按 token 维度进行聚合、过滤和告警。

Span 层级结构

一个真实的 LLM 应用调用链通常长这样:

yaml 复制代码
[Root Span] handle_user_query (2.3s)
├── [Span] llm.chat qwen-max (1.8s)
│   ├── input_tokens: 2,400
│   └── output_tokens: 850
├── [Span] embedding text-embedding-v3 (0.12s)
│   └── input_tokens: 380
└── [Span] llm.chat qwen-turbo (0.3s)       # 二次调用(如 summary)
    ├── input_tokens: 900
    └── output_tokens: 120

这种层级结构让我们可以精确地知道:一次用户请求总共消耗了多少 token,其中多少用于主推理、多少用于 embedding、多少用于后处理。

自动 Instrumentation vs 手动埋点

目前主流的 Python/TypeScript LLM SDK 都提供了 OpenTelemetry 自动 instrumentation:

python 复制代码
# 方式一:自动 instrumentation(推荐起步)
from openllmetry import init
init()  # 自动 patch dashscope, deepseek, langchain 等

# 方式二:手动埋点(精细控制)
from opentelemetry import trace

tracer = trace.get_tracer("my-llm-app")

async def call_llm(prompt: str, user_id: str):
    with tracer.start_as_current_span(
        "llm.chat",
        attributes={
            "gen_ai.system": "dashscope",
            "gen_ai.request.model": "qwen-max",
            "app.user_id": user_id,          # 自定义业务属性
            "app.feature": "document_summary", # 功能标识
        }
    ) as span:
        response = await client.chat.completions.create(...)
        
        # 回填 token 用量
        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

我的建议是两者结合:用自动 instrumentation 覆盖标准 LLM 调用,用手动埋点补充业务上下文(user_id、tenant_id、feature flag)。没有业务上下文的 token trace,在做 cost attribution 时会非常痛苦。

三、Streaming 场景的 Token 计数难题

如果你的 LLM 应用使用了 streaming(绝大多数生产应用都会用),你会发现一个棘手的问题:在 SSE streaming 模式下,你拿不到准确的 token 计数

问题本质

主流大模型 API 的 streaming response 中,每个 chunk 通常只包含 delta content,不包含 usage 信息。虽然部分厂商的最新 API 支持在最后一个 chunk 中返回 usage 信息,但这有两个限制:

  1. 并非所有厂商都支持
  2. 如果 stream 中途断开(网络超时、客户端取消),你永远拿不到最终的 usage

三种解决方案

方案 A:依赖 API 返回的 usage(最准确)

python 复制代码
# Streaming with usage(以通义千问为例)
response = await client.chat.completions.create(
    model="qwen-max",
    messages=messages,
    stream=True,
    stream_options={"include_usage": True}  # 关键参数
)

final_usage = None
async for chunk in response:
    if chunk.usage:
        final_usage = chunk.usage  # 最后一个 chunk 包含 usage
    
if final_usage:
    span.set_attribute("gen_ai.usage.input_tokens", final_usage.prompt_tokens)
    span.set_attribute("gen_ai.usage.output_tokens", final_usage.completion_tokens)
else:
    # stream 中断,降级到估算
    span.set_attribute("gen_ai.usage.output_tokens", estimated_tokens)
    span.set_attribute("app.token_count.source", "estimated")

方案 B:本地 Tokenizer 实时计数(最通用)

python 复制代码
# 使用对应模型的 tokenizer 库
from transformers import AutoTokenizer

class StreamingTokenCounter:
    """实时计算 streaming 输出的 token 数"""
    
    def __init__(self, model: str):
        # 使用对应模型的 tokenizer
        self.encoding = get_tokenizer_for_model(model)
        self._buffer = ""
        self._token_count = 0
    
    def feed(self, delta_content: str) -> int:
        """喂入一个 chunk,返回累计 token 数"""
        self._buffer += delta_content
        # 注意:不能逐字符 tokenize,需要累积后重新计算
        # 因为 BPE tokenizer 的边界可能在 chunk 中间
        new_count = len(self.encoding.encode(self._buffer))
        delta = new_count - self._token_count
        self._token_count = new_count
        return self._token_count
    
    @property
    def total_tokens(self) -> int:
        return self._token_count

这里有一个容易踩的坑:BPE tokenizer 不是字符级别的。你不能对每个 chunk 单独 tokenize 然后累加,因为 token 边界可能跨越两个 chunk。正确做法是累积所有文本后重新 tokenize。

方案 C:API Proxy 层统一采集(推荐生产方案)

如果你有自己的 LLM Gateway / API Proxy,可以在 proxy 层同时拿到 request body(包含 input)和完整的 streaming response(拼接后的 output),在 proxy 侧做 token 计数和 trace 上报。这种方式对业务代码零侵入:

typescript 复制代码
// LLM Gateway 中间件伪代码
app.post("/v1/chat/completions", async (req, res) => {
  const startTime = Date.now();
  const inputTokens = countTokens(req.body.messages, req.body.model);
  
  // 转发请求并收集完整响应
  const upstreamResponse = await forwardToUpstream(req);
  
  if (req.body.stream) {
    // Streaming: 透传 chunks,同时累积内容
    let outputContent = "";
    const passthrough = new TransformStream({
      transform(chunk, controller) {
        outputContent += extractDeltaContent(chunk);
        controller.enqueue(chunk); // 透传给客户端
      },
      flush() {
        // Stream 结束后上报 trace
        const outputTokens = countTokens(outputContent, req.body.model);
        reportTokenTrace({
          userId: req.headers["x-user-id"],
          model: req.body.model,
          inputTokens,
          outputTokens,
          latencyMs: Date.now() - startTime,
          feature: req.headers["x-feature"],
        });
      }
    });
    
    upstreamResponse.body.pipeThrough(passthrough).pipeTo(res.writable);
  } else {
    // Non-streaming: 直接从 response 取 usage
    const outputTokens = upstreamResponse.usage?.completion_tokens 
      ?? countTokens(upstreamResponse.choices[0].message.content, req.body.model);
    reportTokenTrace({ /* ... */ });
    res.json(upstreamResponse);
  }
});

生产建议:优先用方案 C(Proxy 层采集)作为主力,方案 A 作为校验,方案 B 作为 fallback。三者互补,确保 token 计数的覆盖率接近 100%。

四、Cost Attribution:把 Token 变成钱

有了 token trace 数据,下一步是把它转化为可操作的 cost insight。这需要一个 Cost Attribution 模型

四维归因框架

我们在实践中总结出了四个归因维度:

ini 复制代码
Token Cost = f(User, Tenant, Feature, Model)
维度 用途 示例查询
User 识别高消耗个体 "过去 7 天 token 消耗 Top 10 用户"
Tenant 多租户计费/配额 "Tenant A 本月已用多少额度"
Feature 功能级 ROI 分析 "RAG 功能 vs Chat 功能的单次调用成本对比"
Model 模型选型决策 "qwen-max vs deepseek-chat 在同任务上的 cost/quality 比"

实现:从 Trace 到 Cost Dashboard

python 复制代码
# 成本归因处理器
class CostAttributionProcessor:
    """将 token trace 转化为成本记录"""
    
    # 模型定价表(应定期同步厂商最新价格)
    PRICING = {
        "qwen-max": {"input": 2.00, "output": 6.00, "cached_input": 0.50},
        "qwen-turbo": {"input": 0.30, "output": 0.60, "cached_input": 0.075},
        "deepseek-chat": {"input": 1.00, "output": 2.00, "cached_input": 0.10},
        "text-embedding-v3": {"input": 0.07, "output": 0},
    }
    
    def process_trace(self, trace: dict) -> dict:
        model = trace["model"]
        pricing = self.PRICING.get(model)
        if not pricing:
            logger.warning(f"Unknown model pricing: {model}")
            return None
        
        input_cost = trace["input_tokens"] * pricing["input"] / 1_000_000
        output_cost = trace["output_tokens"] * pricing["output"] / 1_000_000
        
        # Cached token 折扣处理
        cached_cost = 0
        if trace.get("cached_tokens"):
            cached_cost = trace["cached_tokens"] * pricing.get("cached_input", pricing["input"]) / 1_000_000
            input_cost -= trace["cached_tokens"] * pricing["input"] / 1_000_000
        
        total_cost = input_cost + output_cost + cached_cost
        
        return {
            "timestamp": trace["timestamp"],
            "user_id": trace.get("user_id"),
            "tenant_id": trace.get("tenant_id"),
            "feature": trace.get("feature", "unknown"),
            "model": model,
            "input_tokens": trace["input_tokens"],
            "output_tokens": trace["output_tokens"],
            "cached_tokens": trace.get("cached_tokens", 0),
            "cost_usd": round(total_cost, 6),
            "latency_ms": trace.get("latency_ms"),
        }

实时估算 vs 异步对账

在生产环境中,我们采用了双轨制

  1. 实时估算:在 API Proxy 层,用本地 tokenizer 即时计算 token 数并乘以单价,写入 Redis 滑动窗口计数器。用于实时告警和配额控制。精度约 ±5%。

  2. 异步对账:每小时从 trace backend(如 ClickHouse)拉取完整 trace 数据,用厂商 API 返回的精确 usage 重新计算成本,修正实时估算的偏差。用于日报、周报和计费。

这种设计的好处是:实时路径保证低延迟(不影响请求处理),异步路径保证高精度(不丢钱)。

五、生产环境的三个关键权衡

1. 采样策略:全量还是抽样?

Token trace 本身也是有成本的。如果你的应用日均 100 万次 LLM 调用,每次 trace 写入 ClickHouse 约 500 bytes,那一天就是 500MB 的 trace 数据。不算大,但如果加上 prompt/completion 原文存储,量级就完全不同了。

我们的采样策略

python 复制代码
class TraceSampler:
    def should_sample(self, trace_context: dict) -> bool:
        # 错误请求:100% 采集
        if trace_context.get("error"):
            return True
        
        # 高延迟请求(>10s):100% 采集
        if trace_context.get("latency_ms", 0) > 10000:
            return True
        
        # 高 token 消耗(>50K):100% 采集
        total_tokens = trace_context.get("input_tokens", 0) + trace_context.get("output_tokens", 0)
        if total_tokens > 50000:
            return True
        
        # 常规请求:10% 采样
        return random.random() < 0.1

核心原则:异常全采,正常抽样。这样既控制了存储成本,又确保出了问题时有完整的 trace 可查。

2. Token 计数精度:够用就行

本地 tokenizer 和厂商 API 返回的 token 数并不总是完全一致。原因包括:

  • 不同模型的 tokenizer 实现有细微差异
  • 各厂商可能使用不同的分词策略
  • system prompt 的 token 计算方式可能不同

务实的做法 :以厂商 API 返回的 usage 为准(ground truth),本地 tokenizer 仅用于 streaming 中断时的 fallback 估算。在成本看板中标注数据来源(api_returned vs estimated),让使用者知道数据的可信度。

3. 隐私合规:trace 里记不记原文?

这是一个需要在安全和可观测性之间做取舍的问题。

分级策略

环境 Prompt 原文 Completion 原文 Token 计数 元数据
开发/测试 ✅ 记录 ✅ 记录
生产(内部工具) ⚠️ 脱敏后记录 ❌ 不记录
生产(面向客户) ❌ 不记录 ❌ 不记录

在生产环境中,我们只记录 token 数量和元数据(model、user_id、feature、latency),不记录 prompt 和 completion 的原文。如果需要调试具体问题,通过 feature flag 临时开启特定用户的详细 trace。

六、落地路线图:从零到可用的四个阶段

如果你的团队还没有 Token 级可观测性,这里是一个渐进式的落地路线:

Stage 1:基础 Trace(1-2 周)

  • 接入 OpenTelemetry GenAI instrumentation
  • 采集 input_tokens / output_tokens / model / latency
  • 写入现有 trace backend(Jaeger/Tempo)
  • 验收标准:能在 Jaeger 中看到每次 LLM 调用的 token 用量

Stage 2:业务上下文注入(1 周)

  • 在 trace 中添加 user_id / tenant_id / feature 等业务属性
  • 建立 Cost Attribution 的数据管道
  • 搭建基础的 Grafana Dashboard
  • 验收标准:能按用户/功能/模型维度查看 token 消耗和成本

Stage 3:告警与配额(1-2 周)

  • 配置基于 token 消耗的异常告警
  • 实现租户级/用户级 token 配额控制
  • 接入实时估算 + 异步对账的双轨成本计算
  • 验收标准:token 消耗异常能在 5 分钟内触发告警

Stage 4:深度分析(持续迭代)

  • Token 消耗趋势分析与预测
  • 模型 cost/quality 对比分析
  • Prompt Caching 命中率监控
  • 与业务指标(转化率、留存率)的关联分析
  • 验收标准:能用数据驱动模型选型和功能优化决策

七、写在最后

回到开头那个凌晨 3 点的故事。在我们建设完 Token 级可观测性之后,类似的异常排查变成了这样:

  1. 收到告警 → 打开 Grafana Token Cost Dashboard
  2. 按 feature 维度下钻 → 发现 document_summary 功能的成本占比从 5% 飙升到 35%
  3. 按 user 维度再下钻 → 确认不是个别用户的问题,是该功能本身的逻辑变更
  4. 查看该功能的 trace 详情 → 发现平均 input tokens 从 3K 涨到了 80K
  5. 定位到根因 → 新版本的 chunking 逻辑去掉了 max_chunk_size 限制

全程 4 分钟。

Token 级可观测性不是一个锦上添花的能力,它是 LLM 应用进入生产环境的入场券。 就像你不会让一个 Web 应用在没有 APM 的情况下上线一样,你也不应该让一个 LLM 应用在没有 Token Trace 的情况下裸奔。

希望这篇文章能帮你在自己的项目中落地这套能力。如果你已经在做了,欢迎在评论区分享你的实践经验。


参考资料

相关推荐
nix.gnehc1 小时前
深入理解 LLM Chat API 调用参数:从 OpenAI 标准到国内厂商实践
llm·openai
星浩AI2 小时前
(六)模型微调效果测试:基于 BERT 的中文评价情感分析[附源码]
人工智能·机器学习·llm
树獭非懒3 小时前
AI Agent 入门:理论、原理与5分钟代码实战
人工智能·llm·agent
swipe13 小时前
DeepAgents 实战:用多 Agent 架构搭一个深度调研助手
javascript·面试·llm
XLYcmy15 小时前
全链路验证测试系统:一个针对智能代理(Agent)系统全链路能力的自动化验证脚本
分布式·python·http·网络安全·ai·llm·agent
johnny23318 小时前
大模型测评之:CLUE、SuperCLUE、GLUE、SuperGLUE
llm·benchmark
格桑阿sir19 小时前
10-大模型智能体开发工程师:RAG检索增强生成
ai·大模型·llm·embedding·agent·检索增强·rag
swipe20 小时前
DeepAgents middleware 工程实战:把复杂 Agent 的运行时基建交给可组合中间件
前端·面试·llm
隐层漫游者21 小时前
深度解密:基于Python的高性能RAG系统设计,从向量检索到缓存穿透防御,这篇就够了
llm