Зачем править состояние вручную
Агент не всегда прав. LLM придумала несуществующий факт, инструмент вернул мусор, модель выбрала неверную ветку. Если человек уже в цикле (граф на паузе из прошлых уроков), логично дать ему не только право вето, но и право исправить: заменить плохой черновик хорошим, дописать в контекст недостающие данные, поправить аргументы перед вызовом инструмента.
Просто так лезть в состояние нельзя — оно живёт в checkpoint'е и подчиняется правилам слияния. Менять его «снаружи» нужно тем же механизмом, что и узлы: через update_state. Эта функция применяет твои изменения как будто их вернул узел — со всеми reducers — и сохраняет новый checkpoint.
update_state работает только с подключённым checkpointer + thread_id: он читает текущий checkpoint, применяет изменения и пишет новый. Без памяти редактировать нечего.
update_state: как это работает
Сигнатура — graph.update_state(config, values, as_node=None). Под капотом происходит ровно то же, что при завершении узла: переданные values прогоняются через reducers полей и вливаются в состояние, создавая новый checkpoint. Старый никуда не девается — история форкается.
Два следствия из «как будто вернул узел»:
- Reducers применяются. Это не «грубая перезапись» состояния, а слияние по тем же правилам, что и для узлов.
- Появляется новый checkpoint. История сохраняется целиком — можно вернуться к версии «до правки».
Тонкость №1: update_state и reducers
Главная ловушка. Раз values проходят через reducer, поведение зависит от поля. Для обычного поля (reducer по умолчанию — перезапись) всё интуитивно: новое значение заменяет старое. А вот для поля с накапливающим reducer'ом — нет.
# Поле 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 не добавляется, а заменяет существующее.
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)]})
Перед update_state задай себе вопрос: какой reducer у этого поля? Перезапись — передавай новое значение. Накопление (operator.add, add_messages) — передаёшь добавляемое, а для замены/удаления используй id или RemoveMessage. Игнорирование reducer'а — причина №1 «почему правка задвоила данные».
Тонкость №2: параметр as_node
Второй неочевидный момент — куда граф пойдёт после правки. Когда узел завершается, LangGraph по исходящим рёбрам решает, что выполнять дальше. Но update_state вызван «снаружи» — от чьего имени считать обновление? Это и задаёт as_node: «примени изменения так, будто их вернул узел X». От этого зависит, какие рёбра сработают и каким станет .next.
# Без 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 позволяет не просто поменять данные, а перенаправить поток управления. Указав узел-источник, ты выбираешь, какие условные рёбра пересчитаются — и куда граф шагнёт при возобновлении.
После статической паузы (interrupt_before/interrupt_after) LangGraph знает, на каком узле стоит, и сам подставит разумный источник — для простой правки данных as_node не нужен. Указывай его явно, когда хочешь сменить маршрут или применить обновление от имени узла, отличного от текущего.
Полный пример: правка черновика на паузе
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.
# Берём конкретный прошлый снимок из истории
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 с checkpoint_id только что созданного снимка. Используй именно его, чтобы продолжить по форкнутой ветке. Это и есть способ «переиграть» решение агента с другого момента.
Типичные ошибки
Передал новый список в поле с add_messages в расчёте «заменить историю» — а он дописался. Для замены сообщения — тот же id, для удаления — RemoveMessage.
update_state — это не state = {...}. Он применяет values через reducers поверх текущего состояния, а не подменяет его целиком. Поля, которые ты не передал, останутся прежними.
Если правка должна изменить, куда пойдёт граф, без правильного as_node пересчитаются не те рёбра. Для смены направления указывай узел-источник явно.
graph.get_state(cfg).values["draft"] = "..." ничего не сохранит — это локальная копия. Менять состояние можно только через update_state, который создаёт новый checkpoint.
Шпаргалка
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
Практическое задание
Научись чинить агента на ходу:
Задание: правка и удаление в диалоге
- Возьми чат-граф с
messages: Annotated[list, add_messages],MemorySaverиinterrupt_after=["agent"]. Проведи реплику, чтобы получить ответ ассистента. - Через
update_stateс тем жеidзамени ответ ассистента на исправленный. Проверь, что история не задвоилась. - Через
RemoveMessageудали последнее сообщение и убедись, что оно исчезло изget_state().values["messages"]. - Добавь обычное поле
draft: strи сравни: правкаdraftперезаписывает, а правкаmessages— дописывает. Объясни разницу через reducer. - Сделай граф с узлом
routerи условным ребром; черезupdate_state(..., as_node="router")подмени решение и проверь, что граф пошёл по другой ветке. - Со звёздочкой: возьми прошлый checkpoint из
get_state_history, форкни его черезupdate_stateи прогони альтернативную ветку, не разрушив исходную.