LangGraph

Состояние графа: схема, редьюсеры, каналы | Курс LangGraph урок 2

Состояние графа: схема, редьюсеры, каналы | Курс LangGraph урок 2
Михаил Омельченко
Автор
Михаил Омельченко
Опубликовано 23.10.2026
5,0
Views 12

Цель урока: сделать состояние, в котором параллельные узлы не затирают правки друг друга.

Необходимые знания:

1) урок 1 этого курса: StateGraph, узлы и рёбра, START и END, компиляция, шаг выполнения, поток в режимах updates и values

2) урок 12 курса по LangChain: список сообщений как история диалога

3) Python на уровне джуниора: TypedDict, dataclass, Annotated, модуль operator

4) Pydantic на уровне "видел BaseModel и знаю, что он проверяет типы"


Чем закончился урок 1

В уроке 1 правка узла затирала значение ключа. В цепочке узлов это работает. Когда два узла одного шага прислали значение в один ключ, запуск упал с InvalidUpdateError.

Узлы одного шага равноправны и выполняются одновременно. Одну из двух правок LangGraph не выбирает: если у ключа нет правила, по которому значения сводятся в одно, запуск падает. Задаётся оно в схеме состояния, поэтому от схемы зависит, могут ли параллельные узлы писать в общий ключ.

Такое правило называется редьюсер, ему отведена половина урока. Вторая половина о границах состояния: что граф принимает и возвращает, как передать данные между двумя соседними узлами и куда положить настройки запуска.

Примеры работают на стенде из урока 1: положите папку mini_repo с файлами app.py, config.py, db.py и utils.py рядом с примерами, листинги файлов есть в уроке 1. Модель и ключ API в этом уроке не нужны.

Схема состояния: TypedDict, dataclass, BaseModel

Схема состояния передаётся в StateGraph первым аргументом.

Основной способ, это TypedDict: обычный словарь с подсказками типов для редактора. Он ничего не проверяет и не замедляет запуск.

dataclass берут ради значений по умолчанию: если ключа нет на входе, узел получит значение из схемы. Состояние приходит в узел объектом, и поля читаются как атрибуты: state.root.

BaseModel из Pydantic берут ради проверки данных. Модель Pydantic собирается из состояния перед каждым узлом, поэтому значение неверного типа роняет запуск с ValidationError раньше, чем узел его прочтёт. Неважно, пришло значение на вход графа или его записал узел предыдущего шага. Документация пишет, что проверяется только вход первого узла, в версии 1.2.16 это не так. Рекурсивная проверка Pydantic медленная.

В Python 3.11 и новее к ValidationError добавляется заметка в трассировке с именем узла, на входе которого сработала проверка. Какой узел записал неверное значение, в заметке нет.

Пример 01_three_schemas.py

"""Пример 1 урока 2: три способа задать схему состояния.

Граф из одного узла над стендом mini_repo собран трижды: на TypedDict, на
dataclass со значением по умолчанию и на модели Pydantic с проверкой входа.
Печатается, чем три схемы отличаются при запуске.
"""

from dataclasses import dataclass, field
from pathlib import Path

from langgraph.graph import END, START, StateGraph
from pydantic import BaseModel, ValidationError
from typing_extensions import TypedDict

REPO = Path(__file__).parent / "mini_repo"


class DictState(TypedDict):
    root: str
    suffix: str
    files: list[str]


def collect_dict(state: DictState) -> dict:
    """Собирает имена файлов и приписывает к ним пометку из состояния."""
    names = sorted(path.name for path in Path(state["root"]).glob("*.py"))
    return {"files": [name + state["suffix"] for name in names]}


@dataclass
class DataState:
    root: str
    suffix: str = " [py]"
    files: list[str] = field(default_factory=list)


def collect_data(state: DataState) -> dict:
    """То же самое, но состояние приходит объектом dataclass."""
    names = sorted(path.name for path in Path(state.root).glob("*.py"))
    return {"files": [name + state.suffix for name in names]}


class ModelState(BaseModel):
    root: str
    suffix: str = " [py]"
    files: list[str] = []


def collect_model(state: ModelState) -> dict:
    """То же самое, но состояние приходит моделью Pydantic."""
    names = sorted(path.name for path in Path(state.root).glob("*.py"))
    return {"files": [name + state.suffix for name in names]}


def build(schema, node) -> object:
    """Собирает граф из одного узла на переданной схеме состояния."""
    builder = StateGraph(schema)
    builder.add_node("collect", node)
    builder.add_edge(START, "collect")
    builder.add_edge("collect", END)
    return builder.compile()


# 1. TypedDict: ключа suffix на входе нет, значения по умолчанию тоже нет.
try:
    build(DictState, collect_dict).invoke({"root": str(REPO)})
except KeyError as error:
    print("1. TypedDict без suffix:", type(error).__name__, error)

# 2. TypedDict: ключ передан явно.
result = build(DictState, collect_dict).invoke({"root": str(REPO), "suffix": " [py]"})
print("2. TypedDict с suffix:", result["files"][:2])

# 3. dataclass: тот же вход без suffix, значение берётся из схемы.
result = build(DataState, collect_data).invoke({"root": str(REPO)})
print("3. dataclass без suffix:", result["files"][:2])
print("   тип результата:", type(result).__name__)

# 4. Pydantic: неверный тип на входе.
try:
    build(ModelState, collect_model).invoke({"root": 42})
except ValidationError as error:
    print("4. Pydantic, неверный тип:", str(error).splitlines()[0])

# 5. Pydantic: верный вход и тип результата.
result = build(ModelState, collect_model).invoke({"root": str(REPO)})
print("5. Pydantic, верный вход:", result["files"][:2])
print("   тип результата:", type(result).__name__)

# Вывод:
# 1. TypedDict без suffix: KeyError 'suffix'
# 2. TypedDict с suffix: ['app.py [py]', 'config.py [py]']
# 3. dataclass без suffix: ['app.py [py]', 'config.py [py]']
#    тип результата: dict
# 4. Pydantic, неверный тип: 1 validation error for ModelState
# 5. Pydantic, верный вход: ['app.py [py]', 'config.py [py]']
#    тип результата: dict

У TypedDict нет значений по умолчанию: обращение в узле к ключу, которого не было на входе, падает с KeyError. Пометка NotRequired касается только типов и значения не подставляет.

При dataclass и BaseModel узел получает объект, а возвращает всё равно словарь с правкой. invoke без version="v2" тоже отдаёт словарь, это видно по типу результата в пунктах 3 и 5. С version="v2" снимки потока values приходят в поле data объектом схемы, а invoke отдаёт GraphOutput с последним снимком в поле value.

Для BaseModel от version="v2" зависит и проверка выхода. После последнего узла модель Pydantic больше не собирается, поэтому без version="v2" его запись приходит в результат непроверенной. С version="v2" итоговое состояние собирается в модель, и неверный тип в нём тоже даёт ValidationError. У dataclass типы не проверяются и при version="v2".

Неверный тип на входе графа Pydantic ловит до первого узла, это пункт 4.

Страница документации про Graph API пишет, что create_agent не поддерживает схему состояния на Pydantic. Ошибки при этом не будет: агент с такой схемой собирается и запускается, но Pydantic в нём ничего не проверяет.

create_agent разбирает любую переданную схему на аннотации полей и собирает из них новый TypedDict, это функция _resolve_schema из пакета langchain 1.4.2. Класс BaseModel до графа не доходит.

Если в схеме на Pydantic есть поле limit: int, агент примет в нём строку "не число": ValidationError не возникнет, и тип не приведётся.

Поэтому в графе с агентом держите схему на TypedDict или dataclass, а проверку входа ставьте отдельным узлом перед агентом.

Берите TypedDict, пока не понадобились значения по умолчанию, а когда понадобились, dataclass. BaseModel нужен, когда вход приходит снаружи, например из формы или чужого сервиса, и в графе нет агента.

Редьюсер: как правка сводится со значением ключа

Редьюсер задаётся каждому ключу отдельно. Ключ без редьюсера выбрасывает старое значение и оставляет присланное узлом.

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

Правкой считается только то, что узел вернул. Допустим, узел добавил элемент методом append прямо в список из состояния и ничего не вернул. Результат invoke этот элемент покажет, а поток updates и checkpoint нет. Поэтому список из состояния в узле не меняйте, а возвращайте правкой. У ключа без редьюсера правка заменит весь список, поэтому верните его целиком вместе с новым элементом.

На странице документации про Graph API вызов редьюсера записан формулой:

new_value = reducer(left=current_state[key], right=node_update[key])

Имена left и right в формуле условные. LangGraph передаёт аргументы по позиции, поэтому параметры редьюсера можно назвать как угодно.

Редьюсер привязывается к ключу аннотацией Annotated: первым идёт тип значения, вторым функция.

Пример 02_reducers.py

"""Пример 2 урока 2: редьюсер сводит значение ключа с правкой узла.

Два узла над стендом mini_repo пишут находки в один ключ состояния. Граф
собран трижды: без редьюсера, с редьюсером на цепочке узлов и с редьюсером на
параллельной паре. Редьюсер печатает свои аргументы. В конце показана
ошибка StateGraph на редьюсер неверной формы.
"""

from pathlib import Path
from typing import Annotated

from langgraph.graph import END, START, StateGraph
from typing_extensions import TypedDict

REPO = Path(__file__).parent / "mini_repo"


def append_findings(left: list[str], right: list[str]) -> list[str]:
    """Складывает накопленное значение ключа (left) с правкой узла (right)."""
    print(f"   редьюсер: left={left}, right={right}")
    return left + right


class PlainState(TypedDict):
    root: str
    findings: list[str]


class ReducedState(TypedDict):
    root: str
    findings: Annotated[list[str], append_findings]


def count_lines(state) -> dict:
    """Считает строки во всех файлах стенда."""
    root = Path(state["root"])
    total = sum(
        len(path.read_text(encoding="utf-8").splitlines())
        for path in sorted(root.glob("*.py"))
    )
    return {"findings": [f"строк {total}"]}


def count_imports(state) -> dict:
    """Считает строки импорта во всех файлах стенда."""
    root = Path(state["root"])
    total = 0
    for path in sorted(root.glob("*.py")):
        for line in path.read_text(encoding="utf-8").splitlines():
            if line.startswith("import ") or line.startswith("from "):
                total += 1
    return {"findings": [f"импортов {total}"]}


def chain(schema) -> object:
    """Два узла цепочкой: count_lines, затем count_imports."""
    builder = StateGraph(schema)
    builder.add_node("count_lines", count_lines)
    builder.add_node("count_imports", count_imports)
    builder.add_edge(START, "count_lines")
    builder.add_edge("count_lines", "count_imports")
    builder.add_edge("count_imports", END)
    return builder.compile()


def pair(schema) -> object:
    """Те же два узла параллельно: обе правки приходят в одном шаге."""
    builder = StateGraph(schema)
    builder.add_node("count_lines", count_lines)
    builder.add_node("count_imports", count_imports)
    builder.add_edge(START, "count_lines")
    builder.add_edge(START, "count_imports")
    builder.add_edge("count_lines", END)
    builder.add_edge("count_imports", END)
    return builder.compile()


# 1. Цепочка без редьюсера: вторая правка затирает первую.
print("1. цепочка, ключ без редьюсера")
result = chain(PlainState).invoke({"root": str(REPO), "findings": []})
print("   итог:", result["findings"])

# 2. Та же цепочка с редьюсером: правки копятся.
print("2. цепочка, ключ с редьюсером")
result = chain(ReducedState).invoke({"root": str(REPO)})
print("   итог:", result["findings"])

# 3. Параллельная пара с редьюсером: обе правки в одном шаге.
print("3. параллельная пара, ключ с редьюсером")
result = pair(ReducedState).invoke({"root": str(REPO)})
print("   итог:", result["findings"])


# 4. Редьюсер неверной формы: один аргумент вместо двух.
def only_one(right: list[str]) -> list[str]:
    return right


class BrokenState(TypedDict):
    findings: Annotated[list[str], only_one]


try:
    StateGraph(BrokenState)
except ValueError as error:
    print("4. редьюсер с одним аргументом:", error)

# Вывод:
# 1. цепочка, ключ без редьюсера
#    итог: ['импортов 3']
# 2. цепочка, ключ с редьюсером
#    редьюсер: left=[], right=['строк 44']
#    редьюсер: left=['строк 44'], right=['импортов 3']
#    итог: ['строк 44', 'импортов 3']
# 3. параллельная пара, ключ с редьюсером
#    редьюсер: left=[], right=['импортов 3']
#    редьюсер: left=['импортов 3'], right=['строк 44']
#    итог: ['импортов 3', 'строк 44']
# 4. редьюсер с одним аргументом: Invalid reducer signature. Expected (a, b) -> c. Got (right: list[str]) -> list[str]
#
# В пункте 3 правки шага применены по алфавиту имён узлов,
# поэтому count_imports идёт раньше count_lines.

В выводе редьюсер вызван по разу на каждый узел, приславший ключ. Левый аргумент второго вызова уже содержит результат первого.

Правило из пункта 3 в LangGraph 1.2.16 действует для всех узлов, запущенных рёбрами. Документация такого порядка не обещает: по её словам, правки параллельного шага могут прийти в разной последовательности. Если порядок важен, соедините узлы ребром, тогда они попадут в разные шаги. Другой способ: пишите рядом с находкой номер и сортируйте по нему в следующем узле.

Ключу с редьюсером начальное значение на входе не нужно: он начинается с пустого значения своего типа, у списка это []. Поэтому второй и третий вызовы графа в примере обходятся одним root.

Сигнатура редьюсера проверяется при создании StateGraph. Функция с одним аргументом даёт ValueError с текстом про ожидаемую сигнатуру (a, b) -> c.

Готовые редьюсеры берут из модуля operator: add для списков и чисел, or_ для множеств и словарей. Для списка сообщений есть add_messages, он в следующем разделе. Своя функция пишется, когда правку надо обработать: отбросить дубли, оставить последнюю запись по каждому файлу, сложить счётчики по ключам.

Список сообщений: почему operator.add не годится

Сообщения это самый частый ключ состояния. Историю диалога можно копить и operator.add, складывая списки.

Это работает до первой правки уже записанного сообщения. Она нужна, когда человек правит черновик при остановке графа (урок 6) и когда следующий узел исправляет ответ из состояния.

operator.add допишет новый вариант к старому, и в истории останутся оба. add_messages сравнивает идентификаторы: сообщение с новым идентификатором дописывает, с существующим заменяет.

Ещё add_messages разбирает вход: краткий словарь вида {"role": "user", "content": "..."} он превращает в объект сообщения LangChain. Поэтому поля сообщения в состоянии читаются как атрибуты: state["messages"][-1].content.

Пример 03_messages.py

"""Пример 3 урока 2: почему списку сообщений нужен add_messages.

Три узла складывают переписку про стенд mini_repo: вопрос, черновик ответа и
его исправленная версия с тем же идентификатором. Граф собран трижды: ключ без
редьюсера, ключ с operator.add и ключ с add_messages. Печатается, что каждый вариант
ключа делает с исправленным сообщением и с краткой формой записи.
"""

import operator
from pathlib import Path
from typing import Annotated

from langchain.messages import AIMessage, AnyMessage
from langgraph.graph import END, START, StateGraph
from langgraph.graph.message import add_messages
from typing_extensions import TypedDict

REPO = Path(__file__).parent / "mini_repo"


class PlainState(TypedDict):
    messages: list[AnyMessage]


class AddState(TypedDict):
    messages: Annotated[list[AnyMessage], operator.add]


class MessagesGraphState(TypedDict):
    messages: Annotated[list[AnyMessage], add_messages]


def ask(state) -> dict:
    """Кладёт вопрос пользователя краткой формой записи."""
    return {"messages": [{"role": "user", "content": "Сколько файлов в репозитории?"}]}


def draft(state) -> dict:
    """Кладёт черновик ответа с явным идентификатором."""
    return {"messages": [AIMessage(content="Файлов несколько.", id="answer")]}


def fix(state) -> dict:
    """Возвращает исправленный ответ под тем же идентификатором."""
    count = len(sorted(REPO.glob("*.py")))
    return {"messages": [AIMessage(content=f"Файлов {count}.", id="answer")]}


def build(schema) -> object:
    """Собирает цепочку из трёх узлов на переданной схеме."""
    builder = StateGraph(schema)
    builder.add_node("ask", ask)
    builder.add_node("draft", draft)
    builder.add_node("fix", fix)
    builder.add_edge(START, "ask")
    builder.add_edge("ask", "draft")
    builder.add_edge("draft", "fix")
    builder.add_edge("fix", END)
    return builder.compile()


def show(title: str, schema) -> None:
    """Печатает итоговую переписку одной строкой на сообщение."""
    print(title)
    try:
        result = build(schema).invoke({"messages": []})
    except Exception as error:
        print("   упало:", type(error).__name__, error)
        return
    for message in result["messages"]:
        kind = type(message).__name__
        text = message.content if hasattr(message, "content") else message
        print(f"   {kind}(id={getattr(message, 'id', None)}): {text}")


show("1. ключ без редьюсера", PlainState)
show("2. ключ с operator.add", AddState)
show("3. ключ с add_messages", MessagesGraphState)

# Вывод:
# 1. ключ без редьюсера
#    AIMessage(id=answer): Файлов 4.
# 2. ключ с operator.add
#    dict(id=None): {'role': 'user', 'content': 'Сколько файлов в репозитории?'}
#    AIMessage(id=answer): Файлов несколько.
#    AIMessage(id=answer): Файлов 4.
# 3. ключ с add_messages
#    HumanMessage(id=d36b75fa-1ef2-418a-823a-8b47ce57379b): Сколько файлов в репозитории?
#    AIMessage(id=answer): Файлов 4.
#
# Идентификатор HumanMessage случайный, при своём запуске он будет другим.

Без редьюсера осталась последняя правка, вопрос пропал вместе с черновиком. С operator.add сохранилось всё: необработанный словарь и обе версии ответа под одним идентификатором. С add_messages осталось два сообщения: вопрос, разобранный в HumanMessage, и исправленный ответ, который заменил черновик с тем же идентификатором.

Длинный идентификатор вопроса проставил add_messages: сообщение без идентификатора получает его при разборе.

Вместо Annotated[list[AnyMessage], add_messages] можно взять готовую схему MessagesState из langgraph.graph: в ней уже есть ключ messages с редьюсером add_messages. Если в состоянии нужны другие ключи, её наследуют:

from langgraph.graph import MessagesState

class State(MessagesState):
    documents: list[str]

Overwrite: когда редьюсер надо обойти

Пустой правкой ключ со складывающим редьюсером не очистить. Если узел с ключом на operator.add вернёт {"errors": []}, редьюсер сложит старый список с пустым, и старый список останется.

Сбросить накопленное нужно перед новым заходом: счётчик попыток, список ошибок, черновик плана. Для этого есть тип Overwrite из langgraph.types: обёрнутое в него значение записывается в ключ, минуя редьюсер.

Пример 04_overwrite.py

"""Пример 4 урока 2: обход редьюсера типом Overwrite.

Ключ errors копит сообщения об ошибках обхода стенда. Показаны три попытки
очистить его перед новым заходом: пустым списком, типом Overwrite и его же
записью словарём. В конце показаны две правки Overwrite в одном шаге.
"""

import operator
from typing import Annotated

from langgraph.errors import InvalidUpdateError
from langgraph.graph import END, START, StateGraph
from langgraph.types import Overwrite
from typing_extensions import TypedDict


class State(TypedDict):
    errors: Annotated[list[str], operator.add]


def record_error(state: State) -> dict:
    """Записывает находку в общий буфер ошибок."""
    return {"errors": ["в utils.py функции без докстрингов"]}


def clear_with_empty(state: State) -> dict:
    """Пробует очистить буфер пустым списком."""
    return {"errors": []}


def clear_with_overwrite(state: State) -> dict:
    """Очищает буфер в обход редьюсера."""
    return {"errors": Overwrite([])}


def clear_with_dict(state: State) -> dict:
    """То же самое записью словарём."""
    return {"errors": {"__overwrite__": []}}


def chain(second) -> object:
    """Собирает цепочку: запись ошибки, затем переданный узел очистки."""
    builder = StateGraph(State)
    builder.add_node("record_error", record_error)
    builder.add_node("clear", second)
    builder.add_edge(START, "record_error")
    builder.add_edge("record_error", "clear")
    builder.add_edge("clear", END)
    return builder.compile()


print("1. очистка пустым списком:", chain(clear_with_empty).invoke({"errors": []}))
print("2. очистка типом Overwrite:", chain(clear_with_overwrite).invoke({"errors": []}))
print("3. очистка словарём:", chain(clear_with_dict).invoke({"errors": []}))

# 4. Два узла одного шага пишут Overwrite в один ключ.
builder = StateGraph(State)
builder.add_node("clear_a", clear_with_overwrite)
builder.add_node("clear_b", clear_with_dict)
builder.add_edge(START, "clear_a")
builder.add_edge(START, "clear_b")
builder.add_edge("clear_a", END)
builder.add_edge("clear_b", END)

try:
    builder.compile().invoke({"errors": ["старая находка"]})
except InvalidUpdateError as error:
    print("4. два Overwrite в одном шаге:", str(error).splitlines()[0])

# Вывод:
# 1. очистка пустым списком: {'errors': ['в utils.py функции без докстрингов']}
# 2. очистка типом Overwrite: {'errors': []}
# 3. очистка словарём: {'errors': []}
# 4. два Overwrite в одном шаге: Can receive only one Overwrite value per super-step.

Вместо типа можно вернуть словарь с единственным ключом __overwrite__. Эта форма нужна, когда правку пишут в JSON, без классов Python.

В одном шаге ключ принимает не больше одного Overwrite, два параллельных узла с Overwrite на общий ключ дают InvalidUpdateError. Обычные правки того же шага при Overwrite пропадают, в ключе остаётся значение Overwrite.

Поэтому сброс делает один узел в своём шаге, перед параллельной парой.

Вход, выход, приватный ключ и настройки запуска

По умолчанию одна схема задаёт вход графа, его выход и то, что получает каждый узел. Пока ключей мало, этого хватает.

Когда ключей много, по одной общей схеме не понять, какие из них передавать на вход. Наружу часто нужен один итоговый ключ, например карточка отчёта, а invoke возвращает всё состояние. Ключи для промежуточных данных двух соседних узлов приходится заводить в общей схеме, хотя больше они никому не нужны.

Это решают схемы:

1) input_schema ограничивает то, что принимается на входе графа

2) output_schema ограничивает то, что возвращает invoke

3) приватная схема заводит ключ для двух соседних узлов, в общей схеме его нет

Общая схема по-прежнему передаётся первым аргументом. Узел получает ключи, перечисленные в аннотации его аргумента state, а писать может в любой ключ графа. Ключи приватной схемы появляются в графе, когда она стоит в аннотации state хотя бы у одного узла. Отдельно её регистрировать не нужно.

Аннотация возврата узла ключей не заводит. Если приватная схема не стоит в аннотации state ни у одного узла, правка с её ключом пропадёт без ошибки. Страница документации про Graph API пишет обратное: ключ заводит и аннотация возврата.

Настройки запуска в состояние не кладут: имя модели, глубину обхода, признак пробного запуска, подключение к базе. Для них есть схема context_schema и аргумент узла runtime.

Значения передаются при вызове параметром context, узел читает их из runtime.context. В состояние и в поток values они не попадают.

Пример 05_io_private_context.py

"""Пример 5 урока 2: раздельные схемы входа и выхода, приватный ключ и контекст.

Граф строит карточку стенда mini_repo. На входе принимается один ключ, наружу
отдаётся тоже один, а промежуточные размеры файлов идут приватным ключом от
первого узла ко второму. Настройки запуска передаются контекстом.
"""

from dataclasses import dataclass
from pathlib import Path

from langgraph.graph import END, START, StateGraph
from langgraph.runtime import Runtime
from typing_extensions import TypedDict

REPO = Path(__file__).parent / "mini_repo"


class InputState(TypedDict):
    root: str


class OutputState(TypedDict):
    card: str


class OverallState(TypedDict):
    root: str
    card: str
    files: list[str]


class PrivateState(TypedDict):
    sizes: dict[str, int]


@dataclass
class RunContext:
    label: str = "обзор"
    max_files: int = 2


def scan(state: InputState, runtime: Runtime[RunContext]) -> PrivateState:
    """Считает строки в первых файлах стенда, число файлов задаёт контекст запуска."""
    print("   узел scan получил runtime.context =", runtime.context)
    context = runtime.context or RunContext()
    paths = sorted(Path(state["root"]).glob("*.py"))[: context.max_files]
    sizes = {
        path.name: len(path.read_text(encoding="utf-8").splitlines())
        for path in paths
    }
    return {"sizes": sizes}


def make_card(state: PrivateState, runtime: Runtime[RunContext]) -> OverallState:
    """Собирает карточку из приватных размеров и подписи из контекста."""
    context = runtime.context or RunContext()
    parts = [f"{name}: {lines}" for name, lines in state["sizes"].items()]
    return {
        "card": f"{context.label}, " + "; ".join(parts),
        "files": list(state["sizes"]),
    }


builder = StateGraph(
    OverallState,
    context_schema=RunContext,
    input_schema=InputState,
    output_schema=OutputState,
)
builder.add_node("scan", scan)
builder.add_node("make_card", make_card)
builder.add_edge(START, "scan")
builder.add_edge("scan", "make_card")
builder.add_edge("make_card", END)

graph = builder.compile()

print("1. без контекста вовсе:", graph.invoke({"root": str(REPO)}))
print("2. пустой контекст:", graph.invoke({"root": str(REPO)}, context={}))
print(
    "3. свой контекст:",
    graph.invoke({"root": str(REPO)}, context={"label": "полный обход", "max_files": 4}),
)

print("4. снимки состояния потоком:")
for chunk in graph.stream({"root": str(REPO)}, stream_mode="values", version="v2"):
    print("   ключи снимка:", sorted(chunk["data"]))

# Вывод:
#    узел scan получил runtime.context = None
# 1. без контекста вовсе: {'card': 'обзор, app.py: 13; config.py: 6'}
#    узел scan получил runtime.context = RunContext(label='обзор', max_files=2)
# 2. пустой контекст: {'card': 'обзор, app.py: 13; config.py: 6'}
#    узел scan получил runtime.context = RunContext(label='полный обход', max_files=4)
# 3. свой контекст: {'card': 'полный обход, app.py: 13; config.py: 6; db.py: 15; utils.py: 10'}
# 4. снимки состояния потоком:
#    ключи снимка: ['root']
#    узел scan получил runtime.context = None
#    ключи снимка: ['root', 'sizes']
#    ключи снимка: ['card', 'files', 'root', 'sizes']

invoke вернул только card, остальные три ключа отфильтровала выходная схема.

Поток values показывает состояние целиком, с приватным ключом sizes: схемы входа и выхода ключи из потока не убирают. Режим updates тоже покажет sizes в правке узла scan. Если нужен только выход, передайте в stream параметр output_keys=["card"].

Без параметра context в узел приходит runtime.context, равный None, и значения по умолчанию из схемы контекста не подставляются. Пустой словарь context={} их подставляет. Поэтому в обоих узлах стоит runtime.context or RunContext().

Каналы: во что превращается ключ

Внутри графа каждый ключ схемы становится каналом, тип канала задаёт аннотация ключа.

Каналы взяты из системы Pregel, как и шаг выполнения. Узлы читают из каналов и пишут в них. Шаг делится на фазы планирования, выполнения и обновления. Правки фазы выполнения другим узлам того же шага недоступны, канал применяет их в фазе обновления.

В курсе встречаются такие каналы:

1) LastValue это канал по умолчанию, он хранит последнее записанное значение. Отсюда поведение ключа без редьюсера и запрет на две записи за шаг

2) BinaryOperatorAggregate обновляет значение редьюсером. Таким каналом становится любой ключ с редьюсером, включая add_messages

3) Topic принимает сколько угодно записей за шаг. Параметр accumulate задаёт, копить их между шагами или очищать канал после каждого шага

Есть и DeltaChannel: он сохраняет в checkpoint только записи шага. Его берут, когда длинная история сообщений раздувает checkpoints. Нужен langgraph не ниже 1.2, канал в статусе бета.

Пример 06_channels.py

"""Пример 6 урока 2: каналы, в которые превращается схема состояния.

Схема из четырёх ключей собрана в граф, и у собранного графа печатается тип
канала для каждого ключа. Дальше два узла цепочки пишут в ключ с редьюсером
и в ключ с Topic. В ключ messages не пишет никто, он показывает начальное
значение канала с редьюсером.
"""

import operator
from typing import Annotated

from langchain.messages import AnyMessage
from langgraph.channels import Topic
from langgraph.graph import END, START, StateGraph
from langgraph.graph.message import add_messages
from typing_extensions import TypedDict


class State(TypedDict):
    root: str
    findings: Annotated[list[str], operator.add]
    messages: Annotated[list[AnyMessage], add_messages]
    events: Annotated[list[str], Topic(str, accumulate=True)]


def first(state: State) -> dict:
    """Пишет по одной записи в ключ с редьюсером и в ключ с Topic."""
    return {"findings": ["первая находка"], "events": ["узел first"]}


def second(state: State) -> dict:
    """То же самое из второго узла цепочки."""
    return {"findings": ["вторая находка"], "events": ["узел second"]}


builder = StateGraph(State)
builder.add_node("first", first)
builder.add_node("second", second)
builder.add_edge(START, "first")
builder.add_edge("first", "second")
builder.add_edge("second", END)

graph = builder.compile()

print("ТИПЫ КАНАЛОВ:")
for key in ("root", "findings", "messages", "events"):
    channel = graph.channels[key]
    print(f"   {key}: {type(channel).__name__}")

print()
result = graph.invoke({"root": "mini_repo"})
print("НА ВЫХОДЕ:")
for key in ("root", "findings", "messages", "events"):
    print(f"   {key}: {result.get(key)}")

# Вывод:
# ТИПЫ КАНАЛОВ:
#    root: LastValue
#    findings: BinaryOperatorAggregate
#    messages: BinaryOperatorAggregate
#    events: Topic
#
# НА ВЫХОДЕ:
#    root: mini_repo
#    findings: ['первая находка', 'вторая находка']
#    messages: []
#    events: ['узел first', 'узел second']

graph.channels это атрибут собранного графа, StateGraph собирает каналы из схемы. Тип канала у ключа смотрят там, когда правки ведут себя не так, как задумано.

Ключ messages узлы не трогали, и он вернулся пустым списком.

Вместо редьюсера в Annotated можно поставить сам канал: Annotated[list[str], Topic(str, accumulate=True)] даёт канал Topic. Так задают журнал событий, куда пишут все узлы.

Поле, которое не попадает в checkpoint

Иногда в состоянии нужно поле с объектом, который нельзя сохранить в checkpoint, например с подключением к базе.

Страница документации про Graph API описывает такое поле в разделе Untracked values, но только для JavaScript. Рецепта для Python там нет.

В пакете для Python класс UntrackedValue есть, он лежит в langgraph.channels. Урок его не применяет: недокументированный код может сломаться при обновлении пакета.

Подключение к базе и другие несохраняемые объекты передавайте контекстом запуска, как в примере 5: runtime.context в состояние не попадает. Кеш, который можно пересобрать, допустим в состоянии, если он сериализуется: такой ключ попадает в checkpoint. Устаревший кеш сбрасывайте отдельным узлом, который идёт раньше узлов, читающих его. Ключ без редьюсера очищается обычной правкой с пустым значением, ключ с редьюсером правкой типа Overwrite.

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

Соберите в файле repo_state.py граф, в котором два параллельных узла собирают находки по стенду mini_repo, а третий строит из них карточку.

Требования:

1) схема на TypedDict: путь к папке, список находок с редьюсером, карточка отчёта

2) три узла: счёт строк, разбор импортов, сборка карточки. Первые два стоят параллельно и пишут находки в один ключ

3) вход графа ограничен одним ключом с путём, выход ограничен одной карточкой

4) наибольшее число файлов задаётся контекстом запуска. Значение по умолчанию в схеме контекста обход не ограничивает, в отличие от примера 5

5) узел сборки карточки получает список файлов приватным ключом от узла счёта строк

Как проверить результат:

1) invoke возвращает словарь из одного ключа

2) в находках лежат обе записи, их видно в потоке values

3) запуск с context={"max_files": 2} даёт карточку короче, чем запуск без ограничения

4) если снять редьюсер с ключа находок, запуск падает с InvalidUpdateError

5) если в узле счёта строк вернуть его запись внутри Overwrite([...]), в находках остаётся только она, правка узла разбора импортов пропадает

Подсказка: приватный ключ становится каналом, когда стоит в аннотации state узла сборки, как у make_card в примере 5. Узлу сборки нужны и находки, поэтому его схема держит оба ключа: findings: list[str] и files: list[str].

Итоги урока

TypedDict ничего не проверяет, dataclass даёт значения по умолчанию, BaseModel проверяет данные на входе каждого узла и работает медленнее. create_agent пересобирает схему в TypedDict, и проверка Pydantic пропадает без ошибки.

Редьюсер сводит накопленное значение ключа с правкой узла. Без редьюсера в ключ за шаг может писать только один узел, с редьюсером сколько угодно. Для сообщений берут add_messages: он заменяет сообщение с тем же идентификатором и превращает словарь вида {"role": "user", "content": ...} в объект сообщения.

Пустая правка накопленное не очищает, для сброса есть Overwrite, один на ключ за шаг. Схемы входа и выхода ограничивают, что граф принимает и возвращает, приватный ключ связывает соседние узлы, настройки запуска передаются контекстом.

Каждый ключ становится каналом: без редьюсера LastValue, с редьюсером BinaryOperatorAggregate, журнал событий Topic. Поле вне checkpoint документировано только для JavaScript.

В уроке 3 разберу ветвление графа: функцию маршрутизации, Command и Send.

Код урока

Примеры этого урока лежат в репозитории курса, папка lesson_02. Закреплённые версии, на которых получен вывод в тексте, лежат в requirements.txt в корне репозитория.


Предыдущий урок: Граф вместо готового цикла

Следующий урок: Ветвление: условные рёбра, Command и Send


Подписывайтесь на мой Telegram канал

Если вам нужен ментор и вы хотите научиться разрабатывать AI агентов, пишите, обсудим условия

5,0

Авторизуйтесь, чтобы оставить комментарий.

Комментариев: 0

Нет комментариев.

Тут может быть ваша реклама

Пишите info@aisferaic.ru

Похожие туториалы