| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104 |
- /**
- * 量「一个问题从发送到看完」的时间,拆成【后端耗时】与【前端打字机延迟】两段。
- *
- * 为什么要量:用户反馈"回答时间很长"。前端有打字机(后端一次返回整段时,打字机是
- * **纯额外延迟**),所以"慢"可能来自两头,必须分开量才知道该治哪个。
- *
- * 用法: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(' '));
|