做 LLM 应用这两年,我最怕的不是模型答错,而是它答错了之后没人说得清为什么。传统服务的链路追踪能告诉你哪个接口慢了、哪台机器挂了,可面对一个大模型应用,你拿到的往往是用户一句“答案不对”,然后日志里躺着几千字的 prompt 和更长的 completion,剩下的全靠猜。LLM 应用的可观测性跟传统 APM 完全是两套东西:传统 APM 盯延迟、错误率、吞吐,而 LLM 应用还要盯 token 消耗、首 token 延迟、提示词版本、内容质量,甚至是“这次回答到底该不该这么贵”。我后来在几个项目里被迫沉淀出一套东西——用Span做最小观测单元,用三层 Trace把一次调用、一个请求、一轮会话串起来,再用Prompt Diff把提示词变更变成可审计的工程动作。这篇文章就把这套方案的来龙去脉、字段取舍、采样算账、埋点实现和踩过的坑一次讲清楚。适合正在做 LLM 应用落地、已经被“线上答错查不出原因”折磨过的后端、算法和平台同学,也适合刚接手可观测性建设、想少走弯路的新手。
1. 为什么 LLM 应用的可观测性要另起一套体系
1.1 传统 APM 在 LLM 场景下的三个失灵点
先说清楚为什么不能直接套。传统 APM 的核心假设是“服务行为确定、错误有明确边界、资源消耗与请求数线性相关”,这三条在 LLM 应用里全部失效。
第一个失灵点是延迟不是一个值,而是一组值。传统接口只看 P50/P95/P99 总耗时,但用户对 LLM 的体感几乎全部集中在首 token 延迟上。一个总耗时 8 秒、首 token 1.2 秒的回答,用户觉得“还行”;一个总耗时 5 秒、首 token 4.5 秒的回答,用户觉得“卡死了”。如果你只记录总耗时,就会得出完全错误的优化方向。实测下来,把首 token 延迟单独立项之后,我们的优化目标从“降低平均耗时”改成了“压 P95 首 token”,效果立竿见影。
第二个失灵点是错误不等于异常。传统 APM 靠 HTTP 状态码和异常栈判断失败,可 LLM 调用返回 200、内容却是一坨幻觉,这在监控系统里是“成功”。内容层面的失败——答非所问、格式不符合 JSON schema、被长度截断、引用了不存在的文档——都需要业务侧自定义判定,再回写到 Span 的状态字段上。我见过一个团队,接口成功率 99.9%,但用户差评率 8%,两边数据根本对不上,原因就是失败定义没对齐。
第三个失灵点是成本不可见。每个请求背后是真金白银的 token 计费,一次失控的 Agent 循环、一段没做长度限制的上下文,都能把一个月的预算在几小时内烧掉。传统 APM 不记录 token 数,更不会把成本分摊到具体功能、具体 prompt 版本上。等你发现账单异常,已经过去三天了。
1.2 观测模型的选型:为什么是 Span、三层 Trace 加 Prompt Diff
这套组合不是拍脑袋定的,它对应的是三个递进的问题:发生了什么、它们怎么串起来、为什么会变成这样。
Span 回答“发生了什么”,是原子数据。每一次模型调用、每一次检索、每一次工具调用,都是一个 Span,记录它自己的耗时、token、状态、参数。Trace 回答“怎么串起来”,是组织结构。一个用户请求进来,可能经历改写、检索、重排、组装、调用、解析、再调用,这些 Span 用父子关系拼成一棵树,你才能看到时间轴上到底哪一段在吃时间。Prompt Diff 回答“为什么变成这样”,是变更归因。模型输出的变化,绝大多数时候不是模型自己变了,而是提示词被人改了、模板变量取空了、检索结果质量掉了。把提示词版本做 diff 并与指标挂钩,才能把“玄学”变成“因果”。
选型上还有一个关键取舍:不要试图自己造一套全新的追踪协议。我们最终采用的是对业界通用追踪语义做扩展的路子——Trace/Span 的基础字段沿用成熟规范,LLM 特有的字段通过 attributes 扩展进去。这样做的好处是生态兼容,已有的链路可视化工具、采样组件、导出管道大多能直接复用;坏处是字段命名容易失控,所以必须提前定一份字段字典,不然三个月后同一个含义会有五种拼写。
提示:字段字典一定要在写第一行埋点代码之前定稿,并且配上 CI 校验。我们吃过亏,早期不同同学分别用了
model、model_name、llm_model三个字段名,后期做聚合查询写了一堆兼容逻辑,返工成本远高于当初多花半天对齐。
1.3 三者之间的关系与落地顺序
很多团队一上来就想把三个都做全,结果做到一半推不动。我的建议是严格按顺序:先 Span,再 Trace,最后 Prompt Diff。
Span 是地基,字段定不好,后面所有聚合都是垃圾进垃圾出。Trace 依赖 Span 的父子关系字段,父子关系错了,整棵树就是断的。Prompt Diff 依赖 Trace 里的版本哈希字段,如果 Span 上没有稳定记录 prompt 版本,diff 就无从关联。按这个顺序推进,每一步都能独立产生价值:Span 做完就能看单次调用的成本分布,Trace 做完就能定位慢在哪一环,Prompt Diff 做完才能做变更归因。反过来,如果先做看板再补埋点,你会发现看板上全是空字段,返工比重做还痛苦。
2. Span:把一次模型调用拆成可度量的最小单元
2.1 字段设计:哪些必须记,哪些坚决不记
Span 字段设计是整个方案里最容易翻车的地方。核心矛盾是:想记的太多,能存的太少,该藏的必须藏。我们的原则是“元数据全量、内容采样、敏感必脱敏”。
必须全量记录的字段,大致分四组。标识组:trace_id、span_id、parent_span_id、span_type。模型组:model_name、model_version、provider、temperature、max_tokens。计量组:input_tokens、output_tokens、ttft_ms、total_ms、retry_count、cache_hit。质量组:finish_reason、status、error_type、prompt_template_id、prompt_version_hash。
坚决不直接记的,是完整的 prompt 和 completion 原文。原因有三个:体积大,一条动辄几十 KB,全量存会让存储成本失控;基数高,原文做不了索引,查起来极慢;合规风险,用户输入里可能夹带个人信息。我们的做法是原文写对象存储,Span 里只存一个引用键和内容哈希,需要排查时按引用回捞。
| 字段类别 | 代表字段 | 是否全量 | 说明 |
|---|---|---|---|
| 链路标识 | trace_id、span_id、parent_span_id | 是 | 基座字段,缺失则链路断裂 |
| 模型信息 | model_name、model_version、provider | 是 | 低基数,适合做聚合维度 |
| 计量指标 | input_tokens、output_tokens、ttft_ms | 是 | 成本与性能分析的核心 |
| 版本信息 | prompt_template_id、prompt_version_hash | 是 | Prompt Diff 的关联键 |
| 内容原文 | prompt_text、completion_text | 否 | 采样写入对象存储,Span 只存引用 |
| 原始请求 | raw_request_body | 否 | 全量存会爆,按需抓取 |
还有一个容易被忽略的字段是span_type。它看起来只是个枚举,但决定了后面所有查询的分组逻辑。如果不设这个字段,你没法区分“这次慢是慢在检索还是慢在生成”,时间轴画出来就是一团糊。
2.2 Span 的类型划分与嵌套规则
我们把span_type收敛成十来个固定枚举值:session、request、retrieval、rerank、prompt_build、llm_call、tool_call、parse、postprocess、guardrail。这个枚举必须封闭,不允许业务随意新增,否则聚合就没意义了。
嵌套规则很简单:request是根,往下可以挂任意中间环节,llm_call通常是叶子节点。有个例外是tool_call,它下面可能还会挂新的llm_call——Agent 的工具调用结果需要再喂回模型,这就形成了嵌套。这种嵌套必须如实记录,不然你在时间轴上会看到工具调用耗时莫名其妙地消失了。
注意事项:中间环节的 Span 一定要在进入时就创建、结束时立刻关闭,不要等整个请求结束再统一补。我们早期为了省事采用批量补写,结果请求异常中断时,整条链路的 Span 全丢了,恰恰是最需要排查的场景什么都没留下。
2.3 采样与成本控制:这笔账要提前算
采样策略直接决定这套系统的可持续性。先算一笔账:假设日活十万、人均八次请求、每次请求平均触发三次模型调用,一天就是 240 万个llm_callSpan。每条 Span 的元数据按 1.5KB 估算,光元数据就是 3.6GB/天;如果再把平均 20KB 的原文塞进去,那就是 48GB/天,一个月 1.4TB。这个量级对大多数团队来说,存储和查询成本都很难接受。
所以策略必须分层。元数据全量落,因为它小、价值高、是聚合分析的基础。原文走尾部采样,也就是请求结束后再决定这条原文要不要留。判定条件设成:出现错误、finish_reason为长度截断、被用户负反馈、命中护栏规则的,一律全量保留;其余按 5% 到 10% 随机保留。
尾部采样的实现要点是“先缓存后判定”。请求过程中原文先写在临时缓冲里,请求结束拿到最终状态再决定落盘还是丢弃。这比头部采样多占一点内存,但能保证所有异常样本不丢——这点很关键,因为异常样本本来就是低频高价值,如果按头部采样一刀切,很可能最有用的那批数据恰好被采掉了。
还有一个小技巧:span_type为retrieval和guardrail的 Span 可以考虑压低采样率,因为这两类量最大、单条信息密度最低,通常只需要统计分布即可。
3. 三层 Trace:从一次调用到一轮会话的完整还原
3.1 第一层 Call Trace:单次模型调用的画像
Call Trace 回答的是一个很具体的问题:这一次调用,模型是谁、用了什么版本的提示词、花了多少 token、快不快、有没有重试、有没有命中缓存。
这里面最值得单独说的是首 token 延迟的测量。流式响应下,首 token 时间戳应该在收到第一个内容分片的瞬间打点,而不是在 HTTP 响应头返回的时刻。这两个时间点可能差好几百毫秒,尤其在经过多层网关的情况下。我们还遇到过一个更隐蔽的问题:某层代理开启了响应缓冲,导致上游其实早就开始吐字了,下游却要等缓冲区填满才收到数据,TTFT 测出来一直偏高,排查了两周才发现跟模型毫无关系。
token 计数也有坑。理想情况是用服务方返回的 usage 字段,但这个字段在流式模式下有时会缺失,在重试场景下也可能只返回最后一次的用量。降级方案是用本地分词器估算,但估算值和服务方计费口径通常有 3% 到 8% 的偏差。我们的做法是:以服务方返回值为准,缺失时用本地估算并打上token_estimated标记,后续做成本核算时按标记区分统计,避免估算值污染真实数据。
重试也是必须记录的。一次调用如果因为限流重试了三次,日志里如果只留最终成功的那次,你就永远发现不了限流的存在。我们在 Span 上记录retry_count和每次重试的error_type,重试次数超阈值直接触发告警。
3.2 第二层 Request Trace:把检索、组装、生成串成一棵树
一次业务请求远不止一次模型调用。典型链路是这样:查询改写 → 向量检索 → 结果重排 → 提示词组装 → 模型调用 → 输出解析 → 可能触发工具调用 → 二次模型调用。这中间的每一步都应该是一个 Span,挂在同一个request根节点下。
父子关系怎么建?我们用上下文传递的方式:请求入口生成trace_id,并在执行上下文里放一个“当前 Span”指针,任何新开的 Span 自动把当前指针作为父节点,然后把自己设为新的当前指针,结束时恢复。这套机制在同步代码里很自然,麻烦的是异步和并发。
举个真实例子:检索环节我们并发查了三个来源,三个检索 Span 的父节点都应该是同一个request,但因为是并发执行,如果用简单的全局变量存当前指针,会出现“A 线程改了指针,B 线程读到了错的父节点”。解决办法是把上下文做成显式传递的对象,在任务分发时把上下文快照一起带过去。这块如果不处理好,链路就会出现大量孤儿 Span,时间轴直接断掉。
Request Trace 最大的价值是回答“慢在哪”。我们做过一次真实排查,某类型请求 P95 耗时 6.8 秒,看着像模型慢。把 Trace 时间轴拉出来一看:检索 2.1 秒、重排 2.6 秒、模型调用只有 1.9 秒。结论是模型没问题,重排环节的批量大小设得太大。改完之后 P95 降到 3.4 秒,模型侧一行代码没动。如果没有这棵树,大概率会去优化模型参数,白忙一场。
3.3 第三层 Session Trace:多轮对话的上下文演进
单轮请求的可观测性只解决了“一次”,但真实业务大量是多轮对话。Session Trace 要解决的问题是:上下文是怎么一轮一轮长起来的,成本为什么越聊越贵,用户的不满为什么到第三轮才爆发。
Session 层要记录的核心字段包括:turn_index(第几轮)、cumulative_tokens(累计 token)、context_compaction_event(是否发生了摘要或裁剪)、user_feedback_signal(用户反馈信号,比如点踩、重问、直接关闭)。这些字段不需要每轮都重复存全量,但必须能在会话维度聚合。
最关键的指标是上下文膨胀率,也就是每一轮新增的 token 与前一轮总量之比。这个比值如果持续大于 0.5,说明上下文在快速膨胀,成本和延迟都会非线性上升。我们线上的经验是,超过八轮的会话如果不做压缩,单次调用成本会涨到首轮的 4 到 6 倍。所以在监控上设了规则:连续三轮膨胀率超阈值,自动标记该会话并抽样分析。
还有一个反直觉的发现:用户的负反馈往往集中在第二轮和第三轮,而不是第一轮。原因很可能是首轮用户还在试探,从第二轮开始才真正带着任务来。这意味着评估模型效果如果只统计首轮,会严重高估。
4. Prompt Diff:把提示词变更变成可审计的工程动作
4.1 版本管理:先解决“谁改了哪一行”
Prompt Diff 的前提是版本管理做得住。我们的做法是模板与变量彻底分离:模板本体存在配置中心,每次修改生成新版本;变量在运行时注入,不进入版本内容。这样做的直接好处是,同一个模板被不同用户、不同参数调用时,版本哈希是稳定的,不会因为变量值不同而产生成千上万个“伪版本”。
版本标识用两层:人类可读的template_id + version_number,和机器可信的content_hash。为什么两个都要?版本号方便沟通和审计,但版本号可能被覆盖、被回滚、被误标;内容哈希是对模板内容做摘要,只要内容变了哈希必变,是最可靠的关联键。我们把prompt_version_hash写进每一个llm_callSpan,后续所有按版本聚合的分析都以它为准。
提示:哈希计算前一定要做规范化——去掉行尾空白、统一换行符、折叠连续空行。我们曾经因为一位同学在编辑器里顺手删了几个行尾空格,导致哈希全变,历史数据和新数据被硬生生切成两个版本,白折腾半天。
4.2 Diff 粒度:字符级、行级还是语义块级
Diff 粒度选错了,要么噪音大到没人看,要么粗到看不出关键变化。三种粒度各有适用场景。
字符级 diff 最精确,能把一个标点、一个空格的改动都标出来。但它噪音太大,一段一千字的提示词改了一个变量名,输出里可能标红几十处,因为字符级对齐会把后续内容整体错位。行级 diff 适合结构化明显的模板,比如 one-shot 示例是分行写的,行级能清楚标出“加了一行示例”。但提示词大量是长段落,一行就是几百字,行级 diff 对段落内部改动无能为力。
我们最终采用的是语义块级为主、字符级为辅的混合方案。先按角色切块(system、user、assistant、工具定义),再按段落切分,块内做字符级 diff。展示的时候默认只展开有变化的块,块内再显示字符级细节。这样既能看到“改的是哪一块”,又能看到“具体改了哪几个字”。
| Diff 粒度 | 精度 | 噪音水平 | 适用场景 |
|---|---|---|---|
| 字符级 | 最高 | 高,易整体错位 | 精确定位单点修改 |
| 行级 | 中 | 中,长段落失效 | 结构化模板、示例增减 |
| 语义块级 | 中高 | 低 | 日常 review 与变更审计 |
| 变量槽位级 | 高 | 极低 | 变量定义变更追踪 |
还有一个关键设计:diff 只对模板做,不对渲染后的完整提示词做。渲染后的内容包含变量值,同一模板不同请求之间天然不同,拿渲染结果做 diff 会产生海量无意义差异。变量本身的变化单独用“槽位级 diff”跟踪,即只比较变量定义字段有没有增减、类型有没有变化。
4.3 变更归因:把 diff 和指标变化绑在一起
有了 diff 还不够,真正有价值的是把它和指标变化关联起来。我们的做法是三步。
第一步,变更打点。每一次模板发布都记录发布时间、发布人、新旧哈希、diff 摘要。这些信息进一张独立的变更表,和 Span 数据通过哈希关联。
第二步,前后窗口对比。变更发生后,取变更后两小时和变更前两小时的同场景流量做对比,指标包括成功率、平均输出 token、P95 首 token 延迟、负反馈率、格式解析失败率。这里必须做场景切片,只比同模型、同业务线、同输入类型的流量,否则流量结构一变,任何指标波动都能被误判成“提示词的锅”。
第三步,自动生成变更卡片。卡片上并排放置 diff 摘要和指标对比,如果某项指标显著恶化,自动标记。这一步的目标不是替代人判断,而是让人在 review 的时候有据可依,而不是凭感觉回滚。
有个教训值得说:我们曾经因为一次提示词微调导致输出 token 平均涨了 22%,成本跟着涨,但成功率没掉,所以没触发任何告警。直到月底对账才发现。后来把“平均输出 token 环比”也加进了变更卡片的必看项,这类慢性劣化才被抓住。
5. 工程化落地:从埋点到看板的完整链路
5.1 SDK 埋点:怎么做到低侵入
埋点最大的阻力永远是“改业务代码太多”。我们的目标是业务侧只加一行装饰器或者一个中间件,其余全部由 SDK 处理。
基本形态是这样的(伪代码,语言无关,看逻辑即可):
# 业务侧只需要这样用 @traced(span_type="llm_call", template_id="qa_main") def call_model(messages, **kwargs): return client.chat(messages=messages, **kwargs)SDK 内部做四件事:进入时读取当前上下文,创建 Span 并挂到父节点;执行过程中拦截底层客户端,拿到 token 用量和首 token 时间戳;结束时计算总耗时、根据异常类型写状态;最后把 Span 异步推送到收集端。
关键设计点是异步推送 + 本地缓冲。如果同步推送,观测系统本身会成为故障源,收集端一抖动,业务接口跟着超时。我们用的是本地有界队列,满了就丢最旧的并计数,丢弃数量本身也作为指标上报——这个自监控指标很重要,它告诉你观测系统是不是在偷偷丢数据。
另一个点是上下文透传。跨线程、跨协程、跨进程(比如任务队列)都要能把trace_id和父 Span 带过去。实践中这块最容易漏,尤其是消息队列消费场景,如果不显式在消息体里带上追踪上下文,消费端就是一棵新的孤儿树。
5.2 存储选型:查询模式反推表结构
存储选型不要看哪个流行,要看你怎么查。我们总结了三类高频查询:按时间范围加状态筛选(比如“最近一小时所有失败的调用”)、按trace_id点查整条链路、按prompt_version_hash加时间窗口做聚合。这三类查询决定了存储要支持高吞吐写入、列式聚合和按主键点查。
我们的落地是冷热分离。元数据进列式分析库,按天分区,trace_id作为排序键前缀,低基数字段(model_name、span_type、status)做普通索引,高基数字段一律不做索引——这点很反直觉,但高基数字段建索引会让写入放大好几倍,查询收益却很小。原文进对象存储,Span 里只留引用路径,配合生命周期策略自动过期。变更记录单独一张小表,量小但重要性高,不做 TTL。
注意事项:不要把原文也塞进分析库。我们早期图省事这么干过,结果单表三个月膨胀到十几 TB,一个简单的按版本聚合查询要跑几十秒,最后花了很大力气做数据迁移。
5.3 看板与告警:只盯五个数
看板最容易犯的错是“什么都想放”,最后没人看。我们最终只保留五个核心指标,每个都配了明确的告警阈值。
| 指标 | 口径 | 告警建议 |
|---|---|---|
| P95 首 token 延迟 | 按分钟聚合 | 超基线 1.5 倍持续 5 分钟 |
| 单请求 token 消耗 | P99 分位 | 超阈值即告警,防失控循环 |
| 内容层失败率 | 业务自定义判定 | 环比突增 50% |
| 日成本环比 | 按功能维度拆分 | 突增 30% 触发人工确认 |
| 版本切换后指标跌幅 | 变更卡片自动比对 | 显著恶化自动标记 |
这五个数的选择逻辑是:前两个管性能和成本,第三个管质量,第四个管钱,第五个管变更。其他的指标不是不重要,而是应该放到按需排查的下钻视图里,而不是占据首屏。首屏指标一多,人就麻木了,真正异常的反而看不见。
告警策略上我们坚持“宁少勿多”。一个天天响的告警等于没有告警。所有新告警上线前先跑一周静默模式,观察触发频次,频次太高的要么调阈值要么直接删掉。
6. 常见问题与排查实录
6.1 典型问题速查表
实际跑下来,问题高度集中在几类,整理成表方便对照。
| 现象 | 可能原因 | 排查动作 |
|---|---|---|
| 链路出现大量孤儿 Span | 异步任务未透传上下文 | 检查线程池、消息队列消费入口 |
| 首 token 延迟虚高但总耗时正常 | 响应被中间层缓冲 | 检查网关、代理的流式配置 |
| token 计数与账单对不上 | 估算口径差异或重试重复计数 | 核对 usage 字段是否缺失,检查重试 Span |
| Span 数量异常膨胀 | Agent 循环未设上限 | 检查循环退出条件与最大轮次 |
| Prompt diff 全是噪音 | 未做空白归一化或对渲染结果做 diff | 改为模板级 diff 并规范化 |
| 版本聚合数据断层 | 哈希计算口径变更 | 比对历史哈希算法是否一致 |
| 观测系统拖慢业务 | 同步推送且无缓冲 | 改异步有界队列,加丢弃计数 |
6.2 几条踩出来的经验
第一条,先定观测契约,再写业务代码。我们最早是边写功能边补埋点,结果同一类调用在不同模块里字段名、状态取值、耗时口径全不一样,后期统一花了将近三周。后来改成在项目启动时先写一份薄薄的观测契约文档,规定字段名、枚举值、必填项,并用 CI 做校验,返工量直接降到几乎为零。
第二条,原文存储必须做脱敏和 TTL,而且要默认开启。不要指望开发者自觉在业务代码里做脱敏,靠不住。正确做法是在 SDK 出口统一做规则匹配和掩码,TTL 默认七天,需要长期保留的走显式申请。这条我们是被合规检查逼出来的,早做早省心。
第三条,采样不要一刀切。全量采太贵,头部采样又会丢掉最有价值的异常样本。尾部采样虽然实现复杂一点,但收益明显。我们的经验值是异常样本 100% 保留、正常样本 5% 到 10%,整体存储成本能压到全量的十分之一以内。
第四条,diff 存快照不存增量。一开始我们只存每次变更的增量 diff,想着省空间。结果要做“任意两个版本之间对比”的时候发现得把中间所有增量串起来重放,逻辑复杂还容易出错。改成每个版本存完整快照之后,任意两版对比就是一次直接的块级比较,简单可靠,存储成本也没涨多少——因为模板本身不大。
第五条,看板是给排查用的,不是给别人参观的。首屏五个数,下钻三层到底,这个结构我们试了好几版才定下来。指标堆得越多,离“发现问题”越远。每次有人提“能不能再加个指标”,先问一句“它异常的时候你会做什么动作”,答不上来的就不加。
我在实际项目里的体会是,可观测性这件事的投入产出比是滞后的,前两个月你会觉得纯粹在增加工作量,直到第一次线上事故靠着一条 Trace 时间轴十分钟定位到根因,你才会明白这套东西值在哪。后续如果要继续扩展,我建议优先做两件事:一是把变更卡片和发布流程打通,让每次提示词上线自动生成对比报告;二是把会话级的质量信号和具体 Span 关联起来,做到“用户点踩,直接跳到那一轮对应的完整调用链路”。这两个方向做完,排查效率还能再上一个台阶。