LLM 应用可观测性完全指南:从一次"用户说答案错了,我却完全不知道当时发生了什么"看懂链路追踪

2024 年我给一个产品做了个 LLM 功能用户问一句后台拼好提示词调大模型把答案返回功能上线了日志怎么打这件事我压根没多想第一版我做得很顺手在调用大模型那行代码的前后各打一条日志一条记开始调用一条记最终的答案文本就完事了本地跑一跑真不错可等这功能真正上线面对成千上万的真实用户一串问题冒了出来第一种最先把我打懵一个用户截图来投诉答案是错的我翻到那条日志日志里确实躺着那个错答案可我盯着它完全不知道当时到底发生了什么那次到底给模型发了什么提示词检索拼进去的是哪几段资料我一点都复现不出来第二种最难缠有用户反馈这个功能好慢我的日志里只有一个总耗时八秒可这八秒到底慢在哪一步检索重排还是模型生成我毫无头绪第三种最头疼月底账单出来大模型的钱比上个月翻了三倍我对着账单说不出一句话是哪个功能烧的哪些用户烧的第四种最莫名其妙监控告警说有一批请求失败了我翻日志只看到一行 call failed 到底是限流是超时还是模型返回了错误重试过几次什么都没有我盯着这一连串问题想了很久才彻底想明白第一版错在一个根本的认知上我以为调用大模型跟调用一个普通函数没区别记一下入参和出参出问题翻日志就行这句话把一次 LLM 调用当成了一次普通的确定的一步到位的函数调用可它根本不是这么回事一次 LLM 调用和一次普通函数调用在四个维度上是彻底不同的它是非确定的它是昂贵的它很少是一步它强依赖外部真正把 LLM 应用做得可观测核心不是多打几条日志而是承认一次 LLM 调用是一个需要被完整录像的复杂事件你要为每一次调用记录一份结构化的 trace 把渲染后的完整提示词模型与参数 token 用量与成本每一步的耗时原始响应异常与重试全都钉进去再用一个 trace_id 把多步链路的每个 span 串成一棵可回放的树本文从头梳理为什么只记最终答案一定不够一次调用到底要记录什么怎么用 trace 和 span 串起多步链路 token 成本为什么必须落到每次调用异常与重试怎么记以及采样 PII 脱敏异步落库这些把可观测性做扎实要避开的坑

2024 年我给一个产品做了个 LLM 功能:用户问一句,后台拼好提示词、调大模型、把答案返回。功能上线了,日志怎么打?这件事我压根没多想。第一版我做得很顺手:在调用大模型那行代码的前后,各打一条日志——一条记"开始调用",一条记 answer: 最终的答案文本就完事了。本地跑一跑——真不错:日志里清清楚楚,每个请求来了、答案出去了,我心里很踏实:"不就是个函数调用嘛,记一下入参出参,出问题翻日志不就行了?"可等这功能真正上线、面对成千上万的真实用户,一串问题冒了出来。第一种最先把我打懵:一个用户截图来投诉"答案是错的",我翻到那条日志,日志里确实躺着那个错答案——可我盯着它,完全不知道当时到底发生了什么:那次到底给模型发了什么提示词?检索拼进去的是哪几段资料?我一点都复现不出来。第二种最难缠:有用户反馈"这个功能好慢",我的日志里只有一个总耗时 8 秒,可这 8 秒到底慢在哪一步——检索?重排?还是模型生成?我毫无头绪。第三种最头疼:月底账单出来,大模型的钱比上个月翻了三倍,我对着账单说不出一句话:是哪个功能烧的?哪些用户烧的?是提示词变长了还是调用量涨了?我根本无从拆解。第四种最莫名其妙:监控告警说有一批请求"失败"了,我翻日志,只看到一行 call failed,到底是限流、是超时、还是模型返回了错误?重试过几次?什么都没有。我盯着这一连串问题想了很久才彻底想明白,第一版错在一个根本的认知上:我以为"调用大模型,跟调用一个普通函数没区别,记一下入参和出参,出问题翻日志就行"。这句话把"一次 LLM 调用"当成了一次普通的、确定的、一步到位的函数调用。可它根本不是这么回事我脑子里,调大模型就是 answer = llm(prompt) 这么一行,跟调 add(1, 2) 没什么两样,记下进去的、记下出来的,就够复盘了。可一次 LLM 调用,和一次普通函数调用,在四个维度上是彻底不同的东西。第一,它是非确定的:同样的输入,模型可能给出不同的输出,所以"答案"本身不是结论,只有"产生这个答案的完整输入"才是;而那个完整输入——经过模板渲染、检索拼接、历史拼接之后真正发给模型的那一大段文字——在你的代码里根本看不到,代码里只有 llm(prompt) 这一行,prompt 长什么样是运行时才确定的。第二,它是昂贵的:每一次调用都按 token 真金白银地花钱,这个成本不记下来,你月底就只能对着一个总数发呆。第三,它很少是一步:真实的 LLM 功能往往是一条链——检索、重排、调模型、再后处理,任何一步都可能慢、可能错,只记最终答案,等于把这条链拍扁成一个黑点。第四,它强依赖外部:它依赖一个会限流、会超时、会抽风的外部 API,失败是常态,而"失败"这两个字背后的具体原因、重试了几次、有没有降级,才是你真正需要的信息。我那个第一版,用"记一下入参出参"这种对付普通函数的思路,去对付一个非确定、昂贵、多步、强外部依赖的操作——它当然什么都复盘不了。真正把 LLM 应用做得"可观测",核心不是"多打几条日志",而是承认一次 LLM 调用是一个需要被完整"录像"的复杂事件:你要为每一次调用记录一份结构化的 trace,把渲染后的完整提示词、模型与参数、token 用量与成本、每一步的耗时、原始响应、异常与重试,全都钉进去;再用一个 trace_id 把多步链路的每个 span 串成一棵可回放的树。这篇文章就把 LLM 应用可观测性这个坑梳理一遍:为什么"只记最终答案"一定不够、一次调用到底要记录什么、怎么用 trace 和 span 串起多步链路、token 成本为什么必须落到每次调用、异常与重试怎么记,以及采样、PII 脱敏、异步落库这些把可观测性做扎实要避开的坑。

问题背景

这个坑普遍,是因为"记一下入参出参"这个习惯,在写普通后端代码时一直是够用的——一个 CRUD 接口出了问题,你看日志里的请求参数和返回值,基本就能定位。可 LLM 功能是一类特殊的东西:它的"入参"不是代码里那个变量,而是运行时拼出来的一大段提示词;它的"出参"是非确定的;它中间还藏着花钱的、多步的、依赖外部的过程。第一版代码用对付普通函数的观测强度,去观测一个复杂得多的对象。它错得隐蔽,是因为功能演示和小流量时一切正常:你自己测,问题答得对、速度也不慢,日志里那条 answer 看着够用了。它只在真实流量、真实账单、真实投诉压上来时才暴露——而那时,你想复盘的那次调用早已过去,现场没有录下来,就永远找不回来了。

把这个现象拆开,错误认知和真相是这样对应的:

  • 现象:用户投诉答案错却无法复现;只知道总耗时不知慢在哪一步;账单翻倍却拆解不出是谁烧的;只看到"失败"二字不知道失败原因和重试情况。
  • 错误认知一:以为记下"最终答案"就能复盘。真相是 LLM 非确定,只有"产生答案的完整渲染后输入"才有复盘价值,而它在代码里看不到。
  • 错误认知二:以为 LLM 功能是一步到位的调用。真相是它通常是检索、重排、生成、后处理的多步链,必须逐步记录、串成链路。
  • 错误认知三:以为成本看月度总账单就够了。真相是成本必须落到每一次调用,才能按功能、按用户、按模型拆解归因。
  • 真相:一次 LLM 调用是非确定、昂贵、多步、强外部依赖的复杂事件,必须为它录一份结构化 trace,并用 trace_id 串起多步 span,才谈得上可观测。

一、为什么"只记最终答案"的日志一定不够

先把第一版那两行日志摆出来。它的思路就是字面意思:调用前记一声,调用后把答案记下来。

# 第一版:调用大模型,只在前后各打一条日志(反面教材)
import logging

logger = logging.getLogger("llm")

def answer_question(user_question: str) -> str:
    logger.info("开始调用大模型")
    prompt = build_prompt(user_question)        # 模板渲染 + 检索拼接,结果只在运行时存在
    answer = call_llm(prompt)                   # 真正发出去的 prompt 长什么样,日志里没有
    logger.info(f"answer: {answer}")            # 只记了最终答案
    return answer

这段代码在演示时看着够用。它的问题,要看清一个事实才显形:你想复盘的东西,根本不在你记下来的东西里。当用户说"答案错了",你需要的不是那个错答案——它就摆在投诉截图里——你需要的是能让你复现这个错答案的全部输入。可那个真正发给模型的 prompt,是 build_prompt 在运行时拼出来的:它把提示词模板渲染了一遍,把检索回来的几段资料塞了进去,可能还拼了对话历史。这一大段文字,代码里看不到、第一版的日志里也没记。于是你手里只有"果",没有"因",复盘从第一步就断了。看一眼真正发出去的 prompt 有多少代码里看不见的东西:

# build_prompt 运行时到底拼出了什么 —— 这些代码里都看不到
def build_prompt(user_question: str) -> str:
    docs = retrieve(user_question)              # 检索:回来哪几段,每次可能不同
    history = load_history(user_question)       # 历史:拼了多长,取决于当前会话
    # 真正发给模型的,是下面这一大段渲染后的文本,不是 user_question
    return PROMPT_TEMPLATE.format(
        context="\n".join(d.text for d in docs),
        history=history,
        question=user_question,
    )
# 第一版只记了 user_question 和 answer,中间这一整段渲染结果全丢了

这里要建立的第一个、也是最重要的认知是:观测一个普通函数和观测一次 LLM 调用,难度根本不在一个量级,原因在于"可复现性"。一个普通函数 add(a, b),它是确定的:你只要记下 a 和 b,就能在任何时候精确复现它的行为——所以记入参出参对它确实够了。可一次 LLM 调用是非确定的:同样一段提示词,模型这次答对、下次可能答错,"答案"本身不构成任何结论。这就意味着,对 LLM 调用来说,有价值的观测对象不是"输出",而是"那个能让你尽可能复现这次行为的、完整的输入快照"。而这个输入快照,恰恰是最容易丢的东西——因为它不是代码里某个明明白白的变量,它是模板、检索、历史在运行时这一刻拼接出来的产物,运行结束它就消散了。第一版最深的错,不是"日志打少了",而是它记的是"最容易拿到、却最没用的东西"(最终答案),漏的是"最难拿到、却最关键的东西"(渲染后的完整输入)。所以做 LLM 可观测性,第一条原则就是:把那段真正发给模型的、渲染后的完整提示词,原原本本地记下来——这是整个 trace 里分量最重的一个字段,缺了它,后面记再多都救不回一次复盘。

二、一次 LLM 调用到底要记录什么

明确了要记"完整输入快照",就能把一次 LLM 调用该记录的字段列全。它不是一两个,而是一组——把它们组织成一条结构化的记录,我们叫它一个 span(一次可观测的操作)。一个 LLM span 至少要有这些字段:

from dataclasses import dataclass, field
from typing import Any

@dataclass
class LLMSpan:
    trace_id: str                 # 串起整条链路的 ID,同一次用户请求里所有 span 共用
    span_id: str                  # 这一个操作自己的 ID
    name: str                     # 操作名,比如 "llm.generate"
    start_ts: float               # 开始时间戳
    end_ts: float = 0.0           # 结束时间戳,end - start 就是耗时
    model: str = ""               # 用的哪个模型
    params: dict = field(default_factory=dict)     # temperature、max_tokens 等参数
    prompt: str = ""              # 关键:渲染后真正发给模型的完整提示词
    response: str = ""            # 模型返回的原始文本
    prompt_tokens: int = 0        # 输入 token 数
    completion_tokens: int = 0    # 输出 token 数
    cost: float = 0.0             # 这次调用花了多少钱
    status: str = "ok"            # ok / error
    error: str = ""               # 出错时的具体错误信息
    retries: int = 0              # 重试了几次
    attributes: dict = field(default_factory=dict)  # 业务标签:功能名、用户 ID 等

有了这个结构,就可以写一个包裹真实 LLM 调用的函数:它在调用前后各掐一下时间,把模型、参数、提示词、响应、token 用量,一并装进一个 LLMSpan 里。业务代码不再直接调 call_llm,而是调这个包裹函数:

import time, uuid

def traced_llm_call(trace_id: str, prompt: str, model: str,
                    params: dict, attributes: dict) -> LLMSpan:
    span = LLMSpan(
        trace_id=trace_id,
        span_id=uuid.uuid4().hex,
        name="llm.generate",
        start_ts=time.time(),
        model=model,
        params=params,
        prompt=prompt,                 # 把渲染后的完整提示词原样钉进去
        attributes=attributes,
    )
    resp = call_llm(prompt, model=model, **params)
    span.end_ts = time.time()
    span.response = resp.text
    span.prompt_tokens = resp.usage.prompt_tokens
    span.completion_tokens = resp.usage.completion_tokens
    return span

这里要建立的认知是:你必须在脑子里把"代码"和"运行时事实"这两样东西彻底分开。代码是静态的、是模板——它描述"一类调用会怎么发生";而一个 span,记录的是动态的、一次性的事实——"在某年某月某日某一毫秒,这一次具体的调用,实际发生了什么"。第一版的思路是"代码我写过、我看得懂,所以出问题我读代码就行",这个思路对确定性的逻辑成立,对 LLM 调用彻底失效——因为决定这次调用结果的,大半是运行时才确定的东西:这次检索回来的是哪几段、这次拼进去的历史多长、模型这次的随机采样落在哪。这些都不在代码里,只在"那一次运行"里。span 这个结构的意义,就是给"那一次运行"做一个完整的、可以脱离代码独立存在的快照——以后任何人,不需要读你的代码、不需要复现你的环境,只要拿到这条 span,就能知道当时发生了什么。所以设计 span 的字段时,判断标准很清晰:凡是"运行时才确定、且影响了这次结果"的东西,都要记;凡是"代码里写死、看一眼源码就知道"的东西,不必记。把这条标准立住,你的 trace 既不会漏掉关键现场,也不会被无用信息淹没。

三、用 trace 和 span 把多步链路串起来

上一节解决了"一次调用记什么"。可真实的 LLM 功能很少是一次调用——它是一条链:先检索资料,再重排,然后调模型生成,可能还有后处理。第二个怪现象(只知道总耗时 8 秒,不知慢在哪)的根子就在这:第一版把整条链拍扁成了一个点。解法是:每一步都开一个自己的 span,而同一次用户请求里的所有 span,共用同一个 trace_id——这样它们就被串成了一棵树,这棵树就叫一条 trace

一次用户请求,它的 trace 结构是这样的:

用代码实现,核心是一个贯穿整条链路的 trace 上下文:它持有 trace_id,并提供一个开 span 的方法。每一步业务逻辑用它包一下,耗时和结果就自动进了同一棵树:

import time, uuid
from contextlib import contextmanager

class Trace:
    def __init__(self, name: str, attributes: dict):
        self.trace_id = uuid.uuid4().hex
        self.name = name
        self.attributes = attributes
        self.spans: list[LLMSpan] = []

    @contextmanager
    def span(self, name: str):
        sp = LLMSpan(trace_id=self.trace_id, span_id=uuid.uuid4().hex,
                     name=name, start_ts=time.time())
        try:
            yield sp                        # 把 span 交给业务代码去填字段
        finally:
            sp.end_ts = time.time()         # 不管成功失败,耗时一定记上
            self.spans.append(sp)

有了它,那条"检索 → 重排 → 生成"的链就变得每一步都可观测了。每个 with trace.span(...) 块,都会自动量出这一步的耗时:

def answer_question(user_question: str, user_id: str) -> str:
    trace = Trace(name="qa", attributes={"feature": "qa", "user_id": user_id})

    with trace.span("retrieve") as sp:
        docs = retrieve(user_question)
        sp.attributes["doc_count"] = len(docs)        # 检索回多少段

    with trace.span("rerank") as sp:
        docs = rerank(user_question, docs)
        sp.attributes["kept"] = len(docs)

    with trace.span("llm.generate") as sp:
        prompt = build_prompt(user_question, docs)
        sp.prompt = prompt                            # 渲染后的完整提示词
        sp.model = "claude-opus-4-7"
        resp = call_llm(prompt, model=sp.model)
        sp.response = resp.text
        sp.prompt_tokens = resp.usage.prompt_tokens
        sp.completion_tokens = resp.usage.completion_tokens

    export_trace(trace)                               # 整条 trace 落库,见后文
    return docs and resp.text

这里要建立的认知是:trace 和 span 这两个概念,本质上是在对抗一种信息丢失——"拍扁"造成的信息丢失。一条有四个步骤的链路,如果你只在最外面记一个总耗时、一个最终结果,你做的就是把一棵有结构的树,拍扁成了一个没有结构的点。点是查不出问题的:总耗时 8 秒这个数字,信息量几乎为零,因为它没告诉你这 8 秒的内部构成。而 trace 做的事,就是把这个结构重新保留下来——它让你能展开那个点,看到里面是"检索 0.3 秒、重排 0.2 秒、模型生成 7.4 秒、后处理 0.1 秒",于是"慢在哪"这个问题,从一个无法回答的问题,变成了一眼就能看见的事实。这里的关键洞察是:可观测性的强弱,不取决于你记了多少条日志,而取决于你记录的信息有没有保留住系统真实的结构。一堆互相没有关联的零散日志,哪怕量很大,也拼不出一次请求的全貌;而一条结构正确的 trace,哪怕字段不多,也能让你顺着 trace_id 把一次请求的来龙去脉完整地走一遍。所以设计观测时,先别急着想"记什么字段",先想清楚"我的系统真实的结构是什么"——它有几步、谁调用谁、哪些操作属于同一次请求——然后让你的 trace 结构去如实地映射这个真实结构。结构对了,排查就有了脚手架。

四、Token 用量与成本:必须落到每一次调用

现在解决第三个怪现象:账单翻倍却拆不出原因。根子在于,第一版从来没有在调用这一层记过钱——成本只存在于月底那张供应商账单上,而那是一个汇总到不能再汇总的总数。要能归因,就必须在每一次调用发生的当下,就把这次花了多少钱算出来、记进 span。算钱不难,token 用量模型已经返回了,你只需要一张模型单价表:

from decimal import Decimal

# 每百万 token 的价格(单位:元),按你的供应商实际价格填
PRICE = {
    "claude-opus-4-7":   {"in": Decimal("105"), "out": Decimal("525")},
    "claude-haiku-4-5":  {"in": Decimal("7"),   "out": Decimal("35")},
}

def calc_cost(model: str, prompt_tokens: int, completion_tokens: int) -> Decimal:
    p = PRICE.get(model)
    if p is None:
        return Decimal("0")               # 未知模型,先记 0,但要告警补上单价
    cost_in = p["in"] * Decimal(prompt_tokens) / Decimal(1_000_000)
    cost_out = p["out"] * Decimal(completion_tokens) / Decimal(1_000_000)
    return cost_in + cost_out             # 输入和输出分开算,单价不同

把它接进 span 的填充逻辑,每个 llm.generate span 就都带上了 cost。再加上 span 里的 attributes(功能名、用户 ID),钱就有了归属:

    with trace.span("llm.generate") as sp:
        # ……前略:发起调用、拿到 resp……
        sp.prompt_tokens = resp.usage.prompt_tokens
        sp.completion_tokens = resp.usage.completion_tokens
        sp.cost = float(calc_cost(sp.model, sp.prompt_tokens, sp.completion_tokens))
        # 这一次调用花了多少钱、属于哪个功能、哪个用户,全都钉死在这条 span 上了

有了带 costattributes 的 span 持续落库,账单就不再是黑盒。任何时候想拆解,都是一句聚合查询的事——比如"按功能看本月花了多少钱"、"哪个用户的调用最烧钱"、"是平均提示词变长了还是调用次数涨了"。

这里要建立的认知是:成本这件事,有一条朴素却容易被忽视的规律——你能把成本归因到多细的粒度,完全取决于你在多细的粒度上记录了它。月底那张供应商账单,是一个粒度极粗的数据:它只有一个总数。你拿一个总数,是永远拆不出"哪个功能、哪个用户、哪种调用"的——这不是你分析能力不够,而是那个数据里压根就不包含这些维度的信息,信息在汇总的那一刻就被丢掉了。想要能按功能拆、按用户拆,唯一的办法是在"每一次调用"这个最细的粒度上,就把成本连同它的功能标签、用户标签一起记下来。一旦每次调用都有了这条带标签的成本记录,任何维度的拆解都只是一次聚合——因为细粒度的数据,天然可以聚合成任何粗粒度的视图,反过来则绝无可能。这个道理不止适用于成本,它适用于一切你将来想分析的指标:延迟、错误率、token 用量、命中率——你想在哪个维度上分析它,就必须在产生它的那一刻,带着那个维度的标签把它记下来。可观测性里有一句话值得记住:你只能分析你记录过的东西,而你记录的粒度,就是你分析能力的上限。所以别等账单出了问题才想起记成本,在写下第一次 LLM 调用时,就该让每一次调用都自带价签。

五、把异常、重试、降级也记进 trace

第四个怪现象是日志里只有一行 call failed。LLM 调用强依赖一个会限流、会超时、会抽风的外部 API,失败是常态而非意外。所以 trace 不能只记录"成功路径",它必须把失败的全过程也如实录下来:具体是什么错、重试了几次、每次重试的间隔、最后有没有降级到别的模型。先看错误怎么记进 span——关键是区分错误的类型,限流、超时、模型报错是完全不同的三件事:

def call_with_trace(sp: LLMSpan, prompt: str):
    try:
        resp = call_llm(prompt, model=sp.model, timeout=30)
        sp.response = resp.text
        sp.prompt_tokens = resp.usage.prompt_tokens
        sp.completion_tokens = resp.usage.completion_tokens
        sp.status = "ok"
    except RateLimitError as e:
        sp.status = "error"
        sp.error = f"rate_limit: {e}"          # 限流:该退避重试
        raise
    except TimeoutError as e:
        sp.status = "error"
        sp.error = f"timeout: {e}"             # 超时:可能要调小 max_tokens
        raise
    except Exception as e:
        sp.status = "error"
        sp.error = f"{type(e).__name__}: {e}"  # 其他:原样记下类型和信息
        raise

再看重试和降级。一次调用可能重试三次、最后降级到一个更便宜的模型才成功——这整个过程,要让 trace 看得出来。每次重试都让 retries 加一,降级了就把 model 和一个降级标记记上:

import time

def generate_with_retry(trace: Trace, prompt: str) -> str:
    models = ["claude-opus-4-7", "claude-haiku-4-5"]   # 主模型 + 降级模型
    for model in models:
        for attempt in range(3):
            with trace.span("llm.generate") as sp:
                sp.model = model
                sp.prompt = prompt
                sp.retries = attempt
                if model != models[0]:
                    sp.attributes["degraded"] = True   # 标记:这是降级调用
                try:
                    call_with_trace(sp, prompt)
                    return sp.response
                except RateLimitError:
                    time.sleep(2 ** attempt)           # 指数退避后再试
                    continue
                except Exception:
                    break                              # 非限流错误,换下一个模型
    raise RuntimeError("所有模型与重试均失败")

这里要建立的认知是:"失败"不是一个状态,它是一个需要被展开的故事。第一版那行 call failed,把一个有丰富情节的故事,压缩成了一个二值的标记——它告诉你"出事了",却把所有能让你处理这件事的信息全删掉了。可对一个强依赖外部 API 的系统来说,失败的"种类"恰恰是最关键的运维信息:如果失败是限流,你该做的是降低并发、加退避,或者申请提额;如果失败是超时,你该查的是提示词是不是太长、max_tokens 是不是设大了;如果失败是模型返回了内容错误,那可能是提示词本身有问题。这三种失败,处理方向完全不同,而 call failed 这一行字,把它们抹成了同一张脸。同样地,"重试了几次才成功"也是必须留下的情节:一个表面成功、其实重试了三次的调用,和一个一次就成的调用,健康度天差地别——如果你不记 retries,你的监控会觉得"成功率 100%,一切正常",而真相是系统正在剧烈地挣扎。所以记录失败的原则是:不要记录"成功还是失败"这个结论,要记录"通往这个结论的整个过程"——错误的具体类型、重试的次数与节奏、有没有降级、降级到了哪。把过程录下来,失败才从一个让你抓瞎的黑点,变成一个你能照着它一步步排查的、有迹可循的事件。

六、工程里那些可观测性的坑

trace 的结构对了,落地时还有几个工程坑反复咬人。第一个,全量记录扛不住高流量。每条 trace 都带着完整提示词和响应,流量一大,存储和写入都会爆。解法是采样:正常请求按比例抽一部分记,但出错的请求一定全记——因为出错的才是你最需要复盘的。

import random

def should_keep(trace: Trace, sample_rate: float = 0.1) -> bool:
    # 出错的 trace 一律全留,这是排查的核心素材
    if any(sp.status == "error" for sp in trace.spans):
        return True
    # 慢请求也全留:总耗时超过阈值的,留下来分析
    total = sum(sp.end_ts - sp.start_ts for sp in trace.spans)
    if total > 5.0:
        return True
    # 其余正常请求,按采样率随机留一部分
    return random.random() < sample_rate

第二个,提示词里可能有用户隐私。trace 要存渲染后的完整提示词,而提示词里常常拼了用户输入,可能含手机号、邮箱、身份证号。落库前必须做 PII 脱敏:

import re

PATTERNS = [
    (re.compile(r"1[3-9]\d{9}"), "[手机号]"),
    (re.compile(r"[\w.]+@[\w.]+"), "[邮箱]"),
    (re.compile(r"\d{17}[\dXx]"), "[身份证]"),
]

def mask_pii(text: str) -> str:
    for pattern, repl in PATTERNS:
        text = pattern.sub(repl, text)
    return text

def export_trace(trace: Trace):
    if not should_keep(trace):
        return
    for sp in trace.spans:
        sp.prompt = mask_pii(sp.prompt)        # 脱敏后再落库
        sp.response = mask_pii(sp.response)
    write_to_store(trace)                      # 见下一段:异步落库

第三个,落库不能阻塞主链路。写 trace 是观测行为,绝不能让它拖慢用户请求,更不能让它的失败影响正常响应。要把它丢进一个队列、异步落库:

import queue, threading, json

_trace_queue: queue.Queue = queue.Queue(maxsize=10000)

def write_to_store(trace: Trace):
    try:
        _trace_queue.put_nowait(trace)         # 非阻塞:队列满了就丢,绝不卡主链路
    except queue.Full:
        pass                                   # 观测数据可以丢,用户请求不能卡

def _worker():
    while True:
        trace = _trace_queue.get()
        try:
            line = json.dumps(trace.__dict__, ensure_ascii=False, default=lambda o: o.__dict__)
            append_to_log_file(line)           # 一条 trace 一行结构化 JSON
        except Exception:
            pass                               # 落库失败也不能影响主流程
        finally:
            _trace_queue.task_done()

threading.Thread(target=_worker, daemon=True).start()

第四个,trace 数据要有 TTL。完整提示词很占空间,trace 存储会快速膨胀,必须设过期时间(比如详细 trace 留 14 天,聚合后的成本和延迟指标长期留),到期自动清理。

这里要建立的认知是:可观测性本身,是有成本的——它要花存储、花写入带宽、花一点点 CPU,还引入了隐私风险。一个不成熟的做法是走极端:要么觉得它有成本就干脆不做(回到第一版),要么觉得它重要就无脑全量记录所有东西,结果观测系统自己先被流量压垮。成熟的做法,是把可观测性当成一个需要"权衡"的工程对象,而不是一个"开或关"的开关。这个权衡的核心,是分清信息的价值密度:出错的 trace、慢的 trace,价值密度极高,必须全留;而海量的、正常的、千篇一律的成功 trace,单条价值很低,采样留一小部分就够代表整体了。同样,完整提示词这种大字段,价值高但占地方,那就给它配上 TTL,让它只在最需要复盘的那段时间内存在。还有隐私——观测是为了排查问题,不是为了囤积用户的敏感信息,所以脱敏不是可选项,是落库前的必经一步。把这些权衡都做到位,你得到的是一个"可持续"的可观测性:它既能在你需要时给出足够的现场,又不会让存储成本失控、不会拖慢用户、不会埋下隐私的雷。可观测性的终极目标,不是"记录一切",而是"用可控的代价,记录下足够把问题查清楚的那些东西"——这中间的分寸,就是工程能力本身。

关键概念速查

概念 说明 关键点
trace 一次用户请求的完整可观测记录 由多个 span 组成,共用一个 trace_id
span 链路中一个可观测操作的记录 检索 重排 生成各是一个独立 span
渲染后提示词 模板检索历史拼接后真正发给模型的文本 trace 里分量最重的字段 缺它无法复盘
非确定性 同样输入模型可能给出不同输出 故有价值的是完整输入快照而非输出
每次调用成本 按 token 用量与模型单价当场算出 带功能用户标签 才能任意维度归因
错误分类 限流 超时 模型报错是不同的失败 处理方向不同 不能笼统记 failed
重试与降级 retries 计数与降级标记记进 span 表面成功实则重试三次健康度天差地别
采样 正常请求抽样 出错与慢请求全留 用可控存储成本保住高价值现场
PII 脱敏 落库前抹掉提示词里的隐私信息 手机号邮箱身份证等 是落库必经步骤
异步落库 trace 写入走队列 不阻塞主链路 队列满可丢 观测绝不能拖慢用户请求

避坑清单

  1. 不要只记最终答案。LLM 非确定,有复盘价值的是渲染后的完整提示词,不是那个答案。
  2. 必须记录运行时才确定的东西。检索回哪几段、拼了多长历史、用了什么参数——代码里看不到的才最该记。
  3. 把多步链路拆成多个 span。检索、重排、生成各开一个 span,别把整条链拍扁成一个总耗时。
  4. 同一次请求的所有 span 共用一个 trace_id,才能串成一棵可回放的树。
  5. 成本要落到每一次调用。按 token 当场算钱并打上功能、用户标签,月底才拆得出账单。
  6. 区分错误类型。限流、超时、模型报错处理方向完全不同,别笼统记一行 failed。
  7. 重试次数和降级一定要记。表面成功实则重试三次的调用,监控里不能显示成"一切正常"。
  8. 高流量下要采样。正常请求按比例抽,但出错和慢的请求必须全留。
  9. 落库前做 PII 脱敏。提示词常拼了用户输入,手机号邮箱身份证必须先抹掉。
  10. trace 落库走异步队列并设 TTL。绝不阻塞主链路,详细 trace 定期过期清理。

总结

回头看,第一版栽的跟头,根子是一个认知误判:我把"调用一次大模型"当成了"调用一个普通函数",于是用对付普通函数的观测强度——记一下入参出参——去对付它。可一次 LLM 调用,是非确定的、是昂贵的、通常是多步的、强依赖一个会抽风的外部 API 的。这四条里的每一条,都让"记入参出参"这个朴素办法失效:非确定让"答案"失去复盘价值,昂贵让"不记成本"等于放弃归因,多步让"只记总耗时"变成一个查不出问题的黑点,强外部依赖让"只记 failed"丢掉了所有能帮你处理失败的信息。问题从来不在"我日志打得不够多",而在我没看清:我要观测的,是一个比普通函数复杂得多的东西。

真正把 LLM 应用做得可观测,工作量不在"多打几条日志",而在一次观念的转变:承认一次 LLM 调用是一个需要被完整"录像"的复杂事件,而不是一行 answer = llm(prompt)。一旦接受这一点,该做的事就都浮现出来了——为每次调用录一份带完整渲染提示词的结构化 span、用 trace_id 把多步链路串成树、让每次调用自带价签、把错误类型和重试降级都录进去、再用采样和异步落库让这套观测可持续。每一步都不复杂,难的是先承认:你看代码看不出这次调用发生了什么,你必须在它发生的当下,把现场录下来。

我后来常拿飞机上的黑匣子来想这件事。一架飞机平稳飞行时,黑匣子看起来毫无用处——它只是默默地、不停地记录着每一个仪表读数、每一次操纵动作。可一旦出了事故,它就是唯一能还原真相的东西:调查员靠它,能一秒一秒地回放出事前到底发生了什么。如果没有黑匣子,一次空难就只剩"飞机掉下来了"这一句话,永远查不清、也就永远防不住下一次。第一版的日志,就是一架没有黑匣子的飞机:平时看着没事,真出了问题——答案错了、变慢了、账单爆了——你手里只有一句"出事了",现场没录,真相永远找不回来。而一份完整的 trace,就是你这个 LLM 功能的黑匣子:它在平时一声不响,却在你最需要的那一刻,把整个现场原原本本地交还给你。

这类问题最咬人的地方,在于它在功能演示、在小流量时几乎永远是"对"的:你自己点几下,答案对、速度也行,那两行日志看着完全够用,谁也不会觉得有问题。它只在真实流量压上来、真实账单寄过来、真实用户开始投诉时才露出獠牙——而那时,你想复盘的那一次次调用早已成为过去,没录下来的现场,再也回不来了。所以别等线上出了说不清的问题才想起可观测性:做 LLM 功能的第一天,就该把"为每次调用录一份 trace"当成和功能代码同等重要的事写进去——它不该是一个"以后有空再补"的运维项,而该是你这个 AI 功能从出生起就带着的黑匣子。把这件事在写第一行调用代码时就想清楚,你才算真正跳出了那个人人都会踩、却要等到投诉和账单面前才追悔莫及的"只记最终答案"。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

数据库外键约束完全指南:从一次"删了用户,订单却还在指着一个不存在的人"看懂数据完整性

2026-5-22 17:53:04

技术教程

登录会话存储完全指南:从一次"用户老是莫名其妙就退出登录"看懂 Cookie 与 Session

2026-5-22 18:06:21

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索