fix: reduce streaming stutter in the web chat (#1085)
Some checks are pending
CI / test (push) Waiting to run
CI / lint (push) Waiting to run
CI / build (push) Waiting to run
CI / typecheck (push) Waiting to run
Nix Build / Check flake.nix workspace sync (push) Waiting to run
Nix Build / nix build .#kimi-code (push) Blocked by required conditions
Release / Release (push) Waiting to run
Release / Deploy docs (push) Blocked by required conditions
Release / Native release artifact (push) Blocked by required conditions
Release / Publish native release assets (push) Blocked by required conditions

* fix(web): coalesce streaming token updates into one render per frame

* fix(server): disable Nagle on WebSocket socket for lower streaming latency

* fix(web): flush pending streaming deltas before re-subscribing

* fix(web): flush pending streaming deltas before forgetting a session
This commit is contained in:
qer 2026-06-25 13:06:38 +08:00 committed by GitHub
parent 8ee5c0ff81
commit f1fad7222c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 359 additions and 55 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/server": patch
---
Reduce streaming latency by disabling Nagle's algorithm on WebSocket connections.

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-web": patch
---
Fix stuttery streaming in the web chat by coalescing rapid token updates into a single render per frame.

View file

@ -0,0 +1,80 @@
// apps/kimi-web/src/composables/client/eventBatcher.ts
// Coalesce high-frequency streaming events onto the next animation frame.
//
// Pure logic (no Vue, no DOM) so it is unit-testable in isolation. See
// useKimiWebClient.ts for where it is wired into the WS event pipeline.
import type { AppEvent } from '../../api/types';
// Events that merely append a chunk to something already streaming. They can
// arrive dozens to hundreds of times per second, so they are worth coalescing.
const RENDER_EVENT_TYPES: ReadonlySet<AppEvent['type']> = new Set<AppEvent['type']>([
'assistantDelta',
'agentDelta',
'toolOutput',
'taskProgress',
]);
/** True for high-frequency render-only events that are safe to delay to the
next animation frame. Everything else (lifecycle / control-flow) must apply
immediately so turn-end cleanup etc. is not delayed by a throttled rAF. */
export function isRenderEvent(appEvent: AppEvent): boolean {
return RENDER_EVENT_TYPES.has(appEvent.type);
}
function defaultScheduleFrame(cb: () => void): number {
return typeof requestAnimationFrame === 'function'
? requestAnimationFrame(cb)
: (setTimeout(cb, 16) as unknown as number);
}
/**
* Coalesce batchable items onto a single scheduled callback, while applying
* non-batchable items immediately.
*
* A non-batchable item first drains any pending batchable items (in arrival
* order) so overall ordering is preserved a lifecycle event never overtakes
* the deltas that arrived before it.
*
* The returned handle is itself callable (enqueue) and also exposes `flush()`
* to synchronously drain pending batchable items. Callers that replace state
* authoritatively (e.g. applying a server snapshot) must `flush()` first so
* stale queued deltas are not applied on top of the new state.
*/
export interface EventBatcher<T> {
(item: T): void;
/** Synchronously drain any pending batchable items in arrival order. */
flush(): void;
}
export function createEventBatcher<T>(
process: (item: T) => void,
isBatchable: (item: T) => boolean,
schedule: (cb: () => void) => number = defaultScheduleFrame,
): EventBatcher<T> {
let pending: T[] = [];
let handle: number | null = null;
const drain = (): void => {
handle = null;
if (pending.length === 0) return;
const batch = pending;
pending = [];
for (const item of batch) process(item);
};
const enqueue = ((item: T) => {
if (isBatchable(item)) {
pending.push(item);
if (handle === null) handle = schedule(drain);
return;
}
// Immediate item: flush pending batchables first to preserve order.
drain();
process(item);
}) as EventBatcher<T>;
enqueue.flush = drain;
return enqueue;
}

View file

@ -18,6 +18,7 @@ import {
saveWorkspaceOrder,
STORAGE_KEYS,
} from '../lib/storage';
import { createEventBatcher, isRenderEvent } from './client/eventBatcher';
import { useAppearance } from './client/useAppearance';
import { useNotification } from './client/useNotification';
import { useTaskPoller } from './client/useTaskPoller';
@ -28,6 +29,7 @@ import { useWorkspaceState } from './client/useWorkspaceState';
const appearance = useAppearance();
const notification = useNotification();
import type {
AppEvent,
AppApprovalRequest,
AppConfig,
AppGoal,
@ -463,6 +465,13 @@ function forgetSession(sessionId: string): void {
// buffered event for this id would otherwise be reduced and recreate the very
// per-session maps we are about to delete.
eventConn?.unsubscribe(sessionId);
// Drain the streaming-event batcher too. unsubscribe() stops future server
// frames, but events already queued for the next animation frame would
// otherwise survive and be reduced AFTER the maps below are cleared —
// recreating entries like messagesBySession[id] and lastSeqBySession[id].
// That would make hasLoadedMessages() treat the stale empty cache as
// authoritative and skip the next snapshot fetch for this id.
enqueueEvent.flush();
removeSession(sessionId);
removeSessionMessages(sessionId);
delete rawState.approvalsBySession[sessionId];
@ -659,6 +668,88 @@ function applyEvent(event: ReturnType<typeof toAppEvent>, sessionId: string, seq
}
}
// ---------------------------------------------------------------------------
// Streaming event batching
// ---------------------------------------------------------------------------
//
// High-frequency "append a chunk" events (assistant/agent deltas, tool/task
// output) can arrive dozens to hundreds of times per second. Applying each one
// synchronously triggers a full Vue re-render per event, which saturates the
// main thread and makes the stream look janky (see messagesToTurns / Markdown).
//
// We coalesce those render-only events onto the next animation frame so Vue
// commits a single render per frame. Lifecycle / control-flow events
// (sessionStatusChanged, messageCreated, approval*, question*, ...) are applied
// immediately: they are infrequent, and some (e.g. sessionStatusChanged idle)
// drive turn-end cleanup that must not be delayed by a throttled rAF in a
// background tab. Ordering is preserved by draining any pending render events
// before applying an immediate event.
type PendingEvent = { appEvent: AppEvent; meta: { sessionId: string; seq: number } };
function processEvent(appEvent: AppEvent, meta: { sessionId: string; seq: number }): void {
// meta carries wire-level seq/sessionId so the reducer can advance
// lastSeqBySession[sessionId] = seq. Compaction completion appends a
// persistent divider marker in the reducer (TUI parity: the scrollback
// is kept, only a marker line records the compaction).
applyEvent(appEvent, meta.sessionId, meta.seq);
const sideTarget = sideChat.sideChatTargetBySession.value[meta.sessionId];
if (sideTarget) {
const { agentId } = sideTarget;
const parentId = meta.sessionId;
if (appEvent.type === 'agentDelta' && appEvent.agentId === agentId) {
if (appEvent.delta.text) {
sideChat.appendSideChatAssistantText(agentId, parentId, appEvent.delta.text);
}
} else if (appEvent.type === 'agentTurnEnded' && appEvent.agentId === agentId) {
sideChat.finishSideChatAgent(agentId, parentId);
} else if (appEvent.type === 'taskProgress' && appEvent.taskId === agentId) {
sideChat.appendSideChatAssistantText(agentId, parentId, appEvent.outputChunk);
} else if (appEvent.type === 'taskCompleted' && appEvent.taskId === agentId) {
sideChat.finishSideChatAgent(agentId, parentId, appEvent.outputPreview);
}
}
// The daemon's prompt.submitted event is projected as a user messageCreated
// carrying the real prompt_id. When the HTTP submit response is lost
// (timeout / network error) this is the fallback that lets Stop work.
if (
appEvent.type === 'messageCreated' &&
appEvent.message.role === 'user' &&
appEvent.message.promptId !== undefined
) {
const sid = appEvent.message.sessionId;
if (rawState.promptIdBySession[sid] !== appEvent.message.promptId) {
rawState.promptIdBySession = {
...rawState.promptIdBySession,
[sid]: appEvent.message.promptId,
};
}
}
if (appEvent.type === 'assistantDelta' && meta.sessionId === rawState.activeSessionId) {
appearance.recordMoonDelta((appEvent.delta.text?.length ?? 0) + (appEvent.delta.thinking?.length ?? 0));
}
// Turn-end cleanup for the session the event belongs to — including
// sessions running in the background (see onSessionIdle).
// Turn-end: both 'idle' and 'aborted' mean the prompt is no longer in
// flight, so both must flush in-flight/queued state. (Awaiting-* is still
// in flight — it's waiting on the user — and must NOT flush.)
if (
appEvent.type === 'sessionStatusChanged' &&
(appEvent.status === 'idle' || appEvent.status === 'aborted')
) {
onSessionIdle(appEvent.sessionId, appEvent.status);
}
}
const enqueueEvent = createEventBatcher<PendingEvent>(
({ appEvent, meta }) => processEvent(appEvent, meta),
({ appEvent }) => isRenderEvent(appEvent),
);
// ---------------------------------------------------------------------------
// WS subscription (lazy, only when a session is selected)
// ---------------------------------------------------------------------------
@ -686,64 +777,20 @@ function connectEventsIfNeeded(): void {
return;
}
// meta carries wire-level seq/sessionId so the reducer can advance
// lastSeqBySession[sessionId] = seq. Compaction completion appends a
// persistent divider marker in the reducer (TUI parity: the scrollback
// is kept, only a marker line records the compaction).
applyEvent(appEvent, meta.sessionId, meta.seq);
const sideTarget = sideChat.sideChatTargetBySession.value[meta.sessionId];
if (sideTarget) {
const { agentId } = sideTarget;
const parentId = meta.sessionId;
if (appEvent.type === 'agentDelta' && appEvent.agentId === agentId) {
if (appEvent.delta.text) {
sideChat.appendSideChatAssistantText(agentId, parentId, appEvent.delta.text);
}
} else if (appEvent.type === 'agentTurnEnded' && appEvent.agentId === agentId) {
sideChat.finishSideChatAgent(agentId, parentId);
} else if (appEvent.type === 'taskProgress' && appEvent.taskId === agentId) {
sideChat.appendSideChatAssistantText(agentId, parentId, appEvent.outputChunk);
} else if (appEvent.type === 'taskCompleted' && appEvent.taskId === agentId) {
sideChat.finishSideChatAgent(agentId, parentId, appEvent.outputPreview);
}
}
// The daemon's prompt.submitted event is projected as a user messageCreated
// carrying the real prompt_id. When the HTTP submit response is lost
// (timeout / network error) this is the fallback that lets Stop work.
if (
appEvent.type === 'messageCreated' &&
appEvent.message.role === 'user' &&
appEvent.message.promptId !== undefined
) {
const sid = appEvent.message.sessionId;
if (rawState.promptIdBySession[sid] !== appEvent.message.promptId) {
rawState.promptIdBySession = {
...rawState.promptIdBySession,
[sid]: appEvent.message.promptId,
};
}
}
if (appEvent.type === 'assistantDelta' && meta.sessionId === rawState.activeSessionId) {
appearance.recordMoonDelta((appEvent.delta.text?.length ?? 0) + (appEvent.delta.thinking?.length ?? 0));
}
// Turn-end cleanup for the session the event belongs to — including
// sessions running in the background (see onSessionIdle).
// Turn-end: both 'idle' and 'aborted' mean the prompt is no longer in
// flight, so both must flush in-flight/queued state. (Awaiting-* is still
// in flight — it's waiting on the user — and must NOT flush.)
if (
appEvent.type === 'sessionStatusChanged' &&
(appEvent.status === 'idle' || appEvent.status === 'aborted')
) {
onSessionIdle(appEvent.sessionId, appEvent.status);
}
// Coalesce high-frequency render events onto the next animation frame;
// everything else is applied immediately. See createEventBatcher /
// processEvent above.
enqueueEvent({ appEvent, meta });
},
onResync(sessionId: string, currentSeq: number, epoch?: string) {
// Flush streaming deltas already queued so they render on the
// pre-snapshot state (the snapshot is authoritative and will overwrite
// them). Stragglers that arrive during the snapshot fetch are drained
// again right before the snapshot write inside syncSessionFromSnapshot,
// so they are applied to the pre-snapshot array too rather than on top
// of the fresh snapshot (which would duplicate text / tool output).
enqueueEvent.flush();
// The server-announced cursor is only a hint; the snapshot fetch
// returns the authoritative {asOfSeq, epoch} and re-subscribes.
if (epoch !== undefined) epochBySession[sessionId] = epoch;
@ -961,6 +1008,13 @@ async function syncSessionFromSnapshot(sessionId: string): Promise<SyncSessionRe
const api = getKimiWebApi();
const snap = await api.getSessionSnapshot(sessionId);
// Drain any queued streaming deltas before the snapshot replaces
// messagesBySession[sessionId]. The snapshot is authoritative (it already
// contains everything up to asOfSeq); applying stale queued deltas on top
// of it would duplicate text / tool output. Flushing here applies them to
// the pre-snapshot array, which the snapshot then overwrites.
enqueueEvent.flush();
updateSession(sessionId, (s) => ({
...snap.session,
model:
@ -1019,6 +1073,11 @@ function hasLoadedMessages(sessionId: string): boolean {
function subscribeToSessionEvents(sessionId: string): void {
connectEventsIfNeeded();
if (eventConn) {
// Apply any queued streaming deltas before re-subscribing so the transcript
// is current. (These deltas are volatile — never replayed by the server and
// they don't advance lastSeqBySession — but flushing here is cheap and
// future-proofs the cursor if the batching set ever changes.)
enqueueEvent.flush();
const seq = rawState.lastSeqBySession[sessionId] ?? 0;
const epoch = epochBySession[sessionId];
eventConn.subscribe(sessionId, { seq, epoch });

View file

@ -0,0 +1,150 @@
// apps/kimi-web/test/event-batcher.test.ts
// Unit tests for the streaming-event coalescing logic.
//
// These verify the batcher's behaviour (coalesce + preserve order + immediate
// passthrough for non-batchable items). They deliberately do NOT try to assert
// "Vue renders once" — that is a property of Vue's scheduler and is covered by
// manual perf verification, not by a unit test.
import { describe, expect, it } from 'vitest';
import { createEventBatcher, isRenderEvent } from '../src/composables/client/eventBatcher';
import type { AppEvent } from '../src/api/types';
interface FakeSchedule {
schedule: (cb: () => void) => number;
calls: () => number;
flush: () => void;
}
// A synchronous, manually-triggered scheduler. Stores the most recent callback;
// `flush()` runs it. Lets tests drive the batcher without real rAF / timers.
function fakeSchedule(): FakeSchedule {
let cb: (() => void) | null = null;
let count = 0;
return {
schedule(fn) {
count += 1;
cb = fn;
return count;
},
calls: () => count,
flush() {
const fn = cb;
cb = null;
fn?.();
},
};
}
describe('createEventBatcher', () => {
it('coalesces consecutive batchable items into one scheduled flush, in order', () => {
const processed: string[] = [];
const f = fakeSchedule();
const enqueue = createEventBatcher<string>((s) => processed.push(s), (s) => s.startsWith('d'), f.schedule);
enqueue('d1');
enqueue('d2');
enqueue('d3');
expect(processed).toEqual([]); // nothing processed yet
expect(f.calls()).toBe(1); // scheduled exactly once
f.flush();
expect(processed).toEqual(['d1', 'd2', 'd3']);
});
it('applies a non-batchable item immediately when the queue is empty', () => {
const processed: string[] = [];
const f = fakeSchedule();
const enqueue = createEventBatcher<string>((s) => processed.push(s), (s) => s.startsWith('d'), f.schedule);
enqueue('X');
expect(processed).toEqual(['X']);
expect(f.calls()).toBe(0); // never scheduled
});
it('drains pending batchables before applying an immediate item', () => {
const processed: string[] = [];
const f = fakeSchedule();
const enqueue = createEventBatcher<string>((s) => processed.push(s), (s) => s.startsWith('d'), f.schedule);
enqueue('d1');
enqueue('d2');
enqueue('X'); // immediate → must flush d1, d2 first
expect(processed).toEqual(['d1', 'd2', 'X']);
// The rAF scheduled for d1 is now stale; firing it must be a harmless no-op.
f.flush();
expect(processed).toEqual(['d1', 'd2', 'X']);
});
it('preserves arrival order across mixed batchable and immediate items', () => {
const processed: string[] = [];
const f = fakeSchedule();
const enqueue = createEventBatcher<string>((s) => processed.push(s), (s) => s.startsWith('d'), f.schedule);
enqueue('d1'); // queued
enqueue('d2'); // queued
enqueue('A'); // immediate → drains d1, d2, then A
enqueue('d3'); // queued again
f.flush(); // drains d3
expect(processed).toEqual(['d1', 'd2', 'A', 'd3']);
});
it('reschedules after a flush when new batchable items arrive', () => {
const processed: string[] = [];
const f = fakeSchedule();
const enqueue = createEventBatcher<string>((s) => processed.push(s), (s) => s.startsWith('d'), f.schedule);
enqueue('d1');
f.flush();
expect(processed).toEqual(['d1']);
enqueue('d2');
expect(f.calls()).toBe(2); // scheduled a second time
f.flush();
expect(processed).toEqual(['d1', 'd2']);
});
it('flush() drains pending batchables synchronously without the scheduler', () => {
const processed: string[] = [];
const f = fakeSchedule();
const enqueue = createEventBatcher<string>((s) => processed.push(s), (s) => s.startsWith('d'), f.schedule);
enqueue('d1');
enqueue('d2');
expect(processed).toEqual([]);
enqueue.flush(); // synchronous drain, no scheduler callback needed
expect(processed).toEqual(['d1', 'd2']);
});
it('flush() on an empty queue is a no-op', () => {
const processed: string[] = [];
const f = fakeSchedule();
const enqueue = createEventBatcher<string>((s) => processed.push(s), (s) => s.startsWith('d'), f.schedule);
enqueue.flush();
expect(processed).toEqual([]);
});
});
describe('isRenderEvent', () => {
it.each(['assistantDelta', 'agentDelta', 'toolOutput', 'taskProgress'])(
'treats %s as batchable',
(type) => {
expect(isRenderEvent({ type } as AppEvent)).toBe(true);
},
);
it.each(['messageCreated', 'messageUpdated', 'sessionStatusChanged', 'approvalRequested', 'configChanged'])(
'treats %s as immediate',
(type) => {
expect(isRenderEvent({ type } as AppEvent)).toBe(false);
},
);
});

View file

@ -63,6 +63,11 @@ export class WSGateway extends Disposable implements IWSGateway {
socket.destroy();
return;
}
// Disable Nagle's algorithm: streaming chat sends many small frames (one per
// token delta), and Nagle + the client's delayed ACK can bunch them into
// ~40 ms clusters, making the stream look stuttery. Trade a little bandwidth
// for lower latency.
socket.setNoDelay(true);
this.wss.handleUpgrade(req, socket, head, (ws) => this.onConnect(ws, req));
}