Резервные копии диалогов: Боль синхронизации и консистентности

12.06.2026 17:00

Пользователь набирает сложный технический промпт на экране смартфона. Текст занимает три экрана, содержит куски кода и подробные бизнес-требования. Большой палец тянется к кнопке «Отправить». В этот момент поезд метро въезжает в туннель. Соединение обрывается. Приложение перезагружается, либо пользователь смахивает его в попытке «починить интернет». При следующем запуске экран пуст. Промпт исчез. Пользователь уходит и больше не возвращается.

Проектирование LLM-интерфейсов часто начинается с фатальной ошибки: разработчики относятся к чату как к обычному stateless-фронтенду, где состояние живет в оперативной памяти (например, в стейте React или Vue), а взаимодействие сводится к банальному await fetch. Если сеть падает, в лучшем случае рисуется красный тост «Ошибка соединения». В худшем — интерфейс зависает в состоянии вечного лоадера.

Цена потери контекста в AI-приложениях несоизмеримо выше, чем в классических CRUD-системах. Диалог с нейросетью — это процесс совместного мышления, и амнезия клиента разрушает ценность продукта. Решение этой проблемы требует радикального пересмотра архитектуры: перехода к Local-First подходу и построению жестких транзакционных границ на стороне клиента.

Иллюзия сетевой надежности и `navigator.onLine`

Первая линия обороны, которую выстраивают инженеры без опыта работы с офлайном — проверка navigator.onLine. Это ловушка. Устройство может быть подключено к роутеру, у которого отвалился апплинк. Единственный достоверный маркер доступности сети — успешный ответ от сервера или таймаут.

Когда мы отправляем запрос к LLM, время ожидания ответа исчисляется секундами или десятками секунд. Окно уязвимости для сетевого сбоя огромно. Запрос может потеряться на пути к серверу. Он может дойти до сервера, сервер передаст его в RouterAPI, нейросеть сгенерирует ответ, токены спишутся со счета, но ответ потеряется на обратном пути к клиенту.

Для пользователя результат один: приложение сломалось, ответ не получен. Если клиент просто повторит POST-запрос при реконнекте, бизнес заплатит за генерацию дважды, а на бэкенде появятся дублирующие записи.

Local-First: Авторитет локального хранилища

Чтобы разорвать эту зависимость от стабильности сети, клиентское приложение обязано стать главным источником истины (Source of Truth) для пользовательских данных. Сеть — это лишь асинхронный механизм транспортной синхронизации.

Архитектура меняется фундаментально:

  1. UI никогда не отправляет данные напрямую в сеть при нажатии кнопки.
  2. UI транзакционно пишет данные в локальную базу (IndexedDB в браузере через Dexie/RxDB, либо SQLite/Room на мобильных платформах).
  3. Независимый фоновый процесс (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-соединение с клиентом уже разорвано.

Схема работы терминатора:

  1. RouterAPI отдает чанки бэкенду.
  2. Бэкенд аппендит токены в Redis-буфер и параллельно транслирует клиенту по SSE.
  3. Устройство клиента заезжает в туннель. Бэкенд ловит Broken pipe.
  4. Бэкенд не прерывает соединение с 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). Архитектуру следует перевести на асинхронные рельсы:

  1. Запрос уходит в бэкенд. Бэкенд фиксирует задачу и сразу отвечает клиенту 202 Accepted.
  2. Фоновый воркер бэкенда вызывает RouterAPI.
  3. Клиент либо поллит статус по Idempotency-Key, либо слушает WebSocket-канал.
  4. Если клиент ушел в офлайн, результат дождется его в серверной БД до следующего такта синхронизации.

Автономия данных

Синхронизация и работа с сетью — это не просто набор паттернов повторных попыток HTTP-запросов. Это философия проектирования. Перенос центра тяжести с эфемерных сетевых вызовов на локальную транзакционную базу меняет класс приложения.

Приложение перестает быть хрупким терминалом к серверу. Оно становится сейфом для идей пользователя. Даже если инфраструктура лежит, балансировщики отдают 504, а дата-центр провайдера испытывает проблемы — пользователь открывает чат, скроллит историю без задержек, копирует старые промпты и формирует новые задачи в оффлайн-черновик.

Интеграция с RouterAPI абстрагирует сложность маршрутизации между десятками нейросетей. Задача инженера клиентской части — обеспечить монолитную отказоустойчивость на последней миле: от пальцев пользователя до локального диска. Консистентность здесь достигается не магией реактивных фреймворков, а жесткой дисциплиной проектирования: сначала коммит на диск, потом сеть, тотальная идемпотентность и асинхронная сверка состояний.

Теги

Ещё по теме