LLM 工程手册LLM ENGINEERING

CHAPTER 07生产层 · OBSERVABILITY

Observability 可观测与评估

从黑盒到玻璃盒:trace 树、prompt 版本化、数据集与离线实验、LLM 裁判与用户反馈闭环。前六章的评估方法散落各处,这一章用 Langfuse 把它们收编成一条持续运转的评估生产线。

开始阅读 难度中级 先修前六章 2小时
07.1

为什么可观测性是刚需

前六章判断"效果变好了吗",靠的是手工跑样例、肉眼比输出。生产系统不能靠感觉:可观测性把质量、成本、延迟从事后猜测变成实时可见,把评估从一次动作变成一条持续运转的生产线。

LLM 应用与传统软件的差异,决定了它需要一套不一样的观测手段:

维度传统服务LLM 应用
输出确定性同输入同输出同输入不同输出,需要采样与裁判
故障形态异常、崩溃,监控直接报警静默的质量劣化:HTTP 200,但答案是错的
成本结构基本固定随对话轮数、检索量、智能体跳数逐请求波动
调试对象一条调用栈一棵 trace 树(多智能体时最深)
上线版本 production 标签 线上流量 真实用户请求 trace 与反馈 埋点 · 赞/踩 · 在线裁判 沉淀黄金集 失败样本优先入库 离线实验 数据集 · 裁判打分 评估飞轮 转得越快,迭代越快 离线环(左下起步)防止回归,在线环(右下)发现新问题;两环咬合转动
评估飞轮:上线版本产生流量,流量沉淀 trace 与反馈,失败样本进入黄金集,离线实验把关后才允许下一个版本上线。

线上故障大致分四类,每一类都有对应的观测信号:

故障类别典型表现对应观测信号
质量问题幻觉、格式漂移、答非所问裁判通过率、踩反馈占比
成本问题token 爆炸、智能体循环单会话成本、平均跳数
延迟问题串行长链、慢工具拖垮体验P95 延迟、按节点耗时分布
安全问题提示注入命中、越权工具调用工具调用审计、拦截计数
评估不是一个动作,是一条流水线

离线环(黄金集、实验、门禁)防止回归,在线环(trace、反馈、在线裁判)发现新问题。两环咬合,就是上面的评估飞轮:跑得越快,迭代越快。本章剩下的内容都在讲怎么把这个飞轮造出来。

没有基线的优化都是玄学

先把 trace 与黄金集建起来,再谈 prompt 迭代。顺序颠倒的团队常见死循环:改提示词、看三五个样例、觉得变好了、上线、两周后用户投诉变多、没人知道是哪次改动造成的。飞轮的第一圈最难,但接入本身只要一行代码(下一节)。

07.2

核心抽象:Trace 与 Observation

一切观测围绕两个对象展开:trace 是一次端到端请求,observation 是 trace 树上的一个节点。理解这两个词,Langfuse 的 UI 与 SDK 就都读得懂了。

Trace 对应一次完整的业务动作:回答一个用户提问、跑完一条多智能体任务。它是树根,携带会话、用户、耗时与总分。Observation 是树上的节点,分三种类型:span 记录一段自定义工作(检索、工具、业务函数),generation 记录一次 LLM 调用(额外携带模型、token 用量、成本),event 记录无时长的日志点(打点、标记)。子节点自动嵌套,不需要手动维护父子关系:

TRACE · answer 一次端到端请求 SPAN · retrieve 检索步骤(自埋点) GENERATION · llm LLM 调用(含 token 与模型) EVENT · rewrite 查询改写(日志点) SPAN · search 向量检索(子步骤) generation 自动携带 usage: 输入/输出 token、模型名、耗时、成本 trace 是树根;span 记步骤、generation 记 LLM 调用、event 记日志点,子节点自动嵌套
一个 RAG 请求的 trace 树。多智能体场景下同一结构自然加深:每个成员子图都是一棵子树(第六章)。
类型是什么什么时候用
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。

07.3

接入 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 起乱。

07.4

给 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
thread_id 就是现成的 session_id

第二章讲过 checkpointer 的 thread_id:一个用户会话一个 ID,贯穿全部轮次。把它同时用作 Langfuse 的 session_id,UI 里就能按会话展开完整对话、算单会话成本。两个系统用同一把钥匙,零额外设计。

PII:脱敏在埋点之前

trace 默认记录输入输出原文。用户的手机号、地址、身份证一旦进库,就从观测数据变成合规负债。在埋点层做替换或截断(正则脱敏中间件),不要依赖平台后处理:数据一旦上报,自托管的数据库与备份里也都有了。

07.5

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 上,改动可归因
prompt 变更 = 代码变更

一句提示词的改动可能比一行代码影响更大,所以流程要一样严:新版本先挂 staging,跑数据集实验(第七节),通过后再把 production 标签切过去。改 prompt 不跑实验就直上 production,等于跳过测试发版。

变量放模板,内容放代码

适合进 prompt 管理的是相对稳定的提示词骨架;高频变化的内容(商品库存、用户名单)应该走 RAG 或工具,不要塞进模板变量硬拼。判断标准:这个字段一天变几次?变得快的,模板里只留占位符,运行时检索。

07.6

数据集:沉淀黄金集

黄金集是评估的地基:一个命名的测试用例集合,每个用例带输入、期望输出与元数据。它从哪来,比它多大重要得多。

数据集由 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 条,每个关键业务场景至少覆盖,其余交给线上裁判抽样。数据集大到跑一次实验几分钟,团队就会开始跳过实验,那比没有数据集更糟。

数据集会腐烂

产品迭代后旧用例会失效(答案变了、功能没了),新失败模式会不断出现。约定节奏:每两周过一遍新增失败样本入库,每季度评审一次存量用例。没有维护节奏的黄金集,半年后就是一堆误导性的过期断言。

07.7

离线评估流水线

数据集加实验运行器,就是一条可回归的质量门禁:改动、跑实验、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 并排对比(每个评估器的均值、逐条分数、失败样本)。通过标准不是"总分更高",而是"没有新失败且老失败变少"。

把实验挂进 CI

在 PR 流水线里跑同一数据集,run 名用 PR 号,低于基线的改动直接挡住合并。提示词、检索参数、模型版本的改动走同一个门禁,评估就从"有空才跑"变成"不跑不能合"。

别只看平均分

平均分掩盖尾部:92 分可能是"大部分不错但每次都答错退款问题"。复盘失败清单比看总分有用:按 metadata 里的 failure_type 分组,哪类失败没收敛一目了然。平均分用于趋势,失败清单用于行动。

07.8

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

裁判的提示词同样会漂移、同样需要迭代。把它也放进 prompt 管理(第五节)带版本号,每次改裁判都要重新校准,并把校准结果记在版本说明里。没有版本管理的裁判,是飞轮上最隐蔽的松动零件。

07.9

用户反馈闭环

离线评估守版本,用户反馈守真实。线上每一条 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 版本切片分析“踩”集中在哪。反馈不止按钮一种,三类信号的组合才有代表性:

类型典型信号价值局限
显式反馈赞/踩按钮、星级、修正框意图明确,可直接当标签量小且有偏,极端体验才动手
隐式反馈重试、复制、立刻改问、放弃会话量大免费,覆盖沉默用户需要推断,误报多
用户改写用户把答案改一遍再使用最接近黄金标注,回流数据集需要产品入口设计
每周 20 条低分复盘

固定节奏:每周从低分 trace 里抽 20 条人工读,把根因归入固定几类(检索缺失、排序差、生成越界、prompt 过期、工具失败、用户误解),数量最多的两类进入下周迭代计划。这个例会比任何指标都先一步告诉你系统在哪儿疼。

反馈是双峰的

只有体验很好和很糟的用户才会反馈,中间的沉默大多数不会说话。所以赞踩比不能当作准确率使用:用它定位“糟”的分布和类型,用离线评估(第七节)测准确率,两个视角缺一不可。

07.10

成本与看板

可观测性的最终形态是一块每天看一眼的看板。延迟、成本、错误率、质量四条线,把“感觉系统还行”变成“系统在哪条线上越界了”。

成本数据不需要额外埋点:CallbackHandler 记录 generation 时会自动带上模型名、输入/输出 token 数与耗时,Langfuse 按模型单价换算金额。于是每个 trace 天然带着一张账单。起步阶段盯五个指标就够:

指标回答的问题异常信号
P95 延迟尾部用户的真实体感P95 与 P50 差距拉大,长尾请求变多
单会话成本商业模型是否健康环比突增,新 prompt 变啰嗦或循环失控
错误率稳定性工具超时集中出现,外部依赖降级
裁判通过率质量趋势缓跌是数据漂移,急跌是上游变更
踩赞比用户声音某类 query 集中被踩,覆盖缺口

成本下钻依赖 trace 树的形状:按 observation 名称聚合 token,就能看到 retrieve、rerank、generate 各节点的占比。这与第六章用 attribute_tokens 做归因是同一件事,区别只在视图:这里看的是线上真实分布,第六章看的是单次实验。

用 P95 而不是平均值

平均值会被大多数正常请求掩盖,尾部才是用户拍照发群里的样子。告警阈值从两条线起步就够:P95 延迟超过基线的 1.5 倍、单 trace 成本超过固定金额。先有告警,再谈调优。

可观测性自身有开销

大 payload(整页 HTML、超长上下文)序列化进 trace 会拖慢请求并快速吃掉存储。对 IO 巨大的函数关闭捕获:装饰器上加 capture_input=Falsecapture_output=False,排查特定问题时再局部打开。埋点的原则与系统本身一致:默认便宜,按需昂贵。

07.11

常见陷阱与 FAQ

工具选型、评估集规模、性能开销这些高频疑问,一次说清。

Langfuse 和 LangSmith 到底选哪个?

功能高度重合:trace、数据集、实验、prompt 管理两边都有。差别在形态:Langfuse 开源、可自托管、基于 OpenTelemetry 标准,适合数据不能出内网或有多语言栈的团队;LangSmith 与 LangChain 生态集成最深、开箱体验最顺。选哪个,本章的概念与流程全部平移适用。

评估集多大才够?

30 到 50 条覆盖主要意图,就足以回答“这次改动有没有变好”;稳定后扩到 100 至 300 条并按意图分层。超过 500 条边际收益快速递减,精力更好的去处是难度标注与失败样本专项集。

trace 会不会拖慢线上响应?

SDK 采用异步批量上报,主路径开销通常在个位数毫秒。真正的开销来自大 payload 序列化,用第十节的 capture 开关控制。权衡不是“要不要埋点”,而是“哪些字段值得埋”。

裁判和人工标注不一致怎么办?

先看分歧样本:多数分歧源于裁判的题目太含糊。对策按顺序试:拆成更小的判断、加 few-shot、给出明确评分标准。一致率仍低于 0.7,就换更强的裁判模型,或诚实地把这个指标降级为“监控趋势”而不是“发布门禁”。

多智能体的 trace 树太大,怎么快速定位问题?

先看树不看消息:层数是否超过预期(循环失控)、哪个子树 token 最重(成本热点)、supervisor 的路由序列是否符合预期(对照第六章 route_sequence)。树的形状几分钟就能指出该钻进哪个子树,比逐条读消息快一个量级。

什么时候引入可观测性最合适?

第一天。一条 CallbackHandler 就是全部启动成本。等线上出事再补埋点,历史数据无法追回,而事故分析恰恰最需要历史数据。飞轮的启动条件不是工具选型,是数据从第一天就在转。

07.12

实战练习与资源

按顺序做完这七件事,可观测与评估就不再是知识点,而是你系统的肌肉记忆。

延伸阅读