大模型应用可观测性:日志、Tracing 与问题复盘

0阅读10分钟

一次线上事故让我重新理解可观测性

去年我负责一个 RAG 客服系统,上线第三周接到运营反馈:「最近回答质量变差了,好多用户在骂。」我去查日志,只看到最终的用户输入和模型输出——中间检索了什么片段、Prompt 拼了什么上下文、模型版本是什么时候切的,全部缺失。排查了两天,最后发现是向量库索引重建后 Top-K 从 5 悄悄变成了 3,检索质量下降导致模型「没料可答」。

这件事让我意识到,大模型应用的可观测性不是「锦上添花」,而是「没有它你就没法debug」。传统后端服务的日志、Metrics、Tracing 三板斧在大模型场景下依然成立,但需要重新设计采集粒度和字段结构。这篇文章把我这两年踩过的坑和整理的方案写下来,希望对正在做 LLM 应用可观测性的团队有帮助。

大模型应用到底需要观测什么

传统 Web 服务的请求链路是「请求 → 路由 → 数据库 → 响应」,每一步延迟可控、结果确定。大模型应用的链路要复杂得多:一次用户请求可能经历意图识别、上下文组装、向量检索、重排序、Prompt 渲染、模型调用、工具调用、后处理、安全过滤等十几个步骤。任何一个环节出问题,最终输出都会偏离预期。

我把需要观测的信号分成三层:

Trace 层:记录一次请求的完整生命周期。每个请求分配一个 trace_id,下面的每个步骤是一个 span。span 之间形成父子关系,可以还原出请求的完整执行树。

Log 层:记录每个 span 内部的详细信息。包括 Prompt 内容、模型参数、检索结果、工具调用参数和返回值。日志需要带 trace_id 和 span_id,才能跟 Trace 关联。

Metrics 层:从 Trace 和 Log 中聚合出来的统计指标。比如 P50/P95 延迟、Token 消耗、单次调用成本、错误率、重试率、用户采纳率。

这三层不是独立存在的。Trace 是骨架,Log 是血肉,Metrics 是仪表盘。没有 Trace,Log 就是一盘散沙;没有 Log,Trace 就是个空壳;没有 Metrics,你就只能一个一个翻 Trace,没法发现系统性问题。

OpenTelemetry GenAI 语义约定:行业标准来了

2024 年之前,大模型应用的可观测性基本靠各团队自己定义字段。OpenTelemetry 社区在 2025 年发布了 GenAI 语义约定(Semantic Conventions for Generative AI),定义了一套标准化的 span 类型和属性名,让不同厂商的工具可以互通。

核心 span 类型有三个:

Span 类型含义典型场景
invoke_agent顶层 span,代表整个 Agent 交互用户发起一次对话请求
chat子 span,代表一次 LLM 调用调用 GPT-4o 生成回答
execute_tool子 span,代表一次工具调用调用搜索 API 获取实时信息

标准化属性方面,关键的有这些:

属性名说明示例
gen_ai.request.model调用的模型名称gpt-4o
gen_ai.usage.input_tokens输入 Token 数1024
gen_ai.usage.output_tokens输出 Token 数512
gen_ai.response.finish_reasons结束原因stoptool_calls
gen_ai.system_instructions系统 Prompt(可选)完整 system message
gen_ai.input.messages输入消息(可选)用户和助手消息列表
gen_ai.output.messages输出消息(可选)模型响应和工具结果

需要注意的是,内容类属性(system_instructionsinput.messagesoutput.messages)默认不采集,因为可能包含敏感数据。只有显式开启 captureContent 才会记录完整内容。这个设计很务实——既保护了隐私,又保留了排查问题时需要的信息。

标准化还定义了两个核心 Metrics:

  • gen_ai.client.operation.duration:LLM 调用延迟的直方图,可以按模型过滤做性能对比。
  • gen_ai.client.token.usage:Token 消耗的直方图,可以按 input/output 类型拆分。

如果你的团队正在从零设计可观测性方案,我建议直接对齐 OTel GenAI 语义约定。这样后续不管换什么观测平台,数据格式都不用大改。

一次 LLM 请求的完整 Trace 结构

把一次典型的 RAG 请求拆开看,Trace 的树形结构大致是这样的:

流程图画布 · 115%
Mermaid 流程图加载中...

对应到 Trace 数据,每个节点都是一个 span。我给一个简化的 JSON 结构:

{
  "traceId": "abc123",
  "spans": [
    {
      "spanId": "span-001",
      "name": "invoke_agent",
      "attributes": {
        "gen_ai.request.model": "gpt-4o",
        "user.id": "u-7890",
        "session.id": "sess-456"
      }
    },
    {
      "spanId": "span-002",
      "parentSpanId": "span-001",
      "name": "vector_search",
      "attributes": {
        "query": "如何退货",
        "top_k": 5,
        "results_count": 5,
        "latency_ms": 45
      }
    },
    {
      "spanId": "span-003",
      "parentSpanId": "span-001",
      "name": "chat",
      "attributes": {
        "gen_ai.request.model": "gpt-4o",
        "gen_ai.usage.input_tokens": 1024,
        "gen_ai.usage.output_tokens": 512,
        "gen_ai.response.finish_reasons": ["stop"],
        "latency_ms": 1200
      }
    }
  ]
}

这个结构的关键在于:每个 span 都有 parentSpanId,形成一棵树。你可以从顶层 invoke_agent 往下遍历,看到请求经过了哪些步骤、每步花了多久、用了什么参数。

主流观测平台对比

2026 年的 LLM 可观测性市场已经相当成熟。我整理了一张对比表,覆盖三个主流平台和两个传统方案:

维度LangfuseLangSmithArize Phoenix自建 ELK自建 OTel + Grafana
开源协议MIT闭源Apache 2.0开源开源
部署方式自托管 / CloudCloud / 企业自托管自托管 / Cloud自托管自托管
框架绑定无绑定LangChain 生态优先无绑定无绑定无绑定
Trace 模型Trace → Observation 层级Run 层级Span 树日志搜索Span 树
Prompt 管理✅ 内置✅ 内置❌ 需外接❌ 需自建❌ 需自建
评估(Eval)✅ 内置✅ 内置✅ 内置❌ 需自建⚠️ 需插件
OTel 支持OTLP 端点接收原生支持原生支持需适配原生支持
成本追踪✅ Token × 单价✅ Token × 单价⚠️ 基础支持❌ 需自建⚠️ 需配置
定价模式按 Observation 数按 Trace 数 + 座位数按数据量基础设施成本基础设施成本
适合团队重视隐私、多框架LangChain 深度用户需要 ML 评估能力已有 ELK 栈已有 OTel 基础设施

再看一张更细粒度的对比,聚焦三个最主流的专业平台:

能力LangfuseLangSmithLaminar
Trace 结构Trace → Observation 嵌套Run 层级 + 会话聚类Span 因果链
Agent 支持多步推理 TraceLangGraph Studio IDE浏览器 Agent 回放
评估集成内置 + 自定义 LLM Judge内置 + 人工标注内置自动评估
实时性近实时(秒级)实时实时 + 会话回放
免费额度50,000 Observations/月5,000 Traces/月1 GB 数据/月
付费起步Hobby 免费,Pro $59/月Plus $39/座/月Pro $50/月
自托管✅ 完全免费⚠️ 仅企业版✅ Docker Compose

选择建议很直接:如果你的团队深度使用 LangChain/LangGraph,LangSmith 的集成体验最好;如果你需要自托管、多框架支持或者对数据隐私有要求,Langfuse 是首选;如果你做的是浏览器自动化 Agent,需要会话回放来排查问题,Laminar 有独特优势。

三个真实场景的 Trace 设计

场景一:RAG 系统的检索质量追踪

RAG 系统最容易出问题的环节是检索。用户问「怎么退货」,系统检索到的文档片段不相关,模型就算再强也答不好。我在检索环节加了这些 span:

# 检索 span
with tracer.start_as_current_span("vector_search") as span:
    span.set_attribute("query", user_query)
    span.set_attribute("top_k", 5)
    span.set_attribute("embedding_model", "text-embedding-3-small")
    
    results = vector_store.similarity_search(user_query, k=5)
    
    span.set_attribute("results_count", len(results))
    span.set_attribute("scores", [r.score for r in results])
    span.set_attribute("latency_ms", elapsed_ms)

关键点:记录 scores 字段。当用户反馈「回答不对」时,你可以回溯 Trace 看检索分数——如果最高分只有 0.6,那问题大概率在检索侧,不是模型的问题。

场景二:多轮工具调用的因果链

Agent 场景下,一次请求可能触发多次工具调用。我需要看到完整的调用链和每次工具调用的输入输出:

# Agent 主循环
with tracer.start_as_current_span("invoke_agent") as agent_span:
    agent_span.set_attribute("gen_ai.request.model", "gpt-4o")
    agent_span.set_attribute("user.id", user_id)
    
    for step in agent_steps:
        if step.type == "llm_call":
            with tracer.start_as_current_span("chat") as llm_span:
                llm_span.set_attribute("gen_ai.request.model", step.model)
                llm_span.set_attribute("gen_ai.usage.input_tokens", step.input_tokens)
                llm_span.set_attribute("gen_ai.usage.output_tokens", step.output_tokens)
                llm_span.set_attribute("gen_ai.response.finish_reasons", [step.finish_reason])
                
        elif step.type == "tool_call":
            with tracer.start_as_current_span("execute_tool") as tool_span:
                tool_span.set_attribute("tool.name", step.tool_name)
                tool_span.set_attribute("tool.arguments", json.dumps(step.args))
                tool_span.set_attribute("tool.status_code", step.status_code)
                tool_span.set_attribute("tool.response_size", len(step.response))

这段代码的关键设计:tool_call span 记录了 tool.nametool.argumentstool.status_codetool.response_size。当某个工具调用失败时,你可以直接定位是哪个工具、什么参数、返回了什么错误码。

场景三:成本异常监控

有一次我发现某天的 API 账单突然翻了三倍。查了 Metrics 才发现,有个内部测试接口在循环调用 GPT-4o,每次传 10 万 Token 的上下文。如果没有 Token 级别的监控,这种问题可能要到月底才被发现。

我设置的告警规则:

alerts:
  - name: token_usage_spike
    metric: gen_ai.client.token.usage
    condition: "sum(input_tokens) > 100000 per minute"
    severity: warning
    
  - name: high_cost_request
    metric: gen_ai.client.operation.duration
    condition: "duration > 30s AND input_tokens > 50000"
    severity: critical
    
  - name: retry_loop
    metric: gen_ai.client.operation.duration
    condition: "same trace has > 5 chat spans in 60s"
    severity: critical

第三条规则专门抓重试循环——同一个 Trace 里 60 秒内出现超过 5 次 chat span,大概率是 Agent 陷入了工具调用死循环。这种问题不看 Trace 根本发现不了。

代码方案对比:从简单到完整

不同阶段的团队需要的可观测性方案复杂度不同。我把四种方案放在一起对比:

方案一:结构化日志 + trace_id(最小可行方案)

适合早期项目,用 Python 标准库就能搞定:

import logging
import uuid
 
logger = logging.getLogger(__name__)
 
def handle_request(user_query: str, user_id: str):
    trace_id = str(uuid.uuid4())
    
    logger.info("request_start", extra={
        "trace_id": trace_id,
        "user_id": user_id,
        "query_length": len(user_query),
    })
    
    # 检索
    contexts = retrieve(user_query)
    logger.info("retrieval_done", extra={
        "trace_id": trace_id,
        "results_count": len(contexts),
        "top_score": contexts[0].score if contexts else 0,
    })
    
    # 模型调用
    response = llm_call(user_query, contexts)
    logger.info("llm_done", extra={
        "trace_id": trace_id,
        "model": "gpt-4o",
        "input_tokens": response.usage.input_tokens,
        "output_tokens": response.usage.output_tokens,
        "latency_ms": response.latency_ms,
    })
    
    return response

优点:零依赖,五分钟接入。缺点:没有层级关系,查询靠 grep。

方案二:Langfuse SDK(开源平台方案)

当项目进入正轨,引入专业的 LLM 观测平台:

from langfuse import Langfuse
 
langfuse = Langfuse()
 
def handle_request(user_query: str, user_id: str):
    trace = langfuse.trace(
        name="rag_request",
        user_id=user_id,
        metadata={"source": "web"}
    )
    
    # 检索 span
    retrieval_span = trace.span(
        name="vector_search",
        input={"query": user_query},
        metadata={"top_k": 5}
    )
    contexts = retrieve(user_query)
    retrieval_span.end(output={"results_count": len(contexts)})
    
    # 模型调用 span
    generation = trace.generation(
        name="llm_call",
        model="gpt-4o",
        input=[{"role": "user", "content": user_query}],
        metadata={"contexts": [c.text for c in contexts]}
    )
    response = llm_call(user_query, contexts)
    generation.end(
        output=response.content,
        usage={
            "input": response.usage.input_tokens,
            "output": response.usage.output_tokens
        }
    )
    
    return response

优点:自动关联、有 UI 可以看 Trace 树、支持评估和 Prompt 管理。缺点:依赖外部服务,需要评估数据量成本。

方案三:OpenTelemetry 原生接入(标准化方案)

如果你的团队已经在用 OTel 做基础设施监控,直接复用现有链路:

from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
 
provider = TracerProvider()
provider.add_span_processor(
    BatchSpanProcessor(OTLPSpanExporter(endpoint="http://otel-collector:4317"))
)
trace.set_tracer_provider(provider)
tracer = trace.get_tracer("llm-app")
 
def handle_request(user_query: str, user_id: str):
    with tracer.start_as_current_span("invoke_agent") as span:
        span.set_attribute("user.id", user_id)
        span.set_attribute("gen_ai.request.model", "gpt-4o")
        
        # 检索
        with tracer.start_as_current_span("vector_search") as search_span:
            contexts = retrieve(user_query)
            search_span.set_attribute("results_count", len(contexts))
        
        # 模型调用
        with tracer.start_as_current_span("chat") as llm_span:
            llm_span.set_attribute("gen_ai.request.model", "gpt-4o")
            response = llm_call(user_query, contexts)
            llm_span.set_attribute("gen_ai.usage.input_tokens", response.usage.input_tokens)
            llm_span.set_attribute("gen_ai.usage.output_tokens", response.usage.output_tokens)
            llm_span.set_attribute("gen_ai.response.finish_reasons", [response.finish_reason])
        
        return response

优点:与现有 OTel 基础设施无缝集成,可以导出到任何支持 OTLP 的后端。缺点:需要自己搭建或维护 OTel Collector 和存储后端。

方案四:异步批量上报(生产级优化)

生产环境下,可观测性不能影响主链路性能。把上报改成异步批量:

import queue
import threading
from collections import defaultdict
 
class AsyncTraceExporter:
    def __init__(self, flush_interval=5, batch_size=100):
        self._queue = queue.Queue()
        self._flush_interval = flush_interval
        self._batch_size = batch_size
        self._worker = threading.Thread(target=self._run, daemon=True)
        self._worker.start()
    
    def emit(self, span_data: dict):
        self._queue.put(span_data)
    
    def _run(self):
        batch = []
        while True:
            try:
                item = self._queue.get(timeout=self._flush_interval)
                batch.append(item)
                if len(batch) >= self._batch_size:
                    self._flush(batch)
                    batch = []
            except queue.Empty:
                if batch:
                    self._flush(batch)
                    batch = []
    
    def _flush(self, batch: list):
        # 批量发送到后端
        exporter.export(batch)
 
exporter = AsyncTraceExporter()
 
# 使用
def handle_request(user_query: str, user_id: str):
    exporter.emit({
        "type": "request_start",
        "user_id": user_id,
        "query_length": len(user_query),
    })
    # 主逻辑不受阻塞

这种模式的核心思想是:Trace 事件先写入本地队列,后台线程批量刷新。主链路只做内存操作,不阻塞。Langfuse 和大多数专业平台内部也是这么做的。

Trace 数据该怎么存、存多久

这个问题没有标准答案,取决于你的业务场景和合规要求。我整理了一个参考:

数据类型建议保留期存储位置说明
Trace 元数据(span 属性、时间戳)30-90 天热存储(ES / ClickHouse)日常排查和 Dashboard 用
完整 Prompt / Response 内容7-30 天热存储 → 冷存储涉及隐私,需要脱敏策略
聚合 Metrics1 年+时序数据库(Prometheus / VictoriaMetrics)趋势分析和容量规划
用户反馈关联数据永久数据库用于评估模型迭代效果
审计日志按合规要求冷存储(S3 / OSS)金融、医疗行业可能有法规要求

一个实用的建议:把 Trace 元数据和内容数据分开存。元数据体积小、查询频率高,放热存储;内容数据体积大、主要是排查问题时按需查看,可以放到对象存储,需要时再拉取。

上线前检查清单

可观测性方案落地后,我用这个清单做最终检查:

Trace 结构

  • 每次请求有唯一 trace_id,且贯穿整个链路
  • 每个 span 有 parentSpanId,形成完整的树形结构
  • span 名称统一使用团队约定的命名规范(如 vector_searchchatexecute_tool
  • 时间戳精确到毫秒级

字段覆盖

  • LLM 调用 span 记录了模型名称、input_tokens、output_tokens、finish_reason
  • 检索 span 记录了 query、top_k、results_count、top_score
  • 工具调用 span 记录了 tool_name、arguments、status_code、response_size
  • 请求 span 记录了 user_id、session_id、task_type

数据质量

  • 敏感字段(用户手机号、身份证、密码)已脱敏
  • 大字段(超长 Prompt、Base64 图片)已截断或只存引用
  • 没有重复上报的 span

性能影响

  • 上报是异步的,不阻塞主链路
  • 批量上报间隔和大小合理(建议 5 秒或 100 条)
  • 本地队列有上限,防止内存爆炸

告警和 Dashboard

  • P50/P95 延迟有 Dashboard 和告警
  • Token 消耗和成本有趋势图,异常时自动告警
  • 错误率按类型拆分(接口失败 / 内容质量 / 业务规则)
  • 用户采纳率或满意度有追踪

可维护性

  • 字段对齐 OTel GenAI 语义约定,方便后续切换平台
  • 有文档说明每个 span 的含义和字段定义
  • 新成员看完文档后能在 10 分钟内查到一次请求的完整 Trace

写在最后

可观测性这件事,越早做成本越低。我见过太多团队在早期觉得「先跑起来再说」,等功能上线、用户量起来之后,发现线上问题根本没法排查,回头补可观测性的成本是最初的十倍。

早期项目不需要一上来就搭完整的观测平台。结构化日志 + trace_id 就够了,关键是字段设计稳定,能按用户、功能、模型版本筛选。等功能进入付费阶段、接入企业客户或自动化流程后,再引入 Langfuse 或 LangSmith 这样的专业平台。

但有一条底线:至少要能看到每次请求「用了什么模型、传了什么上下文、花了多少 Token、花了多少时间」。做不到这四点,你的大模型应用就是在裸奔。

参考资料

评论 0

0 / 1000