probe-answer-tail-timing.mjs 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110
  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. const handleEvent = (block) => {
  34. const m = block.match(/^event: (\S+)\r?\ndata: ([\s\S]*)$/);
  35. if (!m) return;
  36. events.push({ name: m[1], at: Date.now() - startedAt, size: m[2].length });
  37. };
  38. while (true) {
  39. const { value, done } = await reader.read();
  40. if (done) break;
  41. buffer += decoder.decode(value, { stream: true });
  42. let idx;
  43. while ((idx = buffer.search(/\r?\n\r?\n/)) !== -1) {
  44. const nl = buffer.match(/\r?\n\r?\n/)[0].length;
  45. handleEvent(buffer.slice(0, idx));
  46. buffer = buffer.slice(idx + nl);
  47. }
  48. }
  49. if (buffer.trim()) handleEvent(buffer);
  50. const totalMs = Date.now() - startedAt;
  51. /* ---------------- 统计 ---------------- */
  52. const first = (name) => events.find((e) => e.name === name);
  53. const last = (name) => [...events].reverse().find((e) => e.name === name);
  54. const count = (name) => events.filter((e) => e.name === name).length;
  55. const deltaLast = last('answer_delta');
  56. const answerEnd = first('answer_end');
  57. const summary = first('summary');
  58. const result = first('result');
  59. const done = first('done');
  60. const refLinksInPayload = events.some(
  61. (e) => e.name === 'summary' || e.name === 'result'
  62. );
  63. console.log('【事件计数】', JSON.stringify(
  64. events.reduce((acc, e) => ((acc[e.name] = (acc[e.name] || 0) + 1), acc), {})
  65. ));
  66. console.log('\n【收尾时间线】(相对请求发出;括号内为距上一步的间隔)');
  67. const steps = [
  68. ['最后一片 source(参考资料的数据)', last('source')],
  69. ['最后一片 item(卡片数据)', last('item')],
  70. ['answer_start(正文开流)', first('answer_start')],
  71. ['最后一片 answer_delta', deltaLast],
  72. ['answer_end(正文定稿)', answerEnd],
  73. ['summary', summary],
  74. ['result', result],
  75. ['done', done],
  76. ].filter(([, e]) => e);
  77. let prev = null;
  78. for (const [label, e] of steps) {
  79. const gap = prev === null ? '' : ` (+${((e.at - prev.at) / 1000).toFixed(2)}s)`;
  80. console.log(` ${String(e.at / 1000).padStart(6)}s ${label}${gap}`);
  81. prev = e;
  82. }
  83. console.log(` ${String(totalMs / 1000).padStart(6)}s 流结束(EOF)`);
  84. console.log('\n【结论】');
  85. const waitAfterBody = answerEnd ? (totalMs - answerEnd.at) / 1000 : null;
  86. console.log(` 正文定稿 → 流结束:${waitAfterBody === null ? '(无 answer_end)' : waitAfterBody.toFixed(2) + 's'}`);
  87. if (!done) {
  88. console.log(' ⚠️ 没有 done 事件 —— 前端会在 EOF 才 flush(把参考资料推到最后时刻),');
  89. console.log(' 而且会走「断流」分支报 disconnected。这是**最大的等待来源**。');
  90. }
  91. if (answerEnd && summary) {
  92. console.log(` answer_end → summary:${((summary.at - answerEnd.at) / 1000).toFixed(2)}s`);
  93. }
  94. if (summary && result) {
  95. console.log(` summary → result:${((result.at - summary.at) / 1000).toFixed(2)}s(result 是完整快照,通常最大)`);
  96. console.log(` result 载荷大小:${result.size} 字符`);
  97. }
  98. console.log(' 参考资料由 source 事件决定,而 source 在 answer_start 之前就发完了 ——');
  99. console.log(' 也就是说「等参考资料」等的是 done,不是等数据。');