OTel GenAI 语义约定 + Langfuse/Phoenix
昨天(Day 81)把容器推上了 scale-to-zero 的部署模型,解决了「在哪跑」。但部署上去之后有个新问题:你看不见它在干什么——一次模型调用花了多少 token、多少钱、多久、用了哪个 model id,全是黑盒。
阶段: B9 · 部署 + 韧性 + 可观测 + 独立红队(Day 81-90) 标签: #observability #opentelemetry #gen-ai-conventions #langfuse
今日导引(由浅入深)
昨天(Day 81)把容器推上了 scale-to-zero 的部署模型,解决了「在哪跑」。但部署上去之后有个新问题:你看不见它在干什么——一次模型调用花了多少 token、多少钱、多久、用了哪个 model id,全是黑盒。
今天补可观测性的第一块砖:OpenTelemetry GenAI 语义约定。它给 LLM 调用规定了一套标准 span 属性命名(gen_ai.*),让 trace 跨工具可移植;再选一个后端(Langfuse / Phoenix)把 trace 收下来看。这是 B9 可观测主线的起点——今天打 1 条 span,Day 85 把整个 29-task suite 都埋上,Day 87 量产后聚合成 p50/p95/p99 仪表盘。
放到能力曲线上:B1 让我们能离线测一次跑的 token/cost/通过率,但离线测的数字是「死表」;可观测性把同样的数字搬到在线、可聚合、可下钻的 trace 上。Day 82→85→87 这条小链就是「单条 span → 全套埋点 → 量产聚合」的三级跳,最终把评测从「跑完看个总数」升级成「随时按维度切片的流水线」。今天先把最小的一节——单条 span 的属性形状——立住。
最小可判定产出:在 src/agent/eval 调用层包一层 OTel tracer,对一次模型调用(默认 provider deepseek,deepseek-v4-flash)打 1 条 结构正确、带 token/cost 属性的 GenAI trace。OTel wiring 本身今天未建(待跑/待云),但可挂的真实数字底座已就位。
1. 机理精读
OpenTelemetry GenAI 语义约定 = 给 LLM 调用的 span 属性定一套标准命名空间。 没有它,A 工具把 model 写进 model、B 工具写进 llm.model_name、C 工具写进 request.model,trace 就锁死在单一工具里、无法跨平台聚合。约定把这些钉死在 gen_ai.* 下,于是任何遵守约定的后端都能识别同一条 trace。资源:OpenTelemetry GenAI semantic conventions (2026-01)。
核心属性(seed 点名的几个):
gen_ai.request.model—— 请求的模型 id,如deepseek-v4-flash。gen_ai.usage.input_tokens/gen_ai.usage.output_tokens—— 输入/输出 token 数。gen_ai.usage.cost(或gen_ai.usage.cost与cost的命名仍在收敛中)—— 该次调用的成本。
为什么是「span 属性」而不是「日志字段」。 span 是带因果父子关系的,一次 agent run 可能是「计划 span → 工具调用 span → 模型调用 span」的一棵树。把 token/cost 挂在 span 上,你不仅看到单次调用的数字,还能沿树聚合出整条 run 的总 token/总成本——这是 Day 85「eval-as-trace」和 Day 87「按 p50/p95 切片」的前提。
Langfuse vs Phoenix 的取舍。 两者都收 OTel trace,但侧重不同:
- Langfuse:偏 LLM-native,trace + 评分 + 数据集闭环做得顺——你能把「这条 trace 的 judge 评分」直接挂回 trace。如果目标是「把 eval 评分接到可观测流水线」(正是 Day 85 要做的),Langfuse 更顺手。
- Phoenix (Arize):偏 eval + 可视化调试,做 trace 探查和实验对比强。
取舍的判据是一句话:你要不要把 eval 评分直接挂到 trace 上? 要 → Langfuse 更对路(Day 85 也确实选了本地 Langfuse 实例)。
「语义约定」和「具体 SDK」是两层,别混。 OTel GenAI 约定只规定属性怎么命名(gen_ai.request.model 这种 key);具体用哪个 SDK 产 span、导到哪个后端,是另一层。约定的价值恰在于解耦:你今天用 OTel JS SDK + 本地 Langfuse,明天换成别的导出后端,只要双方都认 gen_ai.*,trace 就能无缝迁移。这就是「标准命名空间」相对「各家私有字段」的复利。
为什么 cost 也要进 span,而不是只算 token。 token 数能在后端按某个价目表算成钱,但价目表会变、且同一次调用可能跨 prompt/completion 不同单价(输入 token 通常比输出便宜)。把已算好的 cost 直接写进 span,等于把「这次调用花了多少钱」这个事实在调用现场固化,避免后端事后用错价目表重算。本仓有 $0.0139/run(V4-Flash 实测)这个现成底数,正好是这条 cost 属性的真实填充值。
与「裸打 print/log」的边界。 自己 console.log token 数也能看,但它不可聚合、不可跨工具、没有因果结构。OTel 的价值不在「打出一个数」,而在「这个数被标准化地挂在一棵可聚合的 trace 树上」。
2. 代码走读 / 可挂的真实底座
今天 OTel wiring 未建,但 seed 明确指出「可挂的真实数字底座已有」,走读这些底座:
src/agent/eval/tokenizer.ts(已建并测)—— 这是 token 数的真实来源:
trainBpe(corpus, numMerges):从语料训 byte-level BPE merge 表。encode(text, model)/decode(...):编码/解码,round-trip 无损。tokenReport(texts, model):对一组文本出{ totalChars, totalTokens, meanCharsPerToken, rows[] }——totalTokens就是能填进gen_ai.usage.*_tokens的真实数字(B1 自建 tokenizer,无需 API key 即可测 tokens-per-task)。
成本底座:seed 标注 cost $0.0139/run(V4-Flash 实测)——这是能填进 gen_ai.usage.cost 的真实底数。它来自前面 B 批次的真 API 实测,不是今天臆造的。
tokenReport(texts, model) 的返回结构(第 110 行附近):{ rows, totalChars, totalTokens, meanCharsPerToken },每行是 { index, chars, tokens, charsPerToken }。encode(t, model).length 即该文本的 token 数——这就是 gen_ai.usage.input_tokens 的真实来源。注意它是本仓自训的 byte-level BPE,与 DeepSeek 真实 tokenizer 不完全一致,是「自建可测口径」而非「provider 计费口径」——这点埋点时要诚实标注(span 上可加一个 tokenizer=local-bpe 区分)。
所以「埋点」要做的事就是:在调用层把 tokenReport 的 token 数、$0.0139/run 的 cost、以及 deepseek-v4-flash 这个 model id,按 gen_ai.* 命名写进一条 span。今天这层 wiring 还没写,是「待跑/待云」。
gen_ai.* span 的最小形状(示意,非已运行代码):
span "chat deepseek-v4-flash"
gen_ai.system = "deepseek"
gen_ai.request.model = "deepseek-v4-flash"
gen_ai.usage.input_tokens = <tokenReport 的 input tokens>
gen_ai.usage.output_tokens = <tokenReport 的 output tokens>
gen_ai.usage.cost = 0.0139 # V4-Flash 实测/run 锚点
这条 span 一旦打出,就能在 Langfuse/Phoenix 面板里看到一条带 model/token/cost 的可移植 trace——也就是今天的目标产出。
3. 今日实战
可执行步骤(指向真实路径):
- 在
src/agent/eval的调用层包一层 OTel tracer(NodeSDK + GenAI 约定,pin 当周 SDK 版本)。 - 对一次模型调用(默认 provider
deepseek,modeldeepseek-v4-flash)开一条 span。 - span 属性写:
gen_ai.request.model = "deepseek-v4-flash"、gen_ai.usage.input_tokens/output_tokens(取自tokenizer.ts的tokenReport)、cost 属性(锚 $0.0139/run 的实测口径)。 - 导出到一个后端(Langfuse 或 Phoenix),截一张「带 token/cost 属性的 GenAI trace」图。
实现要点(落地时的真实约束):
- tracer 包在调用层、而非散落到业务逻辑里——一处埋点,所有走该层的调用自动带 trace。
- span 名用
gen_ai.operation.name + model(如chat deepseek-v4-flash),符合约定的 span 命名惯例。 - token 数标注来源(自建 BPE vs provider 计费口径)避免日后误读。
- 后端选 Langfuse 是为 Day 85 的「评分挂回 trace」做准备——若只为看 token/cost,Phoenix 也行。
4. 今日实测 / 产出
- OTel wiring:未建(待跑/待云)。
- 可挂的真实数字底座:已有——cost $0.0139/run(V4-Flash 实测)、tokenizer 在
src/agent/eval/tokenizer.ts。 - 目标产出 = 1 条结构正确、带 token/cost 属性的 GenAI trace 截图(待出)。
诚实状态不升级:wiring 没写、trace 没出,今天只确认了「底座数字真实存在、命名标准明确」。
5. 常见误区 / 陷阱
- 自造非标 span key(如
model_name、tokens_in):直接破坏跨工具可移植性,等于白埋。必须用gen_ai.*。 - 假设 Langfuse/Phoenix 的 SDK 版本:可观测 SDK 迭代快,不 pin 版本号会在依赖升级时静默改属性名。pin 当周版本。
- 把 cost 当固定常量硬编码:$0.0139/run 是某次 V4-Flash 实测的口径,模型/价格变了它就过时。它是「当前底数」不是「永久真值」。
gen_ai.usage.cost命名想当然:约定仍在 stabilizing,cost 到底叫gen_ai.usage.cost还是别的,执行当周对照最新 spec。- 把自建 BPE 的 token 数当 provider 计费口径:本仓
tokenizer.ts是自训 byte-level BPE,与 DeepSeek 真实分词器不一致。当成本对账依据时会有偏差——span 上要标tokenizer=local-bpe,对账以 provider 返回的 usage 为准。 - 把可观测当成「事后才接」:埋点应在调用层一次成型,越晚补越要改散落各处的调用点。Day 82 单条 span 立住,Day 85 才能直接套到 29-task 批量埋点。
6. 学习资源(每条带 YYYY-MM)
- OpenTelemetry — GenAI semantic conventions(
gen_ai.*属性命名空间),2026-01。 - Langfuse docs — OpenTelemetry / trace ingestion & scoring,执行当周 pin 版本,2026-01。
- Arize Phoenix docs — OTel-based tracing & eval,2026-01。
- OpenTelemetry JS — NodeSDK / span attributes,2025-12。
SOTA检查 (2026-06 更新)
- 当前主流:OTel GenAI 约定是 2026 跨工具可观测的事实标准方向,仍是 SOTA 主线;Langfuse / Phoenix 是 LLM 可观测的两大主流后端。
- 仍在收敛:
gen_ai.*约定部分属性名(costvsusage.cost)仍在 stabilizing,执行当周对照最新 spec 校准。 - 过时黑名单:自造非标 span key;假设固定 SDK 版本不 pin。
- 下次复查点:写 OTel wiring 当周,复查 GenAI 约定 spec 状态 + Langfuse/Phoenix SDK 当周版本号。
衔接
- 昨天:Day 81 — Cloud Run scale-to-zero 部署模型(解决「在哪跑」)。
- 今天:用 OTel GenAI 约定给一次模型调用打标准 span,解决「看见它在干什么」,1 条 trace 待出。
- 明天:Day 83 — 韧性模式:backoff+jitter / circuit breaker / fallback(部署 + 可观测之后,给调用链补上「失败时怎么办」)。