Управление состоянием в LangGraph: типичные ошибки и как их избежать
Динамическая передача dict между узлами LangGraph прячет баги, которые всплывают через пять шагов. TypedDict-схемы, редьюсеры и разделение памяти — три паттерна, которые превращают хрупкий граф в отлаживаемую систему.
Проблема, которую никто не замечает вовремя
Вот сценарий, который я видел неоднократно: агент на LangGraph работает, все узлы зелёные, логи чистые. Через три часа клиент звонит — данные повреждены. Начинаешь копать и обнаруживаешь: между узлом retrieval и узлом synthesis список документов тихо перезаписывался вместо дополнения. Ни одного исключения. Граф уверенно шёл дальше, потому что формально ничего не сломалось — сломалось семантически.
Корень проблемы — подход к состоянию как к обычному Python-словарю: кидаем данные между узлами, модель сама разберётся. Не разберётся. Точнее — разберётся по-своему, а вы узнаете об этом, когда что-то несвязанное сломается пятью шагами позже.
Что такое state в LangGraph на самом деле
State в LangGraph — это не dict. Это контракт. Каждый узел — функция, которая читает из контракта и пишет обратно. Записи управляются редьюсерами — функциями, которые решают, как новое значение объединяется с существующим.
Аннотация Annotated[list[BaseMessage], add_messages] говорит: «Когда любой узел пишет в messages, используй add_messages для слияния нового значения с тем, что уже есть». Без редьюсера действует правило last-write-wins — для одиночных значений это нормально, для списков — катастрофа.
Три ошибки, которые я допускал
Ошибка 1: dict как «универсальное» хранилище
Поле data: dict — это чёрная дыра. В любой момент невозможно сказать, какие ключи в ней есть. Каждый узел пишет туда что хочет, каждый читает что ожидает — и несовпадение обнаруживается только при отладке продакшн-инцидента.
Фикс: явные поля с явными типами. Используйте Enum для статусов (а не строки "done"/"Done"/"DONE"), Optional-поля вместо dict-вложенностей, отдельный TypedDict для структур вроде ErrorContext. Разница не косметическая — это разница между «работает, пока не сломается» и «ломается громко и сразу».
Ошибка 2: списки без редьюсеров
Если поле — список, предполагайте, что несколько узлов будут в него писать. Даже если сегодня пишет один. Завтра коллега добавит второй узел и не проверит ваш код.
Редьюсеры — чистые функции: принимают старое и новое, возвращают объединённое. Их проще всего тестировать — не нужно поднимать весь граф. Полезные варианты:
- Дедупликация — по хешу содержимого, чтобы один документ не появлялся дважды
- Ограничение длины — критично для чекпоинтов: если список растёт бесконечно, чекпоинты раздуваются
- Слияние частичных JSON — для параллельных узлов, собирающих разные части ответа
Ошибка 3: вся память в одном контексте
Накапливать всё в цепочке и называть это «контекстом» — работает ровно до момента, когда запускаешь долгую задачу. Модель получает контекст, где 80% — нерелевантная история, и пытается найти в ней нужный фрагмент. Находит не тот.
Паттерн: разделение эпизодической и семантической памяти. Эпизодические трейсы — что конкретно произошло, в каком порядке, с какими параметрами. Семантическая память — извлечённые факты и знания. Когда они лежат отдельно, отладка становится предсказуемой.
Правило контракта между узлами
LangGraph заставляет быть явным в том, что перетекает между узлами. Принцип: каждый узел получает ровно те поля, которые ему нужны, и возвращает ровно те поля, которые он обновляет. Не больше.
Узел retrieval читает current_query, возвращает {"retrieved_docs": docs}. Узел synthesis читает retrieved_docs и messages, возвращает {"final_answer": ..., "status": AgentStatus.COMPLETE}. Когда контракт нарушается — например, узел пишет в несуществующее поле — вы узнаёте об этом немедленно, а не через пять шагов.
Checkpoint recovery для долгих задач
TypedDict-схема даёт предсказуемые чекпоинты. LangGraph сохраняет состояние после каждого узла, и если задача прерывается (таймаут, падение сервиса), её можно возобновить с последнего сохранённого состояния.
Но это работает только когда состояние сериализуемо и детерминировано. data: dict с произвольной вложенностью — нет гарантий. AgentState с типизированными полями и enum-статусами — полная предсказуемость.
Мультиагентные сценарии
В мультиагентных системах state drift — отдельная проблема. Три агента пишут в одно состояние, каждый уверен, что его обновление последнее. Без редьюсеров — гонка записей.
Паттерн: каждый агент владеет своим подмножеством состояния. Общие поля — только через явные редьюсеры. Если агент-критик перезаписывает critic_feedback — это нормально, он единственный владелец. Если два исследователя дополняют researcher_findings — редьюсер operator.add конкатенирует списки.
Версионирование схем
Отдельная боль — эволюция схемы в продакшне. Чекпоинты сохранены со старой схемой, код обновлён на новую. LangGraph пока не имеет встроенной поддержки миграций, поэтому:
- Добавление поля — всегда Optional с дефолтом None
- Удаление поля — оставлять в схеме как deprecated, не читать
- Изменение типа — новое поле + миграция в первом узле
Чеклист перед деплоем
- Каждое поле-список имеет редьюсер
- Нет полей типа dict без строгой внутренней структуры
- Статусы — enum, не строки
- Каждый узел возвращает только те поля, которые обновляет
- Эпизодическая память отделена от семантической
- Чекпоинты работают: тест «убить процесс → перезапустить → продолжить»
- В мультиагентном сценарии каждый агент владеет своим подмножеством полей
Итого
Строгая типизация состояния через TypedDict — не про красоту кода. Это про то, чтобы баги проявлялись сразу, а не маскировались за «успешным» прохождением графа. Редьюсеры для списков, enum для статусов, разделение памяти — три паттерна, которые превращают LangGraph-агент из «работает на демо» в «работает в продакшне».
Состояние — это архитектурное решение. Относитесь к нему соответственно.