章节导航 第 14 章 / 共 21 章
Tracing 与可观测性
自研 Span 体系、@observe 装饰器、SpanController、Processor 插件、远程 Trace 传播、Phase 系统
Nexus 自研 Tracing 的设计考量
Nexus 的 Trace 系统是自研的——nexus/trace/ 目录,约 70KB 代码。当初选择自研有三点考量:
- 轻量需求:不需要 collector、exporter、SDK 那套复杂架构
- 跨进程传播:需要在 Host ↔ Runtime 之间传播 Span,OTel 的跨进程方案偏重
- 训练集成:HyTuner Processor 需要 token-level 的精细数据,OTel 的 attribute 模型不够灵活
然而,OTel 生态在 2025-2026 年快速进化,前两条理由已被部分解决:
- 轻量化:agentrace-ai 仅依赖
otel-api+otel-sdk两个包,2 行代码接入(@trace_llm/@observe装饰器),已证明 OTel 可以做到足够轻量。traceAI 更提供 50+ 集成(OpenAI/Anthropic/Gemini/LangChain/MCP 等),sub-millisecond 开销。2026.06 更新:traceAI 新增 MCP、Claude Agent SDK、SmolAgents、Pydantic AI、AWS Strands 等集成,并新增 GUARDRAIL 和 EVALUATOR span kinds(共 9 种);agentrace-ai 0.2.0 新增@trace_retrieval和@observe通用装饰器,与 Nexus 的@observeAPI 设计相似度更高,桥接成本进一步降低 - 跨进程:traceAI 支持 OTLP 传播,agentrace-ai 支持跨进程 trace propagation
自研的核心理由已进一步退化:MCP 2026-07-28 规范(SEP-414)已将 W3C trace context(traceparent/tracestate/baggage)标准化为协议级跨进程传播答案,GenAI semantic conventions 也已扩展出 invoke_agent/execute_tool 等 agent span types——前两条理由(轻量需求、跨进程传播)在协议层与规范层都有了标准解。Nexus 自研仅剩一条真实理由:token-level 结构化训练数据(prompt_ids / response_ids / response_mask / logprobs)无法高效塞进 OTel 的键值对 attribute 模型,需借助 SpanEvent 或自定义扩展承载。
结论:如果 Nexus 今天重新设计 Tracing,很可能选择 OTel 为底座 + 自定义 SpanEvent 扩展训练数据的混合方案。
核心数据结构
class Span(BaseModel):
span_id: str
parent_id: str | None
trace_id: str
name: str
start_time: datetime
end_time: datetime | None
status: Status # OK / ERROR
attributes: dict # 键值对元数据
events: list[Event] # 时间点事件
class RemoteTraceContext(BaseModel):
trace_id: str
parent_span_id: str | None
@observe 装饰器
@observe 是 Trace 的核心 API。它包装一个函数,自动创建和管理 Span:
@observe(name='agent.main_agent', attributes={'type': 'agent'})
async def run_main_agent(input): ...
Agent、Task、Tool 在注册时被 Processor 管道自动包装了 @observe——你写扩展代码时不需要手动加。
但”包在哪一层”决定 Span 上记的是请求还是实际执行体。 上面只说了”由 Processor 管道统一包装”,没讨论采集点相对于中间改写层(hook / mod)的位置。客户端侧 v2.1.300(2026-10-14)修的是这个维度的一次事故(官方 changelog 原文):
“Fixed a prompt that a mod’s
prompt.submithook rewrote or dropped still being recorded as typed in the OpenTelemetryuser_promptevent and interaction span, and in session title requests”
被 hook 改写或丢弃的 prompt,仍被记为”用户输入的原文”,而且污染扩散到三个消费点(user_prompt 事件、interaction span、会话标题请求)。也就是说:遥测不只会漏记,还会记录根本没发生过的事——这是本章前面所有讨论(上限、脱敏、目的地)都没覆盖的一个维度,因为它们都默认”遥测记录的是发生过的事”。
Nexus 的同构风险很直接:@observe 由 Processor 管道统一包装,若管道内侧存在改写输入的 hook,Span 上记的将是改写前的值。建议:凡存在输入改写层的调用路径,采集点应放在改写之后;若出于性能或结构原因做不到,则在 Span 上显式标注”本记录为改写前/后”。本章已有的 SpanUpdateEvent 机制正好可承载这一标注——改写层完成改写后发一个 SpanUpdateEvent 覆盖输入字段,比新增机制更省。
也支持上下文管理器模式:
with span_context('my_operation', attributes={...}) as span:
# span 自动在 exit 时结束
update_span(attributes={'result': 'success'})
SpanController
SpanController(nexus/trace/controller.py)管理全局的 Span 生命周期——一个事件驱动的系统:
class SpanController:
def add_event(self, event: SpanEvent):
if isinstance(event, SpanStartEvent):
self._active_spans[event.span_id] = new_span
elif isinstance(event, SpanUpdateEvent):
self._active_spans[event.span_id].update(...)
elif isinstance(event, SpanEndEvent):
span = self._active_spans.pop(event.span_id)
self._notify_processors(span)
三种事件:SpanStartEvent、SpanUpdateEvent、SpanEndEvent。Span 结束时通知所有注册的 Processor。
Processor 插件体系
Processor 消费 Span 数据,产出各种形式的输出。通过 processor_context 管理生命周期:
with processor_context([chat_tracker, token_counter], finalize_on_exit=True):
await task_fn(input)
# finalize_on_exit=True 确保所有 Processor 的 finalize() 被调用
内置 Processor 详解
ChatTracker(chat_tracker.py,10KB):
- 追踪每次 LLM completion 的完整 messages
- 输出:有序的消息列表,每条含 role / content / tool_calls / timing
TokenCounter(token_counter.py,3.7KB):
- 统计 prompt_tokens / completion_tokens / total_tokens
- 支持按 client_key 过滤(如只统计
mainclient)
- 缓存写入应按生命周期分档计,而不是一个总数:客户端侧 v2.1.300(2026-10-14)在
api_request事件上新增cache_creation_5m_tokens与cache_creation_1h_tokens,原文 “splitting cache writes by lifetime”。Nexus 侧ExtendedPromptTokensDetails已含 cache 命中类细节,但若不分档,就算不出”这次写入能在多久内被摊薄”——而摊薄窗口正是 Ch15 前缀缓存失效那条代价、与 Ch16 272K 计费悬崖的共同输入。改动很轻:TokenCounter 拆两个计数字段。
HyTuner Processor(hytuner.py,23.4KB):
- 最复杂的 Processor——采集 token-level 训练数据
- 输出:prompt_ids / response_ids / response_mask / logprobs
- 处理 multi-turn 和 context compaction 产生的多段轨迹
ChromeTrace(chrome_trace.py,6.3KB):
- 输出 Chrome Trace 格式(JSON),可用
chrome://tracing可视化时间线
JsonLine(json_line.py,2.3KB):
- 流式写入 JSONL 文件,每个 Span 一行
SocketIO Broadcaster(socketio.py,3.6KB):
- 将 Span 事件通过 Socket.IO 广播给 Host(用于 RemoteRuntime 的 Span 传播)
远程 Trace 传播
Host 和 Runtime 的 Span 需要关联。机制如下:
- Host 调用
runtime.invoke()时,在InvokeRequest.trace_context中填入当前 Span 的trace_id和parent_span_id - Runtime Server 接收到请求后,用
with_remote_trace_context(trace_id, parent_span_id)设置上下文 - Runtime 内部的
@observe创建的 Span 自动成为 Host Span 的子 Span - Runtime 通过
SocketIOSpanBroadcaster将 Span 事件推送回 Host - Host 的
RemoteRuntime接收事件,通过SpanController.add_event()回放
最终效果:Host 和 Runtime 的 Span 形成一棵完整的调用树。
Phase 系统
nexus/trace/phases.py(4.3KB)提供结构化的阶段划分:
with phase('agent'):
with phase('inference'):
completion = await client.create_completion(...)
with phase('observation'):
result = await tool_executor.call(...)
with phase('compaction'):
await compaction_manager.check_and_compact(...)
Phase 是语义化的——不像 Span 那样精确追踪函数调用,而是标记”现在在做什么阶段”。HyTuner Processor 用 Phase 来区分 prompt 和 response 的边界。
与 OpenTelemetry 的桥接
Nexus 自研 Span 体系与 OTel Span 的结构高度相似(span_id / parent_id / trace_id 直接对应),这为桥接提供了天然基础:
Span → OTel Span 映射
| Nexus 字段 | OTel Span 字段 | 说明 |
|---|---|---|
span_id | span_id | 直接映射 |
parent_id | parent_span_id | 直接映射 |
trace_id | trace_id | 直接映射 |
name | name | 直接映射 |
start_time / end_time | start_time / end_time | 直接映射 |
attributes | attributes | 直接映射 |
events | events (SpanEvent) | 直接映射 |
GenAI Conventions 的现状:独立仓库与属性名变动
桥接需注意:OTel GenAI Semantic Conventions 在 v1.42.0(2026-06-12 发布)作为 breaking change 从主仓库 deprecated,并迁至独立的 semantic-conventions-genai 仓库;官方 CHANGELOG 中 v1.43.0(2026-07-03)与 v1.44.0(2026-08-04)两版均未见”移除”条目,主仓库的正式表述止于 deprecated。属性名仍在持续变动——gen_ai.system → gen_ai.provider.name(v1.37.0)、prompt_tokens/completion_tokens → gen_ai.usage.input_tokens/output_tokens(v1.27.0)、invoke_agent 拆分为 client/internal spans(v1.41.0)。gen_ai.operation.name 现覆盖 create_agent/invoke_agent/invoke_workflow/execute_tool/retrieval/plan/memory。这提醒读者:桥接实现应做新旧属性名 coalesce 双字段查询,并维护版本化的内部 schema,避免上游属性名变动导致映射失效。
现在有了第二条、且来自实现层的理由。上文”应做 coalesce”的论据全部来自规范侧(属性名在持续变动、新仓零 tag),而客户端侧 v2.1.300(2026-10-14)已经把新名落到自己的 trace span 上了(官方 changelog 原文):
“Added OpenTelemetry GenAI semantic-convention attributes to trace spans, such as
gen_ai.provider.name,gen_ai.conversation.id,gen_ai.tool.nameanderror.type”
列表第一项 gen_ai.provider.name 正是本节上面点名的改名后属性(v1.37.0 由 gen_ai.system 改来)。含义是:上游不是”将来可能改”,而是”已经改完了”——下游后端若只认 gen_ai.system,就收不到这批属性。coalesce 双字段查询因此不只是防上游改名,也是接得住上游已经改完的名。本章”Nexus 的桥接方向与业界原生实践一致”这句,也由此从判断升级为事实。(属性名取自 changelog 条目,未回 monitoring-usage 文档逐项核 📌。)
2026-09-16 实测两条硬证据,可把上述”保险起见”升格为必需:主仓 open-telemetry/semantic-conventions 的 releases.atom 最新仍为 v1.44.0(与本章一致);而迁往的新仓 open-telemetry/semantic-conventions-genai 的 releases.atom 返回 0 条 entry——截至该日,新仓库连第一个 tag 都还没有发布,尽管仓库本身仍在活跃更新。迁移尚未完成到这个程度,桥接层做 coalesce 与版本化内部 schema 就不是可选加固。
但”deprecated”与”已迁移”是两件事,本章此前只写了前者。 上面 2026-09-01 的审校结论是对的(主仓 CHANGELOG 确实没有”移除”条目),本轮不推翻它,只补上缺失的另一半。IETF 草案 draft-palanisamy-scitt-aac-otel(2026-10,expires 2027-04-05,定稿于 commit 8c1b98a)§3.2 写明:
“The OpenTelemetry GenAI conventions now live in their own repository (open-telemetry/semantic-conventions-genai; the pages in the main semantic-conventions repository redirect there as of v1.44.0). They are not yet stable, so semconv.source MUST name the repository and the commit or release the attribute names were taken from”
也就是说:deprecated 是 CHANGELOG 的措辞,redirect 是实际发生的动作——页面级跳转已在 v1.44.0 完成,只是主仓的发布记录里没写这件事。⚠️ 两点限定:① 该草案把 gen_ai.system 注为 “retired” 而非官方的 “deprecated”,这是第三方用词,引用时须标明出处;② 草案为第三方一手文档,redirect 事实未回 OTel 官方页面核 📌,只能作为旁证,不得据此改写上面的审校结论。
同一节还要求 semconv.source 标注来源仓与 commit/release——这正是本章”维护版本化内部 schema”建议在规范侧得到的呼应。
📌 该状态随时会变,引用时请带抓取日期。2026-10-24 复核:主仓 tags 最新仍 v1.44.0;genai 新仓 tags API 直接返回 0 条(比”releases.atom 返回 0 条 entry”证据等级更高——atom 无 entry 还可能是 feed 未生成,tags 为空则是确无 tag),而其 feed 的 updated 为 2026-10-24,说明仓仍在活跃更新,发布动作尚未发生。
桥接方案
-
SocketIOSpanBroadcaster → OTLP Exporter 替换:当前 Span 通过 SIO 广播给 Host。可以增加 OTLP Exporter,将相同的 Span 数据发送到 OTel Collector,实现与 Grafana/Jaeger/Datadog 的无缝集成
-
HyTuner token-level 数据作为 OTel SpanEvent:prompt_ids / response_ids 等训练数据可作为 OTel SpanEvent 附加到 Span 上,而非塞进 attribute。SpanEvent 支持
attributes: dict[str, Any],比 attribute 更灵活。
不过要有一个量化预期。同样是”内容型数据”,Claude Code 官方为 OTel 内容属性设了 CLAUDE_CODE_OTEL_CONTENT_MAX_LENGTH,默认 61440(即 60 KB)截断,理由是主流后端对单个 attribute 值的上限是 64 KB;工具输入输出内容(OTEL_LOG_TOOL_CONTENT)默认禁用、且需要 tracing 才生效(其覆盖范围在 2.1.283 / 2026-09-25 扩大到 MCP 工具、WebFetch 与 WebSearch 三类输出,引用本条所估算的数据量请带版本)。这个数字说明:即便改走 SpanEvent,工业实现也普遍给内容设上限。Nexus 若要无截断地承载 prompt_ids / logprobs(HyTuner Processor 的 token-level 数据),基本只能走自研通道——有了这条量化对照,本章”自研仅剩一条真实理由”的结论更站得住。
更要紧的是:上限不止这一种,第二种比第一种危险。 上面那条限制的是单个 attribute 值的长度,后果是截断——内容变短,但结构还在。客户端侧 v2.1.302(2026-10-16)修的是另一种(官方 changelog 原文):
“Fixed
classifier_categorymissing from OpenTelemetrytool_decisionevents for most auto mode classifier blocks, and newtool_decisionattributes pushing outtool_use_id,tool_sourceandtool_parameterswhenOTEL_LOGRECORD_ATTRIBUTE_COUNT_LIMITis low”
当 OTEL_LOGRECORD_ATTRIBUTE_COUNT_LIMIT 设得低时,新增属性会把已有属性挤出去,被挤掉的正是 tool_use_id(关联键)、tool_source、tool_parameters。两条限的性质完全不同:
| 内容长度上限 | 属性条数上限 | |
|---|---|---|
| 限制对象 | 单个 attribute 的值 | 一条记录的 attribute 条数 |
| 后果 | 截断(值变短) | 驱逐(键消失) |
| 丢失内容 | 长文本尾部 | 关联键(tool_use_id) |
| 可恢复性 | 部分——结构仍在 | 无——剩下的内容拼不回调用树 |
对 Nexus 的含义有两层:① 遥测设计要同时设”值长度”与”属性条数”两个预算,只设前者等于默认后者无限;② 关键关联字段应列为不可驱逐,至少在驱逐时留下占位与告警。否则”已经采到了”与”能查得出来”是两件事——丢掉 tool_use_id 之后,剩下的 tool_parameters 再完整也无法挂回某次调用。(该环境变量的默认值本轮未查 📌,本章不写”默认多少条”。)
- Agent Observatory 的 fail-open 模式参考:Agent Observatory 提供了一种中间路径——自研 session/span 模型但可桥接到 OTel。不配置全局 OTel 状态、不拥有 tracer provider,streaming-first 追踪。这避免了 OTel 的初始化开销,同时保留桥接能力
实施优先级
对于需要将 Nexus 接入企业可观测性栈的用户,最低成本的路径是:在 SocketIOSpanBroadcaster 旁新增 OTLPSpanExporter,通过配置开关启用。无需修改 Span 数据结构,仅在广播时增加一路 OTLP 输出。
这条最小路径在系统层面属于”去中心化”模式,本章此前没有说明它的适用边界。 AWS 把 agent 可观测性分成两种拓扑(AWS Containers Blog《Observability patterns for AI agents on Amazon EKS》,核于 2026-10-24):去中心化(每个 agent 独立导出,无共享 collector)与中心化(共享 OTel Collector 聚合)。官方给出的七维权衡:
| 维度 | 去中心化 | 中心化 |
|---|---|---|
| 故障隔离 | 导出失败是 per-agent | Collector 成为共享依赖 |
| 自定义属性 | 仅限框架捕获的 | 完整控制 span 元数据 |
| 多后端路由 | 改每个 agent 的配置 | 改一处 Collector 配置 |
| 采样控制 | 仅框架级 | Collector 级尾部采样 |
| 运维开销 | 低 | 中 |
| 适用 | 快速起步、小团队、独立 agent | 规模上来、多后端、集中运维 |
本章”旁挂一条 OTLP”之所以够用,理由可以讲得比”成本低”更硬。 AWS 的关键论点原文是:“The key idea is that trace context, not a shared collector, is what holds a trace together. The traceparent header travels with the request across process boundaries, so the agent and the gateway can run as separate services and still contribute spans to the same story.” 而 Nexus 恰好已经在 InvokeRequest.trace_context 里传播 trace_id + parent_span_id(本节 §远程 Trace 传播 第 1 步)——粘合剂本来就是 trace context,不是共享的收集点。因此 Host 与 Runtime 各自独立导出,仍能在后端拼成一棵树(上文的示例栈里,agent 与 LLM 网关 Bifrost 就是两个独立服务、贡献同一棵树)。
同时点明边界:中心化模式的收益(尾部采样、统一路由、Collector 侧派生指标)要到 agent 规模上来之后才值一个 Collector。AWS 给的判据可直接借用——“Start decentralized when you have a handful of agents and want the least to operate. Move to centralized after you have enough agents that consistent, fleet-wide control is worth running one more piece of infrastructure.” 对 Nexus 的落点:Batch Runner 单批并发(Ch13)就是这里的”agent 数量”,单批内无需 Collector,跨批/跨租户的统一采样与多后端路由才需要。
这条结论只在客户端能决定目的地的前提下成立。 在受管环境下不成立——Claude Code 的官方模式就是现成的反例:OTEL_METRICS_EXPORTER / OTEL_LOGS_EXPORTER / OTEL_TRACES_EXPORTER 走 per-key 优先级(v2.1.223+),开发者侧的设置仍可关闭信号或改到 console exporter,所以想锁定目的地必须一并写进 managed settings;若 detailed beta tracing 激活,目的地还会被 BETA_TRACING_ENDPOINT 接管,而只要任一 managed setting 决定了这两个信号的目的地,Claude Code 就会移除开发者自行设置的 BETA_TRACING_ENDPOINT(v2.1.251 之前行为相反:开发者设置的 endpoint 会重定向 managed 的导出)。
受管链路本身也有失效窗口,不只是优先级问题。 上面一整段讨论的都是”目的地由谁决定”,前提是投递一定会发生。客户端侧 v2.1.304(2026-10-19)修的是这个前提的破口(官方 changelog 原文):
“Fixed OpenTelemetry settings delivered through server-managed settings being ignored for the whole session after signing in during first-run setup or the first start after /logout”
即:在首次运行设置期间登录、或 /logout 后的首次启动这两种情形下,受管下发的 OTel 设置整场会话被忽略。这与上面 per-key 优先级那条是同一主线,但方向更糟——不是”开发者覆盖了受管设置”,而是”受管设置在最该生效的时点根本没生效”。
对 Nexus 的落点:OTLPSpanExporter 在启动时若未在预期时限内收到目的地配置,应显式记录”本场无目的地”,而不是静默不发。这与下面 fail-open + 失败可见化那条是同一要求的两面:失败可见化针对”发了但失败”,本条针对”压根没配”。
对 Nexus 桥接的启示是:OTLPSpanExporter 除了”能不能发”,还要回答”被策略关掉时怎么办”。建议设计成 fail-open——导出失败不阻断 Span 主流程,与上文桥接方案 3 提到的 Agent Observatory fail-open 模式一致(本条为编辑推断 📌)。
但 fail-open 自带一个副作用:失败变得不可见。客户端侧的对应修复是——当配置好的 otelHeadersHelper 失败时新增启动告警,理由原文是”sessions that silently export no telemetry are noticed”(Claude Code 2.1.275,2026-09-17)。因此完整建议是 fail-open + 失败可见化:导出失败不阻断主流程,但必须以启动告警、失败计数器或定期自检的方式显式暴露,且该告警本身不能依赖同一条已失败的导出链路——否则失败与”失败的通知”会一起消失。
被解构对象的原生 OTel 实践:Claude Code
作为逆向工程对象,Claude Code 自身已原生拥抱 OTel。三个信号各自独立开关,全部通过环境变量配置,无需改动调用方式:
- 总开关:
CLAUDE_CODE_ENABLE_TELEMETRY=1 - Metrics(GA):session.count / token.usage / cost.usage / lines_of_code.count 等,经
OTEL_METRICS_EXPORTER=otlp导出 - Log events:见下方事件清单(27 种),经
OTEL_LOGS_EXPORTER=otlp导出 - Traces(beta):官方要求三个变量一并设置——
CLAUDE_CODE_ENABLE_TELEMETRY=1+CLAUDE_CODE_ENHANCED_TELEMETRY_BETA=1+OTEL_TRACES_EXPORTER=otlp。总开关是三者之一,缺任一都不出 traces;只设OTEL_TRACES_EXPORTER无效
traces 的 span 层级为 claude_code.interaction(根,一次完整 agent turn)→ llm_request / hook / tool,其中 tool 下再分 tool.blocked_on_user(权限等待)与 tool.execution(实际执行)两个子 span。子 agent 经 Task 工具派生时,其 span 会嵌套在父 span 之下,天然形成多智能体调用树。 与正文「子 agent 经 Task 工具派生时…」直接相连,中间无换行,破坏段落排版;已将其移至段末 —>
⚠️ 一处必须补的限定:上文的
claude_code.hookspan 只在 detailed beta tracing 激活时才出现——官方原文是”This span appears only when detailed beta tracing is active”,并明确CLAUDE_CODE_ENHANCED_TELEMETRY_BETA单独设置不会产生它。只按上一节那两个变量配置,是看不到 hook span 的。
事件层:27 种 log event
span 树只覆盖调用结构,Claude Code 的大部分可观测信息在事件层——日志型的时间点记录,与 span 是两层。官方事件共 27 种(2026-10-06 按官方 monitoring-usage 文档中「Event Name」条目计数;2026-09-16 时为 26 种):
user_prompt、assistant_response、tool_result、api_request、api_error、api_refusal、api_request_body、api_response_body、tool_decision、permission_mode_changed、auth、mcp_server_connection、internal_error、plugin_installed、plugin_loaded、skill_activated、at_mention、api_retries_exhausted、hook_registered、hook_execution_start、hook_execution_complete、hook_plugin_metrics、compaction、subagent_completed、feedback_survey、retention_sweep、managed_settings_resolved。
新增的 managed_settings_resolved(2.1.274 / 2026-09-17)记录受管设置的来源、策略辅助进程状态,以及机器拒绝启动的原因;开启 OTEL_LOG_MANAGED_SETTINGS=1 时还会输出设置内容与 resolved_sha256。它对本节的补强在于:受管环境下的可观测性不只关乎”信号发到哪”,还关乎”策略本身解析成了什么”——这与上面 §实施优先级 讨论的”目的地由谁决定”是同一条主线。
⚠️ 同一份内容可能同时存在于多个字段。 客户端实测:
user_prompt事件新增了prompt_text,其内容与prompt完全相同,存在的理由只是”部分后端会嵌套点号键”(Claude Code 2.1.287,2026-10-01);官方原文要求 “drop or mask it wherever you drop or maskprompt”。这条对 Nexus 的启示是:内容型数据的脱敏 / 截断策略必须按”内容”定义,而不是按”字段名”定义。HyTuner 的
prompt_ids/logprobs一旦走 OTLP,并因后端兼容产生多字段承载,只按字段名过滤的管线会留下完整副本——表现为”已经脱敏了”,实际等于没脱敏。同一风险族另见 Ch12 §安全管线层 的 PII 脱敏(Presidio)。
两层的关键设计差异在排序方式:span 靠父子关系,event 靠 event.sequence(per-process 计数器)。这条与 Nexus 的 SpanController 事件驱动模型(SpanStartEvent / SpanUpdateEvent / SpanEndEvent 按到达顺序驱动状态机)是同一个思路——本章”自研 Span 体系并非孤例”的判断又多一条例证。
第二层:detailed beta tracing
官方遥测实际上有两层 beta,上节写的只是第一层。
| 第一层 traces beta | 第二层 detailed beta tracing | |
|---|---|---|
| 启用 | CLAUDE_CODE_ENABLE_TELEMETRY=1 + CLAUDE_CODE_ENHANCED_TELEMETRY_BETA=1 + OTEL_TRACES_EXPORTER=otlp | 再加 ENABLE_BETA_TRACING_DETAILED=1 + BETA_TRACING_ENDPOINT |
| 产出 | span 树 | claude_code.hook span、query_source、system_prompt_preview、tool_input、response.model_output 等内容型属性 |
| 稳定性 | — | 官方明确这些不属于稳定 span schema |
| 放行 | 无特殊要求 | 交互式 CLI 会话需组织被 allowlist 放行;Agent SDK 与 -p 非交互会话不需要 |
| 目的地 | 走 logs / traces exporter | logs 与 traces 的目的地被改到 BETA_TRACING_ENDPOINT,不再走原 exporter |
| 设置位置 | 任意 | 在 project 与 local settings 中被忽略,只能设在 shell / user settings / managed settings |
这一层的存在恰好印证本章的核心判断:连官方也要为内容型数据单独开一层、单独设上限、并声明其不稳定。它给”token-level 结构化训练数据塞不进 attribute 模型”提供了来自被解构对象自身的证据。
Compaction 作为一等可观测事件
官方把 compaction 建模为一等事件(event.name: "compaction"),属性包括 trigger(auto / manual)、success、duration_ms、pre_tokens、post_tokens、error,以及 precompute_reuse。
最后这个属性最值得注意:自动 compaction 会在上下文窗口填满之前于后台预先准备一份摘要,/compact 触发时记录是否复用了这份预计算摘要——hit 表示复用;miss_custom_instructions / miss_hook / miss_not_ready 给出重新计算的原因。(要求 v2.1.153+;且该属性只在 trigger 为 "manual" 时才设置。)
第三个面:网关层。 开启 CLAUDE_CODE_GATEWAY_HINT_HEADERS=1 后,x-claude-code-compaction 与 x-claude-code-context-compacted 会作为请求头出现在每一次 LLM 请求上(2.1.273,2026-09-15)——压缩状态被外化到请求本身,网关无需订阅遥测即可感知。这条对 Nexus 尤其合用:Runtime Server 的 LLM Proxy(见 Ch12)天然处在每一次 LLM 请求的路径上,采集压缩信号不必等到 Processor 落地,Proxy 层就能先拿到。(另注:同一版本还加了 x-claude-code-request-class / x-claude-code-agent-type / x-claude-code-prev-tool-durations;x-claude-code-prompt-id 于 2.1.283 加入,用于把服务同一次用户提示的请求归组。)
补上第四个平台,对照才完整。 上面只写了 Claude Code。Gemini CLI 也把压缩做成了一等遥测信号,但极简:gemini_cli.chat_compression 日志事件的属性只有两个——tokens_before(int)与 tokens_after(int);同一文档另有一个名为 gemini_cli.chat_compression 的指标条目,描述为 “Counts compression operations”(同样是这两个属性)。配置侧 model.compressionThreshold 默认值仍为 0.5(本轮复核,与本章一致)。
| 平台 | 压缩的可观测形态 | 属性集 |
|---|---|---|
| OpenAI(服务端压缩) | 对客户端不透明 | 无 |
| Claude Code | compaction 事件(富) | trigger / success / duration_ms / pre_tokens / post_tokens / error / precompute_reuse |
| Gemini CLI | chat_compression 日志事件 + 同名计数器指标(简) | 仅 tokens_before / tokens_after |
这条对照直接支撑下面给 Nexus 的建议,并把门槛降到最低:最小可用集其实只有两个字段——把 pre_tokens / post_tokens 采下来即可,duration_ms 与 success 是加分项而非门槛。以”属性太多、太重”为由不做是不成立的:一个生产级 CLI 用两个字段就把这件事做成了一等信号。
⚠️ 一处口径冲突值得记一笔(Ch15 须回看):Gemini CLI 在其 “Chat history compression” 一节自称该压缩 “is designed to be lossless in terms of the information conveyed”。这与本章开篇”压缩的代价至少有三种”是全章前提——两处口径直接冲突。本书采信”压缩有代价”这一侧(Ch15 有实证),但引用 Gemini CLI 这条自述时须标明是厂商自称。
对 Nexus 有两条直接对位:
- 观测缺口:
CompactionManager目前只在 Phase 系统留下一个phase('compaction')语义标记,没有前后 token 数、没有耗时、没有失败原因。建议由 HyTuner Processor 或新增 Processor 采集pre_tokens/post_tokens/duration_ms/success——这对本章 §训练一致性 要求的”精确复现压缩前后 token 序列”是直接增强:压缩事件本身目前没有结构化记录。按上面的对照,起步只需pre_tokens/post_tokens两个字段。 - 机制对位:官方的”后台预计算 + 命中复用”是 Ch15 §Factory Anchored Iterative Summarization 讨论的”增量合并 vs 每次全量重建”之外的第三条路——预计算但可失效。Ch15 须同步补上。
这验证了章节”若今天重新设计很可能选 OTel 底座”的判断——被解构对象已经用 OTel 统一了自身的 agent 可观测性,Nexus 的桥接方向与业界原生实践一致。tool.blocked_on_user 这一 span 尤其值得注意:它把”权限拦截等待”单独建模为一个可度量阶段,与 Nexus 的 Phase 系统用 phase('observation') 标记阶段的思路异曲同工,可作为 Nexus Phase 语义对齐 OTel 的参考。
正面解法是给”判定来源”单独建模,而不是让一个字段在不同来源下取不同语义。 上面那条 ⚠️ 讲的是同一个 source 字段在不同运行形态下会退化;客户端侧 v2.1.301(2026-10-15)给出的解法是把来源拆成独立属性(官方 changelog 原文):
“Added
classifier_verdictandpermission_modeto OpenTelemetrytool_decisionevents, so auto mode classifier decisions can be told apart from permission rules” “Addedclassifier_categoryto OpenTelemetrytool_decisionevents, naming the built-in rule a classifier block matched, whenOTEL_LOG_TOOL_DETAILS=1”
即”分类器拦的”与”权限规则拦的”现在可区分,且能指名命中了哪条内置规则。对 Nexus 的落点:Phase 系统用 phase('observation') 标记阶段是语义化的(本章原文”不像 Span 那样精确追踪函数调用”),因此在拦截类事件上应额外带一个“判定来源”属性,而不是靠后续解释字段取值——否则就会踩与 tool.blocked_on_user 同一个坑:同一个字段在不同来源下取不同语义,等于没有语义。(属性名取自 changelog 条目,未回 monitoring-usage 文档逐项核 📌。)
⚠️ 但该 span 的关键属性在非交互形态下曾长期不可用。 其
source与decision在-p会话、SDK 会话,以及 PreToolUse hook 审批这三种场景下曾恒为unknown(Claude Code 2.1.288,2026-10-02 修复)。Nexus 的RemoteRuntime恰恰运行在同形态(非交互 / 类 SDK,见 Ch12)。因此把这个 span 作为参考时,须同时确认”权限来源”与”决策结果”两个属性在你的运行形态下是否会退化为unknown——若会,则该 span 只能度量”等待时长”,不能度量”为何被拦”。