Skip to main content

给大模型链路装仪表

传统 APM 能看到请求耗时和错误率,但看不出这三个问题:这次回答为什么是错的、这个月的账单为什么涨了三倍、以及那个卡住的请求卡在哪一步。

原因是 LLM 应用的失败方式不一样——它不会抛异常,它给出一个看起来很正常但内容错误的回答。HTTP 200、耗时正常、错误率 0,唯一的问题是答案是编的。

请求 span(根)1.8sretrieve120msrerank310mschat(生成)1.3s每次 chat 记 token 与模型每条 span 带:operation.name / request.modelusage.input_tokens / usage.output_tokenserror.type · 耗时内容(prompt / 输出)默认不采集,需要显式开启;生产建议外存后只留引用
图:一次请求拆成若干 span,检索与生成各自可测量

1. 一次请求该拆成哪些 span​

拆分的粒度决定了后面能不能定位问题。最小可用的划分:

根 span —— 一次用户请求,带上会话 ID、用户/租户标识、总耗时。

检索 span —— 每次检索一条,记下召回了多少条、用了哪路(稠密还是稀疏)、有没有走过滤。

重排 span —— 候选条数、保留条数、耗时。

生成 span —— 每一轮模型调用一条,记模型名、输入/输出 token、finish reason。

工具调用 span —— 每个工具一次,记工具名、入参摘要、耗时、成功与否。

这样拆完之后,一次"答案不对"的排查就变成了看一眼树的事:检索 span 里有没有包含答案所在的切片?没有,问题在检索;有,问题在生成。

1.1 关键是把"检索"和"生成"分开记​

这是最重要的一条。绝大多数 bad case 的归因就在这两者之间(见《RAG 链路与失败模式》里的判断方法),如果不分开记,就只能靠猜。

分开记之后,"检索对但生成错"这种最难发现的问题会变成一眼可见的事情:检索 span 里能看到正确的切片 ID,生成 span 的输出里却没有对应的内容。

2. 记什么:一套现成的命名​

OpenTelemetry 的 GenAI 语义约定(GenAI SIG)定义了这套属性的标准命名,各家可观测后端基本都认。核心的几组:

属性说明
gen_ai.operation.name操作类型:chat、embeddings、execute_tool、invoke_agent 等
gen_ai.provider.name供应商:openai、anthropic、aws.bedrock 等
gen_ai.request.model请求的模型
gen_ai.response.model实际服务的模型(可能因为路由而不同)
gen_ai.usage.input_tokens输入 token
gen_ai.usage.output_tokens输出 token
gen_ai.response.finish_reasons结束原因(stop、length、tool_calls、content_filter 等,按供应商取值归一化后再上报)
error.type失败类型

用标准命名的收益是后端可移植:同一份埋点数据可以发给不同平台,成本报表和延迟看板不用重写。

但要有一条心理准备:这套约定仍在演进,属性名跨版本会变(比如早期的 gen_ai.system 已被 gen_ai.provider.name 取代,token 相关的键也改过),稳定开关要靠显式开启(如 OTEL_SEMCONV_STABILITY_OPT_IN)。

实践建议是两条:用现成的埋点库(OpenLLMetry、OpenInference 这类),不要手敲属性名;锁版本,升级前看变更记录,否则看板会莫名空掉。

3. 内容:默认不采集​

prompt 和模型输出里通常有用户数据、代码片段、个人信息。所以语义约定的默认规则很明确:内容默认不采集,要采必须显式开启(对应 gen_ai.input.messages、gen_ai.output.messages、gen_ai.system_instructions 这几个属性)。

生产上更稳的做法是:内容存到外部存储(日志库、对象存储),span 上只记一个引用 ID。这样做的好处除了合规,还有一个——外部存储可以做独立的访问控制,而 span 数据通常对运维可见。

顺带一提:token 数量和成本不需要存 prompt 原文就能算出来,所以"为了算成本而记录全文"这个理由不成立。

4. 从 trace 里能看出什么​

埋点不是目的,能诊断问题才是。四类信号最实用:

循环。 同一条 trace 里反复出现相同的工具调用或相同的模型请求 —— Agent 卡住了。这是没有退出条件最典型的表现。

成本异常。 单条 trace 的 token 数相对历史基线突然飙升。常见原因:上下文没裁剪干净、检索塞了太多切片、或者循环了。

延迟漂移。 同一会话内 span 耗时逐轮上升,通常意味着上下文在变长,或者模型侧吞吐下降。

错误级联。 一个工具失败之后,后续的 span 跟着一起失败。这类要看 span 的父子关系——没有正确传递上下文的话,工具 span 会变成孤儿,看不出因果关系。

5. 工具怎么选​

开源侧大致分两类:开发平台型(把 trace、评测、提示管理打包)和遥测型(只做 trace 与指标)。

常见组合是"网关/埋点库 + 评测平台",用 OpenTelemetry 把两边粘起来。选型时真正要看的是三件事:

  • 许可。 有的项目核心开源但服务端闭源,有的用非 OSI 许可(采购有要求时这点会卡住)。
  • 归属稳定性。 这个领域近一年收购频繁,被收购后转入维护模式的项目不止一个。选有明确长期归属、社区活跃的。
  • 是否 OTel 原生。 决定以后换后端要不要重新埋点。

自托管不是免费的:这类平台通常依赖 Postgres、ClickHouse、Redis 和对象存储,升级、备份、扩容都是实打实的运维工作。量小的时候先用托管版,数据敏感或量大之后再考虑自托管。

6. 落地的三条顺序​

先埋 token 和耗时。 这两个是最低成本、最高价值的信号:没有它们,账单和延迟都是黑盒。

再拆检索与生成。 让"检索错"和"生成错"可以分开看。这一步做完,日常排查的效率会有一个明显的台阶。

最后补评测分数。 把线上 trace 抽一部分跑自动评分,同评测集的结果对照着看——离线评测和线上表现在很多时候并不一致,两边都看才能发现漂移。

一条成本提醒:AI 工作负载产生的遥测数据量远大于普通服务(每次调用都有 token 级指标和嵌套 span),存储成本随 trace 深度上升。全量永久保存不现实,做法通常是原始内容短期、汇总指标长期。

采样也要按 AI 负载的特性调。固定比例的头采样(head sampling)会均匀丢样本,而排障需要的恰恰是少数异常样本——它们按比例被丢掉的比例一样高。更合适的做法是尾采样:先把一条 trace 缓冲下来,等它结束后按"是否报错 / 是否超时 / 是否高成本 / 是否被用户点踩"决定留不留。代价是要缓冲,收益是异常样本的留存率接近 100%。

7. 流式链路的三个专属指标​

流式接口的"耗时"几乎不能用。一个 30 秒的响应可能是首字很快、后面匀速,也可能是首字等了 15 秒——平均值一样,体验天差地别。要拆成三个:

指标含义什么变差说明
TTFT从发请求到收到第一个 token排队、上游拥塞、prompt 太长、缓存没命中
TPOT首字之后平均每个 token 的间隔模型大小、并发数、输出被截断
总时长首字 + 生成全部内容通常由输出长度决定,参考价值最低

还有一个必须单独处理的坑:流式响应的 usage 默认拿不到。OpenAI 侧要在请求里显式加 stream_options: { include_usage: true },它会在 [DONE] 之前多发一个只带 usage 的 chunk;而流被中断时这个 chunk 根本不会来——恰好是最需要记账的那次。Anthropic 侧则是 message_start 给输入用量、message_delta 给输出用量,中断时同样可能缺。

所以成本埋点要有兜底:拿不到精确 usage 时,用「已收到的字符数 / 4」估一个值并打上 usage_estimated: true 标记。宁可有一个标了估算的数字,也不要让这次调用在账上凭空消失。

8. 一条判断标准​

问自己一个问题:用户报告"这个回答不对"时,你能不能在两分钟内拿到那次请求的完整 trace?

拿不到,说明可观测性还没做完。拿得到但看不出问题在哪一步,说明 span 拆得不够细。这两件事比任何仪表盘的美观程度都重要。

trace 还有两个下游用法:把成本拆到租户和单次任务(见《成本归因与预算熔断》),以及把轨迹拿去当评测样本(Agent 场景的做法见《Agent 怎么评测》)。埋点做一次,这三件事都靠它。