perf(stream): throttle live-turn snapshot and skip redundant per-token parse (#5455)
Two O(n^2) costs on the per-token/per-frame streaming path, both over the growing answer: WS2.2 — snapshotLiveTurn() serialized the whole live turn via turn.outerHTML on every render frame. That snapshot only backs mid-stream session-switch restore, and the switch path (sessions.js) plus tool/done event boundaries already capture synchronously, so the per-frame call is throttled to a coarse trailing snapshot instead of serializing the entire growing DOM each frame. WS2.3 — the token handler ran _parseStreamState() (strip tool-call XML + extract inline thinking over the FULL accumulated text) on every token, but _doRender re-parses once per throttled frame anyway and the only per-token use is the assistant-row creation gate. Skip the parse once the row exists (the common case); keep it only until the row is created. Also drop the full-string toLowerCase() allocation in _stripXmlToolCalls for a non-allocating regex test. Output-neutral: same rendered DOM; the row-creation gate and snapshot semantics are preserved (row appears within one 66ms frame; switch/settle snapshots stay synchronous). node --check + ESLint runtime guard clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
304a82d9b4
commit
ed922f4f18
1 changed files with 30 additions and 6 deletions
|
|
@ -2122,6 +2122,17 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
|||
function snapshotLiveTurn(){
|
||||
if(typeof snapshotLiveTurnHtmlForSession==='function') snapshotLiveTurnHtmlForSession(activeSid);
|
||||
}
|
||||
// Throttled per-frame variant. snapshotLiveTurnHtmlForSession serializes the
|
||||
// whole (growing) live turn via turn.outerHTML — O(n)/frame -> O(n^2) over a
|
||||
// long answer, and a real GC-pressure source. The snapshot only backs
|
||||
// mid-stream session-switch restore, and the switch path (sessions.js) plus
|
||||
// the stream event boundaries (tool/done) already capture synchronously, so a
|
||||
// coarse trailing snapshot during streaming is sufficient. (#5455 WS2.2)
|
||||
let _snapshotLiveTurnTimer=null;
|
||||
function _throttledSnapshotLiveTurn(){
|
||||
if(_snapshotLiveTurnTimer) return;
|
||||
_snapshotLiveTurnTimer=setTimeout(()=>{_snapshotLiveTurnTimer=null;snapshotLiveTurn();},700);
|
||||
}
|
||||
// Throttled variant for token-by-token updates. persistInflightState()
|
||||
// calls saveInflightState() which does JSON.parse + JSON.stringify + write
|
||||
// on the entire inflight map every call. On a fast model at 60 tok/s with
|
||||
|
|
@ -3789,8 +3800,10 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
|||
// Also handles DSML-prefixed variants from DeepSeek/Bedrock, including
|
||||
// spacing variants like "<|DSML |function_calls" and truncated prefixes.
|
||||
if(!s) return s;
|
||||
const lo=String(s).toLowerCase();
|
||||
if(lo.indexOf('function_calls')===-1 && lo.indexOf('dsml')===-1) return s;
|
||||
// Case-insensitive presence check without allocating a full lowercased copy
|
||||
// of the (growing) text on every call — cuts per-token/per-frame GC pressure.
|
||||
// Equivalent to the previous toLowerCase()+indexOf gate. (#5455 WS2.3)
|
||||
if(!/function_calls|dsml/i.test(String(s))) return s;
|
||||
// Support both plain <function_calls> and DSML-prefixed variants.
|
||||
s=s.replace(/<(?:\s*|\s*DSML\s*[||]\s*)?function_calls>[\s\S]*?<\/(?:\s*|\s*DSML\s*[||]\s*)?function_calls>/gi,'');
|
||||
// Also remove truncated opening tags (missing closing ">" at stream tail).
|
||||
|
|
@ -4639,7 +4652,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
|||
if(typeof _syncLiveWorklogReasonsForAnchor==='function') _syncLiveWorklogReasonsForAnchor(assistantRow, displayText);
|
||||
}
|
||||
scrollIfPinned();
|
||||
snapshotLiveTurn();
|
||||
_throttledSnapshotLiveTurn();
|
||||
};
|
||||
const frameIntervalMs=_shouldUseStreamFade()?33:66;
|
||||
if(sinceLastMs>=frameIntervalMs){
|
||||
|
|
@ -4695,10 +4708,21 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
|||
syncInflightAssistantMessage();
|
||||
if(!S.session||S.session.session_id!==activeSid) return;
|
||||
_completeAutomaticCompressionOnLiveProgress(activeSid);
|
||||
const parsed=_parseStreamState();
|
||||
if(_freshSegment) appendThinking('', _liveThinkingPlacement());
|
||||
if(String((parsed&&parsed.displayText)||'').trim()||assistantRow) ensureAssistantRow();
|
||||
_scheduleRender(parsed);
|
||||
// Once the assistant row exists its creation gate is already satisfied, and
|
||||
// the throttled _doRender re-parses once per frame anyway — so the per-token
|
||||
// full-text parse here is pure waste (O(n)/token -> O(n^2) over the answer).
|
||||
// Still call ensureAssistantRow() every token exactly as before (cheap; it
|
||||
// also starts a new segment on a post-tool _freshSegment). Only the parse is
|
||||
// skipped, and only once the row exists. (#5455 WS2.3)
|
||||
if(assistantRow){
|
||||
ensureAssistantRow();
|
||||
_scheduleRender();
|
||||
}else{
|
||||
const parsed=_parseStreamState();
|
||||
if(String((parsed&&parsed.displayText)||'').trim()) ensureAssistantRow();
|
||||
_scheduleRender(parsed);
|
||||
}
|
||||
});
|
||||
|
||||
source.addEventListener('interim_assistant',e=>{
|
||||
|
|
|
|||
Loading…
Reference in a new issue