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 上了
有了带 cost 和 attributes 的 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 写入走队列 不阻塞主链路 | 队列满可丢 观测绝不能拖慢用户请求 |
避坑清单
- 不要只记最终答案。LLM 非确定,有复盘价值的是渲染后的完整提示词,不是那个答案。
- 必须记录运行时才确定的东西。检索回哪几段、拼了多长历史、用了什么参数——代码里看不到的才最该记。
- 把多步链路拆成多个 span。检索、重排、生成各开一个 span,别把整条链拍扁成一个总耗时。
- 同一次请求的所有 span 共用一个 trace_id,才能串成一棵可回放的树。
- 成本要落到每一次调用。按 token 当场算钱并打上功能、用户标签,月底才拆得出账单。
- 区分错误类型。限流、超时、模型报错处理方向完全不同,别笼统记一行 failed。
- 重试次数和降级一定要记。表面成功实则重试三次的调用,监控里不能显示成"一切正常"。
- 高流量下要采样。正常请求按比例抽,但出错和慢的请求必须全留。
- 落库前做 PII 脱敏。提示词常拼了用户输入,手机号邮箱身份证必须先抹掉。
- trace 落库走异步队列并设 TTL。绝不阻塞主链路,详细 trace 定期过期清理。
总结
回头看,第一版栽的跟头,根子是一个认知误判:我把"调用一次大模型"当成了"调用一个普通函数",于是用对付普通函数的观测强度——记一下入参出参——去对付它。可一次 LLM 调用,是非确定的、是昂贵的、通常是多步的、强依赖一个会抽风的外部 API 的。这四条里的每一条,都让"记入参出参"这个朴素办法失效:非确定让"答案"失去复盘价值,昂贵让"不记成本"等于放弃归因,多步让"只记总耗时"变成一个查不出问题的黑点,强外部依赖让"只记 failed"丢掉了所有能帮你处理失败的信息。问题从来不在"我日志打得不够多",而在我没看清:我要观测的,是一个比普通函数复杂得多的东西。
真正把 LLM 应用做得可观测,工作量不在"多打几条日志",而在一次观念的转变:承认一次 LLM 调用是一个需要被完整"录像"的复杂事件,而不是一行 answer = llm(prompt)。一旦接受这一点,该做的事就都浮现出来了——为每次调用录一份带完整渲染提示词的结构化 span、用 trace_id 把多步链路串成树、让每次调用自带价签、把错误类型和重试降级都录进去、再用采样和异步落库让这套观测可持续。每一步都不复杂,难的是先承认:你看代码看不出这次调用发生了什么,你必须在它发生的当下,把现场录下来。
我后来常拿飞机上的黑匣子来想这件事。一架飞机平稳飞行时,黑匣子看起来毫无用处——它只是默默地、不停地记录着每一个仪表读数、每一次操纵动作。可一旦出了事故,它就是唯一能还原真相的东西:调查员靠它,能一秒一秒地回放出事前到底发生了什么。如果没有黑匣子,一次空难就只剩"飞机掉下来了"这一句话,永远查不清、也就永远防不住下一次。第一版的日志,就是一架没有黑匣子的飞机:平时看着没事,真出了问题——答案错了、变慢了、账单爆了——你手里只有一句"出事了",现场没录,真相永远找不回来。而一份完整的 trace,就是你这个 LLM 功能的黑匣子:它在平时一声不响,却在你最需要的那一刻,把整个现场原原本本地交还给你。
这类问题最咬人的地方,在于它在功能演示、在小流量时几乎永远是"对"的:你自己点几下,答案对、速度也行,那两行日志看着完全够用,谁也不会觉得有问题。它只在真实流量压上来、真实账单寄过来、真实用户开始投诉时才露出獠牙——而那时,你想复盘的那一次次调用早已成为过去,没录下来的现场,再也回不来了。所以别等线上出了说不清的问题才想起可观测性:做 LLM 功能的第一天,就该把"为每次调用录一份 trace"当成和功能代码同等重要的事写进去——它不该是一个"以后有空再补"的运维项,而该是你这个 AI 功能从出生起就带着的黑匣子。把这件事在写第一行调用代码时就想清楚,你才算真正跳出了那个人人都会踩、却要等到投诉和账单面前才追悔莫及的"只记最终答案"。
—— 别看了 · 2026