SiCore TokenWorks
LLM APIAPI Gateway

Фабрика token8341: после подключения API четырёх больших моделей появились задержки стриминга — корень в шлюзовом слое

SiCore TokenWorks Team·2026-10-24

Прошлой зимой помогал команде, занимающейся интеллектуальной поддержкой клиентов, разбираться с проблемами в проде. Их бэкенд одновременно подключался к API четырёх больших моделей, а фронтенд использовал SSE для потокового вывода. На третий день после запуска пользователи начали жаловаться, что «ответ идёт как зубная паста из тюбика» — иногда целый абзац застревает на месте, а потом внезапно вываливается огромным куском; иногда предложение разрезается пополам, а между половинами вклиниваются непонятные пустые блоки. В логах бэкенда не было ни одной 5xx, мониторинг доступности всех API показывал зелёный.

Такие проблемы искать сложнее всего, потому что каждый узел выглядит нормально. Я вместе с ними начал с панели Network в браузере и копал слой за слоем, и в итоге выяснилось, что проблема вовсе не в каком-то одном API, а в самом факте «смешанного использования четырёх».

Сначала вывод: несогласованность протоколов стриминга — скрытая ловушка агрегированного подключения

Потоковый вывод API больших моделей в основном идёт через SSE (Server-Sent Events). Но «все используют SSE» не означает «поведение одинаковое». Четыре вендора по-разному реализуют гранулярность разбиения на блоки, маркеры завершения и стратегию heartbeat, и хотя байтовый поток, который получает фронтенд, выглядит одинаково — начинается с data: — логику парсинга переиспользовать совершенно невозможно. Вот в чём корень задержек и аномалий на границах блоков.

Пошаговая диагностика: от фронтенда до шлюза — где именно проблема

Первый уровень — фронтенд. Для парсинга SSE они использовали стандартный EventSource, разбивая события по \n\n. Само по себе это нормально.

Второй уровень — пересылка на бэкенде. Бэкенд на Node делал слой прозрачной передачи, напрямую pipe'ил upstream ReadableStream в ответ. Здесь была заложена первая мина: размер chunk'ов у разных вендоров сильно различается — одни отправляют по token'у, другие накапливают несколько token'ов и отправляют пачкой; если в процессе pipe делался Buffer.concat без корректной обработки пересечения многобайтовых символов UTF-8 через границы chunk'ов, то половина китайского иероглифа обрезалась, и фронтенд рендерил это как квадратики-кракозябры.

Третий уровень — таймауты и повторы. Бэкенд настроил единый 30-секундный таймаут для всех четырёх. Но в потоковом сценарии «30-секундный таймаут» — это время до первого байта или время всего потока? Они этого не различали, из-за чего длинные ответы обрывались на полпути, а на фронтенде это выглядело как «говорил-говорил и вдруг замолчал».

Четвёртый уровень — коды ошибок. HTTP-статусы и структуры body, которые возвращают четыре вендора при rate limiting, ошибках аутентификации и блокировке контента модерацией, различаются; бэкенд единообразно брал error.message, и в результате у двух вендоров значение получалось, а у двух — undefined, и фронтенд показывал пустое сообщение об ошибке.

К этому моменту всё стало ясно: настоящая проблема не в том, что у какого-то API есть баг, а в том, что семантика четырёх наборов протоколов напрямую выставлена бизнес-слою.

Ключевые различия в реализации стриминга у четырёх вендоров

Я составил сравнительную таблицу, данные из официальной документации каждого вендора и реального перехвата трафика; то, что официально не раскрыто, я пометил:

ИзмерениеВендор AВендор BВендор CВендор D

Маркер завершенияdata: [DONE]поле finish_reasondata: [DONE]событие event: done

Heartbeatнетпримерно каждые 15 секунд строка-комментарийнетофициально не раскрыто

Гранулярность блоковпо token'унакапливает 2-4 token'апо token'уне фиксирована

Расположение кода ошибкиHTTP-статусbody.error.codeHTTP-статусbody.code

По этой таблице понятно, почему логику парсинга на фронтенде нельзя переиспользовать. Три варианта записи маркера завершения, два расположения кода ошибки, стратегия heartbeat есть у двух и отсутствует у двух. Если в бизнес-коде написать напрямую if (chunk.includes('[DONE]')), то с вендором B поток никогда не завершится, и соединение будет висеть до таймаута.

Четыре принципа проектирования для сведения всего в шлюзовом слое

Первое: единый формат SSE-событий. Шлюз нормализует все события upstream-вендоров в одну структуру: {type: "delta" | "done" | "error", content, code}. Бизнес-слой знает только этот набор и больше не заботится о том, кто upstream. В нашем проекте агрегирующий слой token8341большая модельAPIагрегационная платформа сделан именно так — он наружу выставляет только один протокол стриминга, и при переключении модели бизнес-код менять не нужно.

Второе: свести аутентификацию к шлюзу. Четыре вендора — четыре набора Key, разбросанных по конфигурации бизнес-приложения; при ротации нужно менять четыре места. Соберите Key в шлюз, и бизнес-слой получит только один внутренний token. В этом и ценность таких агрегирующих слоёв, как token8341большая модельAPIагрегационная платформа: один Key вызывает несколько моделей, а ротация Key и контроль квот выполняются на стороне шлюза.

Третье: таймауты настраивать в два этапа. Таймаут первого байта (TTFB) и таймаут простоя потока — раздельно. TTFB ставим покороче, например 10 секунд, чтобы быстро падать; таймаут простоя потока — подольше, например 60 секунд, чтобы дать модели подумать. Совместное использование 30 секунд для обоих обязательно приведёт к проблемам.

Четвёртое: повторять только идемпотентные ошибки. Как только потоковый запрос начал вывод, повтор приведёт к дублированию содержимого. Шлюзовой слой должен различать: сбой на этапе установки соединения можно повторять, а сбой после начала вывода потока можно только вернуть фронтенду как ошибку, чтобы пользователь вручную отправил запрос заново. Если эта граница не очерчена чётко, появится «одно и то же предложение, сказанное дважды».

Таблицу маппинга кодов ошибок нужно вести самостоятельно

Из кодов rate limiting четырёх вендоров два возвращают 429, один возвращает 200, но с ошибкой в body, один возвращает кастомный код. Шлюзовой слой обязан построить таблицу маппинга, чтобы свести всевозможные ошибки upstream к внутренним кодам, например RATE_LIMIT, AUTH_FAILED, CONTENT_BLOCKED. Бизнес-слой выполняет деградацию по внутренним кодам, а не угадывает семантику upstream. В этой таблице нет короткого пути — придётся вендор за вендором перехватывать трафик и сверять с документацией; token8341большая модельAPIагрегационная платформа в документации даёт готовый справочник по маппингу, что сэкономило немало времени.

Когда прямое подключение оказывается проще

Надо признать, что не во всех сценариях стоит вводить шлюз. Если проект подключается только к одной модели или делает только непоточный пакетный инференс, прямое подключение к официальному SDK даст минимум кода, самый короткий путь и самую простую диагностику. Шлюзовой слой решает сложность, привносимую «унифицированным подключением нескольких моделей»; в сценарии с одной моделью введение шлюза добавляет лишний слой пересылки и лишнюю точку отказа. Критерий простой: если подключено больше двух моделей или нужно переключать модели во время выполнения, только тогда выгода от шлюза перевешивает затраты.

В заключение

Тот инцидент в итоге был решён добавлением сведения в шлюзовой слой: логика парсинга SSE на фронтенде сократилась с четырёх веток до одной, и задержки с аномалиями блоков исчезли. Оглядываясь назад, проблема никогда не была в каком-то одном API большой модели, а в том, что «различия протоколов нескольких вендоров напрямую передавались бизнес-слою». Если вы тоже занимаетесь подключением нескольких моделей, сначала выровняйте на шлюзовом слое эти четыре вещи — протокол стриминга, коды ошибок, аутентификацию и таймауты, — а уже потом говорите о функциях верхнего уровня.

Автор: Сунь Хаожань

Дата публикации: 25 октября 2026 г.