logo

LLM 观测:Tracing、Token 用量与失败样本

Published on

LLM 观测:Tracing、Token 用量与失败样本

普通后端服务出了问题,通常可以看状态码、堆栈和数据库日志。LLM 应用却经常出现一种让人抓狂的情况:接口返回 200,页面也正常显示,用户却说答案是错的。你打开日志,里面只有一句“模型调用成功”,完全不知道它看了什么、用了哪个 Prompt、检索到了哪些文档,更不知道为什么这次比昨天贵了三倍。

我第一次给大模型应用补观测时,起初只加了请求耗时和错误率。几天后发现,这些指标只能告诉我“坏了”,不能告诉我“坏在哪里”。模型质量下降可能是 Prompt 变了,可能是知识库换了,可能是路由切到了另一个模型,也可能是工具返回了空结果。没有一条贯穿全链路的 Trace,团队只能靠复现和猜测。

LLM 观测的目标不是把所有文本都存下来,而是建立一条足够解释一次请求的证据链:它从哪里来,经过了哪些步骤,消耗了什么资源,得到了什么结果,用户是否认可,失败后能否安全回放。

一、先把一次 LLM 请求拆成 Span

一个 RAG 请求可能经过问题改写、Embedding、检索、重排、Prompt 拼接、模型生成和引用渲染。如果只记录总耗时,就看不出瓶颈在哪一段。

Trace: 用户问题
 ├─ Span: 权限过滤
 ├─ Span: Query 改写
 ├─ Span: Embedding
 ├─ Span: 向量 / 关键词检索
 ├─ Span: Reranker
 ├─ Span: LLM 生成
 └─ Span: 引用校验与响应渲染

每个 Span 至少应有开始时间、结束时间、状态、组件版本和关联 ID。工具调用还要记录工具名、参数摘要、结果状态和重试次数;模型调用要记录模型、Prompt 版本、输入输出 Token 和停止原因。

type TraceSpan = {
  traceId: string
  spanId: string
  parentSpanId?: string
  name: string
  startedAt: string
  endedAt?: string
  status: 'running' | 'ok' | 'error' | 'cancelled'
  attributes: Record<string, string | number | boolean>
  errorCode?: string
}

parentSpanId 很重要。它让你能够从一个用户请求展开完整树状轨迹,而不是在十几个服务的日志里按时间猜哪条记录属于哪次调用。不要把整个请求上下文直接塞进每个 Span,既浪费存储,也会扩大敏感数据暴露范围。

二、Trace ID 不是用户 ID

一次用户操作可能触发多条请求,一个后台任务也可能没有直接用户。userIdsessionIdrequestIdtraceIdspanId 各自有不同用途:

  • userId:谁发起或拥有这次业务操作,受隐私和权限控制。
  • sessionId:哪一段交互上下文。
  • requestId:一次入口请求。
  • traceId:贯穿多个服务和异步步骤的一次完整处理链。
  • spanId:链路中的一个具体步骤。

把它们混成一个 ID,短期看起来简单,长期会让检索、授权和数据留存变得混乱。观测系统应该支持按 Trace 找全链路,也支持按业务任务找多个相关 Trace,但不能让日志平台里的任意人凭用户 ID 读取所有原文。

三、Token 计量不只是为了算账

Token 用量当然关系到成本,但它还能解释许多质量和延迟问题。输入 Token 突然增加,可能是上下文拼接重复或检索召回过多;输出 Token 突然增长,可能是模型陷入循环、Prompt 约束失效或任务难度变化。

type ModelUsage = {
  model: string
  promptTokens: number
  completionTokens: number
  cachedTokens?: number
  reasoningTokens?: number
  estimatedCost: number
}

成本估算要绑定模型价格版本和货币单位。不同供应商可能对输入、输出、缓存和推理 Token 使用不同计价方式,不能把一个统一的 tokens * price 写死在业务代码里。

还要看分布而不是只看平均值。平均每次 2,000 Token 可能掩盖 1% 请求消耗了 100,000 Token;这 1% 可能正好是导致队列堆积和账单失控的长任务。至少要看 P50、P95、P99、最大值和按任务类型分组的用量。

四、延迟要拆成用户真正等待的阶段

LLM 应用的延迟通常包括首 Token 延迟和完整响应延迟。流式聊天更关心用户多久看到第一点内容,结构化抽取和工具执行更关心任务什么时候完整结束。

总延迟
= 排队时间
 + 网络时间
 + 检索时间
 + 首 Token 时间
 + 生成剩余 Token 时间
 + 解析 / 校验时间

记录 time_to_first_tokentime_to_last_token、各 Span 耗时和排队长度,才能知道是模型慢、检索慢、服务排队,还是输出太长。只显示一个“响应耗时 4 秒”,对优化没有指导意义。

流式请求还要记录中断原因:用户取消、客户端断开、上游超时、内容策略停止或服务端错误。中断不一定是失败,例如用户已经得到足够答案主动停止;但如果没有原因分类,团队会把所有中断都当作模型问题。

五、错误要分成可行动的类别

type LlmFailure =
  | { kind: 'transport'; code: 'timeout' | 'disconnect' }
  | { kind: 'provider'; code: 'rate_limit' | 'unavailable' }
  | { kind: 'validation'; code: 'invalid_json' | 'schema_mismatch' }
  | { kind: 'retrieval'; code: 'empty' | 'low_relevance' }
  | { kind: 'policy'; code: 'blocked' | 'permission_denied' }
  | { kind: 'quality'; code: 'unsupported_claim' | 'missing_citation' }

错误分类应该指向下一步动作。网络超时可以重试,JSON 解析失败可以请求模型修复格式,检索为空可能需要追问,权限拒绝不能换模型绕过,缺少引用则应该阻止回答发布。一个统一的“LLM_ERROR”会让所有故障都只能走同一条粗暴路径。

错误日志要保存安全的上下文摘要:模型、版本、任务、错误码、重试次数、输入哈希和 Trace ID。内部堆栈可以限制权限,用户原文则应脱敏或放进短期受控存储。

六、失败样本是最有价值的训练材料

成功请求通常只证明系统这次工作了,失败样本才能暴露边界。要把用户负反馈、自动验证失败、工具错误和人工纠正结果沉淀成可复盘样本。

type FailureCase = {
  id: string
  traceId: string
  category: 'hallucination' | 'irrelevant' | 'tool_error' | 'latency' | 'privacy'
  inputRedacted: string
  outputRedacted: string
  expectedBehavior?: string
  rootCause?: string
  resolution?: string
  reviewed: boolean
}

失败样本不能直接全部拿去训练或公开。先做脱敏、去重、权限分类和人工确认,防止把个人信息、恶意 Prompt 或错误答案反复放大。每个样本最好标记根因和修复方式:改 Prompt、改检索、改工具、改模型、加规则,还是需求本身不清楚。

我建议建立一个“错题本”而不是一个“坏答案仓库”。错题本应该有问题类型、复现条件、当前版本是否修复和回归测试链接。否则团队每周都在看同一批失败截图,却没有真正减少失败。

七、在线质量监控不能只靠用户点赞

点赞和点踩很重要,但它们稀疏、带有用户主观性,也可能受到界面和时机影响。还可以使用一些辅助信号:引用是否存在、答案是否通过 Schema、检索是否命中相关片段、工具是否成功、是否发生人工接管、用户是否马上重复提问。

质量信号
 ├─ 用户反馈:helpful / unhelpful
 ├─ 自动验证:引用、格式、数值、测试
 ├─ 轨迹行为:重复调用、循环、提前取消
 └─ 业务结果:任务完成、转人工、重新提交

这些信号都不是绝对真理。用户没有点踩不代表答案正确,重复提问也不一定是模型失败。把多个信号组合起来做趋势观察,再抽样人工阅读,通常比用一个指标直接判定质量可靠。

线上抽样要有固定比例和分层策略。复杂任务、高风险任务、低置信度任务和近期发生版本变化的任务,应提高抽样率。质量监控的目的不是给团队制造一个漂亮分数,而是尽早发现退化并定位原因。

八、告警要能推动动作

下面这些告警通常比“模型调用次数增加”更有用:

  • 某任务的 schema_pass_rate 连续下降。
  • retrieval_empty_rate 或低相关召回比例突然上升。
  • 输入 Token P95 突然变长,疑似上下文重复。
  • P95 首 Token 延迟超过服务目标。
  • 某模型的限流和超时率高于基线。
  • 无引用回答或用户负反馈在某个版本后增加。
  • 单任务成本超过预算,或重试次数异常。

每条告警都要有负责人、阈值、观察窗口、关联 Trace 查询和应急动作。触发后是暂停放量、切换模型、回滚 Prompt、关闭某个工具,还是转人工?如果告警只发一条消息,却没有下一步,团队很快会对它失去信任。

九、隐私与调试之间要划边界

“为了调试所以保存全部 Prompt 和答案”是很常见的冲动,也很危险。LLM 输入可能包含合同、病历、代码、邮箱和商业秘密;输出可能把这些内容重新组织得更容易泄露。

可以采用分层策略:默认只保留哈希、长度、版本、错误码和指标;低比例抽样保存脱敏文本;短期调试样本放在访问受控的存储;高风险字段使用字段级遮盖;所有原文访问写审计日志并设置自动过期。

默认观测:指标 + 哈希 + 版本
调试抽样:脱敏输入输出 + 受控访问
敏感场景:最小化留存 + 明确授权 + 自动删除

脱敏也要验证。手机号、邮箱和身份证号容易匹配,内部项目名、地址和独特句子可能更难识别。不能因为日志平台有权限控制,就忽略数据最小化原则。

十、Trace 回放要避免产生真实副作用

失败样本回放对修复非常有帮助,但不能把历史请求原样重新发到生产工具。回放模式应该隔离外部副作用:数据库只读或使用副本,邮件和支付工具使用模拟器,代码在沙箱执行,权限固定为最小范围。

type ReplayMode = 'offline' | 'shadow' | 'production'

type ReplayContext = {
  mode: ReplayMode
  allowWrites: boolean
  fixedNow: string
  mockTools: boolean
}

offline 模式用于确定性回归,shadow 模式可以让新版本旁路处理真实请求但不影响用户,production 模式只用于明确授权的业务流程。回放时固定时间、模型版本和工具结果,有助于比较变化;但也要知道外部世界变化会让完全复现不可能,所以 Trace 中应保存必要的输入引用和版本。

十一、观测数据本身也要版本化

Prompt 版本、模型版本、Embedding 版本、检索参数、工具 Schema、评测集和价格表都可能改变结果。Trace 如果只记“使用了 GPT-4”而不记具体版本和配置,半年后就无法解释旧样本。

type RuntimeVersion = {
  model: string
  prompt: string
  toolSchema: string
  retriever: string
  embedding: string
  policy: string
}

版本号不是为了给日志增加字段,而是为了让实验、回滚和质量比较有意义。每次重要发布都应该能回答:哪些 Trace 使用了新版本?质量变化从什么时候开始?如果回滚,是否能恢复到同一套模型、Prompt、索引和策略?

十二、把观测接到研发闭环

观测数据最终要回到研发流程:

线上 Trace
发现异常 / 用户反馈
失败样本脱敏与归因
加入回归集
修改 Prompt、检索、工具或模型
离线评测与灰度
上线继续观察

这条闭环比任何单独的监控大屏更重要。没有回归集,修复只是一次手工调参;没有线上样本,离线集会慢慢脱离真实用户;没有版本信息,前后对比没有可信基础。

十三、我的总结:观测不是给模型装摄像头

LLM 观测的核心,不是保存模型说过的每一个字,而是让系统在质量、成本和安全之间建立可解释的证据链。Trace 告诉我们一次请求经过了哪些步骤,Token 告诉我们资源花在哪里,失败样本告诉我们系统的边界,版本信息告诉我们变化从何而来。

我现在做观测设计,会先问“这条数据能帮助谁做什么决定”。如果不能定位故障、解释账单、判断质量或保护用户,它很可能只是噪声。记录越多不一定越可观测,关键是字段要能和行动对应起来。

大模型应用最危险的状态,不是明显报错,而是悄悄变差:答案仍然流畅,接口仍然 200,成本慢慢上涨,用户开始不再信任。把失败样本认真留下,把一条请求完整串起来,把敏感数据放在该有的边界内,团队才有机会在问题扩大之前看见它。

2024 年的大模型工程,真正成熟的标志不是把模型接通,而是当它出错时,我们不再只能说“模型今天状态不好”。我们应该能指出是哪一个版本、哪一个 Span、哪一种输入、哪一条策略出了问题,并且能用一组回归样本证明修复确实有效。观测让这件事成为可能,也让 AI 系统开始拥有普通软件早就应该拥有的记忆和责任。

🤪 您也可以编辑此页: