Зачем править состояние вручную

Агент не всегда прав. LLM придумала несуществующий факт, инструмент вернул мусор, модель выбрала неверную ветку. Если человек уже в цикле (граф на паузе из прошлых уроков), логично дать ему не только право вето, но и право исправить: заменить плохой черновик хорошим, дописать в контекст недостающие данные, поправить аргументы перед вызовом инструмента.

Просто так лезть в состояние нельзя — оно живёт в checkpoint'е и подчиняется правилам слияния. Менять его «снаружи» нужно тем же механизмом, что и узлы: через update_state. Эта функция применяет твои изменения как будто их вернул узел — со всеми reducers — и сохраняет новый checkpoint.

ℹ️ Это всё ещё про checkpointing

update_state работает только с подключённым checkpointer + thread_id: он читает текущий checkpoint, применяет изменения и пишет новый. Без памяти редактировать нечего.

update_state: как это работает

Сигнатура — graph.update_state(config, values, as_node=None). Под капотом происходит ровно то же, что при завершении узла: переданные values прогоняются через reducers полей и вливаются в состояние, создавая новый checkpoint. Старый никуда не девается — история форкается.

100%
колёсико — масштаб  ·  зажать и тянуть — перемещение
Текущий checkpoint draft: "v1 (плохой)" messages: [...] next: ('review',) update_state(cfg, {draft: "v2"}, as_node="draft") Новый checkpoint draft: "v2" ✎ messages: [...] next: пересчитан по as_node reducer values проходят через reducers полей старый checkpoint остаётся в истории — изменение создаёт форк, а не стирает прошлое

Два следствия из «как будто вернул узел»:

  • Reducers применяются. Это не «грубая перезапись» состояния, а слияние по тем же правилам, что и для узлов.
  • Появляется новый checkpoint. История сохраняется целиком — можно вернуться к версии «до правки».

Тонкость №1: update_state и reducers

Главная ловушка. Раз values проходят через reducer, поведение зависит от поля. Для обычного поля (reducer по умолчанию — перезапись) всё интуитивно: новое значение заменяет старое. А вот для поля с накапливающим reducer'ом — нет.

Поведение зависит от reducer'а поля
python
# Поле draft: str — reducer по умолчанию (перезапись)
graph.update_state(config, {"draft": "новый текст"})
#   → draft станет "новый текст"  ✅ как и ожидалось

# Поле messages: Annotated[list, add_messages] — reducer ДОПИСЫВАЕТ
graph.update_state(config, {"messages": [new_msg]})
#   → new_msg ДОБАВИТСЯ в конец истории, а НЕ заменит её   ⚠️

То есть нельзя «заменить» историю сообщений, просто передав новый список — add_messages его допишет. Чтобы изменить конкретное сообщение, пользуются хитростью самого add_messages: сообщение с тем же id не добавляется, а заменяет существующее.

Правка и удаление сообщений по id
python
from langchain_core.messages import AIMessage, RemoveMessage

# Узнаём id сообщения, которое хотим поправить
last = graph.get_state(config).values["messages"][-1]

# ЗАМЕНИТЬ: тот же id → add_messages перезапишет, а не добавит
graph.update_state(config, {"messages": [AIMessage(id=last.id, content="Исправленный ответ")]})

# УДАЛИТЬ: специальный RemoveMessage с нужным id
graph.update_state(config, {"messages": [RemoveMessage(id=last.id)]})
⚠️ Помни про reducer поля, которое правишь

Перед update_state задай себе вопрос: какой reducer у этого поля? Перезапись — передавай новое значение. Накопление (operator.add, add_messages) — передаёшь добавляемое, а для замены/удаления используй id или RemoveMessage. Игнорирование reducer'а — причина №1 «почему правка задвоила данные».

Тонкость №2: параметр as_node

Второй неочевидный момент — куда граф пойдёт после правки. Когда узел завершается, LangGraph по исходящим рёбрам решает, что выполнять дальше. Но update_state вызван «снаружи» — от чьего имени считать обновление? Это и задаёт as_node: «примени изменения так, будто их вернул узел X». От этого зависит, какие рёбра сработают и каким станет .next.

as_node управляет тем, что выполнится дальше
python
# Без as_node LangGraph подставит последний выполненный узел —
# обычно этого достаточно после паузы interrupt_before/after
graph.update_state(config, {"draft": "правка"})

# С as_node — притворяемся, что обновление пришло от конкретного узла.
# Дальше сработают рёбра, исходящие ИЗ "draft":
graph.update_state(config, {"draft": "правка"}, as_node="draft")

# Приём «направить агента»: подменяем решение и заставляем граф
# пойти по ветке, как будто её выбрал узел router
graph.update_state(config, {"route": "retry"}, as_node="router")

Практический смысл: as_node позволяет не просто поменять данные, а перенаправить поток управления. Указав узел-источник, ты выбираешь, какие условные рёбра пересчитаются — и куда граф шагнёт при возобновлении.

Когда as_node можно опустить

После статической паузы (interrupt_before/interrupt_after) LangGraph знает, на каком узле стоит, и сам подставит разумный источник — для простой правки данных as_node не нужен. Указывай его явно, когда хочешь сменить маршрут или применить обновление от имени узла, отличного от текущего.

Полный пример: правка черновика на паузе

Пауза → инспекция → правка → продолжение
python
from typing import TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import MemorySaver

class State(TypedDict):
    topic: str
    draft: str

def write(state: State) -> dict:
    return {"draft": f"СЫРОЙ черновик про {state['topic']}"}

def publish(state: State) -> dict:
    print("ОПУБЛИКОВАНО:", state["draft"])
    return {}

builder = StateGraph(State)
builder.add_node("write", write)
builder.add_node("publish", publish)
builder.add_edge(START, "write")
builder.add_edge("write", "publish")
builder.add_edge("publish", END)

graph = builder.compile(checkpointer=MemorySaver(), interrupt_after=["write"])

cfg = {"configurable": {"thread_id": "edit-1"}}
graph.invoke({"topic": "релиз"}, cfg)

# Смотрим сырой черновик
print(graph.get_state(cfg).values["draft"])   # "СЫРОЙ черновик про релиз"

# Человек заменяет его (draft — обычное поле, перезапись)
graph.update_state(cfg, {"draft": "Готовый, вычитанный текст про релиз"})

# Проверяем, что правка применилась
print(graph.get_state(cfg).values["draft"])   # "Готовый, вычитанный текст про релиз"

# Возобновляем — publish увидит уже исправленный текст
graph.invoke(None, cfg)
# ОПУБЛИКОВАНО: Готовый, вычитанный текст про релиз

Форк истории: правка прошлого checkpoint

Раз каждый update_state создаёт новый checkpoint, а старые сохраняются, можно редактировать не только текущее состояние, но и прошлое. Берёшь из истории конкретный checkpoint, вносишь правку «от него» — и получаешь новую ветку («что было бы, если...»). Это объединяет редактирование с time travel из урока про checkpointing.

Правка из прошлого = форк ветки
python
# Берём конкретный прошлый снимок из истории
history = list(graph.get_state_history(cfg))
past = history[2]                      # какой-то более ранний checkpoint

# Правим состояние ОТ него — создаётся новая ветка, старая цела
forked_cfg = graph.update_state(past.config, {"draft": "альтернативная версия"})

# Запускаем граф по новой ветке
graph.invoke(None, forked_cfg)
# Исходная история не тронута — у нас теперь два варианта развития
ℹ️ update_state возвращает config новой ветки

update_state отдаёт обновлённый config с checkpoint_id только что созданного снимка. Используй именно его, чтобы продолжить по форкнутой ветке. Это и есть способ «переиграть» решение агента с другого момента.

Типичные ошибки

Ошибка 1: игнорировать reducer поля

Передал новый список в поле с add_messages в расчёте «заменить историю» — а он дописался. Для замены сообщения — тот же id, для удаления — RemoveMessage.

Ошибка 2: ждать «грубой» записи состояния

update_state — это не state = {...}. Он применяет values через reducers поверх текущего состояния, а не подменяет его целиком. Поля, которые ты не передал, останутся прежними.

Ошибка 3: забыть про as_node при смене маршрута

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

Ошибка 4: мутировать объект из get_state

graph.get_state(cfg).values["draft"] = "..." ничего не сохранит — это локальная копия. Менять состояние можно только через update_state, который создаёт новый checkpoint.

Шпаргалка

update_state — всё в одном месте
python
from langchain_core.messages import AIMessage, RemoveMessage

# Базовая правка (поле с перезаписью)
graph.update_state(cfg, {"draft": "новый текст"})

# Поле с reducer add_messages:
graph.update_state(cfg, {"messages": [msg]})                       # ДОПИШЕТ
graph.update_state(cfg, {"messages": [AIMessage(id=old_id, ...)]}) # ЗАМЕНИТ (тот же id)
graph.update_state(cfg, {"messages": [RemoveMessage(id=old_id)]})  # УДАЛИТ

# Управление маршрутом — от имени узла
graph.update_state(cfg, {"route": "retry"}, as_node="router")

# Инспекция и продолжение
graph.get_state(cfg).values        # текущее состояние
graph.invoke(None, cfg)            # продолжить с правками

# Форк из прошлого
past = list(graph.get_state_history(cfg))[2]
fork = graph.update_state(past.config, {...})   # вернёт config новой ветки
graph.invoke(None, fork)

# Правила:
#  • values проходят через reducers (не грубая запись!)
#  • новый checkpoint → история форкается, прошлое цело
#  • as_node решает, какие рёбра сработают дальше
#  • менять состояние ТОЛЬКО через update_state, не мутацией get_state

Практическое задание

Научись чинить агента на ходу:

Задание: правка и удаление в диалоге

  1. Возьми чат-граф с messages: Annotated[list, add_messages], MemorySaver и interrupt_after=["agent"]. Проведи реплику, чтобы получить ответ ассистента.
  2. Через update_state с тем же id замени ответ ассистента на исправленный. Проверь, что история не задвоилась.
  3. Через RemoveMessage удали последнее сообщение и убедись, что оно исчезло из get_state().values["messages"].
  4. Добавь обычное поле draft: str и сравни: правка draft перезаписывает, а правка messages — дописывает. Объясни разницу через reducer.
  5. Сделай граф с узлом router и условным ребром; через update_state(..., as_node="router") подмени решение и проверь, что граф пошёл по другой ветке.
  6. Со звёздочкой: возьми прошлый checkpoint из get_state_history, форкни его через update_state и прогони альтернативную ветку, не разрушив исходную.

Что дальше