dsh一次任务跑完,$DSH_HOME/sessions/里只留下一份 zstd 压缩的 JSONL:事件都在,但哪一轮推理烧掉了多少 Token、哪个工具把墙钟时间吃掉了、这次失败是模型侧还是工具侧,仍然要人工翻流。这篇把复盘拆成三段可跟做的动作:先去 TaoToken(https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_content=dsh_trace_intro)取 Key、把模型出口的 Base URL 设为https://taotoken.net/api,再从会话事件流里还原出任务 Trace 树与模型调用 Span 列表,最后做一张 Token 消耗节点对照表,把"花了多少"钉到具体节点上。
先说清定位差异。DSH 自带的轨迹视图解决的是本机、单会话、实时看;它按轮次列出执行记录,也能看到单条记录的用量与耗时。可一旦要回答"昨天这批任务的 P95 首 Token 延迟是多少""这条长会话的 Token 集中在第几轮""三天前那次中断是在哪一步断的",本机视图就不够了——事件流是按时间排的序列,没有父子关系,也没有各层的时间占用统计。本文不引入额外平台,只做一件事:把这批本地数据加工成带层级、带区间、带用量归属的复盘材料,而模型侧的统一出口交给 TaoToken。
1. 先把模型出口切到 TaoToken:Base URL 与 Key 的最小改动
复盘的前提是链路数据里能读到稳定的model与usage字段。如果一次任务里模型请求散落在多个供应商、多个 Key 上,Span 列表里的模型名会对不上账。所以第一步是收敛出口。
1.1 取 Key 与端点约定
到 TaoToken 控制台创建 API Key,key 形如YOUR_API_KEY的占位形式换成你自己的真实值即可。控制台入口:https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_content=dsh_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_KEY="YOUR_API_KEY" export OPENAI_API_KEY="$TAOTOKEN_API_KEY" export OPENAI_BASE_URL="https://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 循环:一次用户任务叫一个 turn,turn 内的每一轮"推理 → 调工具 → 观察结果"叫一个 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 先于工具结束、用户手动 Ctrl+C,这些场景都必须在数据里留痕。如果你在事件流里看到"有 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 | 输入 Token | usage.prompt_tokens | 流式场景下看末块 |
gen_ai.usage.output_tokens | 输出 Token | usage.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 之和 | 这段会话总共花了多少 |
| L1 | turn | 该 turn 内所有 chat 之和 | 哪一轮任务最贵 |
| L2 | step | 该 step 内所有 chat 之和 | 贵在推理还是贵在反复试错 |
| L3 | chat | 单次请求的usage原值 | 是不是某次重试烧掉的 |
| L4 | tool | 不计 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的那个 shell;profile 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 Code(settings.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 配置混用。
Codex(config.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 = "chat"Codex 用的是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_source=taotoken_aicg_blog_end&utm_content=dsh_trace_chat )发一轮同样的提示词,看usage与finish_reason的真实取值。
如果团队里跑 Agent 的机器不止一台,或者需要把用量做成按周的成本报表,那就该考虑把出口和额度一起管起来。Coding Plan(https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=dsh_trace_plan )适合把编码类任务集中到一套额度下,避免多台机器各拿一把 Key 导致对账困难。新增或轮换 Key 在 API Keys 页面(https://taotoken.net/console/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=dsh_trace_keys )完成,建议按机器或按项目拆 Key,这样复盘时"哪个项目的用量异常"可以直接从 Key 维度切出来。Claude Code 侧的完整环境变量说明在文档页(https://taotoken.net/doc/ClaudeCodeAnthropic?utm_source=taotoken_aicg_blog_end&utm_content=dsh_trace_cc ),配好之后再和 DSH 一起跑同一批任务,两边的 Trace 口径就能对齐了。
最后提醒一句:本文所有解析脚本与配置都在本地执行、本地读取,不要把它们接到生产库或线上环境上跑;YOUR_API_KEY也不要提交进代码仓库,用环境变量或密钥管理工具注入。