Пользователь набирает сложный технический промпт на экране смартфона. Текст занимает три экрана, содержит куски кода и подробные бизнес-требования. Большой палец тянется к кнопке «Отправить». В этот момент поезд метро въезжает в туннель. Соединение обрывается. Приложение перезагружается, либо пользователь смахивает его в попытке «починить интернет». При следующем запуске экран пуст. Промпт исчез. Пользователь уходит и больше не возвращается.
Проектирование LLM-интерфейсов часто начинается с фатальной ошибки: разработчики относятся к чату как к обычному stateless-фронтенду, где состояние живет в оперативной памяти (например, в стейте React или Vue), а взаимодействие сводится к банальному await fetch. Если сеть падает, в лучшем случае рисуется красный тост «Ошибка соединения». В худшем — интерфейс зависает в состоянии вечного лоадера.
Цена потери контекста в AI-приложениях несоизмеримо выше, чем в классических CRUD-системах. Диалог с нейросетью — это процесс совместного мышления, и амнезия клиента разрушает ценность продукта. Решение этой проблемы требует радикального пересмотра архитектуры: перехода к Local-First подходу и построению жестких транзакционных границ на стороне клиента.
Иллюзия сетевой надежности и `navigator.onLine`
Первая линия обороны, которую выстраивают инженеры без опыта работы с офлайном — проверка navigator.onLine. Это ловушка. Устройство может быть подключено к роутеру, у которого отвалился апплинк. Единственный достоверный маркер доступности сети — успешный ответ от сервера или таймаут.
Когда мы отправляем запрос к LLM, время ожидания ответа исчисляется секундами или десятками секунд. Окно уязвимости для сетевого сбоя огромно. Запрос может потеряться на пути к серверу. Он может дойти до сервера, сервер передаст его в RouterAPI, нейросеть сгенерирует ответ, токены спишутся со счета, но ответ потеряется на обратном пути к клиенту.
Для пользователя результат один: приложение сломалось, ответ не получен. Если клиент просто повторит POST-запрос при реконнекте, бизнес заплатит за генерацию дважды, а на бэкенде появятся дублирующие записи.
Local-First: Авторитет локального хранилища
Чтобы разорвать эту зависимость от стабильности сети, клиентское приложение обязано стать главным источником истины (Source of Truth) для пользовательских данных. Сеть — это лишь асинхронный механизм транспортной синхронизации.
Архитектура меняется фундаментально:
- UI никогда не отправляет данные напрямую в сеть при нажатии кнопки.
- UI транзакционно пишет данные в локальную базу (IndexedDB в браузере через Dexie/RxDB, либо SQLite/Room на мобильных платформах).
- Независимый фоновый процесс (Sync Engine) отслеживает изменения в БД и проталкивает их на сервер.
Каждое сообщение получает жесткий конечный автомат (State Machine). Эфемерные boolean-флаги загрузки недопустимы.
Состояния сообщения:
draft— черновик, сохраняется при каждом нажатии клавиши с дебаунсом.pending_send— пользователь нажал «Отправить». Сообщение закоммичено в диск. UI отрисовал его.sending— Sync Engine взял сообщение в работу и открыл TCP-соединение.streaming— сервер начал отдавать Server-Sent Events (SSE) с токенами ответа ИИ.completed— стрим успешно завершен, транзакция закрыта.failed— неисправимая ошибка (например, модерация RouterAPI отклонила промпт).
Идентификаторы сообщений генерируются строго на клиенте. Лучший стандарт для этого — UUIDv7. В отличие от ранних версий, он включает в себя монотонный таймстемп. Это решает проблему лексикографической сортировки сообщений в локальной базе до того, как сервер подтвердит их, и предотвращает конфликты слияния, если локальные часы клиента рассинхронизированы с NTP.
Идемпотентность и защита от двойного списания
Представим сценарий: Sync Engine перевел сообщение в sending, запрос ушел на наш бэкенд, а оттуда в RouterAPI. Сеть на клиенте падает.
Под капотом RouterAPI маршрутизирует запрос к целевому провайдеру (OpenAI, Anthropic), получает потоковый ответ и возвращает его нашему бэкенду. Наш бэкенд пишет ответ в свою серверную PostgreSQL базу. Но клиент об этом не знает. Его локальная БД застряла в статусе sending.
Когда клиент восстанавливает связь, Sync Engine видит зависшее сообщение и повторяет попытку отправки. Идемпотентность становится критическим требованием.
При первой отправке клиент прикрепляет к запросу HTTP-заголовок X-Idempotency-Key, значением которого выступает UUIDv7 клиентского сообщения. Бэкенд, принимая запрос, делает UPSERT или транзакционную проверку:
SELECT id, status, response_text FROM messages WHERE client_message_id = ?
Если запись уже существует и статус ответа completed, бэкенд не дергает RouterAPI. Он возвращает клиенту готовый ответ из собственной базы. Мы мгновенно восстанавливаем консистентность на клиенте и защищаем бюджет на токены.
Боль стриминга: Разрыв посередине ответа
Самый сложный архитектурный вызов — обрыв сети во время генерации токенов (Server-Sent Events). В локальной БД клиента сохранено 40% ответа.
Если архитектура полагается на то, что клиент держит соединение с RouterAPI напрямую (или через прозрачный TCP-прокси), потеря пакетов означает безвозвратную потерю хвоста сообщения. LLM не умеют «догенерировать текст с 41-го процента» без повторной передачи всего контекста и повторной тарификации.
Решение кроется в дизайне бэкенда как буфера-терминатора. Когда наш бэкенд вызывает RouterAPI с флагом stream: true, он продолжает вычитывать стрим провайдера и писать токены в серверную БД, даже если TCP-соединение с клиентом уже разорвано.
Схема работы терминатора:
- RouterAPI отдает чанки бэкенду.
- Бэкенд аппендит токены в Redis-буфер и параллельно транслирует клиенту по SSE.
- Устройство клиента заезжает в туннель. Бэкенд ловит
Broken pipe. - Бэкенд не прерывает соединение с RouterAPI. Он дожидается
[DONE], собирает полный текст и сбрасывает его в PostgreSQL со статусомcompleted.
При возвращении в сеть клиент инициирует фазу сверки (Reconciliation). Он запрашивает: GET /api/sync?since=. Бэкенд отдает полный текст ответа, сгенерированного в фоне. Клиент локально затирает обрывок в IndexedDB полным текстом и меняет статус на completed.
Изоляция сбоев RouterAPI и долгий поллинг
Сбой может произойти выше по течению: между нашим бэкендом, RouterAPI и конечным провайдером. RouterAPI предоставляет надежный слой маршрутизации и умные ретраи, но таймауты upstream-провайдеров неизбежны.
Если RouterAPI возвращает 502 или исчерпывает лимиты повторных попыток, бэкенд транслирует это состояние клиенту. В Local-First парадигме мы не удаляем сообщение из UI. Мы переводим его в статус failed_upstream и рендерим кнопку «Повторить генерацию». Стейт сложного промпта остается в безопасности на диске пользователя.
Для запросов, где RouterAPI использует глубокие fallback-цепочки, время ожидания ответа может превысить жесткие лимиты балансировщиков (например, 60 секунд на AWS ALB или Nginx). Архитектуру следует перевести на асинхронные рельсы:
- Запрос уходит в бэкенд. Бэкенд фиксирует задачу и сразу отвечает клиенту
202 Accepted. - Фоновый воркер бэкенда вызывает RouterAPI.
- Клиент либо поллит статус по
Idempotency-Key, либо слушает WebSocket-канал. - Если клиент ушел в офлайн, результат дождется его в серверной БД до следующего такта синхронизации.
Автономия данных
Синхронизация и работа с сетью — это не просто набор паттернов повторных попыток HTTP-запросов. Это философия проектирования. Перенос центра тяжести с эфемерных сетевых вызовов на локальную транзакционную базу меняет класс приложения.
Приложение перестает быть хрупким терминалом к серверу. Оно становится сейфом для идей пользователя. Даже если инфраструктура лежит, балансировщики отдают 504, а дата-центр провайдера испытывает проблемы — пользователь открывает чат, скроллит историю без задержек, копирует старые промпты и формирует новые задачи в оффлайн-черновик.
Интеграция с RouterAPI абстрагирует сложность маршрутизации между десятками нейросетей. Задача инженера клиентской части — обеспечить монолитную отказоустойчивость на последней миле: от пальцев пользователя до локального диска. Консистентность здесь достигается не магией реактивных фреймворков, а жесткой дисциплиной проектирования: сначала коммит на диск, потом сеть, тотальная идемпотентность и асинхронная сверка состояний.