open-webui/backend
Claude d7b36be54d
perf(stream): emit text tokens as deltas instead of re-serializing the whole chat per SSE event
The streaming hot path in `streaming_chat_response_handler.stream_body_handler`
was calling `serialize_output(full_output())` on every SSE event — rebuilding
an HTML string of the entire accumulated output (text + reasoning + tool calls
+ images + citations) and emitting it via Socket.IO.

For an N-token response this is O(N) work per token -> O(N^2) bytes across
the WebSocket, then amplified again by Socket.IO's AsyncRedisManager pub/sub
(x redis nodes x workers) and a third time by the frontend Markdown
re-parser. A 2000-token reply at normal scale could push ~10,000x more
bytes through Redis than the new tokens actually needed.

Fix: when an SSE event only appended text characters to the currently
active `message` block (the overwhelming common case), accumulate those
chars into a `pending_text_delta` buffer and emit them as a lightweight
`chat:message:delta` event. The frontend already handles that event via
string append (`Chat.svelte:472-473`), so no frontend changes are needed.

Any structural event - reasoning update, reasoning->message transition,
tool call, image attachment, tag-block append, annotation source, etc. -
still goes through the legacy full-serialize path, and when it does it
clears the pending text delta buffer (because the full-content checkpoint
already contains the accumulated text). Ordering is preserved by flushing
pending deltas before each structural emit.

The final `done: true` `chat:completion` emitted by the outer handler at
line 4793 overwrites `message.content` with the canonical
`serialize_output(output)`, reconciling the frontend to the exact backend
state at the end of every response.

Discovered and diagnosed by @Shirasawa; this is the Phase 1 minimum-viable
landing. Phase 2 (typed block model, per-block rendering, Redis op log
with resume-by-seq) is tracked as follow-up work.

Impact summary:
- Per-token WS payload: O(size_so_far) -> O(new_chars)
- Redis pub/sub bytes: drops by ~10-100x on long responses
- Frontend Markdown re-parse cost: now bounded by delta size per event,
  not full response size
- Semantics: identical. Final message content reconciles via the
  existing `done: true` full-content checkpoint.

Out of scope for this change:
- Reasoning-block content still takes the full-serialize path per token
  (Phase 2 will incrementalize it via per-block deltas)
- Tool-call argument streaming still takes the full-serialize path
- DB persistence path (REALTIME_CHAT_SAVE) is untouched — its WS path
  already emitted raw SSE deltas and is unchanged
2026-04-14 20:33:02 +00:00
..
data refac: mv backend files to /open_webui dir 2024-09-04 16:54:48 +02:00
open_webui perf(stream): emit text tokens as deltas instead of re-serializing the whole chat per SSE event 2026-04-14 20:33:02 +00:00
.dockerignore fix: litellm config issue 2024-02-24 22:35:11 -08:00
.gitignore refac 2024-09-06 04:59:20 +02:00
dev.sh refac 2026-03-24 19:43:30 -05:00
requirements-min.txt refac 2026-04-13 23:40:09 -05:00
requirements.txt refac 2026-04-13 23:40:09 -05:00
start.sh refac 2026-03-24 19:43:30 -05:00
start_windows.bat refac 2026-03-24 19:43:30 -05:00