我是小兵,一个动手派AI架构师。「AI工程化实战」系列第 8 篇。
凌晨一点,一条告警第三次亮起
凌晨一点,schema_validation_exhausted 这条结构化告警第三次亮。下游没崩、没有 500,可“为什么偏偏这单走兜底”,连我也答不上来。
网关日志说:主路超时、重试 2 次兜到 gpt4。护栏日志说:medium 风险、strip 放行。SchemaGate 日志说:缺“金额”字段、类型错、重试耗尽。三份日志都对,时间戳也对得上,我人肉拼了两小时才凑出因果链。
全链路 Trace 治的就是这个毛病:可观测不等于“每环节都打日志”。得把第 5 篇《LLM 网关选型》、第 6 篇《OWASP LLM 安全护栏》、第 7 篇《JSON Schema 强约束》三道闸的打点,用同一个 trace_id 串成一根线。工具只是搬运工,真要先想清楚的是“打点什么”。
每环节都有日志,为什么还不是全链路可观测
传统日志三个死穴,LLM 链路全踩。
一是时间戳对齐靠猜。网关和 SchemaGate 不在同一进程,跨服务时钟漂移、时区不一致,拿日志拼因果,第一步就是在对表。
二是上下文断裂。一次请求过 N 个环节,没有一个共享 ID 把它们拴住。三份日志各讲各的,谁是谁的父、谁先谁后,全靠人肉猜。
三是只记结果不记裁决。网关记 200/503,不记 retry_count、兜到谁、为什么降级;护栏不记 risk 多高、动了哪个动作;SchemaGate 不记哪个字段错、重试几次。
而 LLM 链路尤其要看得见三件事:拦没拦(护栏最后落了 block/strip/allow 哪个动作)、救没救(SchemaGate 修复/重试有没有生效)、兜没兜(降级走没走、兜到哪条路)。
结论先放这儿:可观测不是“加监控面板”,是把每个裁决点变成结构化 span 事件、用 trace_id 串成链——工具是最后一步,不是第一步。
打点三问 + 一个贯穿 ID
前几篇的三道闸,本质是三个“裁决点”:网关做路由裁决(走谁、重试不重试、兜不兜),护栏做放行裁决(拦/strip/放行),SchemaGate 做格式裁决(放行/修复/重试/降级)。传统 Trace 只关心“调没调通”,LLM 可观测要关心的是“这个环节做了什么决定”。所以 span 属性里记的是裁决字段(risk/action/retries/degraded/error.code),不是 HTTP status。
每个被插桩的环节,发一个 span 回答三个问题:
-
做了什么——落成 span 名 + kind,比如 gateway.call、gen_ai.completion; -
判了什么——裁决属性,从各组件返回结构里搬:网关的 degraded/error、护栏的 risk/action/alarm、SchemaGate 的 error{code,detail,sample}; -
花多久/多少钱——duration_ms + token 计数。token 只留计数不落文本,这是给第 9 篇《Token 成本六维优化》埋的账本钩子。
再加一个贯穿 ID:网关入口生成 trace_id,用上下文对象一路传(demo 里显式传 ctx,生产由 OTel context 自动传播),所有环节的 span 挂同一个 trace_id。串不起来,打点再全也是一盘散沙。
status 三态:设计内兜底计 ok,设计外降级计 degraded
最开始的教训:把 SchemaGate 重试耗尽、网关 fallback、护栏降级放行都打成 error status,告警第一天就把群炸了;狼来了三天,第四天真出 500 反而没人管。全打成 ok,兜底路径永远看不见,“降级率悄悄爬升”这种慢病无人问津。所以 status 必须是三态:
-
ok:正常完成裁决,包括“重试后兜住了”“修复后放行了”; -
degraded:兜住了但走了设计外降级路径——schema 重试耗尽、护栏自身故障放行。不触发 P1,但要进独立的“降级率趋势”告警,按小时统计 degraded 占比,超基线才响。网关的重试/fallback 属设计内兜底,计 ok,走 gateway.fallback_to 属性留痕。把“兜住了”和“没兜住”分开记账,狼来了才不会天天喊; -
error:真挂了,错误没被任何环节接住,错误 type 一路透传到底,下游分得清是“兜住了”还是“真挂了”,而不是一句“系统繁忙”。
一句真话:OTel 的 status 只有 OK/ERROR,degraded 不是官方值,生产里落成“degraded=true 属性 + status=OK”;我 Demo 里把它做成第三种值,属教学简化。
PII 永不进 Trace:全篇最锋利的一条
Trace 不能变成脱敏闸的绕行通道。第 6 篇在原文进上下文之前拦、出上下文之后挡,这篇要做的是根本不让原文进来。打点只记裁决字段,模型入参/出参只记 token 计数;要回放原文,只拿第 7 篇 GateResult 里那个 sample[:200] 的已脱敏裁剪文本——前一篇留这个口子,就是给打点用的。
我 Demo 里专门留了个 PII 巡检:喂一段含身份证号的原文跑降级场景,collector 全部 span 的 attributes 里找不到那个身份证号。谁把原文塞进 attribute,断言当场把他揪出来。
代码长啥样:接口签名对齐 OTel,换 SDK 打点不改
完整代码(mini_trace.py + 6 个测试断言)在 CSDN 原文,可离线跑、纯标准库、无 API 密钥、不真调模型不真花钱。这里只讲骨架,一个 span 开出来就是这样:
with tracer.start_span(”schema_gate.guard”, ”schema_gate”, ctx) as sg:sg.set_attribute(”schema_gate.retries”, gate.retries)sg.set_attribute(”schema_gate.error_code”, gate.error[”code”])
迷你 Tracer 的接口签名照着 OTel API 长(start_span 作上下文管理器、set_attribute、record_status),Collector 做成内存版 exporter。生产里 Tracer 换 opentelemetry 或 LangFuse SDK、Collector 换 OTLP exporter、桩换前几篇真实组件——打点一行不改。工具只是个插头,事件模型先于工具。
6 个断言,一条锁一个承诺:串链树结构、降级一眼定位、重试兜底看得见、护栏告警与降级放行、PII 不落 Trace、父链回溯。把 SchemaGate 桩的降级改成抛异常,断言②当场挂——degraded 不是 error,这条口径测试说了算。
三个付过费的坑
坑一,打点记原文,把脱敏闸绕过去了。prompt 全文、模型输出整段写进 Trace,第 6 篇刚挡掉的身份证号从后门又回来了。有次我巡检存储,发现 trace 库明文躺着用户身份证,存储还爆了——脱敏闸在前门拦,Trace 在后门放,等于没拦。修复:打点纪律立成代码规则——只记裁决字段,原文默认不落,要回放只拿 sample[:200]。
坑二,把降级/兜底打成 error,狼来了。SchemaGate 每次耗尽、网关每次 fallback 都触发告警,上线第一天响了 30 次,第三天没人看;真出 500 反而淹没在噪音里没人管。修复:三态 status,降级单独占一档 degraded、不炸 P1;再单开一条“降级率趋势”告警盯慢病。
坑三,打点没有共享 ID,接上 LangFuse 也白搭。各环节各打各的,接入之后一个请求在面板里散成 N 条互不相干的 trace,“全链路”名存实亡。根因是:先选工具、没先定“一次请求的边界在哪、trace_id 在哪个入口生成、怎么传下去”。修复:先定事件模型——网关入口生成 trace_id,上下文贯穿全链路,再接工具。数据没串起来,接谁都是白搭。
工具不是二选一:OTel 是链路大盘,LangFuse 是语义工作台
选型一句话判断:OTel 是“通用链路大盘”,LangFuse 是“LLM 语义工作台”,Helicone 是“请求路径,不是裁判”。真取舍不是选哪个,而是要大屏还是工作台、数据出不出得去。
有现成 Prometheus/Grafana 基建或数据出不去(政企合规),走 OTel 自建;从零起、重度 LLM、想直接拿提示词—成本—评分面板,走 LangFuse。Helicone 换 base URL 就能接、零埋点,但 2026-03-03 被 Mintlify 并入后多来源标“维护模式”,评估要带风险溢价。LangSmith 闭源、绑 LangChain,自托管仅 Enterprise,$39/seat/月。
一起上的落地姿势是桥接,不是双写两套语义。LangFuse v4 原生支持 OTLP 摄入:业务打点只对 OTel 一套,OTel Collector 把链路同时送自建大盘和 LangFuse。两边 exporter 各走各的队列与重试,LangFuse 故障只丢它的副本;两个系统 trace_id 天然同源,不存在“双写对不上”。
可核实的版本号(2026 年 9 月):opentelemetry-python1.44.0(2026-07-16 发布);LangFuse 自托管v4(SDK 需 4.7.0+,存储换 ClickHouse);ClickHouse 于2026-01-16 收购 Langfuse,自托管不变、无 license 变更。再补一个现状:OTel 的 GenAI 语义约定仍在 Development、未 stable,gen_ai.system 已弃用,迁 gen_ai.provider.name。落到代码就两条:业务裁决字段自己定、别等约定稳定;LLM 调用字段对齐 gen_ai.* 便于生态互通。
采样:错误/降级全采,正常 1/100
Trace 存储不设采样,成本滚雪球:全量采约 78 GB/月;错误/降级全采 + 正常采 1/100,约 3 GB/月,省约 96%,排障要看的异常样本一条不少。正常样本是给成本分析和离线评测当统计样本用的,1/100 量级足够做分布估计。要是还嫌贵,降正常采样率到 1/1000,别降异常采样率。
三句话总结
回到开头那个排障夜:同样的告警,这次打开 Trace 一眼看到整条链——网关重试 2 次兜到 gpt4、护栏 medium 剥离放行、SchemaGate 两次校验不过降级,5 分钟定位,不用再翻两小时日志。重试几次、哪个字段错、走没走兜底,整条链上都看得见了。
但“看得见”只解决了一半:token 怎么烧的、单请求成本怎么拆、哪条链路最费钱,还藏在 gen_ai.completion span 的 token 计数里没算账。下一篇《Token 成本六维优化》,把这本账摊开算。
这篇是脱水版。完整版约 8000 字,含可离线跑的 mini_trace.py 全量代码、test_mini_trace.py 6 个断言、打点命名对照总表、OTel/LangFuse/Helicone/LangSmith 完整选型对比大表、抽样成本推导附录,已同步发布在 CSDN。点文末「阅读原文」看完整代码和可跑 Demo。
评论区聊聊:你们现在排障 LLM 链路,是还在人肉对时间戳,还是已经有一根 trace_id 串起来的线了?
我是小兵,一个动手派AI架构师。这里只写自己跑过、摔过、复盘过的AI工程化案例。如果你想持续收到这类实战内容,点击关注,下篇见。


