probe-answer-stream.mjs 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179
  1. /**
  2. * 真后端探针:打真实 `/api/chat`,把 F073 正文流式的**原始载荷**与**真实协调器**的表现都测一遍。
  3. *
  4. * 为什么单独有这个脚本:契约(`harness/docs/reference/token-streaming.md`)描述的是"应该是什么样",
  5. * 而**跑着的后端才是事实来源**。这个脚本同时给出原始 SSE 统计与协调器处理后的结果,
  6. * 用来发现"文档与实现不一致"的地方(例如 delta 平均长度、是否夹 heartbeat、拼接是否等于最终文本)。
  7. *
  8. * 怎么跑(先按 _entry-coordinator.ts 头部注释重新打包;需要 dev server 起着走同源代理):
  9. * node harness/tools/probe-answer-stream.mjs "青浦区高新技术企业有什么扶持政策"
  10. * # 可选:CHAT_BASE=https://localhost:8083/chat-api 覆盖地址
  11. *
  12. * 只读:不改任何数据;建议连着跑两轮——一个会出卡片的业务问题 + 一个普通问答。
  13. */
  14. process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
  15. const chatBase = (process.env.CHAT_BASE || 'https://localhost:8083/chat-api').replace(/\/+$/, '');
  16. const question = process.argv[2] || '青浦区高新技术企业有什么扶持政策';
  17. const threadId = `probe-stream-${Date.now()}`;
  18. const { ApiChatCoordinator, applyAnswerStreamEvent, createAnswerStreamState, normalizeChatMarkdown } =
  19. await import('./_coordinator.mjs');
  20. console.log(`问题:${question}`);
  21. console.log(`地址:${chatBase}/api/chat\n`);
  22. /* ------------------------------------------------------------------ *
  23. * ① 拿原始载荷
  24. * ------------------------------------------------------------------ */
  25. const startedAt = Date.now();
  26. const res = await fetch(`${chatBase}/api/chat`, {
  27. method: 'POST',
  28. headers: { 'Content-Type': 'application/json' },
  29. body: JSON.stringify({ thread_id: threadId, question }),
  30. });
  31. console.log(`HTTP ${res.status}`);
  32. const raw = await res.text();
  33. const totalMs = Date.now() - startedAt;
  34. if (res.status !== 200) {
  35. console.log('原始响应前 300 字:', raw.slice(0, 300));
  36. process.exit(1);
  37. }
  38. /* ------------------------------------------------------------------ *
  39. * ② 原始事件统计
  40. * ------------------------------------------------------------------ */
  41. const events = [];
  42. for (const block of raw.split(/\n\n/)) {
  43. const m = block.match(/^event: (\S+)\ndata: (.*)$/s);
  44. if (!m) continue;
  45. let data = {};
  46. try {
  47. data = JSON.parse(m[2]).data || {};
  48. } catch {
  49. /* 忽略坏帧 */
  50. }
  51. events.push({ name: m[1], data, at: null });
  52. }
  53. const counts = {};
  54. for (const e of events) counts[e.name] = (counts[e.name] || 0) + 1;
  55. const deltas = events.filter((e) => e.name === 'answer_delta');
  56. const starts = events.filter((e) => e.name === 'answer_start');
  57. const ends = events.filter((e) => e.name === 'answer_end');
  58. const aborts = events.filter((e) => e.name === 'answer_abort');
  59. const endEvent = ends[0];
  60. const joined = deltas.map((e) => e.data.text || '').join('');
  61. // 压缩事件序列(连续 delta 合并显示)
  62. const compact = [];
  63. let run = 0;
  64. for (const e of events) {
  65. if (e.name === 'answer_delta') {
  66. run++;
  67. continue;
  68. }
  69. if (run) {
  70. compact.push(`delta×${run}`);
  71. run = 0;
  72. }
  73. compact.push(e.name);
  74. }
  75. if (run) compact.push(`delta×${run}`);
  76. console.log('\n【1】原始载荷');
  77. console.log(' 事件计数:', JSON.stringify(counts));
  78. console.log(' 事件序列:', compact.join(' → '));
  79. console.log(` 总耗时:${(totalMs / 1000).toFixed(1)}s`);
  80. if (starts.length) {
  81. console.log(
  82. ` 流目标:${starts.map((e) => e.data.target).join(',')} stream_id=${String(starts[0].data.stream_id).slice(0, 8)}…`
  83. );
  84. }
  85. if (deltas.length) {
  86. const lens = deltas.map((e) => (e.data.text || '').length);
  87. console.log(
  88. ` delta:${deltas.length} 片,共 ${joined.length} 字,平均 ${(joined.length / deltas.length).toFixed(1)} 字/片,单片最大 ${Math.max(...lens)}`
  89. );
  90. // heartbeat 是否夹在 delta 之间(会决定"思考中卡片停发"的守卫是否够)
  91. const firstDelta = events.findIndex((e) => e.name === 'answer_delta');
  92. const lastDelta = events.map((e) => e.name).lastIndexOf('answer_delta');
  93. const between = events
  94. .slice(firstDelta, lastDelta)
  95. .filter((e) => e.name === 'heartbeat' || e.name === 'progress').length;
  96. console.log(` 流中途夹带的 progress/heartbeat:${between} 次(守卫应拦住它们、不再发思考中卡片)`);
  97. }
  98. if (endEvent) {
  99. console.log(` answer_end:status=${endEvent.data.status} operation=${endEvent.data.operation} 长度=${(endEvent.data.text || '').length}`);
  100. }
  101. if (aborts.length) console.log(` ⚠️ answer_abort ×${aborts.length}:`, JSON.stringify(aborts.map((e) => e.data.reason)));
  102. // 契约一致性核对
  103. console.log('\n【2】契约一致性(拿实测对照文档)');
  104. const normFinal = endEvent ? normalizeChatMarkdown(endEvent.data.text || '') : '';
  105. console.log(
  106. ` concat(deltas) === answer_end.text ? ${joined === (endEvent?.data.text || '(无 end)')}(差异 ${joined.length - (endEvent?.data.text || '').length} 字)`
  107. );
  108. console.log(` 归整后是否以增量拼接为前缀(决定"整块替换"能否退化成尾缀追加):${normFinal.startsWith(joined)}`);
  109. const answerEvent = events.find((e) => e.name === 'answer');
  110. const summaryEvent = events.find((e) => e.name === 'summary');
  111. const resultEvent = events.find((e) => e.name === 'result');
  112. const same = (a, b) => !!a && !!b && a === b;
  113. console.log(
  114. ` answer / summary / result.response 是否为同一份文本:${same(answerEvent?.data.text, summaryEvent?.data.text) || '(无 answer)'} / ${same(summaryEvent?.data.text, resultEvent?.data.response)}`
  115. );
  116. console.log(` summary 是否带 stream_id(用于与流关联):${summaryEvent ? 'stream_id' in summaryEvent.data : '(无 summary)'}`);
  117. console.log(` summary.notices:${JSON.stringify(summaryEvent?.data.notices ?? null)}`);
  118. /* ------------------------------------------------------------------ *
  119. * ③ 喂真实协调器(同一份载荷)
  120. * ------------------------------------------------------------------ */
  121. console.log('\n【3】真实协调器处理这轮载荷');
  122. globalThis.fetch = async () =>
  123. new Response(
  124. new ReadableStream({
  125. start(controller) {
  126. controller.enqueue(new TextEncoder().encode(raw));
  127. controller.close();
  128. },
  129. }),
  130. { status: 200, headers: { 'Content-Type': 'text/event-stream' } }
  131. );
  132. const coordinator = new ApiChatCoordinator({ baseUrl: chatBase, threadId });
  133. const seen = { chunks: [], end: null, abort: null, start: null, total: null };
  134. coordinator.addEventListener('message', (m) => seen.chunks.push(m));
  135. coordinator.addEventListener('answerStreamStart', (p) => (seen.start = p));
  136. coordinator.addEventListener('answerStreamEnd', (p) => (seen.end = p));
  137. coordinator.addEventListener('answerStreamAbort', (p) => (seen.abort = p));
  138. coordinator.addEventListener('totalResponse', (p) => (seen.total = p));
  139. await coordinator.generateAnswer(question);
  140. await new Promise((r) => setTimeout(r, 200));
  141. const onScreen = seen.chunks.join('');
  142. const bodyCount = normFinal ? onScreen.split(normFinal.slice(0, 20)).length - 1 : 0;
  143. console.log(` answerStreamStart/End:${!!seen.start} / ${!!seen.end};abort:${seen.abort ? seen.abort.reason : '无'}`);
  144. console.log(` 上屏内容长度:${onScreen.length}(其中正文出现次数≈${bodyCount},应为 1)`);
  145. console.log(` totalResponse.answer 是否以最终正文开头:${String(seen.total?.answer || '').startsWith(normFinal.slice(0, 20))}`);
  146. console.log(` 上屏内容是否含卡片标记:${onScreen.includes('POLICY_TABLE')};含参考资料:${onScreen.includes('<ref_links>')}`);
  147. /* ------------------------------------------------------------------ *
  148. * ④ 结论
  149. * ------------------------------------------------------------------ */
  150. console.log('\n【4】结论');
  151. const problems = [];
  152. if (!starts.length) problems.push('后端没有下发 answer_start(F073 未部署或该问题不走流式)');
  153. if (starts.length && !normFinal.startsWith(joined)) {
  154. problems.push('增量拼接不是最终文本的前缀 → 会触发整块重建(「思考中」卡片会闪现一次)');
  155. }
  156. if (bodyCount > 1) problems.push('正文在上屏内容里出现多次(完整事件被渲染成了第二份)');
  157. if (!seen.end) problems.push('协调器没有收到 answerStreamEnd');
  158. if (problems.length) {
  159. for (const p of problems) console.log(` ⚠️ ${p}`);
  160. process.exit(1);
  161. }
  162. console.log(' ✅ 原始载荷与协调器表现均符合契约');