perf(chat): coalesce stream events per frame and pace text release

Every NDJSON event used to commit its own React update and re-parse the
whole partial answer through react-markdown, so long replies grew
quadratically slower. Stream events now land in a frame buffer that
flushes at most once per animation frame, releases answer and thinking
text at a steady pace with a twelve-frame catch-up, and settles
synchronously on completion, failure and abort. Streaming markdown is
split at the last completed block so only the tail is re-parsed each
frame. Applied to both the consultation hook and the rectification chat.

BUG-473

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
This commit is contained in:
Jesse_Chen
2026-09-02 04:29:01 +00:00
co-authored by Claude Fable 5.1
parent 02f06255c1
commit ad9dba5c79
10 changed files with 778 additions and 93 deletions
@@ -0,0 +1,74 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { createElement } from "react";
import { renderToString } from "react-dom/server";
import { StreamingMarkdown } from "../src/components/chat-message-content.tsx";
import { splitStableMarkdown } from "../src/lib/chat-markdown-split.ts";
const contentSource = readFileSync(new URL("../src/components/chat-message-content.tsx", import.meta.url), "utf8");
const messageRowSource = readFileSync(new URL("../src/components/chat-message-row.tsx", import.meta.url), "utf8");
test("the stable prefix ends at the last completed paragraph and the tail keeps streaming", () => {
assert.deepEqual(splitStableMarkdown("只有一段还没写完"), { stable: "", tail: "只有一段还没写完" });
assert.deepEqual(splitStableMarkdown("第一段。\n\n第二段还在"), { stable: "第一段。", tail: "第二段还在" });
assert.deepEqual(
splitStableMarkdown("## 标题\n\n第一段。\n\n第二段。\n\n第三"),
{ stable: "## 标题\n\n第一段。\n\n第二段。", tail: "第三" },
);
});
test("the cut never lands inside a fence, between list items, or inside a table", () => {
const fenced = "前言。\n\n```txt\n第一行\n\n第二行";
assert.deepEqual(splitStableMarkdown(fenced), { stable: "前言。", tail: "```txt\n第一行\n\n第二行" });
const looseList = "- 甲\n\n- 乙\n\n- 丙还在";
assert.deepEqual(splitStableMarkdown(looseList), { stable: "", tail: looseList });
const listThenParagraph = "- 甲\n- 乙\n\n总结一下";
assert.deepEqual(splitStableMarkdown(listThenParagraph), { stable: "- 甲\n- 乙", tail: "总结一下" });
const orderedListContinues = "1. 甲\n\n2. 乙\n\n 缩进的补充";
assert.deepEqual(splitStableMarkdown(orderedListContinues), { stable: "", tail: orderedListContinues });
const table = "说明。\n\n| 技法 | 状态 |\n| --- | --- |\n| 甲 | 已执行 |\n\n> 引用";
assert.deepEqual(
splitStableMarkdown(table),
{ stable: "说明。\n\n| 技法 | 状态 |\n| --- | --- |\n| 甲 | 已执行 |", tail: "> 引用" },
);
});
test("growing the tail keeps the prefix string identical so the memoised prefix is not re-parsed", () => {
const before = splitStableMarkdown("第一段。\n\n第二段。\n\n第三段正在");
const after = splitStableMarkdown("第一段。\n\n第二段。\n\n第三段正在写,还没有换段");
assert.equal(before.stable, after.stable);
assert.notEqual(before.tail, after.tail);
// Only once a new paragraph completes does the prefix move forward.
const later = splitStableMarkdown("第一段。\n\n第二段。\n\n第三段写完了。\n\n第四");
assert.equal(later.stable, "第一段。\n\n第二段。\n\n第三段写完了。");
});
test("the streaming renderer parses the prefix and the tail as two separate documents", () => {
const calls: string[] = [];
const renderMarkdown = (text: string) => {
calls.push(text);
return createElement("p", null, text);
};
renderToString(createElement(StreamingMarkdown, {
text: "第一段。\n\n第二段。\n\n第三段还在",
renderMarkdown,
}));
// Server rendering evaluates the tail in the parent and the memoised prefix as a child,
// so compare as a set: what matters is that neither call sees the whole answer.
assert.deepEqual([...calls].sort(), ["第一段。\n\n第二段。", "第三段还在"].sort());
// The prefix component is memoised on its text, so an unchanged prefix costs no parse.
assert.match(contentSource, /const StableMarkdownPrefix = memo\(function StableMarkdownPrefix/);
assert.match(contentSource, /splitStableMarkdown\(text\)/);
assert.match(contentSource, /streaming\s*\?\s*<StreamingMarkdown/);
// Settled answers still parse once as one document.
assert.match(contentSource, /: renderMarkdown\s*\?\s*renderMarkdown\(spoken\)/);
assert.match(messageRowSource, /streaming=\{message\.state !== "settled"\}/);
});
@@ -11,6 +11,7 @@ import {
type ChatTranscriptProps,
} from "../src/components/chat-transcript.tsx";
import type { ChatMessage } from "../src/lib/chat-message-view.ts";
import { createStreamFrameBuffer, type StreamFrameScheduler } from "../src/lib/stream-frame-buffer.ts";
import { streamingChatMessageView } from "../src/lib/chat-message-view.ts";
import {
disableHomeStreamingRenderProbe,
@@ -71,7 +72,51 @@ test("the split architecture renders settled history once while streaming tokens
disableHomeStreamingRenderProbe();
assert.equal(split.settledListRenders, 1);
assert.equal(split.streamingRowRenders, tokens.length);
// Former assertion: `streamingRowRenders === tokens.length`. That was a snapshot of the
// status quo (one commit per network token), not the goal; this test drives renders by
// hand, so the count equals the number of hand-driven renders and must never exceed it.
assert.ok(split.streamingRowRenders <= tokens.length);
assert.ok(split.streamingRowRenders >= 1);
assert.equal(unsplit.unsplitListRenders, tokens.length);
assert.ok(unsplit.settledRowRenders > split.settledRowRenders);
});
test("frame coalescing renders the streaming row once per frame, not once per token", () => {
const messages: ChatMessage[] = [{ role: "user", text: "请继续说明这个月的安排。" }];
const frames: Array<() => void> = [];
const scheduler: StreamFrameScheduler = {
requestFrame(callback) {
frames.push(callback);
return frames.length;
},
cancelFrame() { frames.length = 0; },
requestTimeout() { return 0; },
cancelTimeout() {},
hidden: () => false,
};
resetHomeStreamingRenderProbe();
enableHomeStreamingRenderProbe();
const buffer = createStreamFrameBuffer<null>({
initialMeta: null,
scheduler,
flush: (frame) => {
const streamingMessage = streamingChatMessageView(messages, true, frame.answer);
assert.ok(streamingMessage);
renderToString(createElement(StreamingMessageEntry, { message: streamingMessage }));
},
});
// 200 one-character tokens arrive four per frame across fifty frames.
let answer = "";
for (let index = 0; index < 200; index += 1) {
answer += "字";
buffer.setAnswer(answer);
if (index % 4 === 3) for (const callback of frames.splice(0)) callback();
}
buffer.settle();
const probe = homeStreamingRenderProbeSnapshot();
disableHomeStreamingRenderProbe();
assert.ok(probe.streamingRowRenders <= 51, `rendered ${probe.streamingRowRenders} times for 200 tokens`);
assert.ok(probe.streamingRowRenders * 3 <= 200);
});
+210
View File
@@ -0,0 +1,210 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
STREAM_HIDDEN_FLUSH_MS,
STREAM_RELEASE_CATCHUP_DIVISOR,
STREAM_RELEASE_MIN_CHARS,
advanceStreamRelease,
createStreamFrameBuffer,
streamReleaseCount,
type StreamFrameScheduler,
type StreamFrameSnapshot,
} from "../src/lib/stream-frame-buffer.ts";
function fakeScheduler(hidden = () => false) {
const frames: Array<() => void> = [];
const timeouts: Array<{ callback: () => void; delayMs: number }> = [];
let handle = 0;
const scheduler: StreamFrameScheduler = {
requestFrame(callback) {
frames.push(callback);
handle += 1;
return handle;
},
cancelFrame() {
frames.length = 0;
},
requestTimeout(callback, delayMs) {
timeouts.push({ callback, delayMs });
handle += 1;
return handle;
},
cancelTimeout() {
timeouts.length = 0;
},
hidden,
};
return {
scheduler,
tick() {
const pending = frames.splice(0);
for (const callback of pending) callback();
return pending.length;
},
tickTimeouts() {
const pending = timeouts.splice(0);
for (const entry of pending) entry.callback();
return pending;
},
get scheduledFrames() {
return frames.length;
},
};
}
test("release count is at least two characters and catches up a backlog within about twelve frames", () => {
assert.equal(streamReleaseCount(0), 0);
assert.equal(streamReleaseCount(1), 1);
assert.equal(streamReleaseCount(2), STREAM_RELEASE_MIN_CHARS);
assert.equal(streamReleaseCount(5), STREAM_RELEASE_MIN_CHARS);
assert.equal(streamReleaseCount(24), STREAM_RELEASE_MIN_CHARS);
assert.equal(streamReleaseCount(25), 3);
assert.equal(streamReleaseCount(1200), 1200 / STREAM_RELEASE_CATCHUP_DIVISOR);
let released = "";
const target = "字".repeat(3_000);
let frames = 0;
while (released !== target && frames < 100) {
released = advanceStreamRelease(released, target, target.length);
frames += 1;
}
// A 3,000-character backlog that arrived at once clears in twelve frames (~200ms).
assert.equal(frames, STREAM_RELEASE_CATCHUP_DIVISOR);
assert.equal(released, target);
// Without the backlog figure the pace still floors at two characters per frame.
assert.equal(advanceStreamRelease("", "十二个字符十二个字符十二"), "十二");
});
test("a replaced target that no longer extends the released prefix jumps instead of stalling", () => {
assert.equal(advanceStreamRelease("旧的回答", "新"), "新");
// A replacement restarts at the paced rate from the new head rather than showing stale text.
assert.equal(advanceStreamRelease("abc", "abd"), "ab");
});
test("many events collapse into one flush per frame and settle releases everything synchronously", () => {
const fake = fakeScheduler();
const flushes: StreamFrameSnapshot<string[]>[] = [];
const buffer = createStreamFrameBuffer<string[]>({
initialMeta: [],
scheduler: fake.scheduler,
flush: (snapshot) => flushes.push(snapshot),
});
// 200 one-character tokens arrive four per frame across fifty frames, the way a
// model streams Chinese text; each frame is allowed one commit.
let answer = "";
let frameCount = 0;
for (let index = 0; index < 200; index += 1) {
answer += "字";
buffer.setAnswer(answer);
buffer.setMeta((rows) => [...rows, `row-${index}`]);
if (index % 4 === 3) frameCount += fake.tick();
}
assert.equal(frameCount, 50);
assert.equal(flushes.length, 50);
assert.ok(flushes.length * 3 <= 200, "at most one commit per frame, not per token");
for (let index = 1; index < flushes.length; index += 1) {
assert.ok(flushes[index]!.answer.length >= flushes[index - 1]!.answer.length);
assert.ok(flushes[index]!.answer.length - flushes[index - 1]!.answer.length >= STREAM_RELEASE_MIN_CHARS);
}
assert.equal(flushes.at(-1)!.meta.length, 200);
assert.ok(flushes.at(-1)!.answer.length < 200, "pacing is still behind the network");
buffer.settle();
assert.equal(flushes.at(-1)!.answer, answer);
assert.equal(flushes.at(-1)!.settled, true);
assert.equal(fake.scheduledFrames, 0);
assert.equal(buffer.released().answer, answer);
});
test("thinking text is paced separately from the answer and meta-only touches still flush", () => {
const fake = fakeScheduler();
const flushes: StreamFrameSnapshot<null>[] = [];
const buffer = createStreamFrameBuffer<null>({
initialMeta: null,
scheduler: fake.scheduler,
flush: (snapshot) => flushes.push(snapshot),
});
buffer.setThinking("先看事业宫,再看大运。");
fake.tick();
assert.equal(flushes.length, 1);
assert.equal(flushes[0]!.answer, "");
assert.ok(flushes[0]!.thinking.length >= STREAM_RELEASE_MIN_CHARS);
assert.equal(flushes[0]!.settled, false);
buffer.settle();
assert.equal(flushes.at(-1)!.thinking, "先看事业宫,再看大运。");
buffer.touch();
fake.tick();
assert.equal(flushes.length, 3);
assert.equal(flushes.at(-1)!.settled, true);
});
test("a burst that lands mid-stream is cleared within about twelve frames instead of trickling", () => {
const fake = fakeScheduler();
const flushes: StreamFrameSnapshot<null>[] = [];
const buffer = createStreamFrameBuffer<null>({
initialMeta: null,
scheduler: fake.scheduler,
flush: (snapshot) => flushes.push(snapshot),
});
buffer.setAnswer("字".repeat(20));
fake.tick();
buffer.setAnswer("字".repeat(2_420));
let frames = 0;
while (fake.scheduledFrames > 0 && frames < 100) {
fake.tick();
frames += 1;
}
assert.equal(flushes.at(-1)!.answer.length, 2_420);
assert.ok(frames <= STREAM_RELEASE_CATCHUP_DIVISOR + 1, `took ${frames} frames`);
});
test("a hidden document falls back to a timeout and releases everything at once", () => {
const fake = fakeScheduler(() => true);
const flushes: StreamFrameSnapshot<null>[] = [];
const buffer = createStreamFrameBuffer<null>({
initialMeta: null,
scheduler: fake.scheduler,
flush: (snapshot) => flushes.push(snapshot),
});
buffer.setAnswer("字".repeat(500));
assert.equal(fake.scheduledFrames, 0);
const fired = fake.tickTimeouts();
assert.equal(fired.length, 1);
assert.equal(fired[0]!.delayMs, STREAM_HIDDEN_FLUSH_MS);
assert.equal(flushes.length, 1);
assert.equal(flushes[0]!.answer.length, 500);
assert.equal(flushes[0]!.settled, true);
});
test("reset drops received and released text plus scheduled work, and dispose silences the buffer", () => {
const fake = fakeScheduler();
const flushes: StreamFrameSnapshot<number>[] = [];
const buffer = createStreamFrameBuffer<number>({
initialMeta: 1,
scheduler: fake.scheduler,
flush: (snapshot) => flushes.push(snapshot),
});
buffer.setAnswer("第一次尝试的正文");
fake.tick();
assert.equal(flushes.length, 1);
buffer.reset(2);
assert.equal(fake.scheduledFrames, 0);
assert.deepEqual(buffer.released(), { answer: "", thinking: "" });
buffer.touch();
fake.tick();
assert.equal(flushes.at(-1)!.answer, "");
assert.equal(flushes.at(-1)!.meta, 2);
buffer.dispose();
buffer.setAnswer("不再发布");
buffer.touch();
assert.equal(fake.tick(), 0);
buffer.settle();
assert.equal(flushes.at(-1)!.answer, "");
});