/** * 会话每轮指标:TTFT(turn/start → 首个流式 chunk)、轮时长、工具耗时 * 只输出时间与类型,不输出任何正文。 * 用法:node analyze-turn.mjs */ import { readFileSync } from 'node:fs' import { zstdDecompressSync } from 'node:zlib' const raw = readFileSync(process.argv[2]) const MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd]) const offs = [] for (let i = 0; i + 4 <= raw.length; i++) if (raw.compare(MAGIC, 0, 4, i, i + 4) === 0) offs.push(i) let text = '' const frames = offs.length > 0 ? offs : [0] for (let f = 0; f < frames.length; f++) { const s = frames[f], e = f + 1 < frames.length ? frames[f + 1] : raw.length try { text += zstdDecompressSync(raw.subarray(s, e)).toString('utf8') } catch {} } if (text === '') { try { text = zstdDecompressSync(raw).toString('utf8') } catch (e) { console.log('fail', e.message); process.exit(1) } } const pick = (o, keys, d = 0) => { if (o === null || typeof o !== 'object' || d > 3) return undefined for (const k of keys) if (typeof o[k] !== 'undefined') return o[k] for (const v of Object.values(o)) { const h = pick(v, keys, d + 1); if (h !== undefined) return h } return undefined } const ev = [] for (const line of text.split('\n')) { if (line.trim() === '') continue let o; try { o = JSON.parse(line) } catch { continue } const kind = pick(o, ['kind', 'type', 'event']) const ts = pick(o, ['ts', 'time', 'timestamp', 'at', 'createdAt']) const t = typeof ts === 'number' ? (ts < 1e12 ? ts * 1000 : ts) : typeof ts === 'string' ? Date.parse(ts) : NaN if (typeof kind === 'string') ev.push({ k: kind, t: Number.isNaN(t) ? undefined : t }) } const fmt = (ms) => new Date(ms).toISOString().slice(11, 19) + 'Z' let turns = 0 for (let i = 0; i < ev.length; i++) { if (ev[i].k !== 'turn/start') continue turns++ const t0 = ev[i].t let end, firstChunk, firstText for (let j = i + 1; j < ev.length; j++) { if (ev[j].k === 'turn/end' && end === undefined) { end = ev[j].t; break } if (firstChunk === undefined && (ev[j].k === 'assistant/chunk' || ev[j].k === 'reasoning-chunks') && ev[j].t !== undefined) firstChunk = ev[j].t if (firstText === undefined && ev[j].k === 'text-chunks' && ev[j].t !== undefined) firstText = ev[j].t } const ttft = t0 !== undefined && firstChunk !== undefined ? ((firstChunk - t0) / 1000).toFixed(1) : '?' const ttftText = t0 !== undefined && firstText !== undefined ? ((firstText - t0) / 1000).toFixed(1) : '?' const dur = t0 !== undefined && end !== undefined ? ((end - t0) / 1000).toFixed(1) : '?' console.log(`轮 ${String(turns).padStart(2)} ${t0 !== undefined ? fmt(t0) : '?'} TTFT=${ttft}s 首文本=${ttftText}s 轮时长=${dur}s`) } // 工具耗时 const tools = ev.map((e, i) => ({ e, i })).filter((x) => x.e.k === 'tool/call') const dt = [] for (const { i } of tools) { for (let j = i + 1; j < ev.length; j++) { if (ev[j].k === 'tool/result') { if (ev[i].t !== undefined && ev[j].t !== undefined) dt.push((ev[j].t - ev[i].t) / 1000) break } } } if (dt.length > 0) { dt.sort((a, b) => b - a) const avg = dt.reduce((a, b) => a + b, 0) / dt.length console.log(`\n工具调用 ${dt.length} 次;平均 ${avg.toFixed(1)}s;最慢 5 个: ${dt.slice(0, 5).map((x) => x.toFixed(1) + 's').join(', ')}`) } console.log(`\n合计轮数: ${turns}`)