惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

宝玉的分享
宝玉的分享
J
Java Code Geeks
S
SegmentFault 最新的问题
L
LangChain Blog
M
MIT News - Artificial intelligence
Stack Overflow Blog
Stack Overflow Blog
IT之家
IT之家
量子位
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
雷峰网
雷峰网
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
The Cloudflare Blog
MongoDB | Blog
MongoDB | Blog
Microsoft Security Blog
Microsoft Security Blog
腾讯CDC
H
Help Net Security
阮一峰的网络日志
阮一峰的网络日志
Jina AI
Jina AI
N
Netflix TechBlog - Medium
A
About on SuperTechFans
博客园 - 叶小钗
美团技术团队
人人都是产品经理
人人都是产品经理
D
DataBreaches.Net

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания
Один SSE для четырёх LLM: стриминг OpenAI, Anthropic, Dee...
Михаил Берг · 2026-06-15 · via Все публикации подряд на Хабре

Простой

4 мин

0

Мы делаем чат-агрегатор, где в одном окне доступны GPT, Claude, Kimi и DeepSeek. Фронтенду нужно отдавать ответ в реальном времени — токен за токеном, как в ChatGPT. Бэкенд при этом ходит к четырём разным API, и стриминг у них устроен по-разному. Расскажу, как мы свели это к единому SSE-потоку наружу, и про две грабли, на которые наступили: рваные UTF-8 символы и парсинг чужих SSE.

Статья будет полезна всем, кто проксирует LLM через свой сервер.

Зачем вообще свой прокси

Фронтенд не должен знать ключи провайдеров и не должен ходить к ним напрямую. Все запросы идут через наш Node.js-бэкенд: он подставляет ключ, дёргает нужный API с stream: true, парсит входящий поток и отдаёт фронту унифицированные события. Плюс на бэкенде живут лимиты, учёт токенов и подмена провайдера.

Задача: «получить поток от провайдера X → распарсить → отдать фронту в едином формате».

Два разных формата стриминга

Провайдеры делятся на два лагеря.

  1. OpenAI-совместимые (GPT, DeepSeek, Kimi). SSE, где в каждом событии лежит delta:

    data: {“choices”:[{“delta”:{“content”:“При”}}]} data: {“choices”:[{“delta”:{“content”:“вет”}}]} data: [DONE]

  2. Anthropic (Claude). Своя событийная модель с типами:

    data: {“type”:“message_start”,“message”:{“usage”:{“input_tokens”:10}}} data: {“type”:“content_block_delta”,“delta”:{“type”:“text_delta”,“text”:“При”}} data: {“type”:“message_delta”,“usage”:{“output_tokens”:5}}

Текст лежит в разных местах, события называются по-разному, токены usage приходят в разных местах потока. Нам нужно привести всё к одному виду.

Единый формат наружу

Договорились с фронтом о простом протоколе поверх SSE:

data: {“t”:“кусок текста”} // дельта data: {“done”:true,“full”:“весь текст”} data: [DONE]

Дальше — два обработчика, по одному на каждый лагерь.

Парсинг OpenAI-совместимого потока

Чанки из сокета не совпадают с границами SSE-событий, поэтому буферизуем и режем по разделителю \n\n:

let buf = “”; proxyRes.setEncoding(“utf-8”); proxyRes.on(“data”, (chunk) => { buf += chunk; const parts = buf.split(“\n\n”); buf = parts.pop() “”; // хвост — недособранное событие for (const part of parts) { for (const line of part.split(“\n”)) { const s = line.trim(); if (!s.startsWith("data: ") s === “data: [DONE]”) continue; const evt = JSON.parse(s.slice(6)); const delta = evt.choices?.[0]?.delta?.content; if (delta) sseWrite(res, { t: delta }); } } });

Главное здесь — не парсить buf целиком на каждом чанке, а отрезать только завершённые события (до последнего \n\n), а недополученный хвост оставлять в буфере до следующего чанка.

Anthropic парсится так же, только вытаскиваем text из событий с типом content_block_delta, а usage собираем из message_start и message_delta.

Грабля №1: data += chunk ломает русские буквы

Сначала тело ответа мы собирали наивно:

let data = “”; proxyRes.on(“data”, chunk => data += chunk); // ❌

И в ответах периодически появлялись «битые символы» — кракозябры вместо части кириллицы или эмодзи. Причём не всегда, а будто случайно.

Причина: chunk — это Buffer, а не строка. Конкатенация data += chunk неявно вызывает chunk.toString() на каждом куске отдельно. Многобайтные UTF-8 символы (кириллица — 2 байта, эмодзи — 4) могут разорваться на границе сетевого пакета: первый байт символа уедет в конец одного чанка, второй — в начало следующего. toString() каждого чанка по отдельности декодирует половинку символа в U+FFFD — тот самый «ромбик с вопросом».

Чем выше нагрузка и длиннее ответ, тем чаще пакеты бьются не по символам — поэтому баг и казался плавающим.

Два рабочих решения:

  1. Накапливать байты, декодировать один раз в конце:

    const chunks = []; proxyRes.on(“data”, c => chunks.push©); proxyRes.on(“end”, () => { const data = Buffer.concat(chunks).toString(“utf-8”); // ✅ });

  2. Для стриминга, где декодировать нужно по ходу, — переложить склейку байтов на сам поток:

    proxyRes.setEncoding(“utf-8”); // ✅ теперь chunk — корректная строка, // поток сам держит «хвост» неполного символа

Второй вариант мы и используем в стриминговых обработчиках выше — обратите внимание на setEncoding(“utf-8”) перед on(“data”).

Вывод простой, но его легко забыть под нагрузкой: никогда не склеивайте сетевые чанки как строки. Либо Buffer.concat, либо setEncoding на потоке.

Грабля №2: usage приходит в последнем чанке

Количество токенов (для учёта и биллинга) у OpenAI прилетает в самом последнем событии перед [DONE], а у Anthropic — раздельно: input в message_start, output в message_delta. Если разбирать поток лениво и выходить по первому [DONE], можно потерять usage. Поэтому usage аккумулируем в переменные по ходу потока и фиксируем в on(“end”), там же отдаём фронту итоговое {done:true,full}.

Мелкие, но важные детали

— Таймаут на запрос к провайдеру (мы ставим 120с) + аккуратная отдача ошибки в том же SSE, а не обрыв соединения. — Если провайдер вернул не-200 — читаем тело ошибки целиком (через Buffer.concat, см. грабля №1) и отдаём фронту человеческое сообщение. — Фронт тоже буферизует по \n\n: частичное SSE-событие нельзя JSON.parse’ить.

Итог

Свести четыре разных стриминговых API к одному SSE-потоку — это в основном аккуратная работа с буферами и знание форматов каждого провайдера. Две главные ловушки — рваные UTF-8 на границах чанков и потерянный usage — стоили нам больше всего времени, хотя чинятся в одну строку.

Всё это крутится в нашем сервисе Nomi, но код и грабли универсальны для любого LLM-прокси. Если интересно, могу отдельно разобрать unified-формат сообщений и обработку отмены (abort) на стриме.

Пишите в комментариях, кто как решал UTF-8 на стриминге — встречали ли setEncoding-сюрпризы?