Зачем состоянию нужна схема?

Узлы в LangGraph не вызывают друг друга и не передают аргументы напрямую. Единственный канал связи между ними — общее состояние. Узел читает то, что положили предыдущие, и дописывает своё. По сути это «доска объявлений»: каждый шаг подходит, читает доску, что-то на ней меняет и уходит.

Если эту доску оставить бесформенной (просто dict с произвольными ключами), очень быстро начинается хаос: один узел пишет "result", другой ждёт "results", третий кладёт строку туда, где ожидался список. Ошибки всплывают не в момент опечатки, а спустя несколько шагов — и их тяжело искать.

Поэтому LangGraph требует объявить схему состояния заранее: перечислить все поля и их типы. Схема выполняет три задачи:

  • Документирует агента — по схеме сразу видно, какими данными он оперирует.
  • Задаёт контракт — какие ключи существуют и какого они типа; IDE подсказывает поля, а не угадывает.
  • Управляет обновлением — именно к схеме привязываются правила слияния (reducers, тема следующего урока).
ℹ️ Состояние — это не память между сессиями

State живёт в пределах одного прогона графа: подали вход → состояние эволюционировало по шагам → получили выход. Долговременная память (сохранить диалог между запусками) — это отдельный механизм, checkpointing, ему посвящён урок в разделе «Продвинутые возможности». Здесь речь только про «рабочую доску» внутри одного запуска.

TypedDict: схема по умолчанию

Стандартный способ описать состояние — TypedDict из модуля typing. Это «словарь с объявленными полями»: на этапе исполнения он остаётся обычным dict (быстрым и сериализуемым), но для типов и IDE выглядит как структура с известными ключами.

Схема состояния через TypedDict
python
from typing import TypedDict

class AgentState(TypedDict):
    query: str            # исходный вопрос пользователя
    draft: str            # черновик ответа
    steps: int            # счётчик шагов

# Это просто словарь — но с известной формой
initial: AgentState = {"query": "Что такое LangGraph?", "draft": "", "steps": 0}

# Передаём схему в граф — он будет проверять, что узлы пишут известные ключи
from langgraph.graph import StateGraph
builder = StateGraph(AgentState)

Почему именно TypedDict, а не обычный класс? Состояние постоянно копируется между шагами и сериализуется (для checkpointing). TypedDict — это под капотом dict: его легко сохранить в JSON, сравнить и склонировать. Узлы читают поля как state["query"] и возвращают изменения тоже словарём — никаких лишних обёрток.

⚠️ TypedDict не проверяет типы в рантайме

TypedDict — это подсказка для разработчика и статического анализатора (mypy, Pyright), а не валидатор. Если узел вернёт {"steps": "три"} вместо числа — Python не возмутится, ошибка вылезет позже. Нужна реальная проверка значений при записи — смотри раздел про Pydantic.

Частичное обновление: как узел меняет доску

Самое важное и неинтуитивное правило: узел возвращает не всё состояние, а только изменённые ключи. LangGraph берёт этот частичный словарь и вливает его в общее состояние. Ключи, которых в возврате нет, остаются нетронутыми.

100%
колёсико — масштаб  ·  зажать и тянуть — перемещение
State — до шага query: "вопрос" draft: "" steps: 0 draft_node return {draft, steps} только 2 ключа из 3 State — после шага query: "вопрос" ⟲ без изменений draft: "черновик" steps: 1 читает вливается ключ, которого нет в возврате (query), остаётся прежним — узел его не трогает

Почему так? Чтобы узлы были независимыми. Если бы каждый узел был обязан вернуть состояние целиком, ему пришлось бы знать про все поля и аккуратно копировать чужие данные. А так каждый отвечает только за свой кусочек, и добавить новое поле в схему можно, не трогая существующие узлы.

Узел возвращает только то, что меняет
python
def draft_node(state: AgentState) -> dict:
    # читаем нужное поле
    query = state["query"]
    # возвращаем ТОЛЬКО изменённые ключи — query не упоминаем
    return {"draft": f"Черновик ответа на: {query}", "steps": state["steps"] + 1}

# До:    {"query": "вопрос", "draft": "",         "steps": 0}
# После: {"query": "вопрос", "draft": "Черновик...", "steps": 1}
#         ↑ query сохранился сам собой

По умолчанию правило слияния простое: возвращённое значение перезаписывает старое для этого ключа («последний записавший побеждает»). Этого достаточно для большинства полей — статусов, счётчиков, текущего текста. Но иногда поле нужно не перезаписывать, а накапливать (например, историю сообщений). Как поменять правило слияния — тема следующего урока про reducers.

Не мутируй вложенные объекты

Соблазнительно сделать state["draft"] += "..." или state["items"].append(x) прямо внутри узла. Так делать нельзя: ты меняешь состояние в обход механизма слияния, и это ломает checkpointing и стриминг. Всегда возвращай новый словарь с изменениями, а не правь полученное состояние на месте.

Необязательные поля и значения по умолчанию

На старте графа заполнены не все поля: query приходит от пользователя, а draft появится только после первого узла. Если объявить поле в TypedDict, формально оно считается обязательным — и обращение state["draft"] до того, как его кто-то записал, бросит KeyError.

Есть два аккуратных подхода.

Необязательные поля: total=False и .get()
python
from typing import TypedDict

# Способ 1: total=False — все поля необязательны при создании
class AgentState(TypedDict, total=False):
    query: str
    draft: str
    steps: int

# Тогда стартовать можно с неполного словаря:
graph.invoke({"query": "вопрос"})     # draft и steps появятся по ходу

# Способ 2: читать через .get() с дефолтом — безопасно, если ключа ещё нет
def node(state: AgentState) -> dict:
    steps = state.get("steps", 0)     # 0, если шаг ещё не писал steps
    return {"steps": steps + 1}

Практическое правило: поля, которые точно есть на входе, держи обязательными и заполняй в стартовом словаре; поля, которые появляются по ходу, либо клади в total=False, либо инициализируй явными дефолтами при запуске ({"query": q, "draft": "", "steps": 0}). Второе — надёжнее: меньше сюрпризов с отсутствующими ключами.

Как проектировать состояние агента

Хорошая схема состояния читается как описание задачи. Удобно мысленно делить поля на три группы:

входные
То, что подаёт пользователь и что не меняется: вопрос, параметры, ограничения.
рабочие
Промежуточные данные между шагами: черновики, найденные документы, счётчики, флаги.
выходные
Финальный результат, который заберёт вызывающий код: ответ, источники, метрики.
Состояние реалистичного RAG-агента
python
from typing import TypedDict

class RAGState(TypedDict, total=False):
    # --- входные ---
    question: str            # вопрос пользователя
    top_k: int               # сколько документов доставать

    # --- рабочие ---
    documents: list[str]     # найденный контекст
    attempts: int            # сколько раз переформулировали запрос
    enough_context: bool     # хватает ли данных для ответа

    # --- выходные ---
    answer: str              # финальный ответ
    sources: list[str]       # ссылки на источники

Держи состояние плоским и явным: лучше пять понятных полей, чем один словарь "data" со всем подряд внутри. Плоское состояние проще читать в стриминге, проще сохранять и проще объяснять reducer'у, как сливать каждое поле.

ℹ️ Отдельные схемы входа и выхода

Иногда не хочется, чтобы наружу торчали рабочие поля (attempts, enough_context). LangGraph умеет принимать отдельные схемы: StateGraph(RAGState, input=InputSchema, output=OutputSchema). Тогда на вход граф ждёт только поля InputSchema, а invoke вернёт только поля OutputSchema — внутреннее «рабочее» состояние остаётся скрытым. Это удобно для публичного API агента, но на старте достаточно одной общей схемы.

Альтернативы: dataclass и доступ через атрибуты

TypedDict — путь по умолчанию, но не единственный. LangGraph принимает в качестве схемы и dataclass. Разница в основном в синтаксисе: к полям обращаются через точку (state.query), а не по ключу (state["query"]), и легко задать значения по умолчанию.

dataclass как схема состояния
python
from dataclasses import dataclass, field

@dataclass
class AgentState:
    query: str
    draft: str = ""             # дефолты прямо в схеме
    steps: int = 0
    documents: list = field(default_factory=list)

def node(state: AgentState) -> dict:
    # читаем через атрибут
    return {"draft": f"Черновик: {state.query}", "steps": state.steps + 1}

Узел по-прежнему возвращает словарь с изменениями — это не меняется. Меняется только то, как ты читаешь состояние внутри. dataclass удобен, когда хочется дефолтов и привычного доступа через точку, но он, как и TypedDict, не проверяет типы значений в рантайме.

Когда нужна валидация: Pydantic

Если важно, чтобы некорректные данные падали сразу при записи, а не отравляли состояние, — берут Pydantic-модель. Она проверяет типы и ограничения каждый раз, когда поле обновляется: вернул узел строку вместо числа — получишь понятную ошибку валидации прямо на этом шаге.

Pydantic-модель как состояние с валидацией
python
from pydantic import BaseModel, Field

class AgentState(BaseModel):
    query: str
    draft: str = ""
    steps: int = Field(default=0, ge=0)      # steps не может быть отрицательным
    temperature: float = Field(default=0.7, ge=0.0, le=2.0)

# Граф строится так же
builder = StateGraph(AgentState)

# Если узел вернёт {"steps": -1} — Pydantic бросит ValidationError
# прямо на этом шаге, а не «когда-нибудь потом»
Что выбрать

TypedDict — по умолчанию: быстро, легко сериализуется, хватает в 90% случаев. dataclass — если нравится доступ через точку и нужны дефолты. Pydantic — когда данные приходят из ненадёжных источников (пользователь, внешний API) и важно ловить мусор на входе. Цена Pydantic — чуть больше накладных расходов на валидацию каждого обновления.

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

Ошибка 1: KeyError на ещё не записанном поле

state["draft"] до того, как узел его записал, бросит KeyError. Решение: total=False + state.get("draft", ""), либо инициализируй все поля в стартовом словаре invoke.

Ошибка 2: возврат всего состояния целиком

Узел не обязан возвращать всю схему — только изменённые ключи. Возврат полного состояния не ломает граф, но смешивает «что я изменил» с «что просто прочитал», и с reducer'ами (следующий урок) приводит к неожиданному задвоению данных.

Ошибка 3: «мега-поле» вместо плоской схемы

Складывать всё в один state["data"]: dict — антипаттерн. Такое поле перезаписывается целиком, теряет вложенные изменения от соседних узлов и не поддаётся пошаговому слиянию. Делай поля плоскими и осмысленными.

Ошибка 4: расчёт на проверку типов от TypedDict

TypedDict ничего не проверяет в рантайме — это только подсказки для IDE и mypy. Если нужна гарантия, что в steps лежит число, — это Pydantic, а не TypedDict.

Шпаргалка

State в LangGraph — всё в одном месте
python
from typing import TypedDict

# 1. Схема по умолчанию — TypedDict
class State(TypedDict):
    query: str          # обязательное поле
    draft: str
    steps: int

# 2. Необязательные поля
class State(TypedDict, total=False):
    draft: str          # можно не передавать на старте

# 3. Узел читает поля и ВОЗВРАЩАЕТ только изменённые ключи
def node(state: State) -> dict:
    return {"steps": state.get("steps", 0) + 1}   # .get() — безопасно

# 4. Правило слияния по умолчанию — перезапись ключа
#    (накопление вместо перезаписи — reducers, следующий урок)

# 5. Альтернативы:
#    dataclass  -> доступ через точку (state.query), дефолты
#    Pydantic   -> + валидация типов/ограничений в рантайме

# 6. НИКОГДА не мутируй state на месте:
#    state["items"].append(x)   # ❌
#    return {"items": [x]}      # ✅ возвращай изменения

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

Спроектируй состояние для конкретного агента:

Задание: состояние агента-«переводчика с проверкой»

  1. Опиши TypedDict-схему TranslateState для агента, который переводит текст и проверяет качество. Раздели поля на входные (text, target_lang), рабочие (translation, quality_ok, attempts) и выходные (final_text).
  2. Сделай рабочие и выходные поля необязательными (total=False), а входные оставь обязательными.
  3. Напиши узел translate, который читает text и пишет translation + увеличивает attempts через .get("attempts", 0).
  4. Запусти граф с неполным входом {"text": "...", "target_lang": "en"} и убедись, что отсутствие рабочих полей на старте не вызывает ошибок.
  5. Со звёздочкой: перепиши схему на Pydantic и добавь ограничение attempts ≥ 0 и target_lang длиной 2 символа. Проверь, что некорректный вход ловится валидацией.

Что дальше