为什么可观测性是刚需
前六章判断"效果变好了吗",靠的是手工跑样例、肉眼比输出。生产系统不能靠感觉:可观测性把质量、成本、延迟从事后猜测变成实时可见,把评估从一次动作变成一条持续运转的生产线。
LLM 应用与传统软件的差异,决定了它需要一套不一样的观测手段:
| 维度 | 传统服务 | LLM 应用 |
|---|---|---|
| 输出确定性 | 同输入同输出 | 同输入不同输出,需要采样与裁判 |
| 故障形态 | 异常、崩溃,监控直接报警 | 静默的质量劣化:HTTP 200,但答案是错的 |
| 成本结构 | 基本固定 | 随对话轮数、检索量、智能体跳数逐请求波动 |
| 调试对象 | 一条调用栈 | 一棵 trace 树(多智能体时最深) |
线上故障大致分四类,每一类都有对应的观测信号:
| 故障类别 | 典型表现 | 对应观测信号 |
|---|---|---|
| 质量问题 | 幻觉、格式漂移、答非所问 | 裁判通过率、踩反馈占比 |
| 成本问题 | token 爆炸、智能体循环 | 单会话成本、平均跳数 |
| 延迟问题 | 串行长链、慢工具拖垮体验 | P95 延迟、按节点耗时分布 |
| 安全问题 | 提示注入命中、越权工具调用 | 工具调用审计、拦截计数 |
离线环(黄金集、实验、门禁)防止回归,在线环(trace、反馈、在线裁判)发现新问题。两环咬合,就是上面的评估飞轮:跑得越快,迭代越快。本章剩下的内容都在讲怎么把这个飞轮造出来。
先把 trace 与黄金集建起来,再谈 prompt 迭代。顺序颠倒的团队常见死循环:改提示词、看三五个样例、觉得变好了、上线、两周后用户投诉变多、没人知道是哪次改动造成的。飞轮的第一圈最难,但接入本身只要一行代码(下一节)。
核心抽象:Trace 与 Observation
一切观测围绕两个对象展开:trace 是一次端到端请求,observation 是 trace 树上的一个节点。理解这两个词,Langfuse 的 UI 与 SDK 就都读得懂了。
Trace 对应一次完整的业务动作:回答一个用户提问、跑完一条多智能体任务。它是树根,携带会话、用户、耗时与总分。Observation 是树上的节点,分三种类型:span 记录一段自定义工作(检索、工具、业务函数),generation 记录一次 LLM 调用(额外携带模型、token 用量、成本),event 记录无时长的日志点(打点、标记)。子节点自动嵌套,不需要手动维护父子关系:
| 类型 | 是什么 | 什么时候用 |
|---|---|---|
| span | 有时长的自定义步骤 | 检索、工具函数、业务逻辑段 |
| generation | 一次 LLM 调用,含模型与 token 用量 | 模型调用点(自动埋点已覆盖大部分) |
| event | 无时长的日志点 | 打标:命中缓存、触发降级、拦截注入 |
底层标准是 OpenTelemetry:Langfuse 的 SDK 按 OTel 规范产出 span,数据模型兼容 OpenInference 的 LLM 语义约定。这意味着埋点不被锁死:今天自托管 Langfuse,明天换任何兼容 OTel 的后端,埋点代码不用重写。数据始终在你手里(自托管时数据库也是你的)。
observation 的 name 要稳定、静态("retrieve"、"compose"),不要动态拼接("retrieve-v3-final-2")。看板按 name 聚合耗时与成本,名字一变,历史曲线就断。版本信息用 tags 或 metadata 承载,不进 name。
接入 Langfuse
从零到看见第一棵 trace 树只要两步:装 SDK、配密钥。之后按需选择三条埋点路径:自动回调、装饰器、手动 span。
先选部署形态:
| 形态 | 适合 | 说明 |
|---|---|---|
| Langfuse Cloud | 起步、小团队 | 免费额度可用,分钟级开箱 |
| 自托管(Docker) | 数据不能出内网 | 一条 docker compose 拉起,数据库归你 |
| 两者共同的接入方式 | 所有团队 | 三个环境变量:PUBLIC_KEY / SECRET_KEY / BASE_URL |
pip install langfuse
# .env
LANGFUSE_PUBLIC_KEY=pk-lf-...
LANGFUSE_SECRET_KEY=sk-lf-...
LANGFUSE_BASE_URL=https://cloud.langfuse.com # 自托管换成你的域名
路径一:CallbackHandler,一行接入 LangChain/LangGraph
对 LangChain 与 LangGraph 应用,回调处理器自动捕获一切:每次 LLM 调用、每个工具执行、每个图节点、每条检索请求,自动组成 trace 树:
from langfuse.langchain import CallbackHandler
handler = CallbackHandler()
result = graph.invoke(
{"messages": [("user", "什么是 RRF 融合?")]},
config={"callbacks": [handler], "thread_id": "chat-9001"},
)
print(handler.last_trace_id) # 拿到 trace id,用于关联工单与用户反馈
CallbackHandler 挂进 config 后,LangGraph 的每个节点、LangChain 的每次模型与工具调用、检索器的每次检索都会自动成树,与第二章的图结构一一对应。token 用量、模型名、耗时全部自动记录。先用它跑通,再按需加自定义 span。
路径二:@observe 装饰器,任意函数变节点
没有走 LangChain 的代码(自写检索、预处理、传统业务函数)用装饰器接进同一棵树。嵌套调用自动形成父子关系,顶层函数成为 trace:
from langfuse import observe
@observe() # 顶层调用成为 trace
def answer(query: str) -> str:
docs = retrieve(query) # 子 observation 自动嵌套
return compose(query, docs)
@observe()
def retrieve(query: str) -> list[str]:
return vector_db.similarity_search(query, k=5)
@observe(name="compose", as_type="generation") # 标记为 LLM 调用节点
def compose(query: str, docs: list[str]) -> str:
return llm.invoke(build_prompt(query, docs)).content
装饰器自动捕获函数入参、返回值、耗时与异常;as_type="generation" 让节点带上模型语义,token 统计才归入模型调用。入参太大时可加 capture_input=False 关闭捕获,输出同理。
路径三:上下文管理器,非函数粒度
当埋点边界不是函数(比如一个请求处理器的中段),用上下文管理器手动开 span,嵌套规则不变:
from langfuse import get_client
langfuse = get_client()
with langfuse.start_as_current_observation(name="answer") as trace:
docs = do_retrieve(query) # 内部再开的 observation 自动成为子节点
answer_text = do_generate(query, docs)
trace.update(output=answer_text) # 手动补输出;异常也会被记录
推荐节奏:先用 CallbackHandler 一行接入,拿到八成可见性(模型、工具、图节点全自动);跑一周后,把看不清的业务段(自写检索、降级分支、缓存命中)用 @observe 精细补上。别一开始就全手埋:投入大、容易漏,还容易把 name 起乱。
给 Trace 加业务上下文
只有输入输出的 trace 是技术孤儿。挂上 session、user、tags 与 metadata,它才能进业务分析的世界:按用户聚合成本、按环境过滤故障、按功能对比质量。
四个字段各有分工:
| 字段 | 用途 | 典型值 |
|---|---|---|
| session_id | 把多轮请求串成一次会话 | 用户会话 ID / LangGraph thread_id |
| user_id | 用户维度的成本与质量分析 | 业务系统用户主键 |
| tags | 环境与功能过滤 | production / rag / v2.1 |
| metadata | 自由键值,随 trace 展示与检索 | {"plan": "pro", "ab_group": "B"} |
写法上,在请求入口用 propagate_attributes 统一挂上下文,作用域内所有 observation 自动继承:
from langfuse import get_client, propagate_attributes
langfuse = get_client()
def handle_request(user_id: str, session_id: str, query: str) -> str:
with langfuse.start_as_current_observation(name="answer") as trace:
with propagate_attributes(
user_id=user_id,
session_id=session_id, # 建议直接用 LangGraph 的 thread_id
tags=["production", "rag"],
):
answer_text = answer(query) # 内部所有埋点自动继承上下文
trace.update(output=answer_text)
return answer_text
第二章讲过 checkpointer 的 thread_id:一个用户会话一个 ID,贯穿全部轮次。把它同时用作 Langfuse 的 session_id,UI 里就能按会话展开完整对话、算单会话成本。两个系统用同一把钥匙,零额外设计。
trace 默认记录输入输出原文。用户的手机号、地址、身份证一旦进库,就从观测数据变成合规负债。在埋点层做替换或截断(正则脱敏中间件),不要依赖平台后处理:数据一旦上报,自托管的数据库与备份里也都有了。
Prompt 管理与版本化
硬编码在代码里的 prompt:改一个字要发一次版,没有历史、无法回滚、更别提灰度。把它变成带版本与环境标签的独立资源,提示词迭代才能跟上产品节奏。
Langfuse 的做法:prompt 存在平台上,代码运行时拉取;同名再创建即为新版本;用 label(production / staging / 自定义)控制哪个版本在哪个环境生效。先创建:
from langfuse import get_client
langfuse = get_client()
langfuse.create_prompt(
name="support-answer",
type="chat",
prompt=[
{"role": "system", "content": "你是 {{product}} 的客服,用{{tone}}语气回答,答不了就明说。"},
{"role": "user", "content": "{{question}}"},
],
labels=["production"], # 创建即可上线;也可以先不挂 label
)
运行时拉取并填充变量。花括号变量在 compile 时替换,默认拉 production 标签的版本:
prompt = langfuse.get_prompt("support-answer", type="chat")
messages = prompt.compile(product="云数据库", tone="专业简洁", question=query)
resp = llm.invoke(messages)
| 机制 | 行为 |
|---|---|
| 同名再创建 | 生成新版本,旧版本永久保留可回溯 |
| label 环境 | 拉取默认 production;staging 拉另一套,环境隔离 |
| 回滚 | 把 production 标签指回旧版本,秒级生效,不用改代码 |
| trace 关联 | 运行时拉取的 prompt 版本自动记在 trace 上,改动可归因 |
一句提示词的改动可能比一行代码影响更大,所以流程要一样严:新版本先挂 staging,跑数据集实验(第七节),通过后再把 production 标签切过去。改 prompt 不跑实验就直上 production,等于跳过测试发版。
适合进 prompt 管理的是相对稳定的提示词骨架;高频变化的内容(商品库存、用户名单)应该走 RAG 或工具,不要塞进模板变量硬拼。判断标准:这个字段一天变几次?变得快的,模板里只留占位符,运行时检索。
数据集:沉淀黄金集
黄金集是评估的地基:一个命名的测试用例集合,每个用例带输入、期望输出与元数据。它从哪来,比它多大重要得多。
数据集由 item 组成,item 的结构很朴素:
{
"input": {
"question": "退款多久到账?",
"contexts": ["...检索到的片段,可选..."]
},
"expected_output": "原路退回,1-3 个工作日到账",
"metadata": {
"source": "trace-a1b2c3",
"failure_type": "格式漂移"
}
}
input 不只可以是字符串,也可以是带 contexts 的结构(供裁判逐段核对);metadata 记来源与失败类型,复盘时按类型分组看。三种来源各有代价:
| 来源 | 优点 | 风险 |
|---|---|---|
| 手工标注 | 可控、覆盖关键场景 | 费人时,容易写着写着变简单 |
| 线上 trace 沉淀 | 真实分布、失败样本最珍贵 | 带噪声,需要人工确认后再入库 |
| 合成扩充 | 便宜、量大 | 分布偏离真实,只能当补充 |
沉淀路径完全嵌进日常工作流:在 trace 页面看到一条踩反馈或裁判判错的请求,一键 "Add to dataset",确认期望输出后入库。失败样本优先入库,它是回归测试的锚点:下次实验必须让它们通过。
50 到 200 条精选用例,胜过 2000 条随机采样:每个已知失败模式至少 5 条,每个关键业务场景至少覆盖,其余交给线上裁判抽样。数据集大到跑一次实验几分钟,团队就会开始跳过实验,那比没有数据集更糟。
产品迭代后旧用例会失效(答案变了、功能没了),新失败模式会不断出现。约定节奏:每两周过一遍新增失败样本入库,每季度评审一次存量用例。没有维护节奏的黄金集,半年后就是一堆误导性的过期断言。
离线评估流水线
数据集加实验运行器,就是一条可回归的质量门禁:改动、跑实验、UI 里对比多个 run、通过才切 production 标签。整条链路可以进 CI。
SDK 的实验运行器把循环、打分、上报一次包完:
from langfuse import get_client, Evaluation
langfuse = get_client()
dataset = langfuse.get_dataset("support-golden")
def answer_task(*, item, **kwargs):
# item.input / item.expected_output / item.metadata 全部可用
return my_app(item.input["question"])
def correctness(*, input, output, expected_output, **kwargs):
ok = expected_output.strip() in output
return Evaluation(
name="correctness",
value=1.0 if ok else 0.0,
comment="命中黄金要点" if ok else "未命中",
)
result = dataset.run_experiment(
name="hybrid-rerank-v2", # run 名:分支名或 PR 号
description="混合检索 + 重排,对比基线",
task=answer_task,
evaluators=[correctness],
)
print(result.format()) # 汇总:每个评估器的均值与失败清单
评估器是普通函数,拿到 input / output / expected_output,返回一个 Evaluation。除了本章内置的 correctness,第三章的四指标都能这样实现:
| 评估器 | 裁判问题 | 联动 |
|---|---|---|
| correctness | 答案是否包含黄金要点 | 本章,成本最低的首选 |
| faithfulness | 每个论断是否被上下文支撑 | 第三章,幻觉门禁 |
| context recall | 黄金答案所需信息是否被检索到 | 第三章,检索质量归因 |
| 格式合规 | 输出是否满足 schema | 第一章结构化输出的断言版 |
| 轨迹正确率 | 该调的工具调对没有 | 第五、六章,路由门禁 |
工作流串起来:改动落在 staging 标签或代码分支上,跑实验,UI 里把本次 run 与基线 run 并排对比(每个评估器的均值、逐条分数、失败样本)。通过标准不是"总分更高",而是"没有新失败且老失败变少"。
在 PR 流水线里跑同一数据集,run 名用 PR 号,低于基线的改动直接挡住合并。提示词、检索参数、模型版本的改动走同一个门禁,评估就从"有空才跑"变成"不跑不能合"。
平均分掩盖尾部:92 分可能是"大部分不错但每次都答错退款问题"。复盘失败清单比看总分有用:按 metadata 里的 failure_type 分组,哪类失败没收敛一目了然。平均分用于趋势,失败清单用于行动。
LLM-as-Judge 评估器
规则断言(包含、正则)便宜但覆盖不了开放性质量;LLM 裁判补上这一层。裁判不是免费的:它会带偏差,需要设计与校准。
裁判评估器的写法与普通评估器一样,只是打分逻辑换成一次模型调用。以第三章的 faithfulness 为例:
from pydantic import BaseModel, Field
from langchain.chat_models import init_chat_model
class Faithfulness(BaseModel):
"""判断回答是否完全由给定上下文支撑"""
supported: bool
reason: str = Field(description="指出第一个越界论断;没有则写'无'")
judge_llm = init_chat_model("openai:gpt-4o")
faithful = judge_llm.with_structured_output(Faithfulness)
def faithfulness(*, input, output, **kwargs):
contexts = "\n".join(input.get("contexts", []))
verdict = faithful.invoke(
f"上下文:\n{contexts}\n\n回答:\n{output}\n\n"
"回答中的每个论断都能由上下文支撑吗?"
)
return Evaluation(
name="faithfulness",
value=1.0 if verdict.supported else 0.0,
comment=verdict.reason,
)
裁判的四个经典偏差与对策:
| 偏差 | 表现 | 对策 |
|---|---|---|
| 位置偏差 | 偏爱先出现的答案 | 对比场景交换顺序跑两次取平均 |
| 长度偏差 | 长答案显得更可信 | 提示里明说"长度不代表质量" |
| 自我偏好 | 偏爱同家族模型 | 裁判与被评模型用不同家族 |
| 过度宽容 | 不敢打低分 | few-shot 里给真实的低分示例 |
上线门禁前必须校准:抽 30 到 50 条人工标注,与裁判逐条对比,算一致率。低于 0.7 先修裁判:拆小问题、加 few-shot、换更强模型。裁判的分数只有校准过才值得当作门禁依据,否则只是另一个随机数。
裁判模型要比被评模型更强或至少不同家族,版本要固定(裁判换版本等于换了尺子,历史分数全部失去可比性)。成本敏感时用两级:便宜模型当粗筛,分歧样本升级给强模型复判。
裁判的提示词同样会漂移、同样需要迭代。把它也放进 prompt 管理(第五节)带版本号,每次改裁判都要重新校准,并把校准结果记在版本说明里。没有版本管理的裁判,是飞轮上最隐蔽的松动零件。
用户反馈闭环
离线评估守版本,用户反馈守真实。线上每一条 trace 都可以被评分,用户的赞与踩是最廉价、也最诚实的标注来源。
回看第一章的飞轮:评估发现问题、迭代修复、上线、再收集反馈。收尾的一环是把用户信号挂回 trace。Langfuse 的 score 接口接受任意 trace_id,所以第三章埋点时留下的 handler.last_trace_id 在这里派上用场:
from langfuse import get_client
from langfuse.langchain import CallbackHandler
# 路由层:执行时取到 trace_id,随响应返回给前端
handler = CallbackHandler()
result = graph.invoke(
{"messages": [("user", query)]},
config={"callbacks": [handler], "thread_id": session_id},
)
response = {"answer": result["messages"][-1].content,
"trace_id": handler.last_trace_id}
# API 层:用户点击赞/踩后,把评价补写到同一条 trace
@app.post("/feedback")
def feedback(trace_id: str, rating: int, comment: str | None = None):
get_client().score(
trace_id=trace_id,
name="user-feedback",
value=rating, # 1 = 踩, 2 = 一般, 3 = 赞
comment=comment,
)
评分一旦挂在 trace 上,看板里就能按意图、按用户群、按 prompt 版本切片分析“踩”集中在哪。反馈不止按钮一种,三类信号的组合才有代表性:
| 类型 | 典型信号 | 价值 | 局限 |
|---|---|---|---|
| 显式反馈 | 赞/踩按钮、星级、修正框 | 意图明确,可直接当标签 | 量小且有偏,极端体验才动手 |
| 隐式反馈 | 重试、复制、立刻改问、放弃会话 | 量大免费,覆盖沉默用户 | 需要推断,误报多 |
| 用户改写 | 用户把答案改一遍再使用 | 最接近黄金标注,回流数据集 | 需要产品入口设计 |
固定节奏:每周从低分 trace 里抽 20 条人工读,把根因归入固定几类(检索缺失、排序差、生成越界、prompt 过期、工具失败、用户误解),数量最多的两类进入下周迭代计划。这个例会比任何指标都先一步告诉你系统在哪儿疼。
只有体验很好和很糟的用户才会反馈,中间的沉默大多数不会说话。所以赞踩比不能当作准确率使用:用它定位“糟”的分布和类型,用离线评估(第七节)测准确率,两个视角缺一不可。
成本与看板
可观测性的最终形态是一块每天看一眼的看板。延迟、成本、错误率、质量四条线,把“感觉系统还行”变成“系统在哪条线上越界了”。
成本数据不需要额外埋点:CallbackHandler 记录 generation 时会自动带上模型名、输入/输出 token 数与耗时,Langfuse 按模型单价换算金额。于是每个 trace 天然带着一张账单。起步阶段盯五个指标就够:
| 指标 | 回答的问题 | 异常信号 |
|---|---|---|
| P95 延迟 | 尾部用户的真实体感 | P95 与 P50 差距拉大,长尾请求变多 |
| 单会话成本 | 商业模型是否健康 | 环比突增,新 prompt 变啰嗦或循环失控 |
| 错误率 | 稳定性 | 工具超时集中出现,外部依赖降级 |
| 裁判通过率 | 质量趋势 | 缓跌是数据漂移,急跌是上游变更 |
| 踩赞比 | 用户声音 | 某类 query 集中被踩,覆盖缺口 |
成本下钻依赖 trace 树的形状:按 observation 名称聚合 token,就能看到 retrieve、rerank、generate 各节点的占比。这与第六章用 attribute_tokens 做归因是同一件事,区别只在视图:这里看的是线上真实分布,第六章看的是单次实验。
平均值会被大多数正常请求掩盖,尾部才是用户拍照发群里的样子。告警阈值从两条线起步就够:P95 延迟超过基线的 1.5 倍、单 trace 成本超过固定金额。先有告警,再谈调优。
大 payload(整页 HTML、超长上下文)序列化进 trace 会拖慢请求并快速吃掉存储。对 IO 巨大的函数关闭捕获:装饰器上加 capture_input=False、capture_output=False,排查特定问题时再局部打开。埋点的原则与系统本身一致:默认便宜,按需昂贵。
常见陷阱与 FAQ
工具选型、评估集规模、性能开销这些高频疑问,一次说清。
功能高度重合:trace、数据集、实验、prompt 管理两边都有。差别在形态:Langfuse 开源、可自托管、基于 OpenTelemetry 标准,适合数据不能出内网或有多语言栈的团队;LangSmith 与 LangChain 生态集成最深、开箱体验最顺。选哪个,本章的概念与流程全部平移适用。
30 到 50 条覆盖主要意图,就足以回答“这次改动有没有变好”;稳定后扩到 100 至 300 条并按意图分层。超过 500 条边际收益快速递减,精力更好的去处是难度标注与失败样本专项集。
SDK 采用异步批量上报,主路径开销通常在个位数毫秒。真正的开销来自大 payload 序列化,用第十节的 capture 开关控制。权衡不是“要不要埋点”,而是“哪些字段值得埋”。
先看分歧样本:多数分歧源于裁判的题目太含糊。对策按顺序试:拆成更小的判断、加 few-shot、给出明确评分标准。一致率仍低于 0.7,就换更强的裁判模型,或诚实地把这个指标降级为“监控趋势”而不是“发布门禁”。
先看树不看消息:层数是否超过预期(循环失控)、哪个子树 token 最重(成本热点)、supervisor 的路由序列是否符合预期(对照第六章 route_sequence)。树的形状几分钟就能指出该钻进哪个子树,比逐条读消息快一个量级。
第一天。一条 CallbackHandler 就是全部启动成本。等线上出事再补埋点,历史数据无法追回,而事故分析恰恰最需要历史数据。飞轮的启动条件不是工具选型,是数据从第一天就在转。
实战练习与资源
按顺序做完这七件事,可观测与评估就不再是知识点,而是你系统的肌肉记忆。