LangGraphMiddleExperience

Какие ключевые абстракции LangGraph нужно понимать, чтобы не получать формально рабочие, но неверные результаты?

Ключевые абстракции LangGraph: State с reducer-функциями (иначе данные перетираются), чистые Node-функции, детерминированный conditional routing и Checkpointer с уникальным thread_id для корректного resume.

Ключевые абстракции LangGraph

LangGraph строится на четырёх уровнях: State, Node, Edge и Checkpointer. Непонимание любого из них ведёт к формально работающему, но неверному поведению.

State — единственный источник истины

State — это TypedDict (или Pydantic-модель), который передаётся между всеми узлами. Ошибка новичков: мутировать внешние переменные вместо возврата нового State. LangGraph применяет reducer-функции для слияния обновлений; по умолчанию последний write выигрывает, но для списков нужен явный Annotated[list, operator.add].

import operator
from typing import Annotated, TypedDict

class State(TypedDict):
    messages: Annotated[list, operator.add]  # reducer: append, не replace
    current_tool: str                         # replace: последний write побеждает
    retry_count: int

Без явного reducer для messages каждый узел перезапишет список, и история диалога потеряется — граф работает, но результат неверный.

Node — чистая функция над State

Узел получает State, возвращает словарь с изменёнными полями. Узел не должен хранить внутреннее состояние между вызовами — только читать из State и писать в State. Типичная ошибка: кешировать результат LLM в closure вместо State, из-за чего при resume после checkpoint данные теряются.

Edge и conditional routing

Рёбра определяют поток управления. add_edge — безусловный переход. add_conditional_edges принимает routing-функцию, которая возвращает строку с именем следующего узла. Routing-функция должна быть чистой и детерминированной — она вызывается при каждом replay из checkpoint.

from typing import Literal

def should_continue(state: State) -> Literal["tools", "__end__"]:
    last_msg = state["messages"][-1]
    if hasattr(last_msg, "tool_calls") and last_msg.tool_calls:
        return "tools"
    return "__end__"

builder.add_conditional_edges("agent", should_continue)

Checkpointer — персистентность и resume

Checkpointer сохраняет StateSnapshot после каждого узла. Это позволяет прерваться на interrupt_before=["human_review"] и возобновить позже. Ключевой концепт — thread_id: каждый независимый диалог/сессия должен иметь уникальный thread_id, иначе состояния перемешаются.

from langgraph.checkpoint.sqlite import SqliteSaver

with SqliteSaver.from_conn_string("checkpoints.db") as memory:
    graph = builder.compile(checkpointer=memory)

    config = {"configurable": {"thread_id": "user-42-session-7"}}

    # Первый вызов
    result = graph.invoke({"messages": [], "retry_count": 0}, config)

    # Возобновление с того же места
    snapshot = graph.get_state(config)
    graph.update_state(config, {"retry_count": 0})  # коррекция состояния
    resumed = graph.invoke(None, config)  # None = продолжить с checkpoint

Send API для динамического fan-out

Send позволяет запустить один узел несколько раз параллельно с разными данными — для map-reduce паттернов. Без понимания Send разработчики пишут цикл внутри узла, теряя параллелизм и видимость в трассировке.

from langgraph.types import Send

def spawn_workers(state: State):
    return [Send("worker", {"item": item}) for item in state["items"]]

builder.add_conditional_edges("dispatcher", spawn_workers)

Подводные камни

  • Мутация State in-place вместо возврата нового словаря: LangGraph не увидит изменение, snapshot окажется устаревшим.
  • Отсутствие reducer для списков: каждый узел перетирает предыдущие сообщения вместо добавления.
  • Один thread_id на всех пользователей: состояния перемешиваются, получаете чужие данные.
  • Не-детерминированная routing-функция (с side effects или random): при replay из checkpoint граф пойдёт другим путём.
  • Хранение ссылок на внешние объекты (DB-соединение) в State: checkpoint не сериализует их, resume падает с ошибкой.
  • Игнорирование RunnableConfig: без передачи config в вложенные вызовы теряется thread_id и трассировка LangSmith.
  • Использование SqliteSaver в production с параллельными запросами: SQLite не поддерживает concurrent writes — нужен PostgresSaver.
  • Отсутствие явного терминального условия в цикличных графах: граф зависает в бесконечном цикле без исключения.

What hurts your answer

  • Знать термины LangGraph, но не понимать связи между абстракциями
  • Объяснять поведение через отдельные примеры вместо причинной модели
  • Не связывать mental model с диагностикой ошибок

What they're listening for

  • Понимает ключевые абстракции LangGraph
  • Может предсказывать поведение системы через mental model
  • Связывает модель с debugging и production decisions

Related topics