LangGraph повторяет сообщения после восстановления состояния

Агент в LangGraph останавливается перед дорогим действием и ждёт решения пользователя. После возобновления в истории внезапно два одинаковых сообщения помощника. Модель получает повторный контекст, иногда снова предлагает уже согласованный шаг. В журнале кажется, будто checkpoint «воспроизвёл» старый ответ. Но чаще источник дублирования — способ, которым код возвращает список сообщений в состояние.

Как reducer понимает обновление

У каждого поля состояния LangGraph есть правило слияния. Если для messages поставить обычное сложение списков, любое возвращённое сообщение просто добавится к истории. Допустим, в checkpoint уже лежат три сообщения, а обработчик после resume возвращает все четыре, включая прежние три. Сложение даст семь элементов: первые три повторятся. Узел должен возвращать только новые сообщения, которые появились на этом шаге. Когда требуется заменить старое сообщение после правки человеком, нужен reducer, который узнаёт его по стабильному ID.

Для этого LangGraph предоставляет add_messages и готовый MessagesState. add_messages добавляет новые сообщения, а сообщение с тем же ID обновляет существующее. Если пользователь исправил аргумент вызова инструмента, сохраните ID исходного AI-сообщения в исправленной версии. Новый ID будет означать новый элемент истории, а не замену. При обычном ответе модели возвращайте только сгенерированное сообщение; не включайте всё state["messages"] в новое обновление.

from langchain_core.messages import HumanMessage
from langgraph.graph.message import add_messages

before = [HumanMessage(id="q-17", content="Доставка в среду")]
fix = [HumanMessage(id="q-17", content="Доставка в пятницу")]
print([m.content for m in add_messages(before, fix)])
print([m.content for m in before + fix])

Первый результат содержит одну исправленную запись: ['Доставка в пятницу']. Обычное сложение списков оставляет две: ['Доставка в среду', 'Доставка в пятницу']. В графе замену оформляют как обновление поля messages с тем же ID; пример изолирует работу reducer, чтобы было видно правило слияния.

Проверьте шаг, где появляется повтор

Возьмите одну тестовую беседу с фиксированным thread_id. Перед узлом после resume запишите последовательность пар (id, type) из сохранённого состояния, затем пары из обновления узла и результата reducer. Если уже в обновлении есть старые ID, код повторно передаёт историю. Если ID у семантически того же сообщения меняется, механизм обновления по ID не узнает его. Сравнивайте именно ID и роль, а не только текст: одинаковые слова в двух разных пользовательских ходах могут быть законными.

Затем воспроизведите три пути: обычный ход без паузы, resume после interrupt и исправление сообщения пользователем. После каждого шага история должна содержать по одному экземпляру каждого подтверждённого сообщения. На пути с исправлением старый ID остаётся тем же, а содержимое меняется. Если ожидалось удалить сообщение целиком, используйте предусмотренный механизм удаления/перезаписи, а не пустой список: слияние пустого списка само по себе историю не очистит.

Checkpoint сохраняется на границе шагов. При восстановлении затронутый узел может выполняться заново, поэтому побочные эффекты до паузы требуют отдельной идемпотентности. Это другой дефект, чем дублирование messages. Сначала выясните, появились ли два сообщения в самом состоянии; если нет, проверяйте, не вызвали ли внешний инструмент повторно. Об этом рассказано в разборе побочных эффектов checkpoint.

В платном курсе AI Agents PRO: LangGraph, AutoGen и LLMOps в продакшне вы строите граф состояний агента, подключаете инструменты и добавляете наблюдаемость. Разбор сообщений по ID полезен на этом пути: он сохраняет историю диалога понятной после пауз, исправлений и повторных запусков. Откройте программу курса, чтобы отработать граф и его поведение на целостном сервисе.

Продолжить на практике

  • AI Agents PRO: LangGraph, AutoGen и LLMOps в продакшне ↗

    Практический курс по AI-агентам. Соберёте продакшн RAG-сервис: от индекса и графа состояний до API, Docker, метрик качества и контроля стоимости. Плюс профессиональные блоки: LangGraph/AutoGen, Qdrant/FAISS+rerank, eval (RAGAS/custom), quality-гейты, OpenTelemetry+Prometheus/Grafana, CI/CD, ретраи/DLQ/идемпотентность, PII/RBAC.