При разработке персонального цифрового ассистента здоровья «Я Здоров» мы использовали долгосрочную память — LTM (Long-Term Memory). Сервис реализован в формате мобильного приложения: он собирает медицинские данные пользователя в единый цифровой профиль, позволяет хранить и оцифровывать медицинские документы, отслеживать результаты анализов, вести дневники здоровья и общаться с AI-ассистентом в чате.
В диалогах пользователь сообщает ассистенту информацию о своем здоровье: о симптомах, принимаемых препаратах, аллергиях и других состояниях. Часть этих данных важно учитывать не только в текущем разговоре, но и при следующих обращениях. Для этого мы сохраняем их в LTM и при необходимости возвращаем в контекст модели.
На практике работа с долгосрочной памятью оказалась шире, чем просто сохранение фактов. Нужно учитывать их актуальное состояние и изменения, определять, какие данные нужны модели для конкретного запроса, и контролировать, какие операции можно оставить LLM, а какие надежнее выполнять программно.
В статье разберем, как мы с командой ASAP решали эти задачи, как менялась архитектура LTM и к какой реализации пришли сейчас.
Первая версия LTM: все данные из диалогов в одном JSON
Мы начинали с достаточно простой архитектуры. Все факты о здоровье, которые ассистент извлекал из диалогов с пользователем, собирались в одном агрегированном JSON-объекте. По мере общения он дополнялся новыми сведениями, а при формировании ответа целиком передавался в контекст LLM.
По мере накопления данных стало понятно, что такой подход ограничивает дальнейшее развитие LTM. Проблема была не в самом формате JSON, а в том, как была организована память: все данные хранились и обрабатывались как один большой объект. Это создавало сразу несколько проблем.
Первая — в контекст попадало слишком много данных.
Для конкретного запроса модели не нужна вся накопленная информация о здоровье пользователя. Но в первой версии выбрать только релевантные факты было нельзя: LLM получала весь JSON целиком. По мере накопления данных контекст рос, а вместе с нужной информацией в него попадали факты, не связанные с текущим запросом.
Вторая — не сохранялась полноценная история изменений.
В JSON сохранялось текущее агрегированное состояние. Если информация менялась, новое значение заменяло предыдущее, поэтому восстановить последовательность изменений было нельзя.
Например, пользователь мог сначала сообщить, что врач назначил препарат, затем — что он начал его принимать, а позже — что врач изменил дозировку. Для такого сценария одного актуального значения уже недостаточно: нужно знать и текущее состояние, и то, как оно менялось.
Третья — не различались отсутствие информации и подтвержденное отсутствие.
Пустое значение не позволяло понять, почему данных нет: пользователь еще ничего не сообщал или прямо подтвердил отсутствие определенного состояния.
Для аллергий, хронических заболеваний и семейного анамнеза это особенно важно. Если информации по этим разделам не хватает, ассистент может уточнить ее у пользователя. В ответ человек может назвать конкретную аллергию, сказать, что аллергий нет, ответить «не знаю» или вообще еще ни разу не обсуждать эту тему с ассистентом.
Хранить все эти ситуации как одно пустое значение нельзя. «Аллергий нет» — уже известный системе факт. Отсутствие записи означает, что система пока ничего об этом не знает. Ответ «не знаю» — тоже полученная от пользователя информация.
Это различие влияет и на дальнейшую работу ассистента. Например, известную аллергию необходимо учитывать при рекомендациях по питанию. Поэтому для аллергий, хронических заболеваний и семейного анамнеза подтвержденное отсутствие в новой архитектуре фиксируется явно.
Нам было важно перестать работать со всей памятью как с одним объектом: отдельно управлять разными типами данных, сохранять текущее состояние факта и историю его изменений, а в контекст LLM передавать только ту часть памяти, которая нужна для конкретного запроса.

Версия 2: разделили память, чтение и запись
В новой версии долгосрочную память разделили на 11 предметных разделов: лекарства, симптомы, сообщенные пользователем диагнозы, хронические состояния, демографические данные, образ жизни, аллергии, операции, травмы, вакцинации и семейный анамнез.
Изменилась и сама логика работы с памятью. Ее разделили на два контура: синхронное чтение (read-path) и асинхронную запись (write-path).
Read-path участвует в формировании ответа: определяет, какая часть LTM нужна для текущего запроса, получает факты и добавляет их в контекст основной модели. Write-path отвечает за другую задачу — обработку новой информации из разговора и обновление памяти.

Запись вынесли в фон намеренно. Extraction и обновление фактов не должны увеличивать время ответа пользователю. Кроме того, так можно анализировать связный фрагмент диалога, а не пытаться обновлять память после каждой отдельной реплики.
Получается два независимых процесса: для ответа система быстро получает уже сохраненные данные, а после завершения фрагмента разговора отдельно разбирает новую информацию и обновляет LTM.
LLM понимает смысл. Состоянием памяти управляет код
Посмотрим на путь записи подробнее. Допустим, пользователь сообщает: «У меня аллергия на арахис». Сначала LLM-классификатор определяет, какие разделы памяти затронуты в разговоре. В данном случае это allergies. Затем extractor получает правила только для выбранного раздела и извлекает из диалога структурированное событие.

После extraction зона ответственности LLM заканчивается. Полученная структура проходит программную валидацию: проверяются типы полей и допустимые значения. Невалидное событие в LTM не записывается.
Если проверка пройдена, код сопоставляет событие с уже существующими фактами и определяет, что произошло: первое упоминание, повторное подтверждение, изменение или отрицание.
То есть LLM работает с тем, для чего она здесь нужна: понимает естественный язык, классифицирует информацию и извлекает смысл. Но технические ID, время записи и тип изменения факта определяются программно. Модель не получает возможности самостоятельно решить, каким должно стать итоговое состояние памяти.
Это одна из ключевых границ в нашей архитектуре: недетерминированной модели оставляем семантическую работу, критичные переходы состояния — детерминированному коду.
Один факт — это текущее состояние плюс история
Допустим, пользователь говорит: «Пью Энап 5 мг по утрам от давления». Для именованных сущностей можно отдельно хранить исходную формулировку — name_raw — и нормализованное название — name_normalized.
В этом случае исходным значением будет Энап 5 мг, нормализованным — эналаприл. Отдельно фиксируются дозировка, частота приема, статус и показание. В упрощенном виде структура может выглядеть так:
{ "category": "medication", "name_raw": "Энап 5 мг", "name_normalized": "эналаприл", "dosage": "5 мг", "frequency": "1 раз в день, утром", "status": "active", "indication": "повышенное давление" }
Это пример модели данных, а не буквальный production-контракт. Набор полей зависит от типа факта. Главное изменение по сравнению с первой версией находится глубже: текущее состояние факта и история событий теперь существуют отдельно.

Текущее состояние отвечает на вопрос, что известно системе сейчас. История позволяет восстановить, как эта информация менялась.Это особенно хорошо видно на препаратах.
Сначала пользователь сообщает, что врач назначил препарат — появляется первое состояние и событие first mention. Позже говорит, что начал его принимать. Код сопоставляет новое событие с существующим фактом и обновляет его актуальное состояние, а подтверждение сохраняет в истории.
Если врач изменил дозировку, новым текущим значением становится новая дозировка, но предыдущее состояние не исчезает из истории. Когда пользователь сообщает, что больше не принимает препарат, факт становится неактивным. При этом его история сохраняется.

Так нам не приходится передавать модели всю историю при каждом обращении. Для вопроса о текущей терапии обычно достаточно актуального состояния. Но если пользователь спрашивает о прошлой терапии, предыдущие события можно восстановить.
Не каждый сохраненный факт нужен модели прямо сейчас
Теперь другая сторона задачи: в LTM может храниться много информации, но какую ее часть нужно передать основной LLM для конкретного ответа? В первой версии в контекст отправлялся весь агрегированный объект. Во второй версии retrieval начинается с классификации запроса.
Для каждого класса запроса заранее определено, какие разделы LTM могут понадобиться. Если запрос относится сразу к нескольким темам, необходимые разделы объединяются без дублирования.
Например, для ответа могут одновременно понадобиться данные о симптомах и принимаемых препаратах. Тогда система получает эти два раздела, но не добавляет в контекст операции, вакцинации или семейный анамнез только потому, что такая информация тоже есть в памяти.
Дальше применяются правила статусов. Например, завершенный курс препарата не нужен для обычного вопроса о текущем лечении. Но если пользователь спрашивает, какие препараты принимал раньше, тот же факт становится релевантным.
То есть inactive не означает «удалить». Статус влияет на то, когда факт следует вернуть в контекст. Мы сознательно не стали после этого запускать semantic top-K по всем фактам или еще один LLM-reranking. Retrieval остается предсказуемым: тип запроса — набор разделов —факты с подходящими статусами — контекст основной LLM.
Пользователь говорит одно, медицинские документы — другое. Что попадет в контекст?
LTM — не медицинская карта. В долгосрочной памяти хранятся self-reported данные, которые система получила из разговоров с пользователем. Например, человек сам сообщил об аллергии, симптоме или принимаемом препарате.
Документированные медицинские данные хранятся отдельно: результаты анализов и обследований, записи о приемах, загруженные медицинские документы.

При формировании ответа эти источники не смешиваются в одну безымянную массу данных. Они передаются основной модели отдельными маркированными блоками с указанием происхождения информации — provenance.
И здесь возникает неочевидная ситуация. Допустим, пользователь сообщает в чате одно, а в медицинском документе содержится другая информация. Как решить, какой факт правильный?
Отдельного программного resolver-а, который сравнивает два источника и выбирает «истину», в архитектуре нет. У документированных медицинских данных выше доказательный вес, но даже медицинский документ может быть устаревшим, неполным или относиться к другому периоду.
Поэтому основная модель получает оба факта вместе с информацией об их происхождении. Если расхождение клинически значимо, его нельзя просто скрыть: в ответе нужно учитывать неопределенность и при необходимости предложить пользователю уточнить информацию, свериться с документами или врачом.
А если LLM просто пропустила важный факт?
Классификация и extraction остаются LLM-задачами, а значит, полностью исключить ошибки на этих этапах нельзя. Классификатор может не определить нужный раздел памяти, а extractor — пропустить факт или неверно интерпретировать реплику.
Поэтому критичные изменения состояния мы не оставляем на усмотрение модели. LLM извлекает информацию из диалога, но результат проходит программную валидацию, а сопоставление с существующими фактами, определение типа изменения и запись выполняются кодом.
При этом в отдельных разделах одной проверки уже извлеченных данных недостаточно. Для аллергий, хронических заболеваний и семейного анамнеза предусмотрена дополнительная логика: если значимая информация о пользователе еще не заполнена, ассистент может сам вернуться к этому вопросу в диалоге и уточнить данные.
Это не исключает ошибку LLM в конкретной реплике, но не дает системе автоматически приравнять отсутствие извлеченного факта к отсутствию самого состояния. Если данных нет, они остаются неизвестными, а в критичных разделах ассистент сохраняет возможность получить их позже.
То есть мы не пытаемся сделать LLM детерминированной. Архитектура изначально учитывает, где модель может ошибиться, и не отдает ей те операции, которые напрямую меняют состояние долгосрочной памяти.
Проверяем не только ответ, а всю цепочкуПроверяем не только ответ, а всю цепочку
Финальный ответ ассистента сам по себе мало говорит о том, правильно ли отработала память. Ошибка могла возникнуть гораздо раньше: при классификации запроса, extraction, обновлении состояния или retrieval. Поэтому тестируем каждый уровень отдельно.
Classifier должен правильно определить разделы памяти, в том числе когда один запрос затрагивает сразу несколько тем.
На уровне extractor проверяем уже сами факты: вся ли информация из диалога попала в структуру, не добавила ли модель данные, которых пользователь не сообщал, корректно ли определены сущности, отрицания, источник и дата. Отдельно смотрим, чтобы extractor не смешивал симптомы, диагнозы, препараты и аллергии.
Для детерминированной логики прогоняем основные переходы состояния: первое упоминание, повторное подтверждение, изменение и отрицание факта. Здесь же проверяем различие между unknown и confirmed absence в тех разделах, где оно предусмотрено.
Retrieval тестируем с другой стороны: получила ли основная модель именно те факты, которые нужны для текущего запроса, не попали ли в контекст данные из нерелевантных разделов и корректно ли отфильтровались состояния, которые сейчас использовать не нужно.
Наконец, E2E-тесты проходят весь маршрут: пользователь сообщил факт — факт извлечен — записан в LTM — найден в следующем диалоге → попал в контекст — был учтен при формировании ответа.
Отдельно прогоняем изменение и отрицание уже сохраненных фактов, чтобы проверить не только первичную запись, но и дальнейшую работу с ними.
При этом мы не сравниваем V1 и V2 по метрикам точности и полноты (Precision и Recall). Для корректного сравнения понадобился бы воспроизводимый прогон обеих версий на одной модели, одинаковых промптах и одном тестовом наборе. Такого замера у нас не сохранилось, поэтому говорить о количественном росте качества было бы некорректно.
Вместо этого проверяем конкретные свойства архитектуры: extractor работает только с выбранными разделами, его результат проходит валидацию, повторное упоминание обновляет существующий факт, история сохраняется отдельно от текущего состояния, confirmed absence не смешивается с unknown, а в контекст основной модели попадают только выбранные разделы.
Что произойдет, когда фактов станет слишком много
У долгосрочной памяти остается еще одна проблема — она постоянно растет. Ограничение возникает при использовании накопленных данных: чем больше фактов приходится возвращать модели, тем больше становится контекст и тем сложнее удерживать внимание на действительно важной информации.
Выборочное извлечение данных (selective retrieval) уже уменьшает эту нагрузку: вместо всей LTM мы передаем модели только те разделы памяти, которые связаны с текущим запросом. Но со временем количество фактов будет расти и внутри самих разделов.
Отсюда следующий архитектурный вопрос: какие из накопленных фактов со временем можно перестать возвращать в контекст?
На первый взгляд можно просто постепенно исключать старые данные. Для медицинского сценария возраст записи сам по себе ничего не говорит о ее актуальности.
Аллергия не перестает иметь значение только потому, что пользователь сообщил о ней несколько лет назад. Информация о перенесенной операции тоже может понадобиться спустя годы. А завершенный курс препарата может быть не нужен сегодня, но снова стать релевантным, если пользователь спросит об истории лечения.
Поэтому готового механизма забывания (forgetting) в текущей реализации пока нет. Для него нужны правила, которые будут учитывать не только давность информации, но и тип факта, его состояние и сценарий, в котором память запрашивается.
Вместо вывода
В процессе разработки стало понятно, что долгосрочная память AI-ассистента — это не хранилище, куда достаточно складывать извлеченные из диалогов факты. Основные вопросы начинаются после записи: как определить актуальное состояние данных, не потерять историю изменений, выбрать релевантные факты для конкретного запроса и решить, какие операции вообще можно доверить LLM.
В текущей архитектуре мы разделили эти задачи. LLM работает там, где требуется интерпретация естественного языка, а управление состоянием памяти оставили детерминированной логике. При этом сама LTM стала отдельным слоем системы со своими правилами записи, обновления и выборки данных.
Но по мере роста памяти меняется и сама постановка задачи. Вопрос уже не только в том, как сохранить нужный факт, но и в том, как понять, нужен ли он модели именно сейчас.

