使用 OpenTelemetry 观测并优化 Agent

Agent 的可观测性是一个非常重要的方向。过去一段时间,对于开发者而言,Coding Agent 的运行过程更像是一个“半透明的黑盒”——我们在终端或 IDE 里输入一句需求,看着屏幕上的工具调用和思考过程不断滚动,最后拿到代码产出。至于中间到底发起过多少次大模型请求、每次工具调用都做了什么、Subagent 是怎么运行的……往往只能去分析那晦涩的 session 文件。

2026 年 10 月 14 日,Anthropic 发布 Claude Code 2.1.300 版本,在 OpenTelemetry(OTel)中增加了 GenAI semantic-convention 信息,正式补齐了对 OpenTelemetry(OTel)的完整原生支持。

  • Added OpenTelemetry GenAI semantic-convention attributes to trace spans, such as gen_ai.provider.name, gen_ai.conversation.id, gen_ai.tool.name and error.type

在 OTel 的加持下,Claude Code 可以将每一次用户交互(interaction)、每一轮模型推理(llm_request)、每一次工具调用的三段式生命周期(tool →\rightarrow blocked_on_user →\rightarrow tool.execution)、后台异步派生的 Sub-Agent,以标准 OTLP 协议导出为完整的、结构化的 Span 调用树与事件流。

OTel 中的 GenAI semantic-convention 不仅是“多了一个看 Token 账单的监控大盘”,而是彻底改变了我们调试与优化 Agent 的工程范式:

  • Agent Trajectory 还原:我们可以像剖析分布式微服务一样,在毫秒级精度上看清多 Agent 协同中的每一步操作
  • Agent RSI(Recursive Self-Improvement):当 Agent 执行时的所有 Trace、Log 落盘 DuckDB 后,我们可以直接让 Agent 来分析任务执行 Trace、Trajectory,并精准揪出隐形的权限审查耗时(auto_mode)、死循环排查、工具报错拦截以及编排流程中的冗余环节,形成 “任务执行 →\rightarrow OTel 采集 →\rightarrow 瓶颈归因 →\rightarrow Agent RSI →\rightarrow Trace 闭环验证” 的 Looping Engineering。

1. 为 Claude 增加 OTel

安装 otel-desktop-viewer

otel-desktop-viewer 是一个编译好的 golang 单文件二进制程序,内置了 OTLP gRPC/HTTP 接收器、DuckDB 数据库和前端 Web 界面。

1
2
3
$ brew tap ctrlspice/homebrew-otel-desktop-viewer
$ brew trust ctrlspice/otel-desktop-viewer
$ brew install otel-desktop-viewer

启动 otel-desktop-viewer:

1
$ nohup otel-desktop-viewer --host 0.0.0.0 --db otel >otel.log 2>&1 &

配置 Claude Code

为了让 OTel 既能拿到结构化 Span 耗时,又能拿到完整的 Prompt、Assistant 回复与工具输入输出详情,我们在 ~/.claude/settings.json 的 env 中开启全量遥测开关,并通过 OTLP gRPC 把相关数据发送到本地的 otel-desktop-viewer:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"env": {
"CLAUDE_CODE_ENABLE_TELEMETRY": "1",
"CLAUDE_CODE_ENHANCED_TELEMETRY_BETA": "1",
"OTEL_TRACES_EXPORTER": "otlp",
"OTEL_METRICS_EXPORTER": "otlp",
"OTEL_LOGS_EXPORTER": "otlp",
"OTEL_EXPORTER_OTLP_PROTOCOL": "grpc",
"OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4317",
"OTEL_SERVICE_NAME": "claude",
"OTEL_LOG_USER_PROMPTS": "1",
"OTEL_LOG_ASSISTANT_RESPONSES": "1",
"OTEL_LOG_TOOL_DETAILS": "1",
"OTEL_LOG_TOOL_CONTENT": "1",
"OTEL_LOG_RAW_API_BODIES": "1"
}
}

重启 Claude Code,然后输入 hello,我们就能够在 otel-desktop-viewer 的 WebUI 中看到这条 Trace。

2. OpenTelemetry 优化 Agent 实践

在 面向 Coding Agent 的多仓库 Git Worktree 中介绍了我们如何在真实项目中构建 7-Agent 全自动研发流水线 /teamwork-preview,并用于开发真实的大模型评测平台。利用 /teamwork-preview,我们可以非常简单的开发一个需求:

需求指令:/teamwork-preview 把 /leaderboard 页面的title 从“模型排行榜”改为“模型榜单”。

其实,我们之前就有个问题:类似如上这种非常简单的需求,如果采用 /teamwork-preview,即便每次就改几行代码,也需要耗时 70 分钟走整个 workflow。有了 OpenTelemetry 的加持,我们可以用 Agent 来更方便的分析了。

通过如上的 Trace 分析,我们可以直观的感知到:实际代码改动量仅 versus-fe/src/views/LeaderboardView.tsx 第 416 行的 1 行静态文案替换,从<h2>模型排行榜</h2> →\rightarrow <h2>模型榜单</h2>。

然而,就是这 1 行文字修改,整个 7-Agent 流水线足足跑了 69 分 48 秒,期间触发了 319 次 LLM 请求、220 次工具调用,账单消耗 $12.75 USD!

为什么改 4 个中文字要花 70 分钟?时间到底是被哪个 Subagent、哪条命令吃掉的?带着这个问题,我们让 Agent 来继续分析,并给出优化方案:

从 traceId = “bdc8ba25d44671bf0d6016bb9b249345” 关联的任务中,提取出对应的 trajectory,方便分析agent是如何完成任务的,并看下有没有可优化的地方。

如上图所示,该次任务的全链路 10 条 Trace 的量化 Trajectory 全景表如下:

阶段 (seq) Trace ID 执行主体(Main + Subagent) 阶段总耗时 LLM 调用总数 其中 auto_mode 次数(耗时) 工具调用数 失败工具数 API 成本 (USD)
1 bdc8ba25d44671bf… Main + [0] worktree-manager 305.05s 33 15 次 (95.71s) 23 0 $1.02
2 cab8cae24e340e48… Main + [1] requirement-designer 355.07s 17 3 次 (10.27s) 17 2 $1.13
3 a6ce52cdb8a43c98… Main(展示 PRD 并暂停等确认) 16.86s 2 0 次 (0.00s) 1 0 $0.14
4 c2332ec994f98431… Main + [2] go-api-implementer 172.90s 23 10 次 (43.31s) 15 0 $0.79
5 6adce17a881d344f… Main + [3] frontend-engineer 203.64s 24 10 次 (66.51s) 19 0 $1.03
6 b5f72a505a40ed26… Main + [4] test-case-designer 530.18s 35 6 次 (53.09s) 31 1 $2.24
7 7ab7a31c38dc90b1… Main + [5] integration-test-runner 364.24s 52 19 次 (84.00s) 36 1 $1.71
8 e1d35dbd7f784c34… Main + [6] e2e-test-executor 2154.35s 132 55 次 (342.48s) 78 6 $4.48
9 efa18f5746579777… Main(汇总全流水线完成报告) 16.41s 1 0 次 (0.00s) 0 0 $0.22
10 5e3126a748df1bfb… Main(<task-notification> 收尾) 0.01s 0 0 次 (0.00s) 0 0 $0.00
合计 10 条关联 Trace 1 主 Agent + 7 子 Agent 4158.71s (~69.3m) 319 次 118 次 (695.38s / 11.6m) 220 次 10 次 $12.75

真正修改业务代码的操作发生在 阶段 5(seq=5)的 t+60.5s:[3] frontend-engineer 调用 Edit 工具将 LeaderboardView.tsx:416 的 模型排行榜 改为 模型榜单,这个工具调用本身只花了 10 毫秒!

1
2
3
4
5
6
7
8
seq=5, 203.64s: [3] frontend-engineer 修改 1 行代码并执行代码审查

1. Main Agent (t+0s ~ t+44.3s): 派生 [3] frontend-engineer (agent_id = a85738feac3d908e1)。
2. [3] frontend-engineer (t+35.9s ~ t+203.6s, 耗时 167.7s):
- t+44.2s ~ t+50.1s: 并行 Read 读取 PRD.md、codebase-survey.md、api-summary.md、versus-fe/CLAUDE.md 和 LeaderboardView.tsx:400-435。
- t+60.5s: Edit 将 LeaderboardView.tsx:416 的 模型排行榜 改为 模型榜单(全任务唯一业务代码修改,仅耗时 10ms)。
- t+78.5s ~ t+130.6s: 因新 worktree 下无 node_modules,执行 npm ci (4.08s) → npx tsc --noEmit (9.77s) → npm run build (8.82s)。
- t+146.4s ~ t+182.1s: 调用 Skill(code-review-pipeline), 读取审查清单并写出 frontend-review.md。

Gemini 给出了优化方案,仔细 review 这些方案后,我们发现,这些建议真的是切实可行的方案。

问题1:流水线缺乏“轻重分级

明明 [1] requirement-designer 在 codebase-survey.md 中已经 100% 确认“本需求仅修改 LeaderboardView.tsx:416 一行文字,后端零改动”,但流水线依然机械地启动了:

  • [2] go-api-implementer(花 173s 重新 grep 一遍后端代码,写文档证明后端没改);
  • [4] test-case-designer(花 530s 精读 Go 源码与错误码,为完全没改动的后端接口设计了 7 条 API 用例 + 6 条重交互 E2E 用例);
  • [5] integration-test-runner(花 364s 编译启动 Go 服务,并把 7 条 curl 拆成 7 次独立的 Bash 串行调用,每次 curl 执行仅 0.1s,但等 7 次 LLM + auto_mode 花了 85s)。

问题2:auto_mode 权限分类器吃掉 37% 的 LLM 请求

在 OTel 中按 query_source 属性对全部 319 次 claude_code.llm_request 进行分组聚合,结果令人大跌眼镜:

query_source 类型 调用次数 占比 累计耗时 输入 Token 缓存读取 Token 输出 Token 说明
agent.custom 179 56.1% 2438.22s 882,883 8,395,264 104,924 7 个 Subagent 的业务推理
auto_mode 118 37.0% 695.38s 231,902 5,096,320 37,486 每次执行 Bash/工具前的安全权限分类器推理!
sdk 22 6.9% 151.65s 90,381 1,713,408 8,206 主 Agent 的编排推理
xychart-beta
    title "各阶段 LLM 总调用数 (左) vs auto_mode 调用数 (右)"
    x-axis ["seq1", "seq2", "seq4", "seq5", "seq6", "seq7", "seq8"]
    y-axis "调用次数" 0 --> 140
    bar [33, 17, 23, 24, 35, 52, 132]
    bar [15, 3, 10, 10, 6, 19, 55]

接近四成(118 次)的 LLM 请求根本不是在写代码或跑测试,而是 Claude Code 在执行 Bash 命令前调用 auto_mode 分类器判断“这条命令安不安全”! 平均每次 auto_mode 审查都要额外阻塞 5.89 秒,累计白白烧掉了 11.6 分钟、超过 530 万缓存 Token。

为什么配了 .claude/settings.json,还会触发 118 次 auto_mode?

复合命令的“木桶效应”

Claude Code 在校验一条 Bash 命令时,会先将 Shell 命令解析为 AST 语法树,按 &&、||、;、| 拆分为多个子命令。必须每一个子命令都命中 permissions.allow,整条命令才会静态放行,只要有 1 个辅助子命令不在白名单中,整条命令就会回退给 auto_mode 分类器。

从 Trace 中可以看到 Agent 实际跑的命令:

Trace 中的实际命令(均触发了 auto_mode) 已命中的白名单项 导致穿透到 auto_mode 的未授权子命令
cd /Users/.../versus-fe && npx tsc --noEmit (seq=5) Bash(npx:*) ❌ 缺少 Bash(cd:*)(全流程有 30+ 条命令以 cd ... && 开头!)
echo "=== API-002 ===" && curl -s -m 10 ... (seq=7) Bash(curl:*) ❌ 缺少 Bash(echo:*)(导致 7 条 API-001~007 的 curl 全部触发 auto_mode)
lsof -i :9002 -i :4002 2>/dev/null | head -5; echo "---exit $?" (seq=1) Bash(lsof:*) ❌ 缺少 Bash(head:*) 和 Bash(echo:*)
sleep 8; lsof -i :9002 -P -n | head -5; tail -5 ... (seq=7) Bash(lsof:*) ❌ 缺少 Bash(sleep:*)、Bash(head:*)、Bash(tail:*)

“同义不同名”与通配符语法错位

配了 Bash(rg:*),但 Agent 全程在用 grep(25+ 次):settings.json 只放行了 rg,而 7 个 Subagent 的内置习惯是调 grep -rn,导致 25+ 次代码搜索全部走 auto_mode。

配了 Bash(*.sh *),但 Agent 调的是 bash scripts/wt.sh ... 或无参 ./scripts/sync-huashu-design.sh:

  • bash scripts/wt.sh list 的首词是 bash,不匹配 *.sh *;
  • ./scripts/sync-huashu-design.sh 后面没有空格和参数,也不匹配 *.sh *(末尾带了 * 要求必须有参数)。

高频命令漏配:sed(15+ 次)、node(12+ 次)、cat、find、which、nc、mv 均未在白名单中。

漏配 Edit(**)、Write(**) 与 EnterWorktree

原配置只写了 "Read(**)",没有写 "Edit(**)"、"Write(**)" 和 "EnterWorktree"。导致每次写 PRD.md、api-test-cases.json、run-e2e.cjs 都要走一次 auto_mode,而 EnterWorktree 更是直接弹窗阻塞了 132 秒。

问题3:e2e-test-executor 占用 50% 的耗时

为什么一个验证页面标题从“模型排行榜”变成“模型榜单”的 E2E 测试,要跑 36 分钟、132 次 LLM 请求?提取 seq=8 的 Trajectory 后,我们发现 Agent 在这 36 分钟里连踩了 4 个坑:

flowchart LR
    A["坑 1 (6.9 min)
grep && make start 短路乌龙,误查 9002 残留进程 17 次"] --> B["坑 2 (3.5 min)
缺少本地 Playwright 依赖,盲翻 npm 缓存 10 次"] B --> C["坑 3 (0.9 min)
变量拼接 node 脚本路径,连续 3 次撞上 Worktree 安全守卫"] C --> D["坑 4 (11.8 min)
手写 E2E 脚本存在 2 处假阴性 Bug,反复调试重跑 4 轮"]
  • 坑 1 —— 端口切换 && 短路乌龙与残留进程排查(耗时 ~6.9 分钟):
    • 上一棒 [5] integration-test-runner 在 9002/4002 端口跑完接口测试后,没有关闭 9002 上的后端进程(PID 56855)。
    • [6] e2e-test-executor 要求改用 9003/4003 端口。它用 sed 修改了 configs/versus.toml,随后执行了一条组合命令:grep 9003 output/configs/versus.toml && make start。
    • 由于 output/configs/versus.toml 只有在编译后才会更新,grep 未命中返回退出码 1,导致 make start 根本没执行。
    • Agent 看到 9002 依然在监听而 9003 没起来,误以为“Go 后端配置加载逻辑有 Bug,改了 toml 依然绑定 9002”,于是连发 17 次 Bash 去翻查 load.go、Makefile 和进程启动时间,直到 t+338s 才发现真实原因。
  • 坑 2 —— 盲找 Playwright 与 Chromium 路径(耗时 ~3.5 分钟):versus-fe 的 node_modules 下未声明 playwright,Agent 花了 10 次 Bash(伴随 2 次报错)去 ~/.npm/_npx/e41f203b7505f1fb/ 缓存目录和 ~/Library/Caches/ms-playwright 里硬编码寻找依赖路径。
  • 坑 3 —— Worktree 隔离安全守卫连续 3 次拦截脚本执行(耗时 ~1 分钟):Agent 写好测试脚本 run-e2e.cjs 后,连续 3 次尝试用 RUN_DIR="..." node "$RUN_DIR/run-e2e.cjs" 启动,全部被 Claude Code 的 Worktree 隔离安全守卫 当场拦截报错(status_code = 2)。

    This session is isolated in the worktree …, but this command runs node with a script computed at runtime (the variable RUN_DIR), so what it runs cannot be shown not to be git. Refusing to run it.

  • 坑 4 —— 临时手写 Playwright 脚本的 2 个假阴性 Bug 与 4 轮重跑(耗时 ~11.8 分钟):
    • 首轮运行 run-e2e.cjs 报了 2 个错:E2E-001 找不到奖杯图标、E2E-003 切换 t2i 下拉框没捕获到网络请求。
    • Agent 排查前端源码后发现全都是自己临时写的测试脚本有 Bug:一是奖杯图标是 <h2> 父节点的兄弟而非 <h2> 的直接兄弟;二是页面默认模态就是 t2i,脚本用 indexOf 匹配到了初次加载的请求。为此反复改脚本重跑了 4 轮浏览器测试。

workflow 优化

在 teamwork-preview 编排流中增加需求大小的拆分逻辑,避免所有需求都执行完整的全部流程。

重新执行相同的任务,根据 otel-desktop-viewer 的 Traces 分析可以看到,优化已经生效。

核心观测维度 优化前基线(bdc8ba25…) 优化后实测(831aeb9e…) 优化幅度
端到端总耗时 4,158.71 s(~69 分 19 秒) 102.51 s(1 分 42.5 秒) 提速 40.6 倍
产生的独立 Trace 数 10 条(seq=1…10) 1 条(seq=4) -90.0%
OTel Span 总数 989 个 25 个 -97.5%
LLM 请求总次数 319 次 6 次(5 次主模型 + 1 次 auto_mode) -98.1%
auto_mode 权限分类器调用 118 次 1 次 -99.2%
auto_mode 累计请求耗时 695.38 s(11 分 35 秒) 1.88 s(0ddbed5e4e85ddb1) -99.7%
工具调用次数 220 次 6 次(Read×2、Bash×3、Edit×1) -97.3%
工具报错/安全拦截次数 10 次(含 5 次 Worktree 拦截) 0 次 -100%
中途人工弹窗/审核打断 3 次(累计阻塞 215.6 s) 0 次(全程无人值守一气呵成) -100%
Token 消耗(输入/缓存/输出) 1.20M / 15.20M / 150.6K 51.4K / 186.9K / 2.67K -95.7%~-98.8%
估算 API 成本(USD) $12.75 $0.25 -98.0%

auto_mode 优化

重新优化 setting.json 后,我立即执行了一次包含多阶段脚本调用与复合 Git 命令的任务(/push-all-repos versus-49),并在 OTel Viewer 中抓取了对应的 Trace:

flowchart LR
    Root["interaction (9e3b57d0, 81.05s)"]

    Root --> LLM1["1. llm_request (d175a00a, 3.61s)"]
    Root --> Tool1["2. tool #1: commit.sh -n (526c3f69, 639.1ms)"]
    Root --> LLM2["3. llm_request (92679fde, 25.93s, Thinking)"]
    Root --> Tool2["4. tool #2: 6段复合 git/echo/head (b9d0a0c9, 111.7ms)"]
    Root --> LLM3["5. llm_request (2ea37091, 7.35s)"]
    Root --> Tool3["6. tool #3: cd versus-server && git (e0087602, 7.73s)"]
    Root --> LLM4["7. llm_request (e6e6b206, 28.29s, Thinking)"]
    Root --> Tool4["8. tool #4: cd 主目录 && commit.sh (888dc34b, 1.60s)"]
    Root --> LLM5["9. llm_request (38f91f83, 5.12s, 总结汇报)"]

    Tool1 --> B1["✅ blocked_on_user: 6.44 ms (0 次 auto_mode)"]
    Tool1 --> E1["tool.execution: 632.54 ms"]

    Tool2 --> B2["✅ blocked_on_user: 25.91 ms (6 段复合静态放行)"]
    Tool2 --> E2["tool.execution: 85.65 ms"]

    Tool3 --> B3["⚠️ blocked_on_user: 7,662.77 ms"]
    B3 --> AutoLLM["llm_request [auto_mode] (cc4ccf55): 7,601.45 ms"]
    Tool3 --> E3["tool.execution: 68.34 ms"]

    Tool4 --> B4["✅ blocked_on_user: 7.50 ms (静态放行)"]
    Tool4 --> E4["tool.execution: 1,590.40 ms"]

对比这 4 次 Bash 工具调用的权限检查耗时(claude_code.tool.blocked_on_user):

工具调用序号 claude_code.tool Span blocked_on_user Span 是否触发 auto_mode 子 Span? 权限检查耗时 命令实际执行耗时 执行的实际 Bash 命令
#1
(t+3.89s)
526c3f695282f127 33da24d935917fc0 无 ✅ 6.44 ms 632.54 ms ./scripts/commit.sh -m "预览" -n
#2
(t+30.45s)
b9d0a0c9708c2bda 4f3db3b1b29444b1 无 ✅ 25.91 ms 85.65 ms git status --short && echo "---" && git diff HEAD -- versus-server && echo "---settings.json diff---" && git diff HEAD -- .claude/settings.json | head -60
#3
(t+37.94s)
e008760294483512 b2f2e3f4a51dd0d8 有 1 个 ⚠️
(cc4ccf55b0f0aa94)
7,662.77 ms 68.34 ms cd versus-server && git log --oneline 08f2ede..e2a53f9 2>/dev/null | head -5 && echo "---server 状态---" && git status --short | head -10
#4
(t+73.98s)
888dc34b0ac158d3 e54dd3d8aff4a6d1 无 ✅ 7.50 ms 1,590.40 ms cd /Users/.../versus && ./scripts/commit.sh -m "versus-49 ..." -t all

可以看到优化立竿见影:

  • 看工具调用 2(Span 4f3db3b1b29444b1):这是一条由 && 和 | 连接起来的 6 段式复合命令(3 个 git + 2 个 echo + 1 个 head -60)。在优化前,由于缺 echo 和 head,这条命令 100% 会触发 auto_mode 并阻塞 6~8 秒;优化后,Claude Code 静态拆解 6 个子命令全部命中 permissions.allow,权限校验仅耗时 25.91 毫秒!
  • 1、2、4 三次工具调用的权限检查耗时总和仅 39.85 毫秒(0.04 秒),直接省去了近 20 秒的 auto_mode 推理等待。

3. OpenTelemetry 与 Session 文件

Claude Code 默认会在本地 ~/.claude/projects/<project>/<session-id>.jsonl 下保存完整的会话 JSONL 文件。既然这些日志也是交给分析 Agent 自动去读,为什么不直接读原始 .jsonl,而要折腾一套 OpenTelemetry 呢?

在同时对比了 .jsonl 和 OTel Trace 数据后,我发现对于 “分析 Agent 性能瓶颈与任务优化点” 而言,OTel 相比原始 .jsonl 有如下的优点:

对比维度 Claude 原始 .jsonl 会话文件 OpenTelemetry (spans + logs in DuckDB)
工具调用耗时拆解 只有发出 tool_use 和收到 tool_result 的两个离散时间戳,分不清耗时是卡在命令执行、人工弹窗审批,还是权限分类器 原生拆分为三层父子 Span:claude_code.tool →\rightarrow blocked_on_user(权限/人工阻塞)+ llm_request [auto_mode] + tool.execution(真正执行),毫秒级精准归因
隐形旁路 LLM 调用 完全缺失耗时与成本。例如 auto_mode 权限分类器请求不在主对话历史中,.jsonl 仅留一个 "classifierBoundary": true 布尔标记 100% 捕获。所有 query_source = "auto_mode" 的旁路推理请求均作为独立 Span 记录耗时、Token 与费用(本次靠它揪出了 118 次、695.4 秒 的隐形开销)
Subagent 关联视图 多文件碎片化。主 Agent 在 <session>.jsonl,7 个 Subagent 散落在 <session>/subagents/agent-<id>.jsonl 的 7 个独立文件里,需手动遍历对齐时间戳 全局单表视图。主从 Agent 的全部 989 个 Span 自动携带 session.id、agent_id、subagent_type 汇聚在同一张 DuckDB 表中,一条 SQL 拉出全局时间轴
分析 Agent 的 Token 开销 .jsonl 混杂了庞大的 prompt_snapshot(几十 KB 的 Tools Schema)、文件 Read 原文和 thinking,动辄数十 MB,极易撑爆分析 Agent 的 Context Window 支持 “先 SQL 聚合画像(毫秒级),再按 span_id 精准下钻读日志” 的两阶段范式,几百 Token 即可完成 70 分钟长任务的全局体检
成本与错误度量 只有原始 Token 数,无美金成本;需自行正则匹配 is_error: true api_request 事件原生计算 cost_usd;错误 Span 直接标记 status_code = 2 (ERROR)
多 Coding Agent 统一纳管 Claude Code 与 Codex 各自私有 JSONL 格式,分析脚本无法复用 遵循标准 OTLP 协议,Claude Code(service.name=claude)与 Codex(service.name=my-codex)共用同一套 DuckDB SQL 分析体系

4. OTel 的 Trace 机制

在把 7-Agent 任务导入 otel-desktop-viewer 后,我首先遇到了两个反直觉的现象:

  1. 为什么一次 /teamwork-preview 任务没有生成 1 条大 Trace,而是裂变成了 10 条独立的 Trace?
  2. 为什么同一个 session.id 下的 interaction.sequence 字段,有时候连续递增到 11,有时候又突然重置回 1?

为什么后台 Subagent 会触发多条独立 Trace?

在 Claude Code 中,Root Span 的名字叫 claude_code.interaction,它的生命周期严格对应 “主 Agent 的单次交互轮次(Turn)”,而不是整个长程业务任务。

当我们调用 Agent 工具启动一个子 Agent 时,有两种模式:

  • 前台同步模式(run_in_background: false):主 Agent 阻塞等待子 Agent 返回,此时子 Agent 的所有 Span 会挂载在同一条 Trace 下。
  • 后台异步模式(run_in_background: true,Claude Code 默认推荐模式):
    1. 主 Agent 调用 Agent 工具(耗时仅 10~20ms)将子 Agent 放入后台运行,随后主 Agent 向用户输出“已启动某某 Subagent”并立即结束当前 Turn(但因为后台子协程仍在运行并共享当前 Trace 上下文,子 Agent 的 claude_code.agent Span 依然挂载在当前这条 Trace 的 Root Span 下)。
    2. 当后台子 Agent 执行完毕、调用 SendMessage 交接结果时,Claude Code 会向主会话队列注入一条 <agent-message from="agent-xxx"> [Subagent hand-back] ... 消息。
    3. 这条消息会像人类输入一样重新唤醒主 Agent 开启一个新的 Turn,从而生成一条全新的 claude_code.interaction Trace,并在该新 Trace 中派生下一个流水线阶段的 Subagent!

因此,我们的 7-Agent 流水线任务(Starting at traceId = "bdc8ba25d44671bf0d6016bb9b249345")在 OTel 中实际上是由 10 条首尾相接的关联 Trace 组成:

flowchart TB
    T1["Trace 1 · seq=1
主 Agent:76.6s · 校验依赖并启动流水线
异步派生 [0] worktree-manager:231.4s
创建 Worktree 9002/4002"] T2["Trace 2 · seq=2
主 Agent:155.7s · EnterWorktree 切换目录
其中弹窗阻塞 132.0s
异步派生 [1] requirement-designer:204.4s
代码调研并产出 PRD.md"] T3["Trace 3 · seq=3
主 Agent:16.9s
展示 PRD 摘要并暂停等待审核"] APPROVE["人工干预 #3 · 等待确认 77.4s
审阅 PRD 后输入「继续。」"] T4["Trace 4 · seq=4
主 Agent:17.7s · 更新 PRD 状态为已审核
异步派生 [2] go-api-implementer:158.6s
复核后端零改动,产出 api-summary.md"] T5["Trace 5 · seq=5
主 Agent:44.3s · 接收后端结论并派发前端任务
异步派生 [3] frontend-engineer:167.7s
修改 LeaderboardView.tsx:416 + 审查"] T6["Trace 6 · seq=6
主 Agent:21.7s · 接收前端改动并启动用例设计
异步派生 [4] test-case-designer:514.0s
设计 7 条 API + 6 条 E2E 用例"] T7["Trace 7 · seq=7
主 Agent:20.5s · 启动 API 集成测试阶段
异步派生 [5] integration-test-runner:349.8s
启动 9002/4002 并测 7 条 API(PASS)"] T8["Trace 8 · seq=8
主 Agent:27.0s · 启动浏览器 E2E 测试阶段
异步派生 [6] e2e-test-executor:2134.5s / 35.6m
浏览器 E2E 测试 6/6 PASS"] T9["Trace 9 & 10 · seq=9..10
主 Agent:16.4s
输出流水线完成报告并收尾"] T1 -->|Hand-back 唤醒| T2 T2 -->|Hand-back 唤醒| T3 T3 -.-> APPROVE APPROVE -->|开启新 Turn| T4 T4 -->|Hand-back 唤醒| T5 T5 -->|Hand-back 唤醒| T6 T6 -->|Hand-back 唤醒| T7 T7 -->|Hand-back 唤醒| T8 T8 -->|Hand-back 唤醒| T9

interaction.sequence 的重置策略

在关联同一个 session.id 下的 Trace 时,第一反应是用 interaction.sequence == 1 来判断一个新任务的开始。但反编译 claude 二进制中的 startInteractionSpan 源码后,会发现它的真实逻辑非常微妙:

1
2
3
4
5
6
7
8
// claude 二进制中生成 interaction.sequence 的等价逻辑
class InteractionTracker {
lastSequence = 0; // 注意:纯内存变量,不持久化到磁盘 .jsonl!
}
var trackerMap = new HostScopedMap(() => new InteractionTracker());

// 每次创建 claude_code.interaction Span 时:
let seq = ++trackerMap.of(getCurrentHost().host).lastSequence;

也就是说,interaction.sequence 并不是按“业务任务(Task)”重置的,而是绑定在当前客户端内存进程(Host 实例)上的自增计数器:

  • 不关窗口发起新任务,序号不会置 1:比如在上面 10 条 Trace 跑完 15 分钟后,我没有关闭 VS Code 会话窗口,直接敲了一个全新的 /pull-all-repos 命令,它的 interaction.sequence 是 11。
  • 重启窗口恢复老会话(Resume Session),序号会重新置 1:因为 session.id 从磁盘恢复不变,但内存里的 lastSequence 重新从 0 开始加 1。

多 Trace 任务归并策略

在编写自动分析 Agent 时,切勿依赖 interaction.sequence == 1 切分任务,而应该按 start_time 排序后,通过识别 user_prompt 中的 <agent-message from="..."> [Subagent hand-back]、<task-notification> 以及主 Agent 处于等待审批状态后的人类确认输入(如 继续。)构建状态机,将多条 Trace 归并为一个完整的 Task Episode。

5. 数据驱动的 Coding Agent 优化

通过引入 OpenTelemetry 将 Claude Agent 的执行过程白盒化,我们不再靠“感觉哪里慢”去盲目调优,而是建立了一套从 Span 耗时拆解 →\rightarrow 异常拦截归因 →\rightarrow 规则与编排重构的 Loop Engineering。

当 Coding Agent 走向复杂的多 Agent 协同工作流时,可观测性成为 Agent 工程化的分水岭。在 OpenTelemetry 的帮助下,我们不需要手动埋点就能够让 Agent 的每一秒耗时、每一个 Token、每一次工具调用都清晰可见,优化 Agent 也像优化分布式微服务那样直观、确定且充满乐趣。

打赏
  • 版权声明: 本博客所有文章除特别声明外,著作权归作者所有。转载请注明出处!
  • Copyrights © 2020-2026 Wang Wei
  • 本站访问人数: | 本站浏览次数:

请我喝杯咖啡吧~

微信