第 14 章 第二部分:架构原理

Tracing 与可观测性

自研 Span 体系、@observe 装饰器、SpanController、Processor 插件、远程 Trace 传播、Phase 系统

Nexus Tracing Span 可观测性 Processor

Nexus 自研 Tracing 的设计考量

Nexus 的 Trace 系统是自研的——nexus/trace/ 目录,约 70KB 代码。当初选择自研有三点考量:

  1. 轻量需求:不需要 collector、exporter、SDK 那套复杂架构
  2. 跨进程传播:需要在 Host ↔ Runtime 之间传播 Span,OTel 的跨进程方案偏重
  3. 训练集成: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 的 @observe API 设计相似度更高,桥接成本进一步降低
  • 跨进程: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——你写扩展代码时不需要手动加。

也支持上下文管理器模式:

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 过滤(如只统计 main client)

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 需要关联。机制如下:

  1. Host 调用 runtime.invoke() 时,在 InvokeRequest.trace_context 中填入当前 Span 的 trace_id 和 parent_span_id
  2. Runtime Server 接收到请求后,用 with_remote_trace_context(trace_id, parent_span_id) 设置上下文
  3. Runtime 内部的 @observe 创建的 Span 自动成为 Host Span 的子 Span
  4. Runtime 通过 SocketIOSpanBroadcaster 将 Span 事件推送回 Host
  5. 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_idspan_id直接映射
parent_idparent_span_id直接映射
trace_idtrace_id直接映射
namename直接映射
start_time / end_timestart_time / end_time直接映射
attributesattributes直接映射
eventsevents (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,避免上游属性名变动导致映射失效。

2026-09-16 实测两条硬证据,可把上述”保险起见”升格为必需:主仓 open-telemetry/semantic-conventions 的 releases.atom 最新仍为 v1.44.0(与本章一致);而迁往的新仓 open-telemetry/semantic-conventions-genai 的 releases.atom 返回 0 条 entry——截至该日,新仓库连第一个 tag 都还没有发布,尽管仓库本身仍在活跃更新。迁移尚未完成到这个程度,桥接层做 coalesce 与版本化内部 schema 就不是可选加固。📌 该状态随时会变,引用时请带抓取日期。

桥接方案

  1. SocketIOSpanBroadcaster → OTLP Exporter 替换:当前 Span 通过 SIO 广播给 Host。可以增加 OTLP Exporter,将相同的 Span 数据发送到 OTel Collector,实现与 Grafana/Jaeger/Datadog 的无缝集成

  2. 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 数据),基本只能走自研通道——有了这条量化对照,本章”自研仅剩一条真实理由”的结论更站得住。

  1. Agent Observatory 的 fail-open 模式参考:Agent Observatory 提供了一种中间路径——自研 session/span 模型但可桥接到 OTel。不配置全局 OTel 状态、不拥有 tracer provider,streaming-first 追踪。这避免了 OTel 的初始化开销,同时保留桥接能力

实施优先级

对于需要将 Nexus 接入企业可观测性栈的用户,最低成本的路径是:在 SocketIOSpanBroadcaster 旁新增 OTLPSpanExporter,通过配置开关启用。无需修改 Span 数据结构,仅在广播时增加一路 OTLP 输出。

这条结论只在客户端能决定目的地的前提下成立。 在受管环境下不成立——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 的导出)。

对 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.hook span 只在 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 mask prompt”。

这条对 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 exporterlogs 与 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 加入,用于把服务同一次用户提示的请求归组。)

对 Nexus 有两条直接对位:

  1. 观测缺口:CompactionManager 目前只在 Phase 系统留下一个 phase('compaction') 语义标记,没有前后 token 数、没有耗时、没有失败原因。建议由 HyTuner Processor 或新增 Processor 采集 pre_tokens / post_tokens / duration_ms / success——这对本章 §训练一致性 要求的”精确复现压缩前后 token 序列”是直接增强:压缩事件本身目前没有结构化记录。
  2. 机制对位:官方的”后台预计算 + 命中复用”是 Ch15 §Factory Anchored Iterative Summarization 讨论的”增量合并 vs 每次全量重建”之外的第三条路——预计算但可失效。Ch15 须同步补上。

这验证了章节”若今天重新设计很可能选 OTel 底座”的判断——被解构对象已经用 OTel 统一了自身的 agent 可观测性,Nexus 的桥接方向与业界原生实践一致。tool.blocked_on_user 这一 span 尤其值得注意:它把”权限拦截等待”单独建模为一个可度量阶段,与 Nexus 的 Phase 系统用 phase('observation') 标记阶段的思路异曲同工,可作为 Nexus Phase 语义对齐 OTel 的参考。

⚠️ 但该 span 的关键属性在非交互形态下曾长期不可用。 其 source 与 decision 在 -p 会话、SDK 会话,以及 PreToolUse hook 审批这三种场景下曾恒为 unknown(Claude Code 2.1.288,2026-10-02 修复)。Nexus 的 RemoteRuntime 恰恰运行在同形态(非交互 / 类 SDK,见 Ch12)。因此把这个 span 作为参考时,须同时确认”权限来源”与”决策结果”两个属性在你的运行形态下是否会退化为 unknown——若会,则该 span 只能度量”等待时长”,不能度量”为何被拦”。