Суть Читай реальные сниппеты парсинга SSE и streaming-клиента, предсказывай поведение и выбирай фикс с наибольшим рычагом — фрейминг событий, накопление delta, client abort и частичный JSON в аргументах tool.
Высота — путь к senior
НольJuniorMiddleSenior
Ты на senior-высоте — в орбите
◷ 14 min
SSE-парсер, аккумулятор и путь отмены — вот где реально живут баги streaming. Прочитай каждый сниппет, предскажи его поведение под реальным трафиком и выбери фикс, который senior сделает первым.
Цель
Отработай цикл, который ты гоняешь в каждом инциденте со streaming: прочитать код парсинга и клиента, предсказать, где он портит данные или зависает, и потянуться к фиксу фрейминга/накопления/отмены, а не винить модель.
Сниппет 1 — парсер SSE-кадров
const reader = res.body.getReader();const decoder = new TextDecoder();while (true) { const { value, done } = await reader.read(); if (done) break; const text = decoder.decode(value); // один сетевой чанк for (const line of text.split("\n")) { if (line.startsWith("data: ")) { const evt = JSON.parse(line.slice(6)); // парсим сразу handle(evt); } }}
Викторина
Completed
Этот парсер работает в dev, но под нагрузкой бросает ошибки JSON.parse. В чём баг и фикс?
Heads-up Дело не в скорости, а в корректности. Парсинг бросает, потому что полезная нагрузка 'data:' — фрагмент кадра, разрезанный по TCP-чанкам, а не потому что JSON.parse медленный.
Heads-up fetch + ReadableStream — валидный и частый способ читать SSE (и обязательный для POST/auth-заголовков). Дефект — отсутствие буферизации через границы чанков, что обязан делать любой корректный парсер.
Heads-up Кадры сервера валидны; это твой reader режет их посреди кадра на произвольных TCP-границах. Буферизуй, пока не соберёшь полное событие, и только потом парси.
Сниппет 2 — аккумулятор delta
let text = "";const toolArgs = {}; // index -> строковый буферfunction handle(evt) { if (evt.type === "content_block_delta") { if (evt.delta.type === "text_delta") { text += evt.delta.text; render(text); } else if (evt.delta.type === "input_json_delta") { const i = evt.index; toolArgs[i] = JSON.parse(toolArgs[i] + evt.delta.partial_json); // (!) } }}
Викторина
Completed
Путь для текста корректен. Что не так с путём аргументов tool?
Heads-up Каждый content-блок несёт index, и несколько tool-вызовов в одном сообщении различаются именно по нему. Индексация верна; баг — парсинг на каждой delta.
Heads-up Аргументы tool — это структурированный вход функции, а не отображаемый текст. Даже как подсказку «вызываю tool…» ты показываешь сырую частичную строку — но никогда не действуешь по ней до закрытия блока.
Heads-up Оборачивание конкатенации в JSON.parse означает, что каждый промежуточный фрагмент парсится и бросает. Каноническая форма копит строку и парсит только на content_block_stop.
Сниппет 3 — отмена на клиенте
function ask(prompt) { return fetch("/api/chat", { method: "POST", body: JSON.stringify({ prompt }), }).then(consumeStream);}// Пользователь жмёт "Stop". UI перестаёт рисовать токены.// Но сервер продолжает генерацию, а биллинг — считать.
Викторина
Completed
Клик по Stop прячет токены, но модель продолжает генерировать, а ты — платить. Какой правильный фикс сквозь всю цепочку?
Heads-up Отказ от render() прячет токены локально, но оставляет работающими fetch, серверный handler и upstream-генерацию модели. Ты продолжаешь платить за токены, которых никто не видит.
Heads-up Это ограничивает каждый ответ, а не только отменённые, и всё равно не умеет останавливать генерацию по намерению пользователя. Отмене нужен abort-сигнал, прокинутый до провайдера, а не лимит длины.
Heads-up Здесь используется fetch/SSE, а не WebSockets, и fetch-stream отменяемы через AbortController. Реальное требование — прокинуть abort до самого upstream-вызова провайдера.
Сниппет 4 — backpressure на сервере
// SSE-эндпоинт в стиле Express, проксирующий upstream-stream моделиfor await (const evt of upstreamStream) { res.write(`data: ${JSON.stringify(evt)}\n\n`); // игнорируем результат}res.end();
Викторина
Completed
Медленный клиент (мобайл, слабый сигнал) потребляет события медленнее, чем upstream производит. Что делает этот цикл и какой фикс?
Heads-up Управление потоком TCP заполняет буфер ядра, но Node затем неограниченно копит неотправленные данные в userland-памяти. Игнорирование false от write() — ровно то, как медленный потребитель доводит сервер до OOM.
Heads-up write() никогда не отбрасывает данные молча; он буферизует их в памяти и сигналит о заполнении через false. Отказ — это неограниченный рост памяти, а не потеря событий.
Heads-up Стоимость сериализации не узкое место для медленного клиента. Проблема — производить быстрее, чем клиент успевает читать; фикс — уважать backpressure write(), а не быстрее кодировать.
Итог
Любой баг streaming читается в парсере, аккумуляторе, пути отмены или цикле записи: SSE-кадры надо буферизовать и делить по разделителю-пустой-строке, потому что TCP-чанки не совпадают с событиями; фрагменты input_json_delta для tool копятся как строка и парсятся только на content_block_stop; клиентский Stop должен запускать AbortController, чей signal прокидывается до провайдера, иначе ты продолжаешь генерировать и платить; а сервер, проксирующий stream, обязан уважать false от write() (приостановить upstream, возобновить на drain), иначе медленный клиент раздувает память. Прочитай путь кода, предскажи отказ под реальным трафиком, почини фрейминг/отмену/backpressure — и перепроверь на медленном, нестабильном клиенте.