/** * 量「一个问题从发送到看完」的时间,拆成【后端耗时】与【前端打字机延迟】两段。 * * 为什么要量:用户反馈"回答时间很长"。前端有打字机(后端一次返回整段时,打字机是 * **纯额外延迟**),所以"慢"可能来自两头,必须分开量才知道该治哪个。 * * 用法:node harness/tools/probe-answer-latency.mjs "你的问题" [chatApiBase] */ const question = process.argv[2] || '我想在青浦开一家奶茶店,有什么相关扶持政策吗'; const base = (process.argv[3] || 'http://192.168.2.23:8000').replace(/\/+$/, ''); const url = base.endsWith('/api/chat') ? base : `${base}/api/chat`; // ── 前端打字机参数(与 src/components/Chat/TextContent.vue 保持一致)── const TICK_MS = 33; const BASE_CHARS_PER_TICK = 4; const BURST = [ { pendingLength: 32, batchSize: 8 }, { pendingLength: 96, batchSize: 12 }, { pendingLength: 256, batchSize: 16 }, { pendingLength: 512, batchSize: 20 }, { pendingLength: 1024, batchSize: 24 }, ]; /** 模拟前端的打字机循环,返回展开这段内容需要多少毫秒 */ const simulateTypewriter = (totalChars) => { let pending = totalChars; let ms = 0; while (pending > 0) { let batch = BASE_CHARS_PER_TICK; for (const t of BURST) if (pending > t.pendingLength) batch = t.batchSize; pending -= batch; ms += TICK_MS; } return ms; }; const t0 = Date.now(); let tFirstEvent = null; let tFirstAnswer = null; let tDone = null; let eventCount = 0; let answerChars = 0; const payloads = []; const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'text/event-stream' }, body: JSON.stringify({ thread_id: `latency_${Date.now()}`, question }), }); const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; let currentEvent = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop() || ''; for (const raw of lines) { const line = raw.replace(/\r$/, ''); if (line.startsWith('event:')) { currentEvent = line.slice(6).trim(); eventCount++; if (tFirstEvent === null) tFirstEvent = Date.now(); } else if (line.startsWith('data:')) { const p = line.slice(5).trim(); try { const j = JSON.parse(p); const d = j?.data ?? j; // 正文可能走 answer,也可能走 summary(该后端两种都用过); // result.response 是快照,**不重复计数**(适配层也只展示一次) if ((currentEvent === 'answer' || currentEvent === 'summary') && d?.text) { if (tFirstAnswer === null) tFirstAnswer = Date.now(); answerChars += String(d.text).length; } if (currentEvent === 'done') tDone = Date.now(); if (['progress', 'answer', 'item', 'source', 'summary'].includes(currentEvent)) { payloads.push(`${currentEvent}@${Date.now() - t0}ms`); } } catch { /* 分片 */ } } } } if (tDone === null) tDone = Date.now(); const typeMs = simulateTypewriter(answerChars); const backendMs = tDone - t0; const fmt = (ms) => (ms / 1000).toFixed(1) + 's'; console.log(`\n问题:${question}\n`); console.log('【后端】'); console.log(' 首事件到达 :', fmt(tFirstEvent - t0)); console.log(' 首段 answer 到达:', tFirstAnswer ? fmt(tFirstAnswer - t0) : '(没有 answer 事件)'); console.log(' done 到达 :', fmt(backendMs), ' ← **后端总耗时**'); console.log(' 事件数 :', eventCount); console.log(' 回答总字数 :', answerChars); console.log('\n【前端打字机】(后端一次返回整段,这段是**纯额外延迟**)'); console.log(' 展开', answerChars, '字需要:', fmt(typeMs), `(${Math.round(answerChars / (typeMs / 1000))} 字符/秒 平均)`); console.log('\n【用户感知总计】', fmt(backendMs + typeMs), `= 后端 ${fmt(backendMs)} + 打字机 ${fmt(typeMs)}`); console.log('\n事件时间线(前 12 个):', payloads.slice(0, 12).join(' '));