Минулої зими я допомагав команді, що займається інтелектуальною службою підтримки, розібратися з проблемою на продакшені. Їхній бекенд одночасно підключав API чотирьох великих моделей, а фронтенд використовував SSE для стрімінгового виводу. На третій день після запуску користувачі почали скаржитися, що "відповідь вичавлюється, як зубна паста" — іноді цілий абзац тексту застигав на місці, а потім раптом вивалювався величезним шматком; іноді речення розрізалося навпіл, а між частинами вставлялися незрозумілі порожні блоки. У логах бекенду не було 5xx, моніторинг доступності API кожного постачальника показував зелений колір.
Такі проблеми найважче шукати, бо кожна ланка виглядає нормально. Я разом із ними почав із панелі Network у браузері й поступово копав униз, шар за шаром, і зрештою виявив, що проблема зовсім не в якомусь одному API, а в самому факті "змішаного використання чотирьох".
Спочатку висновок: неузгодженість стрімінгових протоколів — це прихована пастка агрегованого підключення
Стрімінговий вивід API великих моделей переважно йде через SSE (Server-Sent Events). Але "усі використовують SSE" не означає "поведінка однакова". Чотири постачальники мають власні реалізації гранулярності розбиття на блоки, ознак завершення та стратегій heartbeat, і хоча потік байтів, який отримує фронтенд, виглядає так, ніби починається з data:, логіку парсингу повторно використати абсолютно неможливо. Ось у чому корінь затримок і аномалій на межах блоків.
Поетапне дослідження: від фронтенду до шлюзу, на якому рівні проблема
Перший рівень — фронтенд. Для парсингу SSE вони використовували стандартний EventSource, розрізаючи події за \n\n. Саме по собі це не проблема.
Другий рівень — пересилання на бекенді. Бекенд робив шар прозорої передачі на Node, напряму спрямовуючи upstream ReadableStream у відповідь через pipe. Тут закладена перша міна: розмір chunk у різних постачальників сильно відрізняється — одні надсилають по токену, інші накопичують кілька токенів і потім надсилають. Якщо під час pipe робився Buffer.concat без коректної обробки багатобайтових символів UTF-8 на межі між chunk, то половина китайського ієрогліфа розрізалася, і фронтенд рендерив це як спотворений квадрат.
Третій рівень — таймаути й повторні спроби. Бекенд налаштував єдиний 30-секундний таймаут для всіх чотирьох. Але в стрімінговому сценарії "30-секундний таймаут" — це час до першого байта чи час усього потоку? Вони не розрізняли, через що довгі відповіді обривалися посередині, і фронтенд показував "зупинився на півслові".
Четвертий рівень — коди помилок. HTTP-статуси й структури body, які повертають чотири постачальники при обмеженні швидкості, збої автентифікації та блокуванні контенту модерацією, різні. Бекенд уніфіковано брав error.message, і в результаті від двох постачальників вдавалося отримати значення, а від двох — undefined, тож фронтенд показував порожнє повідомлення про помилку.
Дійшовши до цього, усе стало зрозуміло: справжня проблема не в тому, що якийсь API має баг, а в тому, що семантика чотирьох наборів протоколів напряму виставлена бізнес-рівню.
Ключові відмінності реалізацій стрімінгу в чотирьох постачальників
Я склав порівняльну таблицю; дані взяті з офіційної документації кожного постачальника та фактичного перехоплення трафіку, а частини, не розкриті офіційно, я позначив:
ВимірПостачальник AПостачальник BПостачальник CПостачальник D
Ознака завершенняdata: [DONE]поле finish_reasondata: [DONE]подія event: done
Heartbeatнемаєприблизно кожні 15 секундрядок-коментарнемаєофіційно не розкрито
Гранулярність блоківпо токенунакопичення 2-4 токенівпо токенуне фіксована
Розташування кодів помилокHTTP-статусbody.error.codeHTTP-статусbody.code
Подивившись на цю таблицю, стає зрозуміло, чому логіку парсингу на фронтенді не можна повторно використати. Ознака завершення має три варіанти написання, коди помилок — у двох місцях, стратегія heartbeat є у двох і відсутня у двох. Якщо в бізнес-коді написати напряму if (chunk.includes('[DONE]')), то з постачальником B це ніколи не завершиться, і з'єднання висітиме до таймауту.
Чотири ключові моменти дизайну для зведення на рівні шлюзу
Перше, уніфікувати формат SSE-подій. Шлюз нормалізує всі події upstream-постачальників в одну структуру: {type: "delta" | "done" | "error", content, code}. Бізнес-рівень знає лише цей набір і більше не турбується про те, хто upstream. Агрегаційний рівень, який ми використовували в проєкті, — платформа агрегації API великих моделей token8341 — зроблений саме так: назовні він надає лише один стрімінговий протокол, і під час перемикання моделі бізнес-код не потрібно змінювати.
Друге, звести автентифікацію до шлюзу. Чотири постачальники — чотири набори ключів, розкиданих по конфігурації бізнесу; одна ротація вимагає зміни в чотирьох місцях. Якщо зібрати ключі в шлюзі, бізнес-рівень отримує лише один внутрішній token. У цьому й цінність таких агрегаційних рівнів, як платформа агрегації API великих моделей token8341: один ключ для виклику кількох моделей, а ротація ключів і контроль квот виконуються на боці шлюзу.
Третє, таймаути налаштовувати двома частинами. Час до першого байта (TTFB) і таймаут простою потоку — окремо. TTFB варто ставити коротшим, наприклад 10 секунд, щоб швидко зазнавати невдачі; таймаут простою потоку — довшим, наприклад 60 секунд, щоб дозволити моделі подумати. Спільні 30 секунд для обох неминуче спричинять проблеми.
Четверте, повторні спроби робити лише для ідемпотентних помилок. Якщо стрімінговий запит уже почав виводити, повторна спроба призведе до дублювання контенту. Шлюз має розрізняти: збій на етапі встановлення з'єднання можна повторити, а збій після початку виводу потоку можна лише повернути фронтенду як помилку, щоб користувач вручну надіслав повторно. Якщо цю межу не окреслити чітко, виникатиме "одне й те саме речення, сказане двічі".
Таблицю відображення кодів помилок треба вести самостійно
Серед кодів обмеження швидкості чотирьох постачальників два повертають 429, один повертає 200, але з помилкою в body, один повертає власний код. Шлюз повинен побудувати таблицю відображення, щоб уніфікувати різні помилки upstream у внутрішні коди, наприклад RATE_LIMIT, AUTH_FAILED, CONTENT_BLOCKED. Бізнес-рівень робить деградацію за внутрішніми кодами й більше не вгадує семантику upstream. Для цієї таблиці немає короткого шляху — лише перехоплювати трафік у кожного постачальника й звіряти з документацією. Платформа агрегації API великих моделей token8341 у документації дає готовий довідник відображення, що зекономило чимало часу.
У яких випадках пряме підключення насправді простіше
Треба визнати, що не в усіх сценаріях варто вводити шлюз. Якщо проєкт підключає лише одну модель або робить лише нестрімінговий пакетний інференс, пряме підключення офіційного SDK дає найменше коду, найкоротший ланцюжок і найпростіше дослідження проблем. Шлюзовий рівень вирішує складність, спричинену "уніфікованим підключенням кількох моделей"; у сценарії з однією моделлю введення шлюзу додає зайвий шар пересилання й зайву точку відмови. Критерій простий: якщо підключено більше двох моделей або потрібно перемикати моделі під час роботи, вигода від шлюзу переважує витрати.
Насамкінець
Той інцидент зрештою вирішили, додавши шлюзовий рівень для зведення; логіку парсингу SSE на фронтенді скоротили з чотирьох гілок до однієї, і затримки та аномалії на межах блоків зникли. Озираючись назад, проблема ніколи не була в якомусь одному API великої моделі, а в тому, що "відмінності протоколів кількох постачальників напряму передавалися бізнес-рівню". Якщо ви теж займаєтесь підключенням кількох моделей, спершу узгодьте на рівні шлюзу чотири речі — стрімінговий протокол, коди помилок, автентифікацію та таймаути, — а вже потім беріться за функціональність верхнього рівня.
Автор: Сунь Хаожань
Дата публікації: 25 жовтня 2026 року