probe-answer-latency.mjs 4.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104
  1. /**
  2. * 量「一个问题从发送到看完」的时间,拆成【后端耗时】与【前端打字机延迟】两段。
  3. *
  4. * 为什么要量:用户反馈"回答时间很长"。前端有打字机(后端一次返回整段时,打字机是
  5. * **纯额外延迟**),所以"慢"可能来自两头,必须分开量才知道该治哪个。
  6. *
  7. * 用法:node harness/tools/probe-answer-latency.mjs "你的问题" [chatApiBase]
  8. */
  9. const question = process.argv[2] || '我想在青浦开一家奶茶店,有什么相关扶持政策吗';
  10. const base = (process.argv[3] || 'http://192.168.2.23:8000').replace(/\/+$/, '');
  11. const url = base.endsWith('/api/chat') ? base : `${base}/api/chat`;
  12. // ── 前端打字机参数(与 src/components/Chat/TextContent.vue 保持一致)──
  13. const TICK_MS = 33;
  14. const BASE_CHARS_PER_TICK = 4;
  15. const BURST = [
  16. { pendingLength: 32, batchSize: 8 },
  17. { pendingLength: 96, batchSize: 12 },
  18. { pendingLength: 256, batchSize: 16 },
  19. { pendingLength: 512, batchSize: 20 },
  20. { pendingLength: 1024, batchSize: 24 },
  21. ];
  22. /** 模拟前端的打字机循环,返回展开这段内容需要多少毫秒 */
  23. const simulateTypewriter = (totalChars) => {
  24. let pending = totalChars;
  25. let ms = 0;
  26. while (pending > 0) {
  27. let batch = BASE_CHARS_PER_TICK;
  28. for (const t of BURST) if (pending > t.pendingLength) batch = t.batchSize;
  29. pending -= batch;
  30. ms += TICK_MS;
  31. }
  32. return ms;
  33. };
  34. const t0 = Date.now();
  35. let tFirstEvent = null;
  36. let tFirstAnswer = null;
  37. let tDone = null;
  38. let eventCount = 0;
  39. let answerChars = 0;
  40. const payloads = [];
  41. const response = await fetch(url, {
  42. method: 'POST',
  43. headers: { 'Content-Type': 'application/json', Accept: 'text/event-stream' },
  44. body: JSON.stringify({ thread_id: `latency_${Date.now()}`, question }),
  45. });
  46. const reader = response.body.getReader();
  47. const decoder = new TextDecoder();
  48. let buffer = '';
  49. let currentEvent = '';
  50. while (true) {
  51. const { done, value } = await reader.read();
  52. if (done) break;
  53. buffer += decoder.decode(value, { stream: true });
  54. const lines = buffer.split('\n');
  55. buffer = lines.pop() || '';
  56. for (const raw of lines) {
  57. const line = raw.replace(/\r$/, '');
  58. if (line.startsWith('event:')) {
  59. currentEvent = line.slice(6).trim();
  60. eventCount++;
  61. if (tFirstEvent === null) tFirstEvent = Date.now();
  62. } else if (line.startsWith('data:')) {
  63. const p = line.slice(5).trim();
  64. try {
  65. const j = JSON.parse(p);
  66. const d = j?.data ?? j;
  67. // 正文可能走 answer,也可能走 summary(该后端两种都用过);
  68. // result.response 是快照,**不重复计数**(适配层也只展示一次)
  69. if ((currentEvent === 'answer' || currentEvent === 'summary') && d?.text) {
  70. if (tFirstAnswer === null) tFirstAnswer = Date.now();
  71. answerChars += String(d.text).length;
  72. }
  73. if (currentEvent === 'done') tDone = Date.now();
  74. if (['progress', 'answer', 'item', 'source', 'summary'].includes(currentEvent)) {
  75. payloads.push(`${currentEvent}@${Date.now() - t0}ms`);
  76. }
  77. } catch { /* 分片 */ }
  78. }
  79. }
  80. }
  81. if (tDone === null) tDone = Date.now();
  82. const typeMs = simulateTypewriter(answerChars);
  83. const backendMs = tDone - t0;
  84. const fmt = (ms) => (ms / 1000).toFixed(1) + 's';
  85. console.log(`\n问题:${question}\n`);
  86. console.log('【后端】');
  87. console.log(' 首事件到达 :', fmt(tFirstEvent - t0));
  88. console.log(' 首段 answer 到达:', tFirstAnswer ? fmt(tFirstAnswer - t0) : '(没有 answer 事件)');
  89. console.log(' done 到达 :', fmt(backendMs), ' ← **后端总耗时**');
  90. console.log(' 事件数 :', eventCount);
  91. console.log(' 回答总字数 :', answerChars);
  92. console.log('\n【前端打字机】(后端一次返回整段,这段是**纯额外延迟**)');
  93. console.log(' 展开', answerChars, '字需要:', fmt(typeMs), `(${Math.round(answerChars / (typeMs / 1000))} 字符/秒 平均)`);
  94. console.log('\n【用户感知总计】', fmt(backendMs + typeMs), `= 后端 ${fmt(backendMs)} + 打字机 ${fmt(typeMs)}`);
  95. console.log('\n事件时间线(前 12 个):', payloads.slice(0, 12).join(' '));