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