目录
- 前言
- 一、问题定义:每环节都有日志,为什么不等于全链路可观测
- [二、核心方案:打点三问 + 一个贯穿 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 链路全踩:
- 时间戳对齐靠猜。 第 5 篇的网关和第 7 篇的 SchemaGate 不在同一进程,跨服务时钟漂移、时区不一致,你拿日志拼因果,第一步就是在对表。
- 上下文断裂。 一次请求过 N 个环节,没有一个共享 ID 把它们拴住。三份日志各讲各的,谁是谁的父、谁先谁后,全靠人肉猜。
- 只记结果不记裁决。 网关记 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.call、gen_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.provider、gateway.retry_count、gateway.fallback_to、gateway.degraded、gateway.error_type;gen_ai.provider.name、gen_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.risk、guardrail.action、guardrail.domain、guardrail.alarm、guardrail.degraded(检测器故障) |
护栏自身故障 = degraded 放行,不拖垮主链路 |
| 结构闸(第 7 篇《JSON Schema 强约束》) | SchemaGate.guard() |
schema_gate.guard |
schema_gate.ok、schema_gate.repaired、schema_gate.retries、schema_gate.errors(字段错误路径前 5 条)、schema_gate.degraded、schema_gate.error_code、schema_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_attribute、record_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.name、gen_ai.usage.input_tokens/output_tokens 对齐 OTel 命名,gateway.*/guardrail.*/schema_gate.* 是本专栏自己的业务裁决命名------这是 2026 年唯一稳的姿势。
5.3 可复用 checklist:6 个"是/否"答完落到方案
- 数据出不出得去? 出不去(政企合规)→ OTel 自建,托管(LangFuse Cloud/LangSmith)直接出局。
- 有没有现成 Prometheus/Grafana/Tempo 基建? 有 → 优先 OTel,别为 LLM 单开一套语义后端。
- 要不要 prompt 回放/token 成本/评分面板? 要 → 重度 LLM 场景 LangFuse 的语义工作台省半年工。
- 团队养不养得起 ClickHouse+Pg+Redis+Valkey+S3 这套自托管? 养不起 → LangFuse Cloud 或 LangSmith(数据出得去才谈)。
- 要不要评测/标注一体化? 要且绑 LangChain → LangSmith;不绑 → LangFuse v4 观测级评测。
- 团队能不能接受双写一致性成本? 不能 → 走桥接(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。

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