offline eval 告诉你模型在测试集上行不行;可观测性告诉你它此刻在生产流量上正在发生什么。
传统服务挂了,看 log + metric + 错误码就能定位。LLM agent 不一样:它不报错也可能错——返回 200、token 正常、延迟正常,但答案是编的、工具选错了、绕了 8 轮才完成本该 2 轮的事。这些都不会进 error log。更糟的是 LLM 系统有两个传统系统没有的失败源:非确定性(同样输入不同输出)和供应商静默更新(你没改一行代码,模型行为变了)。可观测性就是把这层「看不见的内部状态」变成可查询、可告警、可归因的数据。这一期讲四件事:怎么把一次 agent run 建模成 trace + span 树、怎么把 token 成本归因到每个步骤、怎么检测输入和输出的漂移、以及怎么在生产流量上跑在线评估——而不是等用户投诉才知道出了事。
一个 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 之上。
用 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 花在第三轮的重复搜索上」。
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。没有归因时,这些优化全靠拍脑袋。
线下 eval 是一次性快照:发版前测一遍,过了就上。但生产环境是活的,会从两个方向漂移:
两个轴要分开看:输入漂移提示你「该补 few-shot / 重测了」;输出漂移在输入稳定时出现,几乎一定是模型侧或配置侧变了——这是 pin 不住版本的托管 API 最危险的失败模式。
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 条),每天定时跑、记录输出。输入恒定,所以任何输出变化都直接指向模型或配置——这是检测供应商静默更新最便宜的哨兵。
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 的漂移轴:如果忠实度跌、同时输入分布稳定,矛头直指模型或检索侧变更。
把四点串成一条最小但完整的可观测链路,装到自己的 agent 上:
gen_ai.* 约定。后端先用 Langfuse 或 Phoenix 自托管(Docker 一行起)。app.feature + app.user_tier。做一个「按 feature 的成本 + 平均轮数 + p95」dashboard。做完你会建立一个直觉:LLM 系统的可观测性不是给 LLM 加监控,是把「模型在想什么、花了多少、有没有变差」这三个原本不可见的量变成数据。没有它,你对生产 agent 的所有判断都是猜的。