LLM 应用可观测与日志:把黑盒变成可调试的系统
LLM 应用的黑盒特性让传统日志力不从心:一次请求可能触发 5 次模型调用、3 次工具执行、2 次重试,每次调用有独立的 token 消耗和延迟。没有专门的可观测体系,问题只能靠猜。系统化的 Trace 设计是 LLM 应用工程化成熟度的核心指标。
举个真实场景:某天你的 RAG 客服机器人被用户投诉”答非所问”,你打开日志一看,只有一行 POST /api/chat 200 1243ms——请求成功、延迟正常,可是答案就是不对。翻遍应用日志找不到线索,因为传统日志只记录了”服务端做了什么”,没记录”模型在这次请求里经历了什么”:检索到的文档是不是相关、Prompt 里塞了什么上下文、模型有没有在中间某一步把关键信息丢了。这类问题不靠可观测体系基本查不出来,只能凭经验瞎猜,改了半天方向可能还是错的。
关键可观测指标
| 维度 | 指标 | 报警阈值参考 |
|---|---|---|
| 延迟 | TTFT(首 token 时间)、总延迟 | TTFT > 3s,总延迟 > 30s |
| 质量 | 工具调用成功率、任务完成率 | 工具失败率 > 5% |
| 成本 | 每请求 token 用量、日累计费用 | 单请求 > 10k token |
| 可靠性 | 错误率、重试率、超时率 | 错误率 > 1% |
| 业务 | 用户满意度(点赞/踩)、任务完成率 | 满意度 < 70% |
这张表里最容易被低估的是 TTFT(首 token 时间)。它和总延迟是两码事:如果你做了流式输出,用户在意的是”多久开始看到字”,而不是”整个回答生成完要多久”。见过不少团队只盯总延迟报警,结果 TTFT 已经飙到 5 秒、用户体验早就崩了,报警却没触发,因为总延迟被模型输出速度”拉平”了。所以流式场景下 TTFT 要单独埋点、单独报警,不能跟总延迟共用一个阈值。
工具调用成功率这项也容易被忽视——它不是”HTTP 200”就算成功,而是”模型选对了工具、参数填对了、工具执行返回了预期结构”三层都过关才算数。实践中常见的坑是模型把参数类型填错(比如该传整数传了字符串),工具那边直接抛异常,但异常被 try/except 吞掉后直接返回了兜底文案,从外部看请求”成功”了,指标却是失真的。埋点时要把”工具调用发起”和”工具调用真正成功”拆成两个独立计数器,二者的比值才是真实成功率。
Trace 结构设计
用 Trace + Span 的树形结构记录一次完整请求的所有步骤:
import uuid
import time
from dataclasses import dataclass, field
from typing import Optional
@dataclass
class Span:
span_id: str = field(default_factory=lambda: str(uuid.uuid4())[:8])
name: str = ""
parent_id: Optional[str] = None
start_time: float = field(default_factory=time.time)
end_time: Optional[float] = None
input: dict = field(default_factory=dict)
output: dict = field(default_factory=dict)
metadata: dict = field(default_factory=dict) # model, tokens, cost
def finish(self, output: dict):
self.end_time = time.time()
self.output = output
self.metadata["duration_ms"] = round((self.end_time - self.start_time) * 1000)
class Tracer:
def __init__(self, trace_id: str = None):
self.trace_id = trace_id or str(uuid.uuid4())
self.spans: list[Span] = []
def span(self, name: str, parent_id: str = None) -> Span:
s = Span(name=name, parent_id=parent_id)
self.spans.append(s)
return s
def to_dict(self) -> dict:
return {"trace_id": self.trace_id, "spans": [vars(s) for s in self.spans]}
使用示例:
async def run_rag_pipeline(question: str) -> str:
tracer = Tracer()
root = tracer.span("rag_pipeline")
# Span 1: 检索
retrieve_span = tracer.span("retrieve", parent_id=root.span_id)
docs = await vector_search(question)
retrieve_span.finish({"doc_count": len(docs), "top_score": docs[0].score})
# Span 2: LLM 生成
llm_span = tracer.span("llm_generate", parent_id=root.span_id)
response = await call_llm(question, docs)
llm_span.finish({
"model": response.model,
"prompt_tokens": response.usage.prompt_tokens,
"completion_tokens": response.usage.completion_tokens,
"cost_usd": calculate_cost(response.usage),
})
root.finish({"answer_length": len(response.content)})
await log_trace(tracer.to_dict()) # 异步写入,不阻塞主流程
return response.content
这套 Tracer 设计有几个决定是刻意为之的,不是随便写的:
- 用扁平的 spans 列表 + parent_id 字符串,而不是嵌套的树形对象。嵌套结构在序列化、跨进程传递时很麻烦(尤其是要塞进 HTTP header 或消息队列传给下游服务时),扁平列表 + parent_id 更接近 OpenTelemetry 的原生设计,后续要迁移到专业平台成本更低。
- metadata 里只存轻量字段(model、tokens、耗时),不存完整 input/output。上面
run_rag_pipeline示例里docs[0].score只取了 top 分数,没存全部文档内容,这是有意控制单条 trace 的体积——一条 trace 塞进几十 KB 的检索文档内容,量一大存储和查询都会变慢。 await log_trace(...)必须是异步调用,绝不能在返回用户答案之前同步等日志写完。见过真实案例:团队把 trace 写入换成了同步写数据库,本地测试没问题,上线后数据库偶发抖动,直接把 P99 延迟从 800ms 拖到 6s——查了半天以为是模型慢,最后发现是日志拖累了主流程。
生产环境不建议 100% 全量记录 trace,尤其是流量大的场景。常见做法是按比例采样(比如正常请求采 10%),但报错请求和超长延迟请求必须 100% 采样——这样既控制了存储成本,又不会漏掉真正需要排查的案例。
跨服务场景:Trace Context 怎么传下去
如果你的架构不是单体应用,而是网关、检索服务、LLM 网关分开部署,光在一个服务里记 Trace 是不够的——你需要让同一个 trace_id 能穿透所有服务,排查时才能把一次用户请求的完整链路拼起来。
做法是遵循 OpenTelemetry 定义的 W3C Trace Context 标准,用 traceparent 这个 HTTP header 在服务间透传:网关生成 trace_id 后,调用下游服务时把它塞进 header,下游服务收到请求先检查 header 里有没有 traceparent,有就复用同一个 trace_id 继续记录 span,没有才自己新建一个。绝大多数语言的 OTel SDK(Python 的 opentelemetry-sdk、Node 的 @opentelemetry/api)都自带这套传播逻辑,不用自己手写 header 解析。
这里踩过的一个坑是:Nginx 或网关层如果做了 header 白名单过滤,traceparent 这类自定义 header 可能被静默丢弃,下游服务收不到就会各自生成新的 trace_id,链路就断了——排查时看到同一次请求在两个服务里的 trace_id 对不上,先去查网关的 header 转发配置,而不是先怀疑代码逻辑写错了。
接入专业 LLM 观测平台
| 平台 | 开源 | 特点 | 接入方式 |
|---|---|---|---|
| LangSmith | 否 | LangChain 生态最完整,Playground 调试强 | SDK 集成 |
| Phoenix (Arize) | 是 | OpenTelemetry 原生,可本地部署 | pip install arize-phoenix |
| Langfuse | 是(可自托管) | 轻量,支持 prompt 版本管理 | REST API / SDK |
| Helicone | 否 | Proxy 模式,零代码接入 | 改 base_url |
怎么选:团队小、预算有限、想先跑起来看效果——直接上 Helicone 这种 Proxy 模式,改一行 base_url 就有数据看,零代码侵入,最快十分钟接完。团队规模上来了、需要 prompt 版本管理和 A/B 测试——Langfuse 更合适,而且它开源可自托管,数据不用出你自己的机房,对数据合规有要求的团队会更放心。已经深度用 LangChain 生态、需要复杂 debug(比如查看每一步 chain 的中间状态)——LangSmith 的 Playground 体验目前是最完整的,但它不开源、按用量计费,长期用下来成本要提前算清楚。
自托管 Langfuse 需要留意的是:它默认用 Postgres 存明细数据,流量大了以后表会涨得很快,务必提前规划好数据保留策略(比如只留 30 天明细,超期的做汇总归档),不然过几个月你会发现数据库比业务库还大。
最快接入方式(Helicone Proxy):
# 只需改 base_url,所有请求自动记录
client = OpenAI(
api_key=os.environ["OPENAI_API_KEY"],
base_url="https://oai.helicone.ai/v1",
default_headers={
"Helicone-Auth": f"Bearer {os.environ['HELICONE_API_KEY']}",
"Helicone-User-Id": user_id, # 按用户分析
"Helicone-Session-Id": session_id, # 按会话聚合
}
)
结构化日志最佳实践
import structlog
logger = structlog.get_logger()
def log_llm_call(span: Span, error: Exception = None):
logger.info(
"llm_call",
trace_id=span.span_id,
model=span.metadata.get("model"),
prompt_tokens=span.metadata.get("prompt_tokens"),
completion_tokens=span.metadata.get("completion_tokens"),
duration_ms=span.metadata.get("duration_ms"),
cost_usd=span.metadata.get("cost_usd"),
error=str(error) if error else None,
# 不记录完整 prompt/response 内容(数据安全 + 存储成本)
# 只记录前 100 字符用于调试
prompt_preview=str(span.input)[:100],
)
真实踩过的一个坑:加上 prompt_preview=str(span.input)[:100] 这行之前,线上跑了一阵子,日志系统突然开始报 UnicodeEncodeError: 'gbk' codec can't encode character——查下来是某些用户输入里带了 emoji,日志采集端的编码设置成了 gbk 而不是 utf-8,遇到 emoji 直接编码失败导致整条日志丢失,还波及了同一批次的其他日志。修法是全链路统一用 UTF-8(应用输出、日志采集 Agent、存储层三处都要对齐,改一处漏两处照样炸),这也是为什么很多团队会把日志采集这层单独测试覆盖 emoji、生僻字这类边界输入。
另一个常见报错是 KeyError: 'model'——如果某次 LLM 调用因为超时提前中断,span.metadata 里可能压根没写进 model 字段,取值时就崩了。稳妥的写法是像上面代码里那样用 span.metadata.get("model") 而不是 span.metadata["model"],异常路径下宁可拿到 None 也不要让整个日志记录逻辑崩溃——日志本身崩溃是最不该发生的事,它会让你连”出了什么错”都不知道。
原则:不记录完整对话内容到日志——既是数据安全要求,也能控制存储成本。用 trace ID 在专业平台(LangSmith/Langfuse)查完整内容。
常见问题
日志记录会不会影响请求延迟?
日志写入必须异步,用 asyncio.create_task() 或后台线程,不能在主流程中同步写入。Helicone Proxy 模式影响更小,它在代理层拦截而非应用层记录。
token 成本怎么按用户分摊计算?
在每次 LLM 调用后把 usage.prompt_tokens + usage.completion_tokens 写入用户账户维度的计数器(Redis HyperLogLog 或数据库),乘以当前模型单价即为用量成本。每月汇总生成用量报表。
如何发现”沉默失败”(模型返回了但答案质量差)? 沉默失败靠 HTTP 状态码看不出来。需要在业务层加检测:(1) 用户点踩/反馈;(2) 输出长度异常(过短可能是”我不知道”);(3) 定期 LLM-as-Judge 抽查线上回答质量。
采样率怎么定,10% 是不是拍脑袋定的? 没有放之四海皆准的数字,但可以按这个逻辑推:先算你能接受的月存储成本上限,除以单条 trace 平均体积,倒推出能承受的采样条数,再除以月请求总量得到采样率。日常巡检用采样数据就够,报错请求和 P99 以上的慢请求必须全量记录,这两类占比通常很小但排查价值最高。
多环境(dev/staging/prod)的数据会不会混在一起看混乱?
埋点时给每个 trace 加一个 env 标签字段,专业平台(LangSmith/Langfuse)基本都支持按标签筛选看板。开发环境的调试流量千万别和线上真实流量混在同一个视图里分析,不然算出来的平均延迟、成本数据全是失真的。
怎么设报警阈值,是不是照抄上面表格里的数字就行? 表格里的数字只是行业里常见的经验参考,你的业务如果是强实时交互场景(比如客服对话),阈值要收得更紧;如果是离线批处理场景,阈值可以放宽很多。更靠谱的做法是先跑一到两周拿到你自己业务的真实延迟分布,用 P95/P99 作为基线去定阈值,而不是直接套用别人的数字。
上线前自检清单
对照着过一遍,能全打勾再上线:
- 每次 LLM 调用是否都生成了独立的 span,并且能通过 trace_id 串联起一次完整请求
- 日志写入是否异步执行,不阻塞返回给用户的主流程
- 报错请求和超时请求是否做了 100% 采样(不会被抽样漏掉)
- 是否统一了全链路的字符编码(UTF-8),避免 emoji、生僻字导致日志丢失
- 是否记录了每次调用的 token 用量和费用,能按用户、按项目维度汇总
- 是否有 dev/staging/prod 环境标签,避免数据混着看
- 报警阈值是否基于你自己业务的真实数据分布,而不是照抄别人的参考值
跑完这个清单,基本能覆盖大部分线上排查场景——下次用户反馈”答案不对”,你能在几分钟内从 trace 里定位到是检索错了、Prompt 拼错了还是模型本身输出偏了,而不是对着一行 200 状态码的日志干瞪眼。
← 返回 应用模式总览:从 Prompt 到 Agent | 应用模式专题
相关阅读:大模型应用评测 Eval · 调用失败兜底设计
多模型接入后成本难以追踪?力达云聚合 API 提供统一账单视图,按模型、按用户、按项目分维度查看 token 用量与费用。