| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179 |
- /**
- * 真后端探针:打真实 `/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('<ref_links>')}`);
- /* ------------------------------------------------------------------ *
- * ④ 结论
- * ------------------------------------------------------------------ */
- 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(' ✅ 原始载荷与协调器表现均符合契约');
|