
dsh一次任务跑完$DSH_HOME/sessions/里只留下一份 zstd 压缩的 JSONL事件都在但哪一轮推理烧掉了多少 Token、哪个工具把墙钟时间吃掉了、这次失败是模型侧还是工具侧仍然要人工翻流。这篇把复盘拆成三段可跟做的动作先去 TaoTokenhttps://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_contentdsh_trace_intro取 Key、把模型出口的 Base URL 设为https://taotoken.net/api再从会话事件流里还原出任务 Trace 树与模型调用 Span 列表最后做一张 Token 消耗节点对照表把花了多少钉到具体节点上。先说清定位差异。DSH 自带的轨迹视图解决的是本机、单会话、实时看它按轮次列出执行记录也能看到单条记录的用量与耗时。可一旦要回答昨天这批任务的 P95 首 Token 延迟是多少这条长会话的 Token 集中在第几轮三天前那次中断是在哪一步断的本机视图就不够了——事件流是按时间排的序列没有父子关系也没有各层的时间占用统计。本文不引入额外平台只做一件事把这批本地数据加工成带层级、带区间、带用量归属的复盘材料而模型侧的统一出口交给 TaoToken。1. 先把模型出口切到 TaoTokenBase URL 与 Key 的最小改动复盘的前提是链路数据里能读到稳定的model与usage字段。如果一次任务里模型请求散落在多个供应商、多个 Key 上Span 列表里的模型名会对不上账。所以第一步是收敛出口。1.1 取 Key 与端点约定到 TaoToken 控制台创建 API Keykey 形如YOUR_API_KEY的占位形式换成你自己的真实值即可。控制台入口https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_contentdsh_trace_key 。统一的基础地址是https://taotoken.net/api注意这个地址是给工具配置用的不要在后面拼接额外路径再写进配置项。OpenAI 兼容客户端一般会自己在 Base URL 后补/v1/chat/completions之类的路径如果你手写 curl建议先确认控制台文档里给出的完整端点形状再决定是否补/v1。1.2 DSH 侧环境变量优先DSH 的模型适配器、工具集、沙箱策略都是按 profile 装配的插件模型出口通常由某个适配器插件负责。多数 OpenAI 兼容适配器会读取标准环境变量因此最省事的做法是先把下面三个变量注入到启动dsh的那个 shell 里# ~/.zshrc 或 ~/.bashrc写入后重开终端 export TAOTOKEN_API_KEYYOUR_API_KEY export OPENAI_API_KEY$TAOTOKEN_API_KEY export OPENAI_BASE_URLhttps://taotoken.net/api这样做的价值在于无论你后面用dsh的 web、tui 还是 headless 形态只要它们共享同一个内核与环境模型出口就是同一个。1.3 DSH 侧profile 显式配置如果本机的适配器插件不认OPENAI_*环境变量或者你需要在同一个 profile 里挂多套模型就得落到 profile 的 patch 文件里。路径与命名沿用 DSH 的约定$DSH_HOME/profiles/profile/cordis.patch.yml未设置DSH_HOME时默认在~/.dsh/profiles/profile/cordis.patch.yml。# $DSH_HOME/profiles/default/cordis.patch.yml # 说明插件 id 与字段名请以本机已启用的模型适配器为准 # 下面给出的是 OpenAI 兼容适配器最常见的三段式形态。 - id: model-adapter-openai config: baseURL: https://taotoken.net/api apiKey: ${TAOTOKEN_API_KEY} # 若该插件不支持变量插值改用环境变量注入 models: - id: deepseek-chat alias: fast - id: deepseek-reasoner alias: deep改完重启 DSH 服务使配置生效。显式配置的优先级高于环境变量所以两处都写了的情况下以 patch 文件为准——排查我明明改了环境变量还是不生效时先看这里。1.4 十秒钟验证出口是否切换成功在正式跑任务之前先用一条最小的对话请求确认 Key 与端点可用curl -sS https://taotoken.net/api/v1/chat/completions \ -H Authorization: Bearer $TAOTOKEN_API_KEY \ -H Content-Type: application/json \ -d { model: deepseek-chat, messages: [{role: user, content: ping}], max_tokens: 16 }返回体里只要能看到choices与usage两段就说明出口通了。特别留意usage.prompt_tokens与usage.completion_tokens——这两个字段正是后面 Span 列表里 Token 归属的原始来源。若返回 401/403先检查 Authorization 头有没有带Bearer前缀若返回 404多半是端点路径补错了层级。2. 复盘第一层把一次 turn 还原成任务 Trace 树DSH 的执行模型是 ReAct 循环一次用户任务叫一个 turnturn 内的每一轮推理 → 调工具 → 观察结果叫一个 step。循环轮数和调用哪些工具由模型运行时决定所以执行结构事前不可知。这也正是为什么不能靠静态代码去推链路只能从事件流里重建。2.1 先摸清事件类型分布别急着写解析逻辑。第一步是确认你本机这份事件流里各类型事件到底叫什么名字——不同版本的事件字段可能叫type、kind或event。export DSH_HOME${DSH_HOME:-$HOME/.dsh} ls -lt $DSH_HOME/sessions/ | head # 任取一个会话文件看前若干行结构不落地直接管道 zstd -d -c $DSH_HOME/sessions/session-id.jsonl.zst | head -n 5你会看到一行一个 JSON 对象字段里有时间戳、事件类型和一些载荷。记住几个关键点时间戳字段名、turn 开始/结束事件名、step 开始/结束事件名、模型请求与响应事件名、usage 挂在哪一层。2.2 一个可直接跑的树重建脚本下面这个脚本做三件事打印事件类型直方图用于确认字段名、按 turn/step 分组、输出带 Token 归属的 Span 列表。存成dsh-trace.mjs用 Node.js 22 以上跑。// dsh-trace.mjs // 用法: node dsh-trace.mjs ~/.dsh/sessions/session-id.jsonl.zst import { createInterface } from node:readline; import { spawn } from node:child_process; const file process.argv[2]; if (!file) { console.error(用法: node dsh-trace.mjs session.jsonl.zst); process.exit(1); } const raw []; const proc spawn(zstd, [-d, -c, file]); createInterface({ input: proc.stdout, crlfDelay: Infinity }) .on(line, (line) { const s line.trim(); if (!s) return; try { raw.push(JSON.parse(s)); } catch { /* 截断行直接跳过 */ } }) .on(close, main); const get (o, keys, dflt) { for (const k of keys) { const v o?.[k]; if (v ! undefined v ! null) return v; } return dflt; }; const tsOf (e) Number(get(e, [ts, timestamp, time, created_at], 0)); const typeOf (e) String(get(e, [type, kind, event], unknown)); const usageOf (e) { const u get(e, [usage, token_usage, tokens], {}) || {}; return { input: Number(get(u, [prompt_tokens, input_tokens], 0)), output: Number(get(u, [completion_tokens, output_tokens], 0)), }; }; function main() { raw.sort((a, b) tsOf(a) - tsOf(b)); // 1) 事件类型直方图字段名对不上时先看这里再改正则 const hist new Map(); for (const e of raw) hist.set(typeOf(e), (hist.get(typeOf(e)) || 0) 1); console.log( 事件类型分布 ); for (const [k, v] of [...hist].sort((a, b) b[1] - a[1])) { console.log(${k.padEnd(38)} ${v}); } // 2) 重建层级并累加用量 const spans []; let turnIdx -1; let stepIdx 0; let turnTok { input: 0, output: 0 }; let stepTok { input: 0, output: 0 }; const flushStep () { if (stepIdx 0) { spans.push({ level: step, turn: turnIdx, step: stepIdx, tokens: { ...stepTok } }); stepTok { input: 0, output: 0 }; } }; const flushTurn () { if (turnIdx 0) { spans.push({ level: turn, turn: turnIdx, tokens: { ...turnTok } }); turnTok { input: 0, output: 0 }; } }; for (const e of raw) { const t typeOf(e); const u usageOf(e); if (/turn.*(start|begin)/i.test(t)) { flushStep(); flushTurn(); turnIdx 1; stepIdx 0; } else if (/step.*(start|begin)/i.test(t)) { flushStep(); stepIdx 1; } else if (/(chat|completion|llm|response)/i.test(t) (u.input || u.output)) { turnTok.input u.input; turnTok.output u.output; stepTok.input u.input; stepTok.output u.output; spans.push({ level: chat, turn: turnIdx, step: stepIdx, ts: tsOf(e), model: get(e, [model, model_name], unknown), finish: get(e, [finish_reason, stop_reason], -), attempt: get(e, [attempt, retry_count, retry], 1), input: u.input, output: u.output, }); } else if (/(tool).*(end|finish|result|done)/i.test(t)) { spans.push({ level: tool, turn: turnIdx, step: stepIdx, ts: tsOf(e), name: get(e, [tool, tool_name, name], unknown), ms: get(e, [duration_ms, elapsed_ms, ms], 0), error: get(e, [error, error_code], null), }); } } flushStep(); flushTurn(); // 3) 打印先树、后 Span 列表 console.log(\n Trace 树缩进即父子关系 ); for (const s of spans.filter((x) x.level ! chat x.level ! tool)) { if (s.level turn) { console.log(turn#${s.turn} tokens in/out ${s.tokens.input}/${s.tokens.output}); } else { console.log( └─ step#${s.step} tokens in/out ${s.tokens.input}/${s.tokens.output}); } } console.log(\n 模型调用 Span 列表 ); for (const c of spans.filter((x) x.level chat)) { console.log( turn#${c.turn} step#${c.step} attempt${c.attempt} model${c.model} finish${c.finish} in${c.input} out${c.output} ); } }跑起来node dsh-trace.mjs $DSH_HOME/sessions/session-id.jsonl.zst输出里你会得到三块东西事件类型分布、带 Token 累加的树、以及模型调用明细。如果事件类型分布里出现了你正则没覆盖的名字直接改那几个正则即可——脚本刻意把字段名探测和层级重建分开就是为了让这一步可调。2.3 树要满足的两个硬条件好的复盘材料必须满足两个条件否则后面所有聚合都是错的父子关系明确。一次 turn 对应一条链路turn 内的 step 是它的子节点step 内的模型调用与工具调用再往下一层。多轮对话之间用会话 ID 横向关联而不是硬塞进同一条无限膨胀的链路里——长会话一旦塞进单链路任何按耗时的排序都会失真。时间区间完整。每个节点都要有开始与结束。流式响应没正常收尾、step 先于工具结束、用户手动 CtrlC这些场景都必须在数据里留痕。如果你在事件流里看到有 chat 开始、没有 chat 结束那就要在解析时补一个带错误状态的节点而不是静默丢弃——丢掉的正是复盘时最想看的那个失败。3. 复盘第二层模型调用 Span 列表该有哪些字段树负责看得清结构Span 列表负责查得到细节。下面这张表可以直接当核对清单用跑完一次任务逐行确认字段有没有值。字段含义常见来源缺失时怎么办gen_ai.session.id会话标识用于跨 turn 关联会话文件元信息用文件名兜底dsh.turn.index第几轮任务turn 开始事件序号按时间排序自增dsh.step.index轮内第几步step 开始事件序号按时间排序自增dsh.llm.attempt第几次真实调用重试计数请求事件或重试标记默认 1重试场景必修gen_ai.request.model请求的模型名请求体model从 profile 配置回填gen_ai.response.model实际返回的模型名响应体model与请求名不一致时以响应为准gen_ai.usage.input_tokens输入 Tokenusage.prompt_tokens流式场景下看末块gen_ai.usage.output_tokens输出 Tokenusage.completion_tokens同上gen_ai.response.finish_reasons结束原因choices[].finish_reason缺失即视为中断调用耗时单次调用墙钟请求与响应时间戳差用首末事件时间差首 Token 延迟TTFT首个流式分块时间戳非流式调用可留空有两个坑值得单独说。重试不要合并。一次任务里模型调用失败重试是常态。每次真实发生的请求都要生成独立节点并用dsh.llm.attempt这类序号标记。如果你把三次重试合并成一个节点、耗时取总和、用量取末次那这次任务为什么慢的答案就永远找不出来了——慢的往往正是前两次失败的等待。结束原因要原样保留。finish_reason是stop、length还是缺失直接决定这次是正常结束、被截断还是流没走完。很多Token 消耗异常的案子本质是length截断导致模型没输出完整结果Agent 又发起了一轮补救调用。4. 复盘第三层Token 消耗节点对照表有了树和 Span 列表最后一步是把用量钉到节点上。做法很朴素自底向上累加。chat 节点上的输入/输出 Token向上分别归入所属 step 和所属 turn。层级节点类型Token 归属规则复盘时回答的问题L0会话该会话全部 turn 之和这段会话总共花了多少L1turn该 turn 内所有 chat 之和哪一轮任务最贵L2step该 step 内所有 chat 之和贵在推理还是贵在反复试错L3chat单次请求的usage原值是不是某次重试烧掉的L4tool不计 Token只计耗时与状态慢是慢在工具还是慢在模型实际复盘时这张表最常用的三种看法按 turn 排序看头部。把 turn 按输出 Token 降序排通常前 10% 的 turn 吃掉一半以上的量。看看它们是不是同一个任务类型、同一个模型。按 step 看分布。如果一个 turn 里 step 数量明显偏多而单次 chat 用量很小典型症状是模型在反复调工具但没收敛问题在工具描述或提示词不在模型规格。按 chat 看重试。把attempt 1的节点单独拉出来统计。重试率高说明上游不稳定或请求体触发限流这时候要看的不是用量而是错误码分布。如果你还想把这份数据补齐成一个可长期留存、可跨机聚合、可配告警的形态思路就是把上述字段映射到 OpenTelemetry GenAI 语义约定gen_ai.*命名空间各层节点带上独立状态与错误类型再统一上报到支持 Traces / Spans / Sessions 多视角检索的后端。这样三天前那次的 Trace ID才真的能被搜出来而不是靠翻本机文件。5. 三类高频异常的 Span 长相复盘的价值在于把感觉慢变成知道哪一层慢。下面三类是最常遇到的。第一类401 / 403一个 Span 都没有。说明请求根本没出网关。检查顺序环境变量有没有注入到启动dsh的那个 shellprofile patch 里的apiKey有没有被正确插值Authorization 头的前缀是否完整。这类问题不会在 DSH 的会话轨迹里留下明显痕迹最容易被误判成框架不工作。第二类有 chat 节点但没有结束原因用量为 0。典型的流式中断。表现为树上有节点节点上output_tokens是 0 或缺失finish_reason为空。排查动作是在解析脚本里把这一类显式标成status error而不是让它们混进成功节点里拉低平均耗时。第三类step 数量爆炸Token 反而正常。单次调用都很小但一个 turn 里几十个 step。这种时候耗时的大头在工具执行和往返等待上。做法是把 tool 节点的耗时单独汇总和 chat 节点的耗时做占比对照。工具耗时占比超过一半就该去看工具本身的实现或调用频率而不是加模型预算。6. 顺手把 Claude Code 与 Codex 也指向同一个出口既然已经在 TaoToken 上统一了模型出口同一台机器上的其他编码工具也建议一并收敛避免复盘时口径不一致。Claude Codesettings.json{ env: { ANTHROPIC_BASE_URL: https://taotoken.net/api, ANTHROPIC_AUTH_TOKEN: YOUR_API_KEY, ANTHROPIC_MODEL: claude-sonnet-4-5 } }注意 Claude Code 走的是ANTHROPIC_*系列变量这三个不要和下面的 Codex 配置混用。Codexconfig.toml# ~/.codex/config.toml model gpt-5 model_provider taotoken [model_providers.taotoken] name TaoToken base_url https://taotoken.net/api env_key TAOTOKEN_API_KEY wire_api chatCodex 用的是OPENAI体系与model_providers表把ANTHROPIC_*变量写进这里不会生效——这是最常见的配置串台问题。CC Switch 三件套如果你用 CC Switch 做多套配置切换每个 profile 只需要填齐三项即可快速切走Base URL、API Key、Model供应商名可自定义。三件套填完切换供应商就不用改任何工具源码。7. 一次完整的复盘走法把前面的东西串成一条可执行的路径注入TAOTOKEN_API_KEY与OPENAI_BASE_URL重启 DSH 服务。用一条 curl 确认usage字段正常返回。跑一个真实任务让 Agent 至少完成一次模型调用与一次工具调用。找到对应的会话文件跑dsh-trace.mjs先看事件类型分布。对不上的事件名调整脚本里的正则重跑。拿到 Trace 树确认每一层都有开始与结束异常节点手动补状态。导出模型调用 Span 列表逐个核对第 3 节表格里的字段。生成 Token 消耗节点对照表按 turn 排序看头部按 step 看分布按 attempt 看重试。把结论写回任务记录这次慢在哪一层、贵在哪个节点、失败属于模型侧还是工具侧。做到第 9 步你手上就不再是一份事件很多但说不清楚的 JSONL而是一份能直接拿去过评审的复盘材料。需要实际对照模型返回字段时可以打开模型对话页https://taotoken.net/models/detail/chat?utm_sourcetaotoken_aicg_blog_endutm_contentdsh_trace_chat 发一轮同样的提示词看usage与finish_reason的真实取值。如果团队里跑 Agent 的机器不止一台或者需要把用量做成按周的成本报表那就该考虑把出口和额度一起管起来。Coding Planhttps://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentdsh_trace_plan 适合把编码类任务集中到一套额度下避免多台机器各拿一把 Key 导致对账困难。新增或轮换 Key 在 API Keys 页面https://taotoken.net/console/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentdsh_trace_keys 完成建议按机器或按项目拆 Key这样复盘时哪个项目的用量异常可以直接从 Key 维度切出来。Claude Code 侧的完整环境变量说明在文档页https://taotoken.net/doc/ClaudeCodeAnthropic?utm_sourcetaotoken_aicg_blog_endutm_contentdsh_trace_cc 配好之后再和 DSH 一起跑同一批任务两边的 Trace 口径就能对齐了。最后提醒一句本文所有解析脚本与配置都在本地执行、本地读取不要把它们接到生产库或线上环境上跑YOUR_API_KEY也不要提交进代码仓库用环境变量或密钥管理工具注入。