Перейти к содержимому
5 мин чтения

Где хранить состояние диалога, чтобы сменить модель без потерь?

Разбираем, где хранить состояние диалога, чтобы сохранить приватность, восстановить сессии и безболезненно менять LLM, API и провайдера.

Где хранить состояние диалога, чтобы сменить модель без потерь?

Состояние диалога нужно хранить у себя, если приложение должно пережить смену модели, провайдера, схемы API или сбой посреди вызова инструмента. Память у провайдера удобна как механизм продолжения конкретной цепочки, но она не заменяет запись приложения.

Ошибка начинается с фразы «у нас же есть conversation ID». У такого ID нет универсального смысла за пределами одного API. Он может ссылаться на сохранённую историю, на один ответ, на внутренний граф исполнения или только на короткий срок хранения. Когда команда строит продукт вокруг этого указателя, она передаёт поставщику контроль над восстановлением сессии, расследованием инцидента и миграцией.

Это не призыв всегда пересылать модели весь лог чата. Это призыв различать данные, которые принадлежат вашему приложению, и временный контекст, который конкретный API умеет держать для удобства. Для банковского помощника, клинического оператора, B2B-агента и обычного SaaS это различие быстро становится практическим.

Состояние диалога состоит из трёх разных вещей

Стенограмма, рабочий контекст и состояние выполнения должны жить как разные сущности. Если сложить их в один массив messages, система сначала кажется простой, а затем перестаёт объяснять собственные ошибки.

Стенограмма отвечает на вопрос, что произошло. В ней есть вход пользователя, ответ ассистента, вложения, исправления оператора, события модерации и ссылки на версии данных. Это ваш журнал предметных фактов. Его нельзя бездумно перезаписывать сводкой, потому что именно из него разбирают спорный ответ или восстанавливают разговор после дефекта.

Рабочий контекст отвечает на другой вопрос: что модель должна увидеть на следующем ходе. Он почти никогда не равен всей стенограмме. В него могут войти системная инструкция, действующая сводка старых реплик, последние сообщения, найденные документы, профиль пользователя и результаты нескольких актуальных инструментов. Контекст одноразовый: после отправки его можно пересобрать по правилам приложения.

Состояние выполнения нужно агенту, а не собеседнику. Оно включает вызов инструмента, параметры, идентификатор операции во внешней системе, результат, ожидание одобрения человеком, таймаут, повтор и отмену. Если агент начал оформлять возврат, отправил запрос в CRM и упал до следующего ответа модели, продолжить разговор без этого состояния нельзя.

Именно здесь команды путают «помнить чат» и «уметь возобновить работу». Модель может помнить предыдущую реплику через серверный ID. Она не знает, была ли операция во внешней платёжной системе завершена, пока вы не сохраните и не передадите этот факт.

Практичный минимум выглядит так:

conversation           - владелец, tenant, политика хранения, статус
conversation_event     - неизменяемая запись реплики или доменного события
context_snapshot       - сводка, диапазон покрытых событий, версия промпта
run                    - один запуск модели, модель, параметры, трассировка
 tool_call             - имя, аргументы, idempotency_key, статус, результат
provider_cursor        - провайдер, тип API, внешний ID, срок действия если известен

Пробел перед tool_call в этой схеме не важен, важна граница. conversation_event описывает то, что было сказано и сделано в предметной области. provider_cursor хранит чужой указатель, который может ускорить следующий запрос. Не делайте чужой указатель первичным ключом своего разговора.

Память у провайдера ускоряет цепочку, но привязывает вас к ней

Серверное продолжение разговора экономит передачу истории и иногда даёт провайдеру доступ к полным промежуточным данным исполнения. Это полезно, особенно для длинных агентных задач. Но за удобство вы платите переносимостью и менее ясной границей хранения.

Например, документация Gemini Interactions описывает продолжение через previous_interaction_id: сервер извлекает историю по этому идентификатору, а клиенту не нужно повторно отправлять весь чат. При этом системную инструкцию, инструменты и параметры генерации надо задавать для каждого нового взаимодействия заново. Документация также указывает, что сохранение включено по умолчанию, а срок хранения различается по тарифу.

Из этого следует неприятный, но полезный вывод: даже внутри одного API «состояние разговора» не обязательно включает все условия, которые сформировали ответ. Если при возобновлении вы забыли передать новую версию набора инструментов или системную инструкцию, разговор продолжится с другой политикой поведения. В интерфейсе это выглядит как внезапная забывчивость модели, хотя причина лежит в контракте API.

Есть и обратная модель. Документация OpenRouter для Responses API прямо называет интерфейс stateless: каждый запрос независим, а полную историю нужно передать заново. Это не недостаток. Статeless интерфейс заставляет приложение явно владеть контекстом, поэтому его проще перенести на другую модель, повторить в тесте и отфильтровать по правилам доступа.

Хранить состояние у провайдера оправдано в трёх случаях:

  • вы делаете короткий внутренний прототип без чувствительных данных;
  • серверный объект содержит промежуточные данные, которые сложно воспроизвести в первом релизе;
  • вы всё равно сохраняете собственную стенограмму и можете построить новый контекст без этого объекта.

Последний пункт отделяет разумный компромисс от зависимости. Если внешний ID исчез, истёк или стал недоступен, приложение должно честно продолжить беседу из своего журнала. Возможно, без части скрытых промежуточных шагов, но без потери клиентской истории и без опасного повторного действия.

Смена модели ломается на семантике, а не на формате JSON

OpenAI-совместимый формат сообщений помогает подключить новый endpoint, но не делает разговор переносимым автоматически. Поля могут выглядеть одинаково, а смысл ролей, инструментов, структурированного вывода, изображений и скрытых reasoning-данных будет разным.

Особенно опасна попытка скопировать в новую модель сырой массив запросов старого API. В нём часто есть служебные сообщения, специфичные идентификаторы вызовов, фрагменты потокового ответа, формат результата инструмента или системные поля, которые второй провайдер не принимает. В лучшем случае новый API вернёт 400. В худшем, он молча интерпретирует историю иначе.

Нужен канонический слой событий, который не повторяет типы конкретного вендора. Например:

{
  "event_id": "evt_01JX...",
  "conversation_id": "conv_8f2...",
  "sequence": 42,
  "kind": "tool_result",
  "actor": "application",
  "occurred_at": "2026-07-23T10:14:08Z",
  "payload": {
    "tool_name": "get_invoice_status",
    "call_id": "call_73a...",
    "input": {"invoice_id": "inv_481"},
    "output_ref": "obj://conversation-artifacts/evt_01JX...",
    "status": "succeeded"
  },
  "schema_version": 1
}

Здесь kind описывает событие приложения, а не внутреннюю роль одной модели. Полезный набор типов обычно включает user_message, assistant_message, tool_call_requested, tool_result, human_approval_requested, human_approval_resolved, context_compacted и policy_decision. Не добавляйте тип на каждую мелочь. Добавляйте его, когда новое событие меняет то, что можно безопасно восстановить.

При миграции вы строите адаптеры в обе стороны:

  1. адаптер входа превращает ответ провайдера в канонические события;
  2. сборщик контекста выбирает нужные события и создаёт запрос для выбранной модели;
  3. адаптер выхода проверяет ответ, извлекает инструментальные вызовы и записывает их в журнал;
  4. тесты продолжают один и тот же разговор на старой и новой моделях, сравнивая не слова, а ожидаемые действия и ограничения.

Последний пункт часто недооценивают. Вам не нужна идентичная формулировка ответа. Вам нужно, чтобы новая модель не забыла подтверждённый язык клиента, не вызвала запрещённый инструмент и не попросила повторно уже полученный документ. Это проверяют сценариями продолжения с фиксированной стенограммой, а не сравнением одного красивого демо-запроса.

Приватность определяется всем маршрутом данных

Своя PostgreSQL-база в Казахстане не доказывает, что данные остались в Казахстане. В разговоре могут быть сырые сообщения, файлы, эмбеддинги, результаты поиска, трассировки, кэш, резервные копии, журналы ошибок и сохранённые объекты провайдера. Если хотя бы один слой содержит персональные данные, его нужно включить в модель угроз и в политику хранения.

Начните не с таблицы, а с классификации полей. Реплика пользователя может содержать номер договора, медицинское описание, реквизиты платежа или коммерческую тайну. Результат инструмента иногда опаснее самого вопроса: оператор спрашивает «где заказ», а CRM возвращает адрес, телефон и полный список покупок.

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

Хорошая архитектура делает редактирование частью сборки контекста. До вызова модели отдельный слой должен:

  • заменить или удалить поля, которые модели не нужны;
  • развернуть временные токены только для разрешённого инструмента;
  • записать версию правила маскирования рядом с запуском;
  • не отправлять в трассировку исходный текст, если трассировка не имеет отдельного доступа.

Не рассчитывайте на маскирование после ответа. На этом этапе данные уже могли попасть в запрос, журнал SDK или сохранённое состояние API. Для чувствительных процессов предпочтительнее передавать модели ссылку на разрешённый факт, а извлечение полного значения оставить инструменту с проверкой прав.

Маршрутизация добавляет ещё одну переменную. У агрегатора один совместимый endpoint может скрывать выбор между моделями и провайдерами. В документации OpenRouter, например, параметры маршрутизации позволяют указать порядок провайдеров, запретить fallback, требовать поддержку параметров, ограничить маршруты с хранением данных и выбрать ZDR endpoints. Смысл не в том, чтобы копировать именно эти поля в любой стек. Смысл в том, что политика данных должна участвовать в выборе маршрута до отправки запроса, а не жить отдельным PDF, который код не читает.

Контекст нужно собирать по бюджету и по назначению

Маршрутизация вместо зависимости от вендора
Выбирайте модели разных провайдеров без переноса доменных conversation ID между их API.

Полный лог диалога почти всегда превращается в плохой рабочий контекст. Он дорогой, содержит устаревшие решения и со временем даёт модели больше противоречий, чем полезной информации. Но агрессивная сводка тоже ломает беседу: она выкидывает точные условия, ссылки на документы и подтверждения пользователя.

Держите два слоя памяти. Первый, неизменяемый, содержит события. Второй, производный, содержит снапшоты контекста. Снапшот должен иметь автора, версию шаблона, время создания и границы: например, «покрывает события с 1 по 180». Тогда при ошибке вы можете перестроить его из журнала после исправления промпта или логики извлечения.

Сборщик контекста обычно работает в таком порядке:

  1. берёт системные правила для текущего tenant и задачи;
  2. добавляет компактную сводку подтверждённых фактов из старой части разговора;
  3. выбирает последние реплики без сжатия;
  4. присоединяет только те документы и результаты инструментов, которые относятся к текущему намерению;
  5. оставляет резерв токенов для ответа и возможного вызова инструмента.

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

Есть популярная рекомендация: «просто сохраняйте summary после каждого ответа». Она хороша для демонстрации и плоха для контролируемого процесса. Сводка, созданная моделью, сама может содержать галлюцинацию. После пяти циклов сжатия вы получаете уверенное, короткое и неверное описание разговора. Делайте сводку после понятного порога, сохраняйте ссылку на исходные события и проверяйте критичные поля детерминированным кодом или доменными правилами.

Восстановление сессии начинается с идемпотентности

Не отправляйте PII без контроля
Маскирование PII применяется на платформе, прежде чем чувствительные поля попадут в модельный запрос.

После сбоя нельзя просто повторить последний запрос к модели. Если перед сбоем модель попросила вызвать инструмент, а сервис успел выполнить действие, повтор создаст дубликат. Для агента, который читает данные, это неприятно. Для агента, который создаёт платёж, меняет тариф или отправляет документ, это инцидент.

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

1. Записать tool_call_requested со статусом pending.
2. Сформировать idempotency_key из conversation_id и call_id.
3. Выполнить внешний запрос с этим ключом.
4. Записать tool_result со статусом succeeded или failed.
5. Только после этого отправить результат модели.

Внешний сервис должен поддерживать такой ключ либо предоставлять способ проверить итог операции по вашему идентификатору. Если он не умеет ни того ни другого, агенту нельзя давать необратимое действие без явного человеческого подтверждения.

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

Для ручного одобрения храните не только «approved». Нужны снимок аргументов на момент запроса, кто одобрил, когда, что изменилось до исполнения и срок действия решения. Иначе оператор согласовал возврат на 10 000 тенге, а повторный запуск применил одобрение к уже изменённой сумме.

Внешний cursor должен быть кэшем с понятным отказом

Полезно сохранять ID предыдущего ответа или server-side conversation у провайдера. Он уменьшает задержку и стоимость передачи длинной истории. Но приложение должно считать его кэшем: применить, если он совместим и доступен, иначе пересобрать запрос из собственного журнала.

У provider_cursor должны быть как минимум провайдер, модель или семейство модели, тип API, внешний ID, момент создания, версия инструкций и признак того, можно ли его использовать после изменения политики. Не переиспользуйте cursor после смены tenant, набора инструментов или режима обработки данных. Формально разговор тот же, но условия уже другие.

Полезная проверка перед использованием выглядит так:

cursor можно использовать, если:
- он создан для того же tenant;
- политика данных не стала строже;
- API и модель принимают этот тип cursor;
- набор инструментов совместим с сохранённой цепочкой;
- cursor не истёк и не был отозван.

иначе:
- собрать контекст из событий;
- создать новый запуск;
- сохранить новый cursor только после успешного ответа.

Такой fallback надо тестировать намеренно. В staging удалите внешний ID, подмените модель, запретите прежнего провайдера и оборвите процесс между вызовом инструмента и ответом модели. Если после этого команда не может продолжить разговор или точно сказать пользователю, что уже произошло, архитектура ещё зависит от счастливого пути.

Выбор модели хранения зависит от цены ошибки

Один endpoint для разных моделей
Маршрутизируйте запросы к 500+ моделям через один endpoint, а состояние диалога оставляйте приложению.

Для внутреннего ассистента без инструментов разумен гибрид: стенограмма и настройки сессии в вашей базе, полный контекст по запросу, а server-side state у провайдера только как ускорение. Это даёт простое восстановление и не заставляет немедленно строить сложную оркестрацию.

Для клиентского чата с персональными данными храните события, доступы, версии правил маскирования и аудит у себя. Провайдеру передавайте минимальный рабочий контекст. Если API предлагает сохранение цепочек, включайте его только после проверки условий хранения, удаления и маршрутизации для конкретного класса данных.

Для агентной системы с действиями своя запись событий обязательна. Отдельно храните состояние инструментов, подтверждения, идемпотентные ключи и версии бизнес-правил. В этом классе систем ошибка восстановления дороже дополнительной таблицы.

Для команд, которым нужно переключаться между облачными и локально размещёнными моделями, особенно важно не связывать доменные сессии с ID одного поставщика. AI Router даёт единый OpenAI-совместимый endpoint и поддерживает маршрутизацию к разным моделям, но переносимость всё равно создаёт ваш канонический журнал, а не замена base_url.

Начните с одного неприятного упражнения. Возьмите реальную активную сессию и ответьте письменно на четыре вопроса: какие события уже произошли, какие действия внешних систем могли выполниться, какие данные можно передать следующей модели и что останется, если внешний conversation ID исчезнет. Если последний ответ звучит как «мы не знаем», сначала исправьте модель хранения, а потом добавляйте ещё одну модель.

Часто задаваемые вопросы

Что нужно хранить для многоходового LLM-диалога?

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

Можно ли хранить историю чата только у LLM-провайдера?

Технически можно, если API позволяет продолжить цепочку по идентификатору прошлого ответа. Архитектурно это плохая единственная опора: при смене провайдера, удалении ресурса, изменении retention или ошибке доступа вы потеряете возможность точно восстановить работу. Сохраняйте свой журнал даже тогда, когда используете серверную память.

Нужно ли отправлять модели всю историю сообщений на каждом запросе?

Не обязательно пересылать весь сырой диалог. Обычно приложение хранит полный журнал, а на каждый ход собирает рабочий контекст: системные правила, актуальную сводку, последние реплики и нужные факты из поиска. Это снижает стоимость и не отдаёт провайдеру больше данных, чем нужно для конкретного ответа.

Как перенести активные чаты на другую модель?

Потому что переносится не идентификатор, а смысл истории. Разные модели по-разному обрабатывают роли, вызовы инструментов, изображения, reasoning-элементы и системные инструкции. При смене модели соберите новый канонический контекст из своих событий и прогоните его через тесты на продолжение диалога.

Как не выполнить вызов инструмента дважды после сбоя?

Храните результат инструмента вместе с идентификатором вызова, параметрами, временем и признаком завершения. Повторный запуск после таймаута должен сначала проверить, есть ли уже результат по этому ключу. Иначе агент может дважды создать платёж, заявку или письмо.

Можно ли заменить историю диалога одной summary?

Сводка полезна как рабочая память, но не заменяет журнал. Она может пропустить условие, ошибочно объединить сущности или устареть после исправления данных. Держите исходные события по политике хранения, а сводку версионируйте и помечайте диапазон событий, который она покрывает.

Решает ли своя база вопрос data residency?

Нет. Локализация данных зависит от того, где лежат исходные реплики, вложения, результаты инструментов, резервные копии, логи и временные данные провайдера. Если требование строгое, архитектура должна подтверждать размещение каждого из этих слоёв, а не только базы сообщений.

Как безопасно разделить историю диалогов разных клиентов?

В базе приложения храните идентификатор арендатора, пользователя или сервиса, но не подменяйте им авторизацию. Для доступа проверяйте принадлежность каждой сессии на сервере, ведите аудит чтений и не отдавайте клиенту последовательные внутренние идентификаторы. В многопользовательской системе ошибка в фильтре tenant_id опаснее, чем потеря одного ответа модели.

Какие метаданные нужны для воспроизведения ответа LLM?

Сохраняйте минимум метаданных: выбранную модель, версию системной инструкции, версию набора инструментов, параметры генерации, хэш или версию контекста, идентификатор запроса и факт fallback. Без этого вы увидите жалобу пользователя, но не сможете воспроизвести условия, в которых модель ответила.

Когда допустимо не заводить собственное хранилище диалогов?

Для прототипа это нормально, если разговоры не содержат чувствительных данных и смена модели не входит в ближайший план. Для продакшена с клиентскими данными, инструментами, аудитом или несколькими провайдерами лучше сразу сделать свою запись событий. Переделка после появления важных сессий обычно дороже, чем простая таблица и понятный контракт событий в начале.