大模型工程化实战(八):OTel + LangFuse 全链路 Trace——把网关、护栏、SchemaGate 全部打点串起来,出问题一眼定位到环节

目录

  • 前言
  • 一、问题定义:每环节都有日志,为什么不等于全链路可观测
  • [二、核心方案:打点三问 + 一个贯穿 ID,把三道闸变成可观测的裁决点](#二、核心方案:打点三问 + 一个贯穿 ID,把三道闸变成可观测的裁决点)
    • [2.1 打点三问:每个被插桩的环节,发一个 span 回答三个问题](#2.1 打点三问:每个被插桩的环节,发一个 span 回答三个问题)
    • [2.2 打点命名对照表(在哪打、打什么、status 怎么定)](#2.2 打点命名对照表(在哪打、打什么、status 怎么定))
    • [2.3 status 三态:设计内兜底计 ok,设计外降级计 degraded](#2.3 status 三态:设计内兜底计 ok,设计外降级计 degraded)
    • [2.4 打点纪律:PII 永不进 Trace](#2.4 打点纪律:PII 永不进 Trace)
  • [三、代码实战:一个可离线跑的迷你 Trace 采集器](#三、代码实战:一个可离线跑的迷你 Trace 采集器)
  • 四、踩坑记录:这三个坑每个都真付过费
    • [4.1 打点记原文,把脱敏闸绕过去了](#4.1 打点记原文,把脱敏闸绕过去了)
    • [4.2 把降级/兜底打成 error status,狼来了](#4.2 把降级/兜底打成 error status,狼来了)
    • [4.3 打点没有共享 ID,接上 LangFuse 也白搭](#4.3 打点没有共享 ID,接上 LangFuse 也白搭)
  • [五、选型对比:OTel 和 LangFuse 不是二选一](#五、选型对比:OTel 和 LangFuse 不是二选一)
    • [5.1 大表 A:定位分工------通用链路大盘 vs LLM 语义工作台](#5.1 大表 A:定位分工——通用链路大盘 vs LLM 语义工作台)
    • [5.2 大表 B:OTel GenAI 语义约定现状(2026,诚实告知)](#5.2 大表 B:OTel GenAI 语义约定现状(2026,诚实告知))
    • [5.3 可复用 checklist:6 个"是/否"答完落到方案](#5.3 可复用 checklist:6 个“是/否”答完落到方案)
  • [六、总结 + 下一篇预告](#六、总结 + 下一篇预告)
  • [附录 A:真实 SDK 最小接入片段(正文迷你 Tracer 的接口签名就是照着它对齐的)](#附录 A:真实 SDK 最小接入片段(正文迷你 Tracer 的接口签名就是照着它对齐的))
  • [附录 B:一条 span 的 JSON 结构示意](#附录 B:一条 span 的 JSON 结构示意)
  • [附录 C:抽样与存储成本估算(推导放这,正文只留结论)](#附录 C:抽样与存储成本估算(推导放这,正文只留结论))
  • [附录 D:术语表](#附录 D:术语表)

前言

凌晨一点,schema_validation_exhausted 这条结构化告警第三次亮起。下游没崩、没有 500,可"为什么偏偏这单走兜底"连我也答不上来:网关日志显示主路超时、重试 2 次兜到 gpt4;护栏日志显示 medium 风险 strip 放行;SchemaGate 日志显示缺"金额"字段、类型错、重试耗尽。三份日志都对,时间戳也对得上,人肉拼了两小时才凑出因果链。全链路 Trace 治的就是这个毛病:可观测不等于"每环节都打日志",得把第 5 篇《LLM 网关选型》、第 6 篇《OWASP LLM 安全护栏》、第 7 篇《JSON Schema 强约束》三道闸的打点用同一个 trace_id 串成一根线。OTel 和 LangFuse 只负责搬运和展示,真要先想清楚的是"打点什么",想清楚了,出问题才能一眼定位到环节。

一、问题定义:每环节都有日志,为什么不等于全链路可观测

传统日志有三个死穴,LLM 链路全踩:

  1. 时间戳对齐靠猜。 第 5 篇的网关和第 7 篇的 SchemaGate 不在同一进程,跨服务时钟漂移、时区不一致,你拿日志拼因果,第一步就是在对表。
  2. 上下文断裂。 一次请求过 N 个环节,没有一个共享 ID 把它们拴住。三份日志各讲各的,谁是谁的父、谁先谁后,全靠人肉猜。
  3. 只记结果不记裁决。 网关记 200/503,不记 retry_count、兜到谁、为什么降级;护栏不记 risk 多高、动了哪个动作;SchemaGate 不记哪个字段错、重试几次。

而 LLM 链路尤其要看得见三件事:拦没拦 (护栏命中风险没有、最后落了 block/strip/allow 哪个动作)、救没救 (SchemaGate 修复/重试有没有生效、哪个字段错)、兜没兜 (降级走没走、兜到哪条路)。结论先放这儿:可观测不是"加监控面板",是把每个裁决点变成结构化 span 事件、用 trace_id 串成链------工具是最后一步,不是第一步。

二、核心方案:打点三问 + 一个贯穿 ID,把三道闸变成可观测的裁决点

第 5/6/7 篇的三道闸,本质是三个"裁决点":网关做路由裁决(走谁、重试不重试、兜不兜),护栏做放行裁决(拦/strip/放行),SchemaGate 做格式裁决(放行/修复/重试/降级)。传统 Trace 只关心"调没调通",LLM 可观测要关心的是"这个环节做了什么决定"------所以 span 属性里记的是裁决字段(risk / action / retries / degraded / error.code),不是 HTTP status。

2.1 打点三问:每个被插桩的环节,发一个 span 回答三个问题

问什么 落成 span 的什么 举例
做了什么 span 名 + kind + 子动作属性 gateway.callgen_ai.completion、fallback/repair/mask
判了什么 裁决属性,从各组件自己的返回结构里搬 网关的 degraded/error、护栏的 risk/action/alarm、SchemaGate 的 GateResult.error{code,detail,sample}
花多久/多少钱 duration_ms + token 计数 gen_ai.usage.input_tokens/output_tokens------token 只留计数不落文本,这是给第 9 篇《Token 成本六维优化》埋的账本钩子

再加一个贯穿 ID:网关入口生成 trace_id,用上下文对象一路传(demo 里显式传 ctx,生产由 OTel context 自动传播),所有环节的 span 挂同一个 trace_id。串不起来,打点再全也是一盘散沙。

2.2 打点命名对照表(在哪打、打什么、status 怎么定)

这张表就是排障时的字典。生产里照抄,demo 里的 attributes key 和它一一对应。

环节(出处) 生产打点位置 span 名 关键裁决属性 status 语义
入口请求 网关入口生成 trace_id request request.tenant 等业务上下文 整条链兜完返回 = ok
网关(第 5 篇《LLM 网关选型》) GatewayPipeline.handle() / Provider.complete() gateway.call / gen_ai.completion gateway.providergateway.retry_countgateway.fallback_togateway.degradedgateway.error_typegen_ai.provider.namegen_ai.usage.input_tokens/output_tokens 重试/fallback 兜住 = ok;全链无可用 = error(503),错误 type 透传到底
护栏(第 6 篇《OWASP LLM 安全护栏》) InputGuard.check() / scan_docs() / OutputGuard.mask() guardrail.input / guardrail.scan_docs / guardrail.output guardrail.riskguardrail.actionguardrail.domainguardrail.alarmguardrail.degraded(检测器故障) 护栏自身故障 = degraded 放行,不拖垮主链路
结构闸(第 7 篇《JSON Schema 强约束》) SchemaGate.guard() schema_gate.guard schema_gate.okschema_gate.repairedschema_gate.retriesschema_gate.errors(字段错误路径前 5 条)、schema_gate.degradedschema_gate.error_codeschema_gate.sample 修复/重试后放行 = ok;耗尽降级 = degraded,不返 500

2.3 status 三态:设计内兜底计 ok,设计外降级计 degraded

把 SchemaGate 重试耗尽、网关 fallback、护栏降级放行打成 error status,告警第一天就把群炸了;狼来了三天,第四天真出 500 反而没人管;全打成 ok,兜底路径永远看不见,"降级率悄悄爬升"这种慢病无人问津。所以 status 必须是三态:

  • ok:这个环节正常完成它的裁决,包括"重试后兜住了""修复后放行了"------结果是好的。
  • degraded :兜住了但走了设计外 降级路径(schema 重试耗尽、护栏自身故障放行)。网关的重试/fallback 属设计内兜底,计 ok,走 gateway.fallback_to 属性留痕,不进 degraded。不触发 P1,但要进独立的"降级率趋势"告警------按小时统计 degraded span 占比,超过基线才响。把"兜住了"和"没兜住"分开记账,狼来了才不会天天喊。
  • error:真挂了,错误没被任何环节接住。错误 type 一路透传(第 5 篇踩坑③"网关吞错误"的心智搬到 Trace):下游分得清是"兜住了"还是"真挂了",而不是一句"系统繁忙"。

真实 OTel 的 status 只有 OK/ERROR,degraded 不是官方值,生产里落成"xxx.degraded=true 属性 + status=OK";迷你 Tracer 把 degraded 做成第三种 status 值,是为了在 demo 里把三态讲清,属教学简化(映射见附录 A 注释)。

2.4 打点纪律:PII 永不进 Trace

这是全篇最锋利的一条:Trace 不能变成第 6 篇脱敏闸的绕行通道。 第 6 篇在原文进上下文之前拦、出上下文之后挡,第 8 篇要做的,是根本不让原文进来。打点只记裁决字段,模型入参/出参只记 token 计数;要回放原文,只拿第 7 篇 GateResult 里那个 sample[:200] 的已脱敏裁剪文本------第 7 篇正文明写的"给第 8 篇打点的 sample 裁剪",就是这里 schema_gate.sample 的数据源。原文默认不落,跟"监控面板漂不漂亮"无关,跟"Trace 是不是第二道脱敏闸的绕行通道"有关。

整条链路长这样(每个环节一个 span,父挂父):

复制代码
一次请求  trace_id=T-8f3a...(网关入口生成,Context 一路传)
   │
   ▼
gateway.call:主路 claude 超时 → 重试 2 次 → 兜到 gpt4      status=ok
   │
   ▼
guardrail.input:扫注入 → risk=medium/action=strip/告警     status=ok
   │
   ▼
gen_ai.completion:gpt4,token 计数落账(文本不落)           status=ok
   │
   ▼
guardrail.output:脱敏动作进 Trace(原文不进)               status=ok
   │
   ▼
schema_gate.guard:缺"金额"类型错 → 重试 2 次耗尽 → 降级      status=degraded
   │
   ▼
下游:收到结构化 error(code=schema_validation_exhausted),不是 500

三、代码实战:一个可离线跑的迷你 Trace 采集器

先交代自包含方案:本地没有第 5/6/7 篇的源码文件(它们嵌在各自文章里),所以 mini_trace.py 不 import 任何不存在的文件,demo 内联三个"裁决点桩",桩的返回结构逐字段对齐第 5 篇 Response、第 6 篇 risk/action/alarm、第 7 篇 GateResult------生产里把这三行换成真实组件,打点逻辑一行不改。demo 的护栏桩只出 {risk, action, alarm} 决策、不真改内容;生产里 strip 是在 InputGuard 内部把命中片段剥掉后才放行下游,打点不改变第 6 篇的执行顺序。

再说纯标准库约束:OTel/LangFuse 是第三方库,进不了 demo。 所以迷你 Tracer 的接口签名照着 OTel API 长(start_span 作上下文管理器、set_attributerecord_status),Collector 就做成内存版 exporter。生产里把 Tracer 换成 opentelemetry SDK、Collector 换成 OTLP exporter 或 LangFuse SDK,打点代码不用动。这就是"事件模型先于工具"的落点:先把模型钉死,工具只是个插头。

python 复制代码
"""
迷你全链路 Trace:Span / Tracer / Collector / Context + 三个"裁决点桩",
把第 5 篇网关、第 6 篇护栏、第 7 篇 SchemaGate 的裁决结构串成一条可回放、可定位的链。
纯标准库(dataclasses / uuid / json / re / time),无第三方依赖、无 API 密钥,
三个裁决点桩模拟真实组件的决策,不真调模型、不真花钱。

依赖安装:无(Python >= 3.10 即可)|运行:python mini_trace.py / python test_mini_trace.py
(Windows 控制台打印中文若报 GBK 编码错:PowerShell 用 `$env:PYTHONUTF8=1; python mini_trace.py`,cmd 用 `set PYTHONUTF8=1 && python mini_trace.py`)

七条设计为什么:
1. Span 是"裁决事件"不是"调用记录":kind 记环节、attributes 记裁决字段(risk/action/retries/degraded),
   不只记 HTTP status------LLM 链路要看"这个环节做了什么决定"。
2. 接口签名对齐 OTel API:Tracer.start_span(name, kind, ctx) 上下文管理器 + set_attribute/record_status;
   生产换 opentelemetry SDK / LangFuse SDK,打点一行不改(事件模型先于工具)。
3. trace_id 靠 Context 贯穿:网关入口生成、span 栈压栈弹栈,共享 ID 传漏/传错全链路就散架(踩坑③)。
4. status 三态:设计内兜底(fallback/修复)= ok,设计外降级(重试耗尽/护栏故障)= degraded 不炸 P1,真挂 = error;错误 type 透传到底(第 5 篇踩坑③心智)。
5. PII 永不进 Trace:只记裁决字段;SchemaGate 桩的 sample 是 output_mask 后的 text[:200](第 7 篇口径)。
6. 裁决点桩逐字段对齐前篇返回结构:gateway 对齐第 5 篇 Response(degraded/error/retry/fallback),
   guardrail 对齐第 6 篇 {risk, action, alarm},schema_gate 对齐第 7 篇 GateResult{ok, repaired,
   retries, degraded, error{code, detail, sample}}。
7. 可注入失败模式 + 可注入时钟:场景选 happy / schema 降级 / 护栏故障,FixedClock 让测试不等真实时间。
"""
import json
import re
import time
import uuid
from dataclasses import dataclass, field

# ============================================================
# 基础类型:Span / Context / Clock
# ============================================================

@dataclass
class Span:
    """一个裁决事件。kind 记环节,status 三态,attributes 只记裁决字段、不记原文。"""
    name: str
    kind: str                       # request / gateway / guardrail / gen_ai / schema_gate
    trace_id: str
    span_id: str
    parent_id: str | None
    status: str = "ok"
    attributes: dict = field(default_factory=dict)
    start_ms: float = 0.0
    end_ms: float = 0.0
    duration_ms: float = 0.0

    def set_attribute(self, key: str, value) -> None:
        self.attributes[key] = value

    def record_status(self, status: str) -> None:
        self.status = status        # ok / degraded / error


class Context:
    """贯穿全链路的上下文:持 trace_id + 当前 span 栈。
    为什么用栈:每开一个子 span 压栈、结束弹栈,父挂父的关系由栈顶决定,不用手动传 parent_id。"""
    def __init__(self) -> None:
        self.trace_id: str | None = None
        self._stack: list[Span] = []

    def push(self, span: Span) -> None:
        self._stack.append(span)

    def pop(self) -> Span:
        return self._stack.pop()

    def parent(self) -> Span | None:
        return self._stack[-1] if self._stack else None


class Clock:
    def now(self) -> float:
        raise NotImplementedError


class SystemClock(Clock):
    """生产默认:单调时钟,跨服务时间戳对齐靠 trace_id,不靠墙上时钟(日志死穴①)。"""
    def now(self) -> float:
        return time.monotonic() * 1000.0


class FixedClock(Clock):
    """测试用:每次调用步进 step_ms,让 start/end 与顺序确定可复现,测试不 sleep。"""
    def __init__(self, step_ms: float = 1.0) -> None:
        self._t = 0.0
        self.step_ms = step_ms

    def now(self) -> float:
        self._t += self.step_ms
        return self._t


# ============================================================
# Collector(exporter 实现):生产换 OTLP exporter / LangFuse SDK
# ============================================================

class Collector:
    """内存 exporter。生产把 add() 换成 OTLP span exporter 或 LangFuse SDK,打点一行不改。"""
    def __init__(self) -> None:
        self.spans: list[Span] = []

    def add(self, span: Span) -> None:
        self.spans.append(span)

    def trace_ids(self) -> list[str]:
        return list({s.trace_id for s in self.spans})

    def query(self, trace_id: str) -> list[Span]:
        return [s for s in self.spans if s.trace_id == trace_id]

    def find(self, trace_id: str | None = None, **attrs) -> list[Span]:
        """按属性筛 span------demo 里做"一眼定位"。
        属性名带点(gateway.retry_count)就精确匹配;不带点(degraded)就做后缀匹配,
        方便排障时只记"degraded=True"不用背全名。"""
        spans = self.query(trace_id) if trace_id else list(self.spans)
        out = []
        for s in spans:
            ok = True
            for key, want in attrs.items():
                if "." in key:
                    if s.attributes.get(key) != want:
                        ok = False
                        break
                else:
                    if not any(ak.endswith("." + key) and av == want
                               for ak, av in s.attributes.items()):
                        ok = False
                        break
            if ok:
                out.append(s)
        return out

    def export(self, trace_id: str | None = None) -> str:
        """按父子缩进打一棵 trace 树。为什么排序按 start_ms:父先开子后开,时间序即调用序。"""
        spans = self.query(trace_id) if trace_id else list(self.spans)
        if not spans:
            return ""
        by_parent: dict[str | None, list[Span]] = {}
        for s in spans:
            by_parent.setdefault(s.parent_id, []).append(s)
        roots = [s for s in spans if s.parent_id is None]
        lines: list[str] = []

        def walk(s: Span, depth: int) -> None:
            attr = " ".join(f"{k}={v!r}" for k, v in s.attributes.items())
            lines.append(f"{'  ' * depth}{s.name} [{s.kind}] status={s.status} "
                         f"{s.duration_ms:.1f}ms {attr}".rstrip())
            for c in sorted(by_parent.get(s.span_id, []), key=lambda x: x.start_ms):
                walk(c, depth + 1)

        for r in sorted(roots, key=lambda x: x.start_ms):
            walk(r, 0)
        return "\n".join(lines)


# ============================================================
# Tracer:接口签名对齐 OTel API(start_span 上下文管理器 / set_attribute / record_status)
# ============================================================

class _SpanScope:
    """上下文管理器:enter 时给 span 记 start 并压栈,exit 时记 end/duration、交 exporter。
    为什么在 exit 才 add:子 span 先于父 span 结束,按结束顺序进 collector 后,树关系仍由
    parent_id 还原------顺序只影响展示排序,不影响串链正确性。"""
    def __init__(self, tracer: "Tracer", span: Span, ctx: Context) -> None:
        self.tracer = tracer
        self.span = span
        self.ctx = ctx

    def __enter__(self) -> Span:
        self.span.start_ms = self.tracer.clock.now()
        self.ctx.push(self.span)
        return self.span                 # with ... as span: span.set_attribute(...)

    def __exit__(self, exc_type, exc, tb) -> bool:
        self.span.end_ms = self.tracer.clock.now()
        self.span.duration_ms = self.span.end_ms - self.span.start_ms
        if exc_type is not None and self.span.status == "ok":
            self.span.record_status("error")
            self.span.set_attribute("error.type", exc_type.__name__)
        self.ctx.pop()
        self.tracer.collector.add(self.span)
        return False                     # 异常继续往外抛,不吞


class Tracer:
    """迷你 Tracer。生产替换点:这个类的 start_span 换成 opentelemetry.trace.Tracer 的
    start_as_current_span,Span.set_attribute/record_status 语义与 OTel 完全同构。"""
    def __init__(self, collector: Collector, clock: Clock | None = None) -> None:
        self.collector = collector
        self.clock = clock or SystemClock()

    @staticmethod
    def _new_id() -> str:
        return uuid.uuid4().hex[:16]

    def start_span(self, name: str, kind: str, ctx: Context) -> _SpanScope:
        if ctx.trace_id is None:
            ctx.trace_id = self._new_id()        # 网关入口生成 trace_id,之后所有 span 复用
        parent = ctx.parent()
        span = Span(name=name, kind=kind, trace_id=ctx.trace_id,
                    span_id=self._new_id(),
                    parent_id=parent.span_id if parent else None)
        return _SpanScope(self, span, ctx)


# ============================================================
# 三个裁决点桩(生产替换成第 5/6/7 篇真实组件,打点一行不改)
# ============================================================

# 订单抽取 schema 的合法/非法模型输出(模拟第 7 篇模型返回)
_GOOD_JSON = json.dumps(
    {"订单号": "D20260901-001", "金额": 1234.0, "是否退款": False,
     "收货地址": {"城市": "北京", "街道": "朝阳路 88 号"}},
    ensure_ascii=False)
_BAD_JSON = json.dumps(
    {"订单号": "D20260901-001", "金额": "¥1,234", "运费": 88,
     "身份证": "110101199003071234", "收货地址": {"城市": "北京", "街道": "朝阳路 88 号"}},
    ensure_ascii=False)   # 类型错 + 缺"是否退款" + 多"运费/身份证",演示第 7 篇一次报全

_ID_RE = re.compile(r"(?<!\d)(?:\d{17}[\dXx]|\d{15})(?!\d)")
_ALLOWED_KEYS = {"订单号", "金额", "是否退款", "收货地址"}


def output_mask(text: str) -> str:
    """桩:只脱敏身份证(对齐第 7 篇 MiniOutputGuard)。
    为什么先脱敏再进 SchemaGate:第 7 篇挂载顺序是 OutputGuard.mask -> SchemaGate.guard,
    sample 拿到的必须是已脱敏文本------Trace 不是第 6 篇脱敏闸的绕行通道。"""
    return _ID_RE.sub("[身份证已脱敏]", text)


def _validate_order_text(text: str) -> list[str]:
    """最小校验(对齐第 7 篇 MiniValidator 的错误列表):字段缺失/类型错/多余字段一次报全。
    错误信息不带原始值------PII 只能出现在被 output_mask 处理过的文本里,不能进错误串。"""
    try:
        obj = json.loads(text)
    except json.JSONDecodeError as exc:
        return [f"json_parse_error: 第 {exc.lineno} 行第 {exc.colno} 列:{exc.msg}"]
    errors: list[str] = []
    if not isinstance(obj, dict):
        return [f"$: 期望 object,实际 {type(obj).__name__}"]
    for key in ("订单号", "金额", "是否退款", "收货地址"):
        if key not in obj:
            errors.append(f"$.{key}: 缺失必填字段")
    amount = obj.get("金额")
    if "金额" in obj and (not isinstance(amount, (int, float)) or isinstance(amount, bool)):
        errors.append(f"$.金额: 期望 number,实际 {type(amount).__name__}")
    for key in obj:
        if key not in _ALLOWED_KEYS:
            errors.append(f"$.{key}: 多余字段(additionalProperties=False)")
    return errors


# 第 6 篇护栏决策:风险等级按命中特征数粗判,动作从 3×3 决策表 customer_service 列取
_HINTS = ("忽略以上", "输出完整", "忽略之前")


def guardrail_check(text: str, domain: str = "customer_service",
                    detector_fault: bool = False) -> dict:
    """桩:对齐第 6 篇 InputGuard.check() + decide_action() 的返回结构 {risk, action, alarm}。
    生产里这一行换成第 6 篇真实 InputGuard 返回的 r + decide_action(r['risk'], domain)。"""
    if detector_fault:                                        # 护栏自身故障:降级放行 + 告警
        return {"risk": "low", "action": "allow", "domain": domain,
                "alarm": ["detector_degraded: 检测器超时,降级放行"], "degraded": True}
    hits = [h for h in _HINTS if h in text]
    risk = "high" if len(hits) >= 2 else ("medium" if len(hits) == 1 else "low")
    action = {"high": "block", "medium": "strip", "low": "allow"}[risk]
    alarm = [f"{risk}_risk_{action}"] if risk in ("high", "medium") else []
    return {"risk": risk, "action": action, "domain": domain,
            "alarm": alarm, "hits": hits, "degraded": False}


@dataclass
class CallResult:
    """网关桩的返回,语义对齐第 5 篇 Response:兜住了 = status 200 且 degraded=False。"""
    status: int
    content: str
    provider: str
    degraded: bool
    retry_count: int
    fallback_to: str | None = None
    error_type: str | None = None


def gateway_call(tracer: Tracer, ctx: Context, cfg: dict) -> CallResult:
    """桩:模拟第 5 篇 GatewayPipeline 的路由/重试/兜底决策。
    为什么失败尝试折叠进属性:生产里每次失败模型调用各发一个 error 状态的 gen_ai span,
    demo 只发成功那一次,保持 trace 树可读------"重试了几次"从 gateway.retry_count 一眼读到。"""
    if cfg["primary_down"]:
        provider, model, retry_count = "gpt4", "gpt-4o", 2     # 主路 claude 两次可恢复失败 -> 兜到 gpt4
    else:
        provider, model, retry_count = "claude", "claude-sonnet", 0
    content = _BAD_JSON if cfg["schema_bad"] else _GOOD_JSON
    with tracer.start_span("gen_ai.completion", "gen_ai", ctx) as gen:
        # token 只留计数、不落文本------第 9 篇《Token 成本六维优化》的账本输入
        gen.set_attribute("gen_ai.provider.name", provider)   # gen_ai.system 已弃用,用新名
        gen.set_attribute("gen_ai.request.model", model)
        gen.set_attribute("gen_ai.usage.input_tokens", 320)
        gen.set_attribute("gen_ai.usage.output_tokens", 96)
    return CallResult(status=200, content=content, provider=provider,
                      degraded=False, retry_count=retry_count,
                      fallback_to="gpt4" if retry_count else None)


@dataclass
class GateResult:
    """SchemaGate 桩的返回,逐字段对齐第 7 篇 GateResult{ok, repaired, retries, degraded,
    error{code, detail, sample}}。降级包成对象不抛异常:下游接得住、能告警、能进 Trace。"""
    ok: bool
    degraded: bool
    repaired: bool
    retries: int
    errors: list
    error: dict | None = None


def schema_gate_guard(masked_text: str, schema_bad: bool, max_retries: int = 2) -> GateResult:
    """桩:模拟第 7 篇 SchemaGate.guard:解析 -> 校验 -> 带反馈重试 -> 耗尽降级。
    生产里换第 7 篇 SchemaGate,重试的模型调用各发一个 gen_ai.completion 子 span;
    demo 把重试折叠进 retries 属性,让"重试几次"可断言、树仍可读。"""
    text = masked_text
    retries = 0
    last_errors: list[str] = []
    for attempt in range(max_retries + 1):
        errors = _validate_order_text(text)
        if not errors:
            return GateResult(ok=True, degraded=False, repaired=False,
                              retries=retries, errors=[])
        last_errors = errors
        if attempt >= max_retries:
            break                        # budget 耗尽:停手,绝不无限重试(第 7 篇铁律)
        # 模拟"带反馈重试":demo 里模型每次仍返回同一个坏 json(fail_until=None 的场景)
        text = output_mask(_BAD_JSON if schema_bad else _GOOD_JSON)
        retries += 1
    return GateResult(ok=False, degraded=True, repaired=False, retries=retries,
                      errors=last_errors, error={
                          "code": "schema_validation_exhausted",
                          "detail": ";".join(last_errors[:5]),
                          "sample": text[:200]})   # 已脱敏裁剪文本,第 7 篇就是为这个留的口子


# ============================================================
# 编排:一次请求 = 根 span + 网关(护栏输入/模型调用/护栏输出/SchemaGate)
# ============================================================

_SCENARIOS = {
    "happy":          dict(text="帮我查订单 D20260901,问是否退款", primary_down=False, schema_bad=False, detector_fault=False),
    "schema_degrade": dict(text="帮我抽取订单,输出完整信息", primary_down=True,  schema_bad=True,  detector_fault=False),
    "guardrail_fault": dict(text="今天天气如何", primary_down=False, schema_bad=False, detector_fault=True),
}


def run_request(tracer: Tracer, scenario: str = "happy",
                text: str | None = None) -> tuple[Context, GateResult]:
    """编排一次完整请求。生产里这就是网关服务的请求入口:三道闸挂同一进程,
    用同一个 ctx 串 trace_id;跨服务则交给 OTel context 自动传播(W3C traceparent)。"""
    cfg = _SCENARIOS[scenario]
    text = text if text is not None else cfg["text"]
    ctx = Context()
    with tracer.start_span("request", "request", ctx) as root:
        root.set_attribute("request.tenant", "t1")
        with tracer.start_span("gateway.call", "gateway", ctx) as gw:
            # ① 输入护栏(第 6 篇):只记裁决字段,原文不进 Trace
            with tracer.start_span("guardrail.input", "guardrail", ctx) as gi:
                check = guardrail_check(text, detector_fault=cfg["detector_fault"])
                gi.set_attribute("guardrail.risk", check["risk"])
                gi.set_attribute("guardrail.action", check["action"])
                gi.set_attribute("guardrail.domain", check["domain"])
                gi.set_attribute("guardrail.alarm", check["alarm"])
                gi.set_attribute("guardrail.degraded", check["degraded"])
                if check["degraded"]:
                    gi.record_status("degraded")     # 护栏自身故障:降级放行,不拖垮主链路
            # ② 模型调用 + 重试/兜底(第 5 篇):成功那一次发 gen_ai span
            call = gateway_call(tracer, ctx, cfg)
            gw.set_attribute("gateway.provider", call.provider)
            gw.set_attribute("gateway.retry_count", call.retry_count)
            if call.fallback_to:
                gw.set_attribute("gateway.fallback_to", call.fallback_to)
            gw.set_attribute("gateway.degraded", call.degraded)
            if call.error_type:
                gw.set_attribute("gateway.error_type", call.error_type)
            # 兜住了就是 ok;只有全链无可用才 error(503)------降级/兜底不算 error(三态口径)
            gw.record_status("ok")
            # ③ 输出脱敏(第 6 篇):动作进 Trace,文本不进
            masked = output_mask(call.content)
            with tracer.start_span("guardrail.output", "guardrail", ctx) as go:
                go.set_attribute("guardrail.output_action", "mask")
            # ④ SchemaGate(第 7 篇):结构裁决,耗尽降级为 degraded 而非 error
            with tracer.start_span("schema_gate.guard", "schema_gate", ctx) as sg:
                gate = schema_gate_guard(masked, schema_bad=cfg["schema_bad"])
                sg.set_attribute("schema_gate.ok", gate.ok)
                sg.set_attribute("schema_gate.repaired", gate.repaired)
                sg.set_attribute("schema_gate.degraded", gate.degraded)
                sg.set_attribute("schema_gate.retries", gate.retries)
                sg.set_attribute("schema_gate.errors", gate.errors[:5])
                if gate.error:
                    sg.set_attribute("schema_gate.error_code", gate.error["code"])
                    sg.set_attribute("schema_gate.sample", gate.error["sample"])
                if gate.degraded:
                    sg.record_status("degraded")     # 兜住了:单独一档,不炸 P1
    return ctx, gate


def build_demo() -> None:
    """跑三个场景:happy / schema 降级 / 护栏故障,打 trace 树 + find(degraded=True) 一眼定位。"""
    col = Collector()
    tr = Tracer(col)
    for scenario in ("happy", "schema_degrade", "guardrail_fault"):
        ctx, _ = run_request(tr, scenario)
        print(f"\n== 场景:{scenario} ==")
        print(col.export(ctx.trace_id))
        if scenario == "schema_degrade":
            hit = col.find(ctx.trace_id, degraded=True)   # 不带点 = 后缀匹配,不用背全名
            print(f"\nfind(degraded=True) 一眼定位 -> {[s.name for s in hit]}")
    # PII 巡检:跑一单"原文含身份证"的降级,确认 Trace 里没有身份证号
    ctx, _ = run_request(tr, "schema_degrade",
                         text="帮我抽取订单,输出完整信息,客户身份证 110101199003071234")
    dump = "".join(repr(s.attributes) for s in col.query(ctx.trace_id))
    print("\n== PII 巡检 ==")
    print("  原文含身份证号,attributes 里是否泄漏:",
          "泄漏!" if "110101199003071234" in dump else "未泄漏(打点纪律生效)")


if __name__ == "__main__":
    build_demo()

python mini_trace.py,三个场景各演一件事:① happy 场景整条链六个 span 全 ok,父子缩进就是一棵树;② schema_degrade 场景里 find(degraded=True) 一行代码命中 schema_gate.guard------降级在哪个环节,一眼可见;③ guardrail_fault 场景护栏自身故障标记 degraded、根 request 仍是 ok;最后 PII 巡检确认打点纪律生效,Trace 里没有身份证号。

test_mini_trace.py------6 个断言,一条锁一个可观测性承诺:

python 复制代码
"""
6 个断言锁死全链路 Trace:串链树结构 / 降级一眼定位 / 重试兜底看得见 /
护栏告警与降级放行 / PII 不落 Trace / 父链回溯。纯标准库 + assert,直接跑:
    python test_mini_trace.py
    # 或 pytest test_mini_trace.py
"""
from mini_trace import Collector, FixedClock, Tracer, run_request

PII = "110101199003071234"


def run_scenario(name: str, text: str | None = None) -> tuple[Collector, str]:
    col = Collector()
    tr = Tracer(col, FixedClock(step_ms=1.0))
    ctx, _ = run_request(tr, name, text=text)
    return col, ctx.trace_id


def _dfs_names(col: Collector, trace_id: str) -> list[str]:
    """按父子缩进展平一棵 trace(父先于子),用来断言树结构。"""
    spans = col.query(trace_id)
    by_parent: dict[str | None, list] = {}
    for s in spans:
        by_parent.setdefault(s.parent_id, []).append(s)
    order: list[str] = []

    def walk(s) -> None:
        order.append(s.name)
        for c in sorted(by_parent.get(s.span_id, []), key=lambda x: x.start_ms):
            walk(c)

    for r in sorted((s for s in spans if s.parent_id is None), key=lambda x: x.start_ms):
        walk(r)
    return order


def test_one_trace_tree_structure_happy_path():
    """断言①:串链 + 树结构------happy 场景只有 1 条 trace,6 个 span,
    执行顺序 request -> gateway.call -> guardrail.input -> gen_ai.completion
    -> guardrail.output -> schema_gate.guard 全对,且全 status ok。"""
    col, tid = run_scenario("happy")
    assert col.trace_ids() == [tid]                  # 一条 trace,不是散落的 N 段
    assert len(col.query(tid)) == 6
    assert _dfs_names(col, tid) == [
        "request", "gateway.call", "guardrail.input",
        "gen_ai.completion", "guardrail.output", "schema_gate.guard",
    ]
    root = next(s for s in col.query(tid) if s.name == "request")
    assert root.kind == "request" and root.parent_id is None
    assert all(s.status == "ok" for s in col.query(tid))


def test_schema_degrade_locate_span_by_attributes():
    """断言②:降级一眼定位------schema 降级场景 find(schema_gate.degraded=True) 精确命中,
    且属性带 retries=2、error_code=schema_validation_exhausted------
    '校验不通过 / 重试几次 / 走没走兜底'全从 span 属性读到,不用翻日志。"""
    col, tid = run_scenario("schema_degrade")
    hit = col.find(tid, **{"schema_gate.degraded": True})
    assert len(hit) == 1 and hit[0].name == "schema_gate.guard"
    sg = hit[0]
    assert sg.status == "degraded"
    assert sg.attributes["schema_gate.retries"] == 2
    assert sg.attributes["schema_gate.error_code"] == "schema_validation_exhausted"
    assert sg.attributes["schema_gate.ok"] is False


def test_gateway_retry_and_fallback_visible():
    """断言③:重试/兜底看得见(串第 5 篇)------网关 span 带 retry_count=2 + fallback_to='gpt4',
    兜住了 status=ok(不是 error)------三态口径:fallback 是 degraded/error 之外被接住的 ok。"""
    col, tid = run_scenario("schema_degrade")
    gw = col.find(tid, **{"gateway.retry_count": 2})
    assert len(gw) == 1 and gw[0].name == "gateway.call"
    assert gw[0].attributes["gateway.fallback_to"] == "gpt4"
    assert gw[0].attributes["gateway.degraded"] is False
    assert gw[0].status == "ok"


def test_guardrail_alarm_and_self_degradation():
    """断言④:护栏告警 + 降级放行(串第 6 篇)------medium 场景护栏 span 带 risk='medium'、
    action='strip'、alarm 非空;护栏自身故障场景护栏 status=degraded 放行、根 request 仍 ok,
    不拖垮主链路。"""
    col, tid = run_scenario("schema_degrade")        # 默认文本含"输出完整" -> medium/strip
    gi = col.find(tid, **{"guardrail.risk": "medium"})
    assert gi and gi[0].attributes["guardrail.action"] == "strip"
    assert gi[0].attributes["guardrail.alarm"]       # 非空:medium 命中也留痕
    col2, tid2 = run_scenario("guardrail_fault")     # 检测器抛异常
    g2 = col2.find(tid2, **{"guardrail.degraded": True})
    assert len(g2) == 1 and g2[0].status == "degraded"
    root = next(s for s in col2.query(tid2) if s.name == "request")
    assert root.status == "ok"                       # 护栏挂了,主链路不跟着挂


def test_pii_never_lands_in_trace():
    """断言⑤:PII 不落 Trace------喂含身份证号的原文跑降级场景,collector 全部 span 的
    attributes 里找不到该身份证号(只记裁决字段 + 已脱敏裁剪样本,不记原文)。"""
    col, tid = run_scenario("schema_degrade",
                            text=f"帮我抽取订单,输出完整信息,客户身份证 {PII}")
    dump = "".join(repr(s.attributes) for s in col.query(tid))
    assert PII not in dump
    # schema_gate.sample 必须是已脱敏文本------第 7 篇留的裁剪口子就是干这个
    sg = next(s for s in col.query(tid) if "schema_gate.sample" in s.attributes)
    assert "[身份证已脱敏]" in sg.attributes["schema_gate.sample"]


def test_parent_chain_backtrack_to_root():
    """断言⑥:父链回溯 = 串起来了------从 schema_gate 叶子 span 沿 parent_id 回溯到根,
    环节顺序恰为 schema_gate.guard -> gateway.call -> request------
    '一根线串起来'可被代码证明,不是口头说法。"""
    col, tid = run_scenario("schema_degrade")
    spans = col.query(tid)
    by_id = {s.span_id: s for s in spans}
    sg = next(s for s in spans if s.name == "schema_gate.guard")
    chain, cur = [], sg
    while cur is not None:
        chain.append(cur.name)
        cur = by_id.get(cur.parent_id) if cur.parent_id else None
    assert chain == ["schema_gate.guard", "gateway.call", "request"]


if __name__ == "__main__":
    test_one_trace_tree_structure_happy_path()
    test_schema_degrade_locate_span_by_attributes()
    test_gateway_retry_and_fallback_visible()
    test_guardrail_alarm_and_self_degradation()
    test_pii_never_lands_in_trace()
    test_parent_chain_backtrack_to_root()
    print("全部 6 个断言通过:串链树结构 / 降级一眼定位 / 重试兜底 / 护栏告警与降级 / PII 不落 / 父链回溯")

python test_mini_trace.py,全绿。把 SchemaGate 桩的降级改成抛异常,断言②当场挂------degraded 不是 error,这条口径测试说了算;把网关桩的 degraded 语义改成"fallback 也算 error",断言③挂;把 schema_gate_guard 重试轮里的 output_mask 去掉、让 sample 拿到模型原始输出,断言⑤ 当场把身份证号从 sample 里揪出来。"sample 必须是脱敏后文本",不用写进规范文档,测试已经替我把话说死了。

生产替换三处就够:Tracer 换成 opentelemetry SDK 或 LangFuse SDK、Collector 换成 OTLP exporter 或 LangFuse、三个桩换成第 5/6/7 篇真实组件------打点代码一行不改。

四、踩坑记录:这三个坑每个都真付过费

4.1 打点记原文,把脱敏闸绕过去了

  • 症状:prompt 全文、模型输出整段写进 Trace,第 6 篇刚挡掉的身份证号从 Trace 后门又回来了。有次我巡检存储,发现 trace 库里明文躺着用户身份证,存储还爆了------脱敏闸在前门拦,Trace 在后门放,等于没拦。
  • 排查:翻打点代码,发现为了"定位爽"把入参出参原样 set 进 span。
  • 根因:没有打点纪律,原文默认就落了。
  • 修复:打点纪律立成代码规则------只记裁决字段,原文默认不落,要回放只拿第 7 篇 sample[:200] 的裁剪样本。demo 里 SchemaGate 桩每轮重试都会重新脱敏,所以"PII 不落 Trace"是结构性保证;断言⑤ 锁的是"任何 span attribute 都不得出现原文"这条最终状态,谁把原文塞进 attribute 当场被揪。

4.2 把降级/兜底打成 error status,狼来了

  • 症状:SchemaGate 每次耗尽、网关每次 fallback 都触发告警,上线第一天响了 30 次,第三天没人看;真出 500 反而淹没在噪音里没人管。
  • 排查:看告警后台,error 全是被兜住的降级,不是真故障。我们当时的根因是:status 只有两态,把"这次兜住了"和"这次没兜住"混在一个桶里。
  • 修复:改成三态 status,降级单独占一档 degraded、不炸 P1;再单开一条"降级率趋势"告警盯慢病,按小时算 degraded 占比,超基线才响。demo 的 record_status("degraded") + 断言②③,就是把口径写死在代码里。

4.3 打点没有共享 ID,接上 LangFuse 也白搭

  • 症状:各环节各打各的,接入 LangFuse 之后一个请求在面板里散成 N 条互不相干的 trace,"全链路"名存实亡。
  • 排查:面板里按时间看全是碎片,按 session 也串不起来。我们当时的根因是:先选工具、没先定"一次请求的边界在哪、trace_id 在哪个入口生成、怎么传下去"。
  • 修复:先定事件模型------网关入口生成 trace_id,上下文对象贯穿全链路(demo 的 ctx 传播就是最小演示),再接工具。工具只负责搬运和展示,数据没串起来,接谁都是白搭。

五、选型对比:OTel 和 LangFuse 不是二选一

5.1 大表 A:定位分工------通用链路大盘 vs LLM 语义工作台

方案 定位 数据归属 私有化 prompt/token/成本 评测标注 上手成本 风险提示
OTel 自建(Tempo/Jaeger/自研 collector) 开放标准 + 传输管道 全在自己手里 ✓✓ 数据不出域 △ 自己打 gen_ai 属性才有 ✗ 无 高(collector+后端自己养) 通用链路大盘,LLM 语义面板要自己拼
LangFuse v4 自托管 LLM 语义一体化后端 自己服务器(ClickHouse) ✓✓ MIT 自托管免费 ✓✓ prompt/token/成本/评分 ✓✓ 评测/标注内置 中(v4 需 ClickHouse+Pg+Redis/Valkey+S3) v4 存储换 ClickHouse,比 v3 重;社区支持
LangFuse Cloud 同上,托管 数据出境 ✓✓ ✓✓ 2026-11-16 起 Cloud 仅 v4
LangSmith LangChain 生态全链路 托管为主 △ 自托管 Enterprise-only(v0.13) ✓ 与 LangChain 绑定 低(绑定 LangChain 时) 闭源、强框架锁定,$39/seat/月
Helicone 网关式代理(换 base URL 接入) 请求路径 ✓ Apache-2.0 自托管 △ 有基础 token/成本 ✗ 不做评测 极低(零埋点) 2026-03-03 并入 Mintlify,多来源标"维护模式",路线图有风险
Phoenix(Arize) OSS LLM 可观测 自己服务器 △ 基础 △ 有评估 OSS 备选,生态比 LangFuse 小

一句话判断:OTel 是"通用链路大盘",LangFuse 是"LLM 语义工作台",Helicone 是"请求路径,不是裁判"。 你真正的取舍不是选哪个,而是:要大屏还是工作台?数据出不出得去?已有 Prometheus/Grafana 基建或数据出不去 → OTel 自建(私有化可观测平台是本专栏后面要单独讲的承接点);从零起、重度 LLM、想直接拿提示词---成本---评分面板 → LangFuse;只想记录不改代码 → Helicone,但 2026 年被 Mintlify 并入后维护状态存疑,评估要带风险溢价。

一起上的落地姿势是桥接,不是双写两套语义。 LangFuse v4 原生支持 OTLP 摄入(direct OTEL ingestion 需在 span exporter 上带 x-langfuse-ingestion-version: 4 头,鉴权用附录 A 里同一对 LANGFUSE_PUBLIC_KEY/SECRET_KEY 配到该 exporter 上):业务打点只对 OTel 一套,OTel Collector 把链路同时送自建大盘和 LangFuse。两边 exporter 各走各的队列与重试,LangFuse 故障只丢它的副本,不影响自建大盘。两个系统的 trace_id 天然同源,不存在"双写对不上"的一致性问题------OTel 负责传输,LangFuse 负责 LLM 语义消费,这是踩坑③"两个系统 trace_id 对得上"的最省力解。

可核实的版本号(2026 年 9 月):opentelemetry-python 1.44.0 (2026-07-16 发布);LangFuse self-hosted v4(v4.0.0 于 2026-07-29 前后 GA,现已到 v4.29.x,MIT core;存储换 ClickHouse,配 Postgres/Redis/S3;需 LangFuse Python SDK 4.7.0+;v3 安全补丁到 2027-01;Cloud 2026-11-16 起 v4-only)。ClickHouse 于 2026-01-16 收购 Langfuse(400M Series D、15B 估值),自托管不变、无 license 变更。LangSmith 闭源,自托管仅 Enterprise(Self-Hosted v0.13,2026-01-16),$39/seat/月,2026-03 Agent Builder 更名 Fleet。Helicone Apache-2.0、约 5.8k star、可自托管(gateway+Postgres),2026-03-03 并入 Mintlify。

5.2 大表 B:OTel GenAI 语义约定现状(2026,诚实告知)

选 LangFuse 还是 OTel,第二个要认清的现状是:OTel 的 GenAI/LLM 语义约定仍在 Development (未 stable)。2026-06 已从主 semantic-conventions 仓库拆到独立仓库 semantic-conventions-genai

语义点 现状(2026-09) 对你的影响
稳定性 Development,未 stable,属性名还会动 别把每个属性名硬编码进业务代码,抽一层常量/映射
模型厂商标识 gen_ai.system 已弃用,迁 gen_ai.provider.name demo 里用的就是新名,生产照抄
cost 属性 无标准 cost 属性 成本想进 span 得自己定业务属性(第 9 篇的账本从 token 计数算)
工具调用/会话/流式 核心 spec 缺失;2026 年扩展覆盖了 MCP;无会话串联、无流式语义 Agent 工具调用、流式输出按标准打不了,得自己扩
接入方式 实验版需 OTEL_SEMCONV_STABILITY_OPT_IN 打开 不用 opt-in 会拿到旧默认值

落到代码上就两条:业务裁决字段自己定、别等约定稳定;LLM 调用字段对齐 gen_ai.* 便于生态互通。 demo 里 gen_ai.provider.namegen_ai.usage.input_tokens/output_tokens 对齐 OTel 命名,gateway.*/guardrail.*/schema_gate.* 是本专栏自己的业务裁决命名------这是 2026 年唯一稳的姿势。

5.3 可复用 checklist:6 个"是/否"答完落到方案

  1. 数据出不出得去? 出不去(政企合规)→ OTel 自建,托管(LangFuse Cloud/LangSmith)直接出局。
  2. 有没有现成 Prometheus/Grafana/Tempo 基建? 有 → 优先 OTel,别为 LLM 单开一套语义后端。
  3. 要不要 prompt 回放/token 成本/评分面板? 要 → 重度 LLM 场景 LangFuse 的语义工作台省半年工。
  4. 团队养不养得起 ClickHouse+Pg+Redis+Valkey+S3 这套自托管? 养不起 → LangFuse Cloud 或 LangSmith(数据出得去才谈)。
  5. 要不要评测/标注一体化? 要且绑 LangChain → LangSmith;不绑 → LangFuse v4 观测级评测。
  6. 团队能不能接受双写一致性成本? 不能 → 走桥接(OTel Collector → LangFuse),别两套 SDK 各写各的。

六、总结 + 下一篇预告

回到开头那个排障夜------同样的 schema_validation_exhausted 告警,这次打开 Trace 一眼看到整条链:网关重试 2 次兜到 gpt4、护栏 medium 剥离放行、SchemaGate 两次校验不过降级,5 分钟定位,不用再翻两小时日志。"校验不通过 / 重试几次 / 哪个字段错的 / 走没走兜底",这些裁决事件在整条链路上看得见了。

但"看得见"只解决了一半:token 怎么烧的、单请求成本怎么拆、哪条链路最费钱------这些还藏在 gen_ai.completion span 的 token 计数里没算账。下一篇《Token 成本六维优化》:把这本账摊开算。

评论区聊聊:你们现在排障 LLM 链路,是还在人肉对时间戳,还是已经有一根 trace_id 串起来的线了?

附录 A:真实 SDK 最小接入片段(正文迷你 Tracer 的接口签名就是照着它对齐的)

opentelemetry-python 1.44.0

python 复制代码
# pip install opentelemetry-sdk opentelemetry-exporter-otlp-proto-http
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter

provider = TracerProvider()
provider.add_span_processor(BatchSpanProcessor(
    OTLPSpanExporter(endpoint="${OTEL_EXPORTER_OTLP_ENDPOINT}")))
trace.set_tracer_provider(provider)
tracer = trace.get_tracer("llm-gateway")

# 与正文 mini_trace 的 start_span/set_attribute/record_status 一一对应
with tracer.start_as_current_span("gateway.call") as span:
    span.set_attribute("gateway.retry_count", 2)
    span.set_attribute("gateway.fallback_to", "gpt4")
    span.set_status(trace.StatusCode.OK)          # OK / ERROR;degraded 用属性表达

LangFuse Python SDK(4.7.0+,v4 数据模型):

python 复制代码
# pip install "langfuse>=4.7.0"
from langfuse import Langfuse

langfuse = Langfuse(public_key="${LANGFUSE_PUBLIC_KEY}",
                    secret_key="${LANGFUSE_SECRET_KEY}",
                    host="${LANGFUSE_HOST}")
trace = langfuse.trace(name="request", session_id="${SESSION_ID}")
gen = trace.generation(name="gen_ai.completion", model="gpt-4o",
                       usage={"input": 320, "output": 96})   # 只记计数不落文本
span = trace.span(name="schema_gate.guard",
                  metadata={"ok": False, "degraded": True,
                            "retries": 2, "error_code": "schema_validation_exhausted"})
span.end()
trace.update(output="ok")

LangFuse v4 直接 OTLP 摄入:span exporter 加 HTTP 头 x-langfuse-ingestion-version: 4,业务只对 OTel 打一套,LangFuse 从 Collector 消费。

附录 B:一条 span 的 JSON 结构示意

SchemaGate 降级这条 span,落库后长这样(collector 的 attributes 与 demo 一致):

json 复制代码
{
  "trace_id": "7f2a...c1",
  "span_id": "a91c...d4",
  "parent_id": "3b0e...aa",
  "name": "schema_gate.guard",
  "kind": "schema_gate",
  "status": "degraded",
  "start_ms": 1788672000128.0,
  "duration_ms": 128.0,
  "attributes": {
    "schema_gate.ok": false,
    "schema_gate.repaired": false,
    "schema_gate.degraded": true,
    "schema_gate.retries": 2,
    "schema_gate.errors": [
      "$.是否退款: 缺失必填字段",
      "$.金额: 期望 number,实际 str",
      "$.运费: 多余字段(additionalProperties=False)",
      "$.身份证: 多余字段(additionalProperties=False)"
    ],
    "schema_gate.error_code": "schema_validation_exhausted",
    "schema_gate.sample": "{\"订单号\": \"D20260901-001\", \"金额\": \"¥1,234\", ...[身份证已脱敏]...}"
  }
}

sample 里只有已脱敏文本,原文身份证号不在任何 span 的属性里------这是附录 A/B 之间那条纪律的落点。

附录 C:抽样与存储成本估算(推导放这,正文只留结论)

正文结论:错误/降级全采、正常采 1/100。 这里给推导。

假设:一次订单抽取请求约 6 个 span(request/gateway/gen_ai/护栏×2/schema),单 span 序列化约 1 KB,一条 trace 约 6 KB;QPS 5(日请求量 5×86400 ≈ 43.2 万)。

  • 全量采 :43.2 万 × 6 KB ≈ 2.6 GB/天 ≈ 78 GB/月。存储成本滚雪球,这也是为什么 Trace 数据必须配生命周期清理策略(开篇《四大支柱》支柱三的提醒)。
  • 错误/降级全采 + 正常采 1/100 :设异常率 3%,则异常全采 = 43.2 万 × 3% × 6 KB ≈ 0.078 GB/天 ≈ 2.3 GB/月;正常按 1/100 采 = 43.2 万 × 97% × 0.01 × 6 KB ≈ 0.025 GB/天 ≈ 0.75 GB/月。合计约 0.10 GB/天 ≈ 3 GB/月,比全量(约 78 GB/月)省约 96%,且排障要看的异常样本一条不少。

公式化:月存储 = 日请求量 × 单 trace KB × 30 × (异常率 + 正常率 × 正常采样率)。正常采样率是唯一旋钮------排障只需异常全采,正常样本是给第 9 篇成本分析和第 2 篇那类离线评测当统计样本用的,1/100 量级足够做分布估计。要是连 3 GB/月都嫌贵,再降正常采样率到 1/1000,别降异常采样率。

附录 D:术语表

  • Trace(链路):一次请求从入口到出口经过的所有 span 的集合,由同一个 trace_id 标识。
  • Span(段):一个环节的一次裁决事件,含 name/kind/status/attributes/时间与父子关系。
  • trace_id / span_id / parent_id:贯穿 ID / 单段 ID / 父段 ID------三者构成一棵可回溯的树。
  • Context(上下文) :贯穿全链路传 trace_id 与当前 span 栈的载体;生产由 OTel context 自动传播,跨服务走 W3C traceparent 头。
  • semconv(语义约定) :OpenTelemetry 规定属性名"应该叫什么"的规范。GenAI semconv 2026 年仍是 Development,gen_ai.system 已弃用迁 gen_ai.provider.name
  • OTLP:OpenTelemetry 的传输协议,SDK 把 span 发给 Collector/后端的标准管道。
  • W3C traceparent:HTTP 头里传播 trace_id/span_id 的标准格式,跨服务串链靠它。
  • 采样(sampling):只把部分 span 落库。结论是错误/降级全采、正常采 1/100,推导见附录 C。

🎯 更多专栏系列文章可以查看博客主页📑 👍 若文章对你有所触动,恳请点赞 ⭐ 关注 ⭐ 收藏

相关推荐
Csvn1 小时前
第 19 章 Agent Skill 能力复用体系
人工智能·aigc·agent
LayZhangStrive1 小时前
Agent开发 - 实现人类与Manus智能体的终端窗口命令交互
ai·交互·agent·react·终端·manus
Raas1003 小时前
MAI Gateway(魔芋企业级AI网关)技术辨析:AI网关和大模型网关区别?选型必读
大数据·人工智能·大模型·gateway·mai gateway·企业级产品
是Dream呀12 小时前
一个 AI Agent 是怎么长出来的:提示词、上下文与 Harness 工程
人工智能·大模型·agent
明月_清风13 小时前
MCP vs ACP vs LSP:AI 时代三大协议的「三足鼎立」
人工智能·网络协议·agent
asaotomo16 小时前
从抓包插件到浏览器安全 Agent:Hx0 鹰眼 v1.0.6,正式接入 MCP
安全·渗透测试·agent·浏览器插件·ai工具·mcp
王国强200916 小时前
The Anatomy of an Agent Harness(解剖 Agent Harness)
agent
阿里云大数据AI技术16 小时前
DataWorks Data Agent 实战课堂(七):数据治理 Agent——AI 驱动的自动化治理
人工智能·agent
机械改造鹅16 小时前
从零开始拆解Pi系列——(11)配置系统
agent