欢迎光临

大模型应用的可观测性工程:构建LLM系统的监控、追踪与告警体系

当大语言模型从实验项目走向生产系统,它就不再只是一个”调用API拿到文本”的简单组件,而是一个涉及提示词管理、上下文编排、多轮对话状态、工具调用链路、向量检索质量和成本控制的复杂分布式子系统。传统应用的监控手段——CPU利用率、QPS、错误率——远远不够描述一个LLM系统的真实健康状态。本文从工程实战角度,系统拆解LLM应用可观测性的三层体系:指标(Metrics)、追踪(Tracing)和日志(Logs),并给出可落地的代码与配置。

LLM可观测性仪表盘

一、为什么LLM应用需要全新的可观测性策略

传统Web应用的监控以请求为中心:一个HTTP请求进来,经过若干中间件,访问数据库,返回响应。整条链路的延迟、错误率、吞吐量构成了SRE的黄金信号。但LLM应用的调用链路有本质不同:

  • 非确定性输出:同一个输入可能产生不同输出,传统的”请求-响应”模型无法描述输出质量。
  • 延迟分布长尾严重:Token生成是流式的,首token延迟(TTFT)和整体生成延迟(E2E latency)差异巨大,P99可能是P50的5-10倍。
  • 成本与Token强相关:一次请求的成本不取决于请求体大小,而取决于输入输出Token数量,传统按请求计费的监控模型失效。
  • 质量退化隐蔽:模型版本更新、提示词修改、检索索引漂移都可能导致输出质量下降,但HTTP状态码仍然是200。
  • 多跳调用链:一个用户请求可能触发RAG检索、工具调用、子Agent委派,形成深层嵌套的调用树。

这意味着,LLM应用的可观测性必须从”监控基础设施”升级为”监控语义质量”——不仅要知道系统在不在运行,还要知道系统运行得好不好。

二、指标层:从Token经济学到质量信号

2.1 核心指标定义

一个成熟的LLM监控体系至少需要以下四类指标:

指标类别 具体指标 采集方式
性能指标 TTFT、Tokens/s、E2E Latency、流式中断率 客户端计时 + 服务端中间件
经济指标 Input Tokens、Output Tokens、$/1K tokens、日均成本 API响应解析 + 计费中间件
质量指标 用户反馈率、重试率、幻觉检测分、引用准确率 用户行为日志 + 评估管线
可靠性指标 错误率、超时率、限流命中率、降级触发次数 网关日志 + 熔断器状态

2.2 用OpenTelemetry采集LLM指标

OpenTelemetry(OTel)已成为云原生可观测性的事实标准。对于LLM应用,我们可以通过自定义Meter来采集Token级指标。以下是一个基于Python的采集示例:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
from opentelemetry import metrics
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter
from opentelemetry.sdk.resources import Resource
import time, tiktoken

# 初始化OTel Meter
resource = Resource.create({"service.name": "llm-gateway"})
exporter = OTLPMetricExporter(endpoint="http://otel-collector:4317")
reader = PeriodicExportingMetricReader(exporter, export_interval_millis=15000)
meter_provider = MeterProvider(resource=resource, metric_readers=[reader])
metrics.set_meter_provider(meter_provider)
meter = metrics.get_meter("llm.observability")

# 定义指标
ttft_histogram = meter.create_histogram(
    name="llm.ttft.seconds",
    description="Time to first token in seconds",
    unit="s"
)
token_counter = meter.create_counter(
    name="llm.tokens.total",
    description="Total tokens consumed",
)
cost_counter = meter.create_counter(
    name="llm.cost.usd",
    description="LLM API cost in USD",
    unit="USD"
)

def instrument_llm_call(model, prompt, response_gen, provider):
    # 包装一个流式LLM调用,自动采集指标
    encoder = tiktoken.encoding_for_model(model) if "gpt" in model else None
    input_tokens = len(encoder.encode(prompt)) if encoder else 0
    token_counter.add(input_tokens, {"model": model, "type": "input", "provider": provider})

    first_token_time = None
    start = time.monotonic()
    output_tokens = 0
    full_response = []

    for chunk in response_gen:
        if first_token_time is None:
            first_token_time = time.monotonic() - start
            ttft_histogram.record(first_token_time, {"model": model, "provider": provider})
        text = chunk.choices[0].delta.content or ""
        if text:
            output_tokens += 1
            full_response.append(text)

    e2e = time.monotonic() - start
    token_counter.add(output_tokens, {"model": model, "type": "output", "provider": provider})

    # 按模型定价计算成本
    pricing = {"gpt-4o": (0.005, 0.015), "claude-sonnet-4": (0.003, 0.015)}
    in_price, out_price = pricing.get(model, (0, 0))
    cost = (input_tokens * in_price + output_tokens * out_price) / 1000
    cost_counter.add(cost, {"model": model, "provider": provider})

    return "".join(full_response), {
        "ttft": first_token_time, "e2e": e2e,
        "input_tokens": input_tokens, "output_tokens": output_tokens,
        "cost": cost
    }

这段代码的核心思路是将OpenTelemetry的Histogram和Counter嵌入到LLM调用的流式迭代器中。TTFT通过记录第一个chunk到达的时间差来计算,这对流式响应至关重要——它直接决定了用户体验的”首屏速度”。

2.3 Prometheus告警规则

将OTel指标导出到Prometheus后,可以配置关键告警规则:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
# prometheus/rules/llm-alerts.yml
groups:
  - name: llm-observability
    rules:
      # TTFT超过3秒,用户感知明显卡顿
      - alert: LLMHighTTFT
        expr: |
          histogram_quantile(0.95,
            rate(llm_ttft_seconds_bucket[5m])) > 3
        for: 10m
        labels:
          severity: warning
        annotations:
          summary: "LLM首Token延迟P95超过3秒"

      # 错误率超过5%
      - alert: LLMHighErrorRate
        expr: |
          rate(llm_request_total{status="error"}[5m]) /
          rate(llm_request_total[5m]) > 0.05
        for: 5m
        labels:
          severity: critical
        annotations:
          summary: "LLM请求错误率超过5%"

      # 日成本超过预算阈值
      - alert: LLMCostBudgetExceeded
        expr: |
          sum(rate(llm_cost_usd_total[1h])) * 24 > 500
        for: 30m
        labels:
          severity: warning
        annotations:
          summary: "LLM日均成本预计超过500美元预算"

三、追踪层:还原多跳调用树

LLM应用的调用链通常远比传统微服务复杂。一个RAG请求可能涉及:用户请求 -> 意图分类 -> 查询改写 -> 向量检索 -> 重排序 -> 上下文组装 -> LLM生成 -> 工具调用 -> 二次生成 -> 响应后处理。如果其中任何一环出问题,没有分布式追踪就等于盲人摸象。

分布式追踪调用链

3.1 用OTel Span构建LLM调用树


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
from opentelemetry import trace
from opentelemetry.trace import SpanKind

tracer = trace.get_tracer("llm.pipeline")

def rag_pipeline(user_query):
    # 带完整追踪的RAG流水线
    with tracer.start_as_current_span("rag_pipeline", kind=SpanKind.INTERNAL) as root:
        root.set_attribute("user.query", user_query)
        root.set_attribute("user.query.length", len(user_query))

        # 1. 意图分类
        with tracer.start_as_current_span("intent_classification") as span:
            intent = classify_intent(user_query)
            span.set_attribute("intent.result", intent)
            span.set_attribute("intent.confidence", 0.92)

        # 2. 查询改写
        with tracer.start_as_current_span("query_rewrite") as span:
            rewritten = rewrite_query(user_query, intent)
            span.set_attribute("query.original", user_query)
            span.set_attribute("query.rewritten", rewritten)

        # 3. 向量检索
        with tracer.start_as_current_span("vector_search", kind=SpanKind.CLIENT) as span:
            results = vector_db.search(rewritten, top_k=10)
            span.set_attribute("search.results_count", len(results))
            span.set_attribute("search.top_score", results[0].score if results else 0)
            span.set_attribute("search.latency_ms", 45)

        # 4. 重排序
        with tracer.start_as_current_span("rerank") as span:
            reranked = reranker.rerank(rewritten, results, top_k=3)
            span.set_attribute("rerank.input_count", len(results))
            span.set_attribute("rerank.output_count", len(reranked))

        # 5. LLM生成
        with tracer.start_as_current_span("llm_generation", kind=SpanKind.CLIENT) as span:
            context = assemble_context(reranked)
            response, call_metrics = instrument_llm_call(
                model="gpt-4o", prompt=context + user_query,
                response_gen=stream_completion(context, user_query),
                provider="openai"
            )
            span.set_attribute("llm.input_tokens", call_metrics["input_tokens"])
            span.set_attribute("llm.output_tokens", call_metrics["output_tokens"])
            span.set_attribute("llm.ttft_seconds", call_metrics["ttft"])
            span.set_attribute("llm.cost_usd", call_metrics["cost"])
            span.set_attribute("llm.context_docs", len(reranked))

        return response

在Jaeger或Tempo中查看这条Trace时,你会看到一个完整的瀑布图:每个Span的持续时间、属性、状态都清晰可见。当用户反馈”回答质量差”时,你可以快速定位是检索召回率低(top_score偏低)、重排序失效、还是LLM本身幻觉——而不是在日志海中捞针。

3.2 Span属性的最佳实践

LLM追踪的Span属性设计是决定可观测性质量的关键。以下是推荐的属性命名规范:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 标准化LLM Span属性(遵循OpenTelemetry GenAI语义约定)
span.set_attribute("gen_ai.system", "openai")
span.set_attribute("gen_ai.request.model", "gpt-4o")
span.set_attribute("gen_ai.request.temperature", 0.7)
span.set_attribute("gen_ai.request.max_tokens", 4096)
span.set_attribute("gen_ai.usage.prompt_tokens", input_tokens)
span.set_attribute("gen_ai.usage.completion_tokens", output_tokens)
span.set_attribute("gen_ai.response.id", response_id)

# RAG专用属性
span.set_attribute("rag.retrieval.top_k", 10)
span.set_attribute("rag.retrieval.score", 0.87)
span.set_attribute("rag.context.doc_ids", "doc1,doc2,doc3")
span.set_attribute("rag.context.total_chars", 12500)

遵循OpenTelemetry GenAI语义约定(gen_ai.*前缀)能确保你的追踪数据与生态工具兼容,包括Langfuse、Arize Phoenix、Datadog LLM Observability等。

四、质量评估层:超越基础设施监控

指标和追踪回答了”系统在做什么”,但没回答”系统做得好不好”。质量评估是LLM可观测性区别于传统APM的核心分水岭。

4.1 在线评估 vs 离线评估

维度 在线评估 离线评估
时机 请求实时进行中 异步批量处理
延迟要求 小于100ms 无限制
方法 规则检查、轻量分类器 LLM-as-Judge、人工标注
用途 实时降级、拦截低质输出 趋势分析、版本回归检测

4.2 实时质量门控

在LLM响应返回给用户之前,可以插入轻量级质量检查。以下是一个基于规则的实时评估器:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
import re
from dataclasses import dataclass

@dataclass
class QualityResult:
    passed: bool
    score: float
    reasons: list

class LLMQualityGate:
    def __init__(self):
        self.rules = [
            self._check_length,
            self._check_hallucination_markers,
            self._check_citation_coverage,
            self._check_language_consistency,
        ]

    def evaluate(self, response, context_docs, query):
        results = [rule(response, context_docs, query) for rule in self.rules]
        scores = [r[0] for r in results]
        reasons = [r[1] for r in results if not r[0]]
        overall = sum(scores) / len(scores)
        return QualityResult(passed=overall >= 0.7, score=overall, reasons=reasons)

    def _check_length(self, response, context_docs, query):
        # 过短响应可能信息不足
        if len(response) < 50:
            return (0.0, "响应过短,可能信息不足")
        return (1.0, "")

    def _check_hallucination_markers(self, response, context_docs, query):
        # 检测幻觉常见标记
        markers = ["根据我的知识", "据我所知", "通常情况下", "一般来说"]
        matches = sum(response.count(m) for m in markers)
        if matches > 2:
            return (0.3, "检测到%d个幻觉标记" % matches)
        return (1.0, "")

    def _check_citation_coverage(self, response, context_docs, query):
        # 检查引用是否覆盖了检索文档
        if not context_docs:
            return (0.5, "无上下文文档,无法验证引用")
        cited = sum(1 for doc in context_docs if doc.id in response)
        coverage = cited / len(context_docs)
        if coverage < 0.3:
            return (0.4, "引用覆盖率仅%.0f%%" % (coverage * 100))
        return (1.0, "")

    def _check_language_consistency(self, response, context_docs, query):
        # 语言一致性检查
        def is_chinese(text):
            return any("\u4e00" <= c <= "\u9fff" for c in text)
        if is_chinese(query) != is_chinese(response):
            return (0.2, "响应语言与查询语言不一致")
        return (1.0, "")

这个质量门控可以嵌入到追踪Span中,将质量分数作为Span属性记录,并在低于阈值时触发降级策略(如切换模型、追加上下文、返回兜底响应)。

4.3 LLM-as-Judge异步评估

对于无法实时运行的深度质量评估,可以使用LLM-as-Judge模式异步处理。以下是一个基于批处理的评估管线:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
import asyncio, json
from openai import AsyncOpenAI

client = AsyncOpenAI()

JUDGE_PROMPT = """你是一个严格的技术内容质量评估专家。请对以下AI助手的回答进行评分。

用户问题:{query}
AI回答:{response}
参考文档:{context}

请从以下维度各打1-5分:
1. 准确性:回答是否与参考文档一致,有无事实错误
2. 完整性:是否充分回答了用户的问题
3. 引用准确性:引用的信息是否确实来自参考文档
4. 结构清晰度:回答的逻辑结构是否清晰易读

输出JSON格式如下:
{"accuracy": N, "completeness": N, "citation": N, "clarity": N, "overall": N, "reasoning": "..."}"""

async def judge_response(query, response, context):
    prompt = JUDGE_PROMPT.format(query=query, response=response, context=context)
    result = await client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": prompt}],
        response_format={"type": "json_object"},
        temperature=0,
    )
    return json.loads(result.choices[0].message.content)

async def batch_evaluate(session_logs, batch_size=20):
    # 批量评估历史会话,用于趋势分析
    semaphore = asyncio.Semaphore(batch_size)

    async def eval_one(log):
        async with semaphore:
            return await judge_response(log["query"], log["response"], log["context"])

    results = await asyncio.gather(*[eval_one(log) for log in session_logs])
    n = len(results)
    return {
        "accuracy": sum(r["accuracy"] for r in results) / n,
        "completeness": sum(r["completeness"] for r in results) / n,
        "citation": sum(r["citation"] for r in results) / n,
        "clarity": sum(r["clarity"] for r in results) / n,
    }

将评估结果写入时序数据库(如Prometheus或InfluxDB),配合Grafana可以绘制质量趋势图。当某天的平均准确性分数突然下降0.5分,就说明可能有模型版本变更、提示词回归或知识库污染需要排查。

五、日志层:结构化LLM调用日志

LLM应用的日志不能只记录”请求来了、响应发了”,必须结构化记录完整的调用上下文,才能支持事后排查和审计。推荐使用JSON结构化日志:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import structlog, logging

logging.basicConfig(format="%(message)s")
structlog.configure(
    processors=[
        structlog.processors.TimeStamper(fmt="iso"),
        structlog.processors.add_log_level,
        structlog.processors.JSONRenderer(),
    ]
)
log = structlog.get_logger("llm.gateway")

def log_llm_call(trace_id, span_id, **kwargs):
    # 结构化记录LLM调用
    log.info("llm.call.complete",
        trace_id=trace_id,
        span_id=span_id,
        model=kwargs.get("model"),
        provider=kwargs.get("provider"),
        input_tokens=kwargs.get("input_tokens"),
        output_tokens=kwargs.get("output_tokens"),
        ttft_ms=kwargs.get("ttft_ms"),
        e2e_ms=kwargs.get("e2e_ms"),
        cost_usd=kwargs.get("cost"),
        prompt_hash=hash(kwargs.get("prompt", "")),
        response_quality_score=kwargs.get("quality_score"),
        fallback_triggered=kwargs.get("fallback", False),
    )

注意几个关键设计:第一,不记录原始Prompt和响应全文到日志系统——它们可能包含敏感用户数据,只记录hash和长度。全文应存储在专用的事务存储中(如Langfuse或S3),通过trace_id关联。第二,每条日志都带上trace_id和span_id,实现日志与追踪的交叉关联。第三,记录quality_score和fallback_triggered等业务语义字段,而不仅仅是技术指标。

六、成本可观测性:Token就是金钱

LLM应用的成本可观测性是很多团队忽视直到账单爆炸才重视的维度。一个良好的成本监控体系应该做到:

  • 实时成本看板:每小时刷新一次的Token消耗和美元成本,按模型、按租户、按功能模块维度聚合。
  • 成本异常检测:当某用户的Token消耗突然增长10倍时自动告警,可能是Prompt注入攻击或循环调用bug。
  • 预算熔断:在网关层实现per-tenant的Token预算硬限制,超过预算自动降级到更便宜的模型。
  • 成本归因:将每个用户请求的LLM成本精确归因到具体功能点,支持按功能计费或成本优化决策。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
from dataclasses import dataclass
from collections import defaultdict
import time

@dataclass
class TokenBudget:
    tenant_id: str
    daily_limit: int
    hourly_limit: int

class TokenBudgetGuard:
    def __init__(self):
        self.hourly_usage = defaultdict(int)
        self.daily_usage = defaultdict(int)
        self.fallback_model = "gpt-4o-mini"

    def check_and_select_model(self, tenant_id, requested_model, estimated_tokens):
        hour_key = (tenant_id, int(time.time()) // 3600)
        day_key = (tenant_id, int(time.time()) // 86400)

        if self.hourly_usage[hour_key] + estimated_tokens > self._get_hourly_limit(tenant_id):
            log.warning("hourly_budget_exceeded",
                tenant=tenant_id, usage=self.hourly_usage[hour_key])
            return self.fallback_model

        if self.daily_usage[day_key] + estimated_tokens > self._get_daily_limit(tenant_id):
            log.error("daily_budget_exceeded",
                tenant=tenant_id, usage=self.daily_usage[day_key])
            raise BudgetExceededError("Daily budget exceeded for " + tenant_id)

        self.hourly_usage[hour_key] += estimated_tokens
        self.daily_usage[day_key] += estimated_tokens
        return requested_model

    def reconcile(self, tenant_id, estimated, actual):
        # 响应返回后修正预估Token与实际Token的差值
        diff = actual - estimated
        hour_key = (tenant_id, int(time.time()) // 3600)
        day_key = (tenant_id, int(time.time()) // 86400)
        self.hourly_usage[hour_key] += diff
        self.daily_usage[day_key] += diff

七、可观测性架构总览与工具选型

将以上各层整合起来,一个生产级LLM应用的可观测性架构如下:

系统架构示意

  • 采集层:OpenTelemetry SDK嵌入应用代码,统一采集Metrics、Traces、Logs。
  • 传输层:OTel Collector作为统一网关,支持缓冲、重试、采样和敏感数据脱敏。
  • 存储层:Prometheus存指标,Tempo/Jaeger存追踪,Loki/ELK存日志,Langfuse存LLM专用数据。
  • 可视化层:Grafana统一看板,集成Prometheus、Tempo、Loki数据源;Langfuse提供LLM专用分析界面。
  • 告警层:Alertmanager处理Prometheus告警路由,PagerDuty或Slack接收通知。

在工具选型上,如果你的LLM应用以RAG和Agent为主,Langfuse是性价比最高的LLM专用可观测性平台,它原生支持OpenAI、Anthropic、LangChain等主流框架的自动追踪,并内置了LLM-as-Judge评估能力。如果你已有Grafana技术栈,则直接用OTel + Prometheus + Tempo + Loki是最自然的选择。对于大规模部署,建议两者结合:Langfuse做LLM语义层分析,Grafana做基础设施层监控。

结语:可观测性是LLM应用上线的前提

很多团队把可观测性当作”上线之后再补”的nice-to-have,这在传统应用中或许勉强可行,但在LLM应用中是致命的。大模型的非确定性意味着你无法通过测试覆盖所有行为路径,唯一可靠的保障手段就是持续的可观测性——在真实流量中发现问题、定位问题、修复问题。

从指标采集到追踪链路、从质量评估到成本熔断,本文给出的方案并非空中楼阁,而是在真实生产环境中验证过的工程实践。可观测性的投入看起来是”额外成本”,但它能在一次模型版本回归中帮你省下数小时的排查时间,在一次Prompt注入攻击中帮你止损数千美元的Token消耗。在LLM从demo走向production的路上,可观测性不是可选项,而是必选项。

【本站文章皆为原创,未经允许不得转载】:汤不热吧 » 大模型应用的可观测性工程:构建LLM系统的监控、追踪与告警体系
分享到: 更多 (0)