DAY 37 / PHASE 4 · PRODUCTION

AI 可观测性

Trace / Span · Cost Attribution · Drift Detection · Online Eval

2026-06-16 · BigCat

offline eval 告诉你模型在测试集上行不行;可观测性告诉你它此刻在生产流量上正在发生什么。

// WHY THIS MATTERS

传统服务挂了,看 log + metric + 错误码就能定位。LLM agent 不一样:它不报错也可能错——返回 200、token 正常、延迟正常,但答案是编的、工具选错了、绕了 8 轮才完成本该 2 轮的事。这些都不会进 error log。更糟的是 LLM 系统有两个传统系统没有的失败源:非确定性(同样输入不同输出)和供应商静默更新(你没改一行代码,模型行为变了)。可观测性就是把这层「看不见的内部状态」变成可查询、可告警、可归因的数据。这一期讲四件事:怎么把一次 agent run 建模成 trace + span 树、怎么把 token 成本归因到每个步骤、怎么检测输入和输出的漂移、以及怎么在生产流量上跑在线评估——而不是等用户投诉才知道出了事。

// 01

Trace / Span:agent 的调试单位不是 log 行

论断:一次 agent run 是一棵 span 树,不是一串 log。看不到树形结构,你就只能猜它中间发生了什么。

背景与原理

一个 agent 调用链是嵌套的:顶层一次 user request(trace),里面套着多轮 LLM call、每轮里又套着 tool call、tool 里可能再套子 agent。用扁平的 log 行记录这种结构,等于把一棵树拍扁成一列——你永远拼不回「哪个 tool 的输出导致了下一轮的错误决策」。分布式追踪的 span 模型天生适配:每个操作是一个 span(带 start/end/属性),span 之间用 parent_id 串成树,整棵树是一个 trace

2024 年起 OpenTelemetry 的 GenAI semantic conventions 把这件事标准化:它规定了 LLM call 该记哪些属性(model、input/output token、prompt、tool call、provider),让一个 LangChain agent 的 span 和一次裸 OpenAI 调用长得一样,跨框架跨厂商可比。这意味着你不该自己发明字段名,而是对齐 gen_ai.* 这套约定——Langfuse、Arize Phoenix 都建在 OTel 之上。

trace: "用户问:帮我退掉上周的订单" (2.4s, 11.2K tok, $0.04) │ ├─ span LLM:plan 0.6s in 1.1K out 180 [stop=tool_use] │ ├─ span tool:search_order 0.3s → order_id=A123 │ └─ span db.query 0.2s SELECT ... WHERE ... │ ├─ span LLM:decide 0.5s in 2.4K out 90 [stop=tool_use] │ ├─ span tool:refund 0.4s ✗ ERROR: 超过退款窗口 │ └─ span LLM:respond 0.6s in 3.0K out 220 [stop=end_turn] ↑ 模型读到 tool 的 error,改口告知用户无法退款 ✓ ← 没有这棵树,你只看到「请求成功、220 token」, 完全看不到 refund 失败、也看不到模型如何自我修复

实战示例

用 OTel SDK 给自建 harness 加 span,对齐 GenAI 语义约定的属性名:

from opentelemetry import trace
tracer = trace.get_tracer("my-agent")

def llm_call(msgs, tools):
    with tracer.start_as_current_span("llm.generate") as sp:
        r = client.messages.create(model=MODEL, messages=msgs, tools=tools, max_tokens=2048)
        # 对齐 OTel gen_ai.* 约定,别自创字段名
        sp.set_attribute("gen_ai.request.model", MODEL)
        sp.set_attribute("gen_ai.usage.input_tokens",  r.usage.input_tokens)
        sp.set_attribute("gen_ai.usage.output_tokens", r.usage.output_tokens)
        sp.set_attribute("gen_ai.response.finish_reason", r.stop_reason)
        sp.set_attribute("app.prompt_version", PROMPT_VERSION)  # 自定义:哪版 prompt
        return r

关键是 span 嵌套要跟着 agent 的逻辑结构走:tool call 是 llm span 的兄弟还是子节点?子 agent 的整棵树要不要 link 回主 trace?把这些 parent 关系建对,你才能在 UI 里一眼看到「11.2K token 里 7K 花在第三轮的重复搜索上」。

失败模式:(1)把整个 prompt/completion 原文塞进 span 属性——高流量下追踪存储爆炸,且可能把用户 PII 泄进可观测后端。OTel 约定里 content 记录是可选 opt-in 的,默认应只记 token 数与元数据,原文按比例采样。(2)只在最外层包一个 span——退化成一行 log,等于没做。
进阶资源 · OpenTelemetry GenAI Observability, opentelemetry.io/blog · genai-observability · Langfuse Tracing 数据模型, langfuse.com/docs · data-model
// 02

成本与 Token 归因:每个 span 都要记账

论断:「这个月 API 花了 $4000」是没用的数字;「退款 agent 的第三轮重搜占了 40% 成本」才是能行动的数字。

背景与原理

LLM 系统的成本不像 CPU 那样均匀——它高度集中在少数昂贵路径上:某个 feature 的 prompt 没用 cache、某类查询触发了 8 轮 agent loop、某个长文档每次都全量塞进 context。聚合账单看不出这些,你需要把成本沿着 span 树归因(attribution)到维度上:哪个 feature、哪个 user、哪版 prompt、哪个 step。这正是 §1 的 span 属性能直接喂出来的——每个 LLM span 已经记了 input/output token 和 model,再带上 cache_read token 和业务维度,就能按任意维度切分成本。

三个最该盯的派生指标:cache hit 率(prefix caching 命中越高,重复前缀越省钱——Day 15/16 讲过)、每任务轮数(agent loop 轮数突增通常是工具描述退化或模型在绕圈)、每任务成本分布的 p95(平均值会被便宜请求拉低,长尾才是烧钱的)。

实战示例

在 span 上记全成本字段,后端就能按维度聚合:

# 记录足以还原成本的字段(含 cache 命中)
u = r.usage
sp.set_attribute("gen_ai.usage.input_tokens",  u.input_tokens)
sp.set_attribute("gen_ai.usage.output_tokens", u.output_tokens)
sp.set_attribute("gen_ai.usage.cache_read_tokens",  getattr(u, "cache_read_input_tokens", 0))
sp.set_attribute("gen_ai.usage.cache_write_tokens", getattr(u, "cache_creation_input_tokens", 0))
# 业务维度:成本归因的 group-by 键
sp.set_attribute("app.feature", "refund_agent")
sp.set_attribute("app.user_tier", user.tier)

# 后端查询(伪 SQL):找出烧钱的 feature
# SELECT app.feature, SUM(cost), AVG(turns), PERCENTILE(cost, 0.95)
# FROM spans WHERE day = today GROUP BY app.feature ORDER BY 2 DESC

有了这张表,优化就有靶子:cache hit 低的 feature 去固定 prompt 前缀;轮数高的去查工具描述;p95 长尾的去做 context 裁剪或 model routing。没有归因时,这些优化全靠拍脑袋。

失败模式:只监控总 token / 总花费,不带业务维度。等账单翻倍时你知道「贵了」,但不知道哪里贵——而 LLM 成本异常往往是单一 feature 的一次回归(比如有人去掉了 cache 断点、或把一个 workflow 误改成 agent),淹没在总量里根本看不见。
进阶资源 · Langfuse LLM Observability Overview(含 cost/usage 追踪), langfuse.com/docs · observability · 本系列 Day 16 Cost Engineering(token 经济学与 caching)
// 03

漂移检测:输入变了,还是模型变了?

论断:LLM 系统会在你没改任何代码的情况下变差。漂移检测就是在用户发现之前替你盯住「悄悄变了」。

背景与原理

线下 eval 是一次性快照:发版前测一遍,过了就上。但生产环境是活的,会从两个方向漂移:

两个轴要分开看:输入漂移提示你「该补 few-shot / 重测了」;输出漂移在输入稳定时出现,几乎一定是模型侧或配置侧变了——这是 pin 不住版本的托管 API 最危险的失败模式。

输出稳定 输出漂移 ┌──────────────┬──────────────────────┐ 输入稳定 │ ✓ 健康 │ ⚠ 模型/配置变了 │ │ │ (供应商静默更新? │ │ │ prompt/温度被改?) │ ├──────────────┼──────────────────────┤ 输入漂移 │ ⚠ 新话题/新 │ ⚠⚠ 双重漂移 │ │ 用法涌入, │ 先归因输入,再看 │ │ 该重测+补例 │ 残余输出漂移 │ └──────────────┴──────────────────────┘

实战示例

import numpy as np
def psi(ref, cur, bins=10):
    # ref/cur:参考期 vs 当前期的某个标量分布(如 embedding 某主成分、回答长度)
    q = np.quantile(ref, np.linspace(0, 1, bins+1))
    q[0], q[-1] = -np.inf, np.inf
    r = np.histogram(ref, q)[0] / len(ref) + 1e-6
    c = np.histogram(cur, q)[0] / len(cur) + 1e-6
    return float(np.sum((c - r) * np.log(c / r)))

# 每天对比:embedding 投影 / 回答长度 / 拒答率
score = psi(ref_lengths, today_lengths)
if score > 0.25:                       # Evidently 经验阈值
    alert(f"输出长度漂移 PSI={score:.2f},排查模型版本/prompt 变更")

实战里最省事的「金丝雀」:维护一组 固定探针 prompt(输入永远不变的 20~50 条),每天定时跑、记录输出。输入恒定,所以任何输出变化都直接指向模型或配置——这是检测供应商静默更新最便宜的哨兵。

失败模式:(1)只在 embedding 空间算漂移却不接业务指标——PSI 报警了但质量没掉,徒增噪音;漂移信号要和下游质量(在线 eval 分数、用户反馈)联动才有意义。(2)参考期选错:拿促销日的异常分布当 baseline,之后天天误报。参考期要选一段「已知健康」的代表性窗口。
进阶资源 · Evidently 5 methods to detect embedding drift, evidentlyai.com/blog · embedding-drift · Evidently 开源库, github.com/evidentlyai/evidently
// 04

在线评估:在真实流量上持续打分

论断:offline golden set 测的是「过去的代表性样本」;online eval 测的是「此刻真实流量」——两者互补,缺一不可。

背景与原理

Day 6 / Day 29 讲过线下 eval:golden set + LLM-as-judge,发版前跑。问题是 golden set 永远滞后于真实分布,且覆盖不到生产里的长尾 case。在线评估补上这一段:对生产 trace 采样,实时(或近实时)用 evaluator 打分,把分数作为一条 metric 写回可观测后端,能告警、能 dashboard、能切分。Arize Phoenix 把这能力叫 online evals——evaluator 挂到 incoming trace 上,每条 live execution 边跑边被打分。

三种 evaluator,按成本递增、覆盖率递减叠加用:

关键工程约束:在线 eval 必须异步、旁路,绝不能挡在用户响应的关键路径上。打分跑在 trace 落库之后的后台 worker 里,慢一点没关系,但不能让评估拖慢产品。

实战示例

# 旁路 worker:从 trace 队列消费,分层打分后写回 metric
def score_trace(trace):
    out = trace.final_output
    # 1) 规则层:全量、亚毫秒
    emit_metric("eval.json_valid", is_valid_json(out), trace.id)
    emit_metric("eval.refused",   hit_refusal(out),    trace.id)
    # 2) LLM-judge 层:采样 5%,控成本
    if sample(0.05):
        s = judge(question=trace.input, answer=out,
                  context=trace.retrieved_docs,
                  rubric="答案是否完全被 context 支撑?1-5")
        emit_metric("eval.faithfulness", s, trace.id)
        if s <= 2: alert(f"低忠实度回答 trace={trace.id}")

eval.faithfulness 的 p50 做成时间序列 dashboard,它跌了就是质量回归的最早信号——比等用户投诉早几天。再叠加 §3 的漂移轴:如果忠实度跌、同时输入分布稳定,矛头直指模型或检索侧变更。

失败模式:(1)把 LLM-judge 当 ground truth——judge 有偏(偏好长答案、自家模型、位置偏置,Day 6 讲过),必须定期用人工标注校准,否则你在用一个会漂的尺子量漂移。(2)全量跑 LLM-judge——评估成本可能反超推理成本本身,采样是必须的。
进阶资源 · Arize Phoenix Evaluation / Online Evals, arize.com/docs/phoenix · llm-evals · 本系列 Day 6 Eval 工程(LLM-as-judge 去偏)· Day 29 Eval Beyond Benchmark

// 综合实战 · 给你的 agent 装一层可观测性(一个周末)

把四点串成一条最小但完整的可观测链路,装到自己的 agent 上:

  1. Trace 化(§1):用 OpenTelemetry SDK 包住 agent loop,每个 LLM call / tool call 一个 span,属性对齐 gen_ai.* 约定。后端先用 Langfuse 或 Phoenix 自托管(Docker 一行起)。
  2. 成本归因(§2):每个 LLM span 记 input/output/cache token + app.feature + app.user_tier。做一个「按 feature 的成本 + 平均轮数 + p95」dashboard。
  3. 探针漂移(§3):选 30 条固定探针 prompt,每天定时跑,记录输出长度 / 格式合规 / 拒答率,算 PSI,>0.25 告警。这是供应商静默更新的哨兵。
  4. 在线 eval(§4):旁路 worker 对全量跑规则层、对 5% 采样跑 LLM-judge 忠实度,分数写回后端。把 faithfulness p50 做成时间序列。
  5. 联动看板:把成本、轮数、漂移 PSI、faithfulness 放一张图。一周后你会第一次「看见」自己 agent 的真实运行状态——而这之前它一直是个黑盒。

做完你会建立一个直觉:LLM 系统的可观测性不是给 LLM 加监控,是把「模型在想什么、花了多少、有没有变差」这三个原本不可见的量变成数据。没有它,你对生产 agent 的所有判断都是猜的。

// ENGLISH GLOSSARY

Span
追踪中的一个操作单元(带 start/end/属性)。一次 LLM call 或 tool call 即一个 span。
Trace
由 parent_id 串成的 span 树,代表一次完整请求的执行路径。agent 的调试单位。
OTel GenAI Semantic Conventions
OpenTelemetry 为 LLM/agent 定义的标准属性集(gen_ai.*),跨框架跨厂商可比。
Cost Attribution
把 token 成本沿 span 树归因到 feature / user / step 等维度,定位烧钱路径。
Input / Data Drift
生产输入分布相对参考期发生偏移。用 PSI / KS / JS 散度检测。
Output / Behavior Drift
输入稳定但输出变了,常因供应商静默更新模型或 prompt/配置变更。
PSI
Population Stability Index,衡量两个分布偏移程度。经验阈值 0.25 视为显著漂移。
Probe Prompt
输入恒定的固定探针集,每天跑以隔离模型/配置侧变化(金丝雀哨兵)。
Online Eval
对生产流量采样并实时打分的评估,区别于发版前的 offline golden set。
Faithfulness
答案是否被检索证据支撑的评估维度,RAG 在线 eval 的核心指标之一。

// 深入思考

传统 APM(Datadog/Prometheus)那套 metric-log-trace 三件套,直接搬到 LLM 系统够用吗?缺了什么?
骨架够用——trace 模型本来就适配 agent 的嵌套结构,这也是 OTel 能扩展出 GenAI conventions 的原因。但缺三样 LLM 特有的:(1) 语义质量——传统 APM 只看「成功/失败/延迟」,LLM 要看「答得对不对」,这需要在线 eval,APM 没有;(2) 非确定性——同输入不同输出,单条 trace 不能定性,必须看分布;(3) 内容维度——prompt/completion 是核心信号但又是 PII 风险,需要采样+脱敏策略。所以是「APM 打底 + LLM 专用层叠加」。
输出漂移最可能的根因是供应商静默更新模型。但你 pin 了模型版本号也可能漂——为什么?
因为 pin 版本号 pin 不住的东西很多:(1) 同一个版本号下,供应商可能调了 system-level 的安全/格式后处理;(2) 你自己的 RAG 检索库在持续更新,喂给模型的 context 变了;(3) 温度、max_tokens 等参数被改;(4) prompt 模板里嵌的动态内容(日期、用户画像)分布变了。这正是为什么探针 prompt 要连参数和检索都固定,才能把变量收敛到「纯模型侧」。可观测性的价值就是帮你做这种归因二分。
在线 eval 用 LLM-as-judge 打分,但 judge 本身也是会漂的 LLM。这是不是套娃式的不可靠?怎么破?
是真问题,但不是死循环。破法是分层锚定:judge 不是 ground truth,而是放大器——它把稀疏的人工标注放大到大流量上。所以要 (1) 定期用一小批人工标注校准 judge(算 judge 和人的一致率,掉了就重做 judge prompt);(2) judge 用比被测系统更强或至少不同族的模型,降低同源偏置;(3) 关键决策(比如下线一个 prompt 版本)不能只靠 judge,要回到人工/离线 golden set 复核。judge 适合做「趋势告警」,不适合做「终审判决」。
成本归因做得越细(per-user / per-request),可观测后端自己的存储和计算成本也越高。这个 trade-off 怎么权衡?
分层采样 + 分层保留。metric(token 数、轮数、cost)是低基数聚合,全量留、便宜;trace 全文(prompt/completion)是高基数大体积,按比例采样留(如全量错误 trace + 1% 正常 trace),并设短保留期。归因维度也分层:feature/tier 这种低基数维度可全量打在 metric 上;user_id 这种高基数维度只在采样的 trace 上保留。核心原则:聚合指标要完整,原文要采样——95% 的洞察来自聚合,原文只在 debug 单条时才需要。
如果一个 agent 的可观测性数据显示它「绕了 8 轮才完成 2 轮的事」,但最终结果是对的。这算 bug 吗?该告警吗?
算「效率 bug」,该进 dashboard 但未必实时告警。结果对说明它没坏,但 8 轮意味着 4× 成本和延迟,且暴露了脆弱性(多绕几轮可能就超 max_iters 失败了)。处理:把「每任务轮数」做成分布监控,p95 突增时告警(通常指向工具描述退化、context 污染或模型更新)。单条不告警,趋势告警。这正体现可观测性比 error log 高的维度——它能抓住「没报错但变笨」这类传统监控完全看不见的退化。

// 延伸阅读