probe-answer-tail-timing.mjs 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161
  1. /**
  2. * 量「正文结束 → 参考资料出现」这段**收尾等待**到底花在哪。
  3. *
  4. * 背景:正文是逐片流式上屏的,但参考资料(`<ref_links>`)要等 `done` 才由 `flushContent`
  5. * 追加。用户反馈正文结束后还要等 1~2 秒才看到参考资料 —— 这个脚本就是量这段。
  6. *
  7. * 它**增量读 SSE**(不是 `await res.text()` 攒完再解析),所以能拿到每个事件真正的到达时刻。
  8. *
  9. * 怎么跑(需要 dev server 起着走同源代理):
  10. * node harness/tools/probe-answer-tail-timing.mjs "青浦区高新技术企业有什么扶持政策"
  11. * # 可选:CHAT_BASE=https://localhost:8083/chat-api 覆盖地址
  12. *
  13. * 只读:不改任何数据。
  14. */
  15. process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
  16. const chatBase = (process.env.CHAT_BASE || 'https://localhost:8083/chat-api').replace(/\/+$/, '');
  17. const question = process.argv[2] || '青浦区高新技术企业有什么扶持政策';
  18. const startedAt = Date.now();
  19. const res = await fetch(`${chatBase}/api/chat`, {
  20. method: 'POST',
  21. headers: { 'Content-Type': 'application/json' },
  22. body: JSON.stringify({ thread_id: `probe-tail-${Date.now()}`, question }),
  23. });
  24. if (!res.ok) {
  25. console.error(`HTTP ${res.status}`);
  26. process.exit(1);
  27. }
  28. console.log(`问题:${question}\n地址:${chatBase}/api/chat\n`);
  29. const reader = res.body.getReader();
  30. const decoder = new TextDecoder();
  31. let buffer = '';
  32. const events = []; // { name, at, size }
  33. /** 原始事件块 + 到达时刻 —— 回放时按同样的节奏喂,才能量出「不动 done 能提前多久」 */
  34. const blocks = [];
  35. const handleEvent = (block) => {
  36. const m = block.match(/^event: (\S+)\r?\ndata: ([\s\S]*)$/);
  37. if (!m) return;
  38. const at = Date.now() - startedAt;
  39. events.push({ name: m[1], at, size: m[2].length });
  40. blocks.push({ text: `${block}\n\n`, at });
  41. };
  42. while (true) {
  43. const { value, done } = await reader.read();
  44. if (done) break;
  45. buffer += decoder.decode(value, { stream: true });
  46. let idx;
  47. while ((idx = buffer.search(/\r?\n\r?\n/)) !== -1) {
  48. const nl = buffer.match(/\r?\n\r?\n/)[0].length;
  49. handleEvent(buffer.slice(0, idx));
  50. buffer = buffer.slice(idx + nl);
  51. }
  52. }
  53. if (buffer.trim()) handleEvent(buffer);
  54. const totalMs = Date.now() - startedAt;
  55. /* ---------------- 统计 ---------------- */
  56. const first = (name) => events.find((e) => e.name === name);
  57. const last = (name) => [...events].reverse().find((e) => e.name === name);
  58. const count = (name) => events.filter((e) => e.name === name).length;
  59. const deltaLast = last('answer_delta');
  60. const answerEnd = first('answer_end');
  61. const summary = first('summary');
  62. const result = first('result');
  63. const done = first('done');
  64. const refLinksInPayload = events.some(
  65. (e) => e.name === 'summary' || e.name === 'result'
  66. );
  67. console.log('【事件计数】', JSON.stringify(
  68. events.reduce((acc, e) => ((acc[e.name] = (acc[e.name] || 0) + 1), acc), {})
  69. ));
  70. console.log('\n【收尾时间线】(相对请求发出;括号内为距上一步的间隔)');
  71. const steps = [
  72. ['最后一片 source(参考资料的数据)', last('source')],
  73. ['最后一片 item(卡片数据)', last('item')],
  74. ['answer_start(正文开流)', first('answer_start')],
  75. ['最后一片 answer_delta', deltaLast],
  76. ['answer_end(正文定稿)', answerEnd],
  77. ['summary', summary],
  78. ['result', result],
  79. ['done', done],
  80. ].filter(([, e]) => e);
  81. let prev = null;
  82. for (const [label, e] of steps) {
  83. const gap = prev === null ? '' : ` (+${((e.at - prev.at) / 1000).toFixed(2)}s)`;
  84. console.log(` ${String(e.at / 1000).padStart(6)}s ${label}${gap}`);
  85. prev = e;
  86. }
  87. console.log(` ${String(totalMs / 1000).padStart(6)}s 流结束(EOF)`);
  88. console.log('\n【结论】');
  89. const waitAfterBody = answerEnd ? (totalMs - answerEnd.at) / 1000 : null;
  90. console.log(` 正文定稿 → 流结束:${waitAfterBody === null ? '(无 answer_end)' : waitAfterBody.toFixed(2) + 's'}`);
  91. if (!done) {
  92. console.log(' ⚠️ 没有 done 事件 —— 前端会在 EOF 才 flush(把参考资料推到最后时刻),');
  93. console.log(' 而且会走「断流」分支报 disconnected。这是**最大的等待来源**。');
  94. }
  95. if (answerEnd && summary) {
  96. console.log(` answer_end → summary:${((summary.at - answerEnd.at) / 1000).toFixed(2)}s`);
  97. }
  98. if (summary && result) {
  99. console.log(` summary → result:${((result.at - summary.at) / 1000).toFixed(2)}s(result 是完整快照,通常最大)`);
  100. console.log(` result 载荷大小:${result.size} 字符`);
  101. }
  102. console.log(' 参考资料由 source 事件决定,而 source 在 answer_start 之前就发完了 ——');
  103. console.log(' 也就是说「等参考资料」等的是 done,不是等数据。');
  104. /* ------------------------------------------------------------------ *
  105. * ③ 按**原时间轴**回放给真实协调器:量「参考资料提前上屏」省了多久
  106. * ------------------------------------------------------------------ */
  107. console.log('\n【回放】按原时间轴喂真实协调器,看参考资料什么时候上屏');
  108. const { ApiChatCoordinator } = await import('./_coordinator.mjs');
  109. globalThis.fetch = async () => {
  110. const encoder = new TextEncoder();
  111. return new Response(
  112. new ReadableStream({
  113. async start(controller) {
  114. let elapsed = 0;
  115. for (const block of blocks) {
  116. const wait = Math.max(0, block.at - elapsed);
  117. if (wait) await new Promise((r) => setTimeout(r, wait));
  118. elapsed = block.at;
  119. controller.enqueue(encoder.encode(block.text));
  120. }
  121. controller.close();
  122. },
  123. }),
  124. { status: 200, headers: { 'Content-Type': 'text/event-stream' } }
  125. );
  126. };
  127. const replayStart = Date.now();
  128. const coordinator = new ApiChatCoordinator({ chatBaseUrl: undefined, baseUrl: 'http://stub', threadId: 'replay' });
  129. let refsAt = null;
  130. let doneAt = null;
  131. coordinator.addEventListener('message', (msg) => {
  132. if (refsAt === null && msg.includes('<ref_links>')) refsAt = Date.now() - replayStart;
  133. });
  134. coordinator.addEventListener('totalResponse', () => {
  135. if (doneAt === null) doneAt = Date.now() - replayStart;
  136. });
  137. await coordinator.generateAnswer(question);
  138. await new Promise((r) => setTimeout(r, 400));
  139. if (refsAt !== null && doneAt !== null) {
  140. const savedMs = doneAt - refsAt;
  141. console.log(` 参考资料上屏:${(refsAt / 1000).toFixed(1)}s`);
  142. console.log(` 收尾完成(done):${(doneAt / 1000).toFixed(1)}s`);
  143. console.log(` → **提前了 ${(savedMs / 1000).toFixed(1)} 秒**(修前等于 done 时刻)`);
  144. } else {
  145. console.log(` ⚠️ 没测到:参考资料@${refsAt}ms 收尾@${doneAt}ms`);
  146. }