/** * 真后端探针:打真实 `/api/chat`,把 F073 正文流式的**原始载荷**与**真实协调器**的表现都测一遍。 * * 为什么单独有这个脚本:契约(`harness/docs/reference/token-streaming.md`)描述的是"应该是什么样", * 而**跑着的后端才是事实来源**。这个脚本同时给出原始 SSE 统计与协调器处理后的结果, * 用来发现"文档与实现不一致"的地方(例如 delta 平均长度、是否夹 heartbeat、拼接是否等于最终文本)。 * * 怎么跑(先按 _entry-coordinator.ts 头部注释重新打包;需要 dev server 起着走同源代理): * node harness/tools/probe-answer-stream.mjs "青浦区高新技术企业有什么扶持政策" * # 可选:CHAT_BASE=https://localhost:8083/chat-api 覆盖地址 * * 只读:不改任何数据;建议连着跑两轮——一个会出卡片的业务问题 + 一个普通问答。 */ process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; const chatBase = (process.env.CHAT_BASE || 'https://localhost:8083/chat-api').replace(/\/+$/, ''); const question = process.argv[2] || '青浦区高新技术企业有什么扶持政策'; const threadId = `probe-stream-${Date.now()}`; const { ApiChatCoordinator, applyAnswerStreamEvent, createAnswerStreamState, normalizeChatMarkdown } = await import('./_coordinator.mjs'); console.log(`问题:${question}`); console.log(`地址:${chatBase}/api/chat\n`); /* ------------------------------------------------------------------ * * ① 拿原始载荷 * ------------------------------------------------------------------ */ const startedAt = Date.now(); const res = await fetch(`${chatBase}/api/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ thread_id: threadId, question }), }); console.log(`HTTP ${res.status}`); const raw = await res.text(); const totalMs = Date.now() - startedAt; if (res.status !== 200) { console.log('原始响应前 300 字:', raw.slice(0, 300)); process.exit(1); } /* ------------------------------------------------------------------ * * ② 原始事件统计 * ------------------------------------------------------------------ */ const events = []; for (const block of raw.split(/\n\n/)) { const m = block.match(/^event: (\S+)\ndata: (.*)$/s); if (!m) continue; let data = {}; try { data = JSON.parse(m[2]).data || {}; } catch { /* 忽略坏帧 */ } events.push({ name: m[1], data, at: null }); } const counts = {}; for (const e of events) counts[e.name] = (counts[e.name] || 0) + 1; const deltas = events.filter((e) => e.name === 'answer_delta'); const starts = events.filter((e) => e.name === 'answer_start'); const ends = events.filter((e) => e.name === 'answer_end'); const aborts = events.filter((e) => e.name === 'answer_abort'); const endEvent = ends[0]; const joined = deltas.map((e) => e.data.text || '').join(''); // 压缩事件序列(连续 delta 合并显示) const compact = []; let run = 0; for (const e of events) { if (e.name === 'answer_delta') { run++; continue; } if (run) { compact.push(`delta×${run}`); run = 0; } compact.push(e.name); } if (run) compact.push(`delta×${run}`); console.log('\n【1】原始载荷'); console.log(' 事件计数:', JSON.stringify(counts)); console.log(' 事件序列:', compact.join(' → ')); console.log(` 总耗时:${(totalMs / 1000).toFixed(1)}s`); if (starts.length) { console.log( ` 流目标:${starts.map((e) => e.data.target).join(',')} stream_id=${String(starts[0].data.stream_id).slice(0, 8)}…` ); } if (deltas.length) { const lens = deltas.map((e) => (e.data.text || '').length); console.log( ` delta:${deltas.length} 片,共 ${joined.length} 字,平均 ${(joined.length / deltas.length).toFixed(1)} 字/片,单片最大 ${Math.max(...lens)}` ); // heartbeat 是否夹在 delta 之间(会决定"思考中卡片停发"的守卫是否够) const firstDelta = events.findIndex((e) => e.name === 'answer_delta'); const lastDelta = events.map((e) => e.name).lastIndexOf('answer_delta'); const between = events .slice(firstDelta, lastDelta) .filter((e) => e.name === 'heartbeat' || e.name === 'progress').length; console.log(` 流中途夹带的 progress/heartbeat:${between} 次(守卫应拦住它们、不再发思考中卡片)`); } if (endEvent) { console.log(` answer_end:status=${endEvent.data.status} operation=${endEvent.data.operation} 长度=${(endEvent.data.text || '').length}`); } if (aborts.length) console.log(` ⚠️ answer_abort ×${aborts.length}:`, JSON.stringify(aborts.map((e) => e.data.reason))); // 契约一致性核对 console.log('\n【2】契约一致性(拿实测对照文档)'); const normFinal = endEvent ? normalizeChatMarkdown(endEvent.data.text || '') : ''; console.log( ` concat(deltas) === answer_end.text ? ${joined === (endEvent?.data.text || '(无 end)')}(差异 ${joined.length - (endEvent?.data.text || '').length} 字)` ); console.log(` 归整后是否以增量拼接为前缀(决定"整块替换"能否退化成尾缀追加):${normFinal.startsWith(joined)}`); const answerEvent = events.find((e) => e.name === 'answer'); const summaryEvent = events.find((e) => e.name === 'summary'); const resultEvent = events.find((e) => e.name === 'result'); const same = (a, b) => !!a && !!b && a === b; console.log( ` answer / summary / result.response 是否为同一份文本:${same(answerEvent?.data.text, summaryEvent?.data.text) || '(无 answer)'} / ${same(summaryEvent?.data.text, resultEvent?.data.response)}` ); console.log(` summary 是否带 stream_id(用于与流关联):${summaryEvent ? 'stream_id' in summaryEvent.data : '(无 summary)'}`); console.log(` summary.notices:${JSON.stringify(summaryEvent?.data.notices ?? null)}`); /* ------------------------------------------------------------------ * * ③ 喂真实协调器(同一份载荷) * ------------------------------------------------------------------ */ console.log('\n【3】真实协调器处理这轮载荷'); globalThis.fetch = async () => new Response( new ReadableStream({ start(controller) { controller.enqueue(new TextEncoder().encode(raw)); controller.close(); }, }), { status: 200, headers: { 'Content-Type': 'text/event-stream' } } ); const coordinator = new ApiChatCoordinator({ baseUrl: chatBase, threadId }); const seen = { chunks: [], end: null, abort: null, start: null, total: null }; coordinator.addEventListener('message', (m) => seen.chunks.push(m)); coordinator.addEventListener('answerStreamStart', (p) => (seen.start = p)); coordinator.addEventListener('answerStreamEnd', (p) => (seen.end = p)); coordinator.addEventListener('answerStreamAbort', (p) => (seen.abort = p)); coordinator.addEventListener('totalResponse', (p) => (seen.total = p)); await coordinator.generateAnswer(question); await new Promise((r) => setTimeout(r, 200)); const onScreen = seen.chunks.join(''); const bodyCount = normFinal ? onScreen.split(normFinal.slice(0, 20)).length - 1 : 0; console.log(` answerStreamStart/End:${!!seen.start} / ${!!seen.end};abort:${seen.abort ? seen.abort.reason : '无'}`); console.log(` 上屏内容长度:${onScreen.length}(其中正文出现次数≈${bodyCount},应为 1)`); console.log(` totalResponse.answer 是否以最终正文开头:${String(seen.total?.answer || '').startsWith(normFinal.slice(0, 20))}`); console.log(` 上屏内容是否含卡片标记:${onScreen.includes('POLICY_TABLE')};含参考资料:${onScreen.includes('')}`); /* ------------------------------------------------------------------ * * ④ 结论 * ------------------------------------------------------------------ */ console.log('\n【4】结论'); const problems = []; if (!starts.length) problems.push('后端没有下发 answer_start(F073 未部署或该问题不走流式)'); if (starts.length && !normFinal.startsWith(joined)) { problems.push('增量拼接不是最终文本的前缀 → 会触发整块重建(「思考中」卡片会闪现一次)'); } if (bodyCount > 1) problems.push('正文在上屏内容里出现多次(完整事件被渲染成了第二份)'); if (!seen.end) problems.push('协调器没有收到 answerStreamEnd'); if (problems.length) { for (const p of problems) console.log(` ⚠️ ${p}`); process.exit(1); } console.log(' ✅ 原始载荷与协调器表现均符合契约');