LangGraph

Пауза и человек в цикле | Курс LangGraph урок 6

Пауза и человек в цикле | Курс LangGraph урок 6
Михаил Омельченко
Автор
Михаил Омельченко
Опубликовано 29.10.2026
0,0
Views 6

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

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

1) урок 17 курса по LangChain: HumanInTheLoopMiddleware, решения approve, edit и reject, возобновление запуска вызовом с Command(resume=...)

2) урок 2 этого курса: схема на BaseModel из Pydantic и ValidationError

3) урок 3 этого курса: Command с параметрами update и goto, аннотация Command[Literal[...]], Send и копии узла

4) урок 4 этого курса: узел при возврате выполняется с начала, побочные действия должны выдерживать повтор

5) урок 5 этого курса: чекпойнтер, thread_id, SqliteSaver.from_conn_string, get_state с полями next и tasks, продолжение запуска вызовом invoke(None, config)

6) Python на уровне джуниора: subprocess.run, try/except


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

В уроке 5 запуск графа пережил сбой процесса. Checkpoints лежали в чекпойнтере, и вызов invoke(None, config) продолжил запуск с последнего законченного шага. При этом граф останавливался сам: когда выполнять больше нечего, при ошибке или при сбое.

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

HumanInTheLoopMiddleware из урока 17 курса по LangChain ставит такую остановку только перед вызовом инструмента агента. В своём графе паузу ставит вызов interrupt() в любом месте узла. Ответ человека возвращается в узел вызовом с Command(resume=...).

Примеры работают на стенде из урока 1: положите папку mini_repo с файлами app.py, config.py, db.py и utils.py рядом с примерами, листинги файлов есть в уроке 1. Узлы примеров выполняют эти файлы отдельным процессом. app.py печатает одну строку, остальные три файла ничего не печатают. Пакеты langgraph-checkpoint-sqlite для примера 2 и langchain для примера 5 указаны в requirements.txt из урока 1. Модель и ключ API в этом уроке не нужны.

Пауза: interrupt() внутри узла

Функция interrupt лежит в модуле langgraph.types. Первым аргументом она принимает значение, которое получит человек. Дальше называю его значением паузы. Подходит значение, пригодное для JSON: строка, число, True и False, None, список или словарь из таких значений.

Пока ответа нет, вызов interrupt() ничего не возвращает: он бросает исключение GraphInterrupt. LangGraph перехватывает его, сохраняет состояние чекпойнтером и заканчивает вызов invoke. Значение паузы приходит в результате под ключом __interrupt__. Под ключом лежит список объектов Interrupt, у каждого есть поля value и id.

Запуск стоит на паузе, пока не придёт ответ. Ответ передаётся новым вызовом invoke: вместо входа стоит Command(resume=ответ), в конфигурации тот же thread_id. Такой вызов тоже продолжает запуск, но с ответом для паузы. Дальше называю его возобновлением, по имени параметра resume.

Пример 01_pause.py

"""Пример 1 урока 6: пауза внутри узла и возобновление.

Узел choose_file останавливает запуск вызовом interrupt() и отдаёт наружу
список файлов стенда. Человек отвечает именем файла, ответ приходит вызовом
с Command(resume=...). При возобновлении узел choose_file выполняется с первой
строки, interrupt() возвращает ответ, и узел run_file выполняет выбранный файл
отдельным процессом.
"""

import subprocess
import sys
from pathlib import Path

from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import END, START, StateGraph
from langgraph.types import Command, interrupt
from typing_extensions import TypedDict

REPO = Path(__file__).parent / "mini_repo"
CONFIG = {"configurable": {"thread_id": "run-1"}}
CALLS: list[str] = []


class RunState(TypedDict):
    root: str
    files: list[str]
    chosen: str
    output: str


def collect_files(state: RunState) -> dict:
    """Собирает имена файлов стенда."""
    CALLS.append("collect_files")
    names = sorted(path.name for path in Path(state["root"]).glob("*.py"))
    return {"files": names}


def choose_file(state: RunState) -> dict:
    """Ставит запуск на паузу с вопросом, какой файл выполнить."""
    CALLS.append("choose_file")
    chosen = interrupt({"вопрос": "Какой файл выполнить?", "файлы": state["files"]})
    return {"chosen": chosen}


def run_file(state: RunState) -> dict:
    """Выполняет выбранный файл стенда отдельным процессом."""
    CALLS.append("run_file")
    done = subprocess.run(
        [sys.executable, state["chosen"]],
        cwd=state["root"],
        capture_output=True,
        text=True,
        encoding="utf-8",
    )
    return {"output": f"код {done.returncode}, вывод: {done.stdout.strip() or '-'}"}


builder = StateGraph(RunState)
builder.add_node("collect_files", collect_files)
builder.add_node("choose_file", choose_file)
builder.add_node("run_file", run_file)
builder.add_edge(START, "collect_files")
builder.add_edge("collect_files", "choose_file")
builder.add_edge("choose_file", "run_file")
builder.add_edge("run_file", END)
graph = builder.compile(checkpointer=InMemorySaver())

START_STATE = {"root": str(REPO), "files": [], "chosen": "", "output": ""}

# 1. Первый вызов останавливается на interrupt() в узле choose_file.
result = graph.invoke(START_STATE, CONFIG)
pause = result["__interrupt__"][0]
print("1. ключи результата:", sorted(result))
print("   значение паузы:", pause.value)
print("   выполнены узлы:", CALLS)

# 2. Что сохранил чекпойнтер на паузе.
snapshot = graph.get_state(CONFIG)
print("2. next:", snapshot.next)
print("   задача на паузе:", snapshot.tasks[0].name)
print("   то же значение в get_state:", snapshot.interrupts[0].value == pause.value)

# 3. Ответ человека: возобновление тем же thread_id.
CALLS.clear()
result = graph.invoke(Command(resume="app.py"), CONFIG)
print("3. выполнены узлы:", CALLS)
print("   выбран:", result["chosen"])
print("   выполнение файла:", result["output"])
print("   пауза в результате:", "__interrupt__" in result)

# Вывод:
# 1. ключи результата: ['__interrupt__', 'chosen', 'files', 'output', 'root']
#    значение паузы: {'вопрос': 'Какой файл выполнить?', 'файлы': ['app.py', 'config.py', 'db.py', 'utils.py']}
#    выполнены узлы: ['collect_files', 'choose_file']
# 2. next: ('choose_file',)
#    задача на паузе: choose_file
#    то же значение в get_state: True
# 3. выполнены узлы: ['choose_file', 'run_file']
#    выбран: app.py
#    выполнение файла: код 0, вывод: [('select count(*) from orders', 3)]
#    пауза в результате: False

Пункт 1: первый вызов вернул состояние и ключ __interrupt__. Ключи chosen и output в результате пришли из START_STATE, там они пустые строки. Узел collect_files выполнился целиком, choose_file до вызова interrupt(), а run_file не выполнялся.

Пункт 2 показывает, что сохранил чекпойнтер. Последний checkpoint стоит перед узлом choose_file, поэтому next называет этот узел. Пауза записана в задачу узла, в поле interrupts задачи из tasks. В поле interrupts самого снимка собраны паузы всех задач шага, которые ждут ответа. Поэтому значение паузы можно прочитать по одному thread_id, без результата первого вызова. В своём коде читайте поле снимка, snapshot.interrupts, а поле задачи берите, когда важно, какой узел встал на паузу.

Чекпойнтер хранит значение паузы, а позже и ответ человека, правками этой задачи, отдельно от checkpoint. Это те же pending writes из урока 5.

Пункт 3: при возобновлении choose_file выполнился второй раз, с первой строки. Теперь interrupt() вернул ответ человека, "app.py", и узел записал его в chosen.

Узел повторяется целиком, потому что checkpoint пишется на границе шага, урок 1. Внутри функции узла точки сохранения нет. При повторном выполнении interrupt() возвращает сохранённый ответ, а весь код до него выполняется ещё раз. Из этого следуют правила в конце урока.

Ответом, как и значением паузы, может быть любое значение, пригодное для JSON. Форму ответа можно задать параметром response_schema, он разобран в примере 3.

Для входа invoke из параметров Command предназначен только resume. Параметры update, goto и graph предназначены для возврата из узла, урок 3. Если человек исправил действие, его исправление записывает в состояние сам узел, как в примере 3.

Примеры на странице документации про паузы вызывают stream_events(..., version="v3"). В коде langgraph 1.2.16 этот режим помечен как экспериментальный, поэтому в уроке вызываю invoke.

При компиляции можно задать и параметры interrupt_before и interrupt_after. Тогда граф останавливается до или после названных узлов. Это точки остановки для отладки. Для человека в цикле подходит interrupt(): его можно поставить в любом месте узла и вызвать по условию.

Что нужно для возобновления

Возобновлению нужны три вещи: чекпойнтер, thread_id и значение паузы, которое чекпойнтер умеет сохранить. Документация называет чекпойнтер требованием самой паузы. В коде без чекпойнтера граф на паузе останавливается, а падает только возобновление, как в пункте 1 примера 2. Без thread_id граф с чекпойнтером падает с ValueError до работы узлов, как в уроке 5. Значение паузы разобрано в правиле 3 в конце урока.

InMemorySaver из примеров урока держит паузу в памяти процесса. Если программа закроется до ответа, возобновлять будет нечего. Когда ответ может прийти через несколько часов, нужен чекпойнтер с записью на диск, например SqliteSaver из урока 5.

Пример 02_wait.py

"""Пример 2 урока 6: что нужно возобновлению и как пауза ждёт ответа.

Граф из примера 1. Без чекпойнтера запуск останавливается, но возобновить
его нельзя. С SqliteSaver пауза лежит в файле: первый день заканчивается на
паузе и закрывает соединение. Второй день открывает файл новым соединением и
собирает граф заново. Вызов invoke(None, CONFIG) вместо ответа снова
останавливается на той же паузе, ответ даёт вызов с Command(resume=...).
Повтор от checkpoint с паузой ставит паузу заново, и новый ответ возобновляет
уже повтор.
"""

import subprocess
import sys
from pathlib import Path

from langgraph.checkpoint.sqlite import SqliteSaver
from langgraph.graph import END, START, StateGraph
from langgraph.types import Command, interrupt
from typing_extensions import TypedDict

HERE = Path(__file__).parent
REPO = HERE / "mini_repo"
DB_PATH = HERE / "pause.sqlite"
CONFIG = {"configurable": {"thread_id": "run-1"}}
START_STATE = {"root": str(REPO), "files": [], "chosen": "", "output": ""}
CALLS: list[str] = []


class RunState(TypedDict):
    root: str
    files: list[str]
    chosen: str
    output: str


def collect_files(state: RunState) -> dict:
    """Собирает имена файлов стенда."""
    CALLS.append("collect_files")
    names = sorted(path.name for path in Path(state["root"]).glob("*.py"))
    return {"files": names}


def choose_file(state: RunState) -> dict:
    """Ставит запуск на паузу с вопросом, какой файл выполнить."""
    CALLS.append("choose_file")
    chosen = interrupt({"вопрос": "Какой файл выполнить?", "файлы": state["files"]})
    return {"chosen": chosen}


def run_file(state: RunState) -> dict:
    """Выполняет выбранный файл стенда отдельным процессом."""
    CALLS.append("run_file")
    done = subprocess.run(
        [sys.executable, state["chosen"]],
        cwd=state["root"],
        capture_output=True,
        text=True,
        encoding="utf-8",
    )
    return {"output": f"код {done.returncode}, вывод: {done.stdout.strip() or '-'}"}


def build(checkpointer=None):
    """Собирает граф примера 1 с переданным чекпойнтером или без него."""
    builder = StateGraph(RunState)
    builder.add_node("collect_files", collect_files)
    builder.add_node("choose_file", choose_file)
    builder.add_node("run_file", run_file)
    builder.add_edge(START, "collect_files")
    builder.add_edge("collect_files", "choose_file")
    builder.add_edge("choose_file", "run_file")
    builder.add_edge("run_file", END)
    return builder.compile(checkpointer=checkpointer)


def remove_db() -> None:
    """Удаляет файл базы вместе со служебными файлами SQLite."""
    for suffix in ("", "-wal", "-shm"):
        Path(f"{DB_PATH}{suffix}").unlink(missing_ok=True)


# 1. Без чекпойнтера пауза случается, а возобновить запуск нечем.
plain = build()
result = plain.invoke(START_STATE)
print("1. без чекпойнтера, пауза в результате:", "__interrupt__" in result)
try:
    plain.invoke(Command(resume="app.py"))
except RuntimeError as error:
    print("   возобновление:", error)

# 2. Первый день: пауза записана в файл, соединение закрыто.
remove_db()
with SqliteSaver.from_conn_string(str(DB_PATH)) as checkpointer:
    result = build(checkpointer).invoke(START_STATE, CONFIG)
    print("2. первый день, пауза:", result["__interrupt__"][0].value["вопрос"])

# 3. Второй день: новое соединение, граф собран заново.
with SqliteSaver.from_conn_string(str(DB_PATH)) as checkpointer:
    graph = build(checkpointer)
    snapshot = graph.get_state(CONFIG)
    print("3. второй день, next:", snapshot.next)
    print("   пауза из файла:", snapshot.interrupts[0].value["вопрос"])

    # 4. invoke(None, CONFIG) не отвечает на паузу, узел снова встаёт на неё.
    CALLS.clear()
    result = graph.invoke(None, CONFIG)
    print("4. invoke(None): выполнены узлы", CALLS, "пауза:", "__interrupt__" in result)

    # 5. Ответ человека.
    CALLS.clear()
    result = graph.invoke(Command(resume="app.py"), CONFIG)
    print("5. Command(resume='app.py'): выполнены узлы", CALLS)
    print("   выполнение файла:", result["output"])

    # 6. Второй ответ законченному запуску узлов не выполняет.
    CALLS.clear()
    result = graph.invoke(Command(resume="utils.py"), CONFIG)
    print("6. повторный ответ: выполнены узлы", CALLS, "выбран", result["chosen"])

    # 7. Повтор от checkpoint с паузой (урок 5): прежний ответ не используется.
    paused = next(
        item
        for item in graph.get_state_history(CONFIG)
        if any(task.interrupts for task in item.tasks)
    )
    CALLS.clear()
    result = graph.invoke(None, paused.config)
    print("7. повтор: выполнены узлы", CALLS, "пауза:", "__interrupt__" in result)
    result = graph.invoke(Command(resume="utils.py"), CONFIG)
    print("   новый ответ: выбран", result["chosen"], "|", result["output"])
remove_db()

# Вывод:
# 1. без чекпойнтера, пауза в результате: True
#    возобновление: Cannot use Command(resume=...) without checkpointer
# 2. первый день, пауза: Какой файл выполнить?
# 3. второй день, next: ('choose_file',)
#    пауза из файла: Какой файл выполнить?
# 4. invoke(None): выполнены узлы ['choose_file'] пауза: True
# 5. Command(resume='app.py'): выполнены узлы ['choose_file', 'run_file']
#    выполнение файла: код 0, вывод: [('select count(*) from orders', 3)]
# 6. повторный ответ: выполнены узлы [] выбран app.py
# 7. повтор: выполнены узлы ['choose_file'] пауза: True
#    новый ответ: выбран utils.py | код 0, вывод: -

Пункт 1: без чекпойнтера граф всё равно останавливается на interrupt() и возвращает __interrupt__. Возобновить такой запуск нельзя: вызов с Command(resume=...) падает с RuntimeError. Состояние на паузе нигде не записано, поэтому возобновлять запуск не с чего.

Пункты 2 и 3: пауза пережила закрытие соединения. Во второй день граф собран заново на новом соединении. Метод get_state прочитал из файла и next, и значение паузы. Всё нужное для возобновления лежит в файле SQLite, поэтому программу можно закрыть до ответа человека. Возобновление в новом процессе идёт так же, как продолжение в примере 2 урока 5. Режим durability="exit" из урока 5 пишет состояние при выходе из вызова. Пауза заканчивает вызов invoke. Поэтому в режиме "exit" пауза тоже записывается.

В приложении первый запрос вызывает граф и, получив __interrupt__, показывает человеку value. Рядом с вопросом приложение сохраняет thread_id. Ответ человека приходит отдельным запросом. Этот запрос вызывает граф с Command(resume=ответ) и тем же thread_id.

Пункт 4: invoke(None, CONFIG) выполнил choose_file заново, и узел снова остановился на той же паузе. Продолжение берёт последний checkpoint, а ответа для interrupt() в нём нет. Ответ передаёт только возобновление, пункт 5.

Пункт 6: второй ответ законченному запуску не выполнил ни одного узла и ошибки не выдал. Вызов вернул итоговое состояние с прежним chosen. Повторный ответ безопасен, только когда запуск уже закончился. Если после первого ответа граф встал на следующую паузу, второй ответ достанется ей. Так будет в примере 5 ниже с вопросом о db.py. Поэтому повторное нажатие кнопки отсекайте в приложении, до вызова графа. Паузу от законченного запуска отличает get_state: у законченного запуска пустые next и tasks, урок 5.

Ответ с другим thread_id до паузы не дойдёт. В пустом thread граф начнёт новый запуск с пустым состоянием. Узел, которому нужен ключ входа, упадёт с KeyError, в этом графе это collect_files.

Пункт 7: повтор из урока 5 от checkpoint с паузой снова остановился на ней. Такой checkpoint находится по полю interrupts у задач, как точка повтора в уроке 5 искалась по next. Прежний ответ, app.py, при повторе не используется. Повтор добавил в thread новую цепочку записей, как в уроке 5. Вызов с CONFIG без checkpoint_id берёт самую свежую запись thread, а она стоит в цепочке повтора. Поэтому новый ответ, utils.py, получила пауза повтора, и run_file выполнил другой файл.

Одобрить, исправить или отклонить

Значение паузы и форму ответа задаёт узел. Перед опасным действием человеку показывают это действие, и он выбирает одно из трёх решений: выполнить как есть, исправить и выполнить, отказать с причиной. Решение "исправить" дальше называю исправлением человека. Решения approve, edit и reject с тем же смыслом давал HumanInTheLoopMiddleware в уроке 17 курса по LangChain. Здесь они написаны в узле review_run.

Узел review_run возвращает Command из урока 3: goto выбирает следующий узел, update записывает исправление человека в состояние. Аннотация возврата Command[Literal["run_file", "skip_run"]] нужна, чтобы переходы попали на схему графа.

Форму ответа узел описывает схемой ответа, параметром response_schema у interrupt(). Параметр появился в langgraph 1.2.12, в более ранних версиях вызов падает с TypeError: interrupt() got an unexpected keyword argument 'response_schema'. Схема ответа, это класс на BaseModel из Pydantic, TypedDict или dataclass, как схема состояния в уроке 2. LangGraph переводит класс в JSON Schema, стандартное описание данных JSON: какие поля, каких типов, какие обязательны. Описание приходит вместе с паузой, в поле response_schema объекта Interrupt. По нему приложение может построить форму для ответа человека.

При возобновлении interrupt() проверяет ответ по классу и возвращает узлу проверенный ответ: объект модели для BaseModel, словарь для TypedDict, объект для dataclass.

Пример 03_review.py

"""Пример 3 урока 6: одобрить, исправить или отклонить выполнение файла.

Узел plan_run предлагает выполнить db.py. Узел review_run показывает
предложение человеку, LangGraph проверяет ответ по схеме Decision, и узел по
решению возвращает Command: approve ведёт в run_file как есть, edit меняет
файл и ведёт в run_file, reject записывает причину и ведёт в skip_run. Три
решения идут в трёх разных thread. В четвёртом thread первый ответ не
проходит проверку по схеме, и человек отвечает на ту же паузу ещё раз.
"""

import subprocess
import sys
from pathlib import Path
from typing import Literal

from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import END, START, StateGraph
from langgraph.types import Command, interrupt
from pydantic import BaseModel, ValidationError
from typing_extensions import TypedDict

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


class RunState(TypedDict):
    root: str
    chosen: str
    note: str
    output: str


class Decision(BaseModel):
    """Ответ человека: решение и поля, которые оно использует."""

    type: Literal["approve", "edit", "reject"]
    file: str | None = None
    reason: str = "без причины"


def plan_run(state: RunState) -> dict:
    """Предлагает файл для выполнения. В проекте это решение модели."""
    return {"chosen": "db.py"}


def review_run(state: RunState) -> Command[Literal["run_file", "skip_run"]]:
    """Ставит паузу с предложением и переходит по решению человека."""
    decision = interrupt(
        {"действие": "выполнение файла", "файл": state["chosen"]},
        response_schema=Decision,
    )
    if decision.type == "approve":
        return Command(goto="run_file")
    if decision.type == "edit":
        return Command(update={"chosen": decision.file}, goto="run_file")
    return Command(update={"note": decision.reason}, goto="skip_run")


def run_file(state: RunState) -> dict:
    """Выполняет файл стенда отдельным процессом."""
    done = subprocess.run(
        [sys.executable, state["chosen"]],
        cwd=state["root"],
        capture_output=True,
        text=True,
        encoding="utf-8",
    )
    return {"output": f"код {done.returncode}, вывод: {done.stdout.strip() or '-'}"}


def skip_run(state: RunState) -> dict:
    """Записывает отказ вместо выполнения файла."""
    return {"output": f"не выполнен: {state['note']}"}


builder = StateGraph(RunState)
builder.add_node("plan_run", plan_run)
builder.add_node("review_run", review_run)
builder.add_node("run_file", run_file)
builder.add_node("skip_run", skip_run)
builder.add_edge(START, "plan_run")
builder.add_edge("plan_run", "review_run")
builder.add_edge("run_file", END)
builder.add_edge("skip_run", END)
graph = builder.compile(checkpointer=InMemorySaver())

START_STATE = {"root": str(REPO), "chosen": "", "note": "", "output": ""}
DECISIONS = {
    "review-approve": {"type": "approve"},
    "review-edit": {"type": "edit", "file": "app.py"},
    "review-reject": {"type": "reject", "reason": "db.py ходит в базу"},
}

for number, (thread_id, decision) in enumerate(DECISIONS.items(), start=1):
    config = {"configurable": {"thread_id": thread_id}}
    paused = graph.invoke(START_STATE, config)
    result = graph.invoke(Command(resume=decision), config)
    print(f"{number}. {decision['type']}")
    print("   человеку показано:", paused["__interrupt__"][0].value)
    print("   файл:", result["chosen"], "|", result["output"])

# 4. Схема ответа приходит вместе с паузой, в поле response_schema.
schema = paused["__interrupt__"][0].response_schema
print("4. решения в схеме:", schema["properties"]["type"]["enum"])
print("   обязательные поля:", schema["required"])

# 5. Ответ не по схеме: ValidationError, пауза остаётся на месте.
config = {"configurable": {"thread_id": "review-invalid"}}
graph.invoke(START_STATE, config)
try:
    graph.invoke(Command(resume={"type": "approved"}), config)
except ValidationError as error:
    problem = error.errors()[0]
    print("5. ошибка в поле", problem["loc"], "|", problem["msg"])
snapshot = graph.get_state(config)
print("   next:", snapshot.next, "| пауз:", len(snapshot.interrupts))
try:
    graph.invoke(None, config)
except ValidationError as error:
    print("   invoke(None) после неверного ответа:", type(error).__name__)
result = graph.invoke(Command(resume={"type": "approve"}), config)
print("   исправленный ответ: файл", result["chosen"], "|", result["output"])

# Вывод:
# 1. approve
#    человеку показано: {'действие': 'выполнение файла', 'файл': 'db.py'}
#    файл: db.py | код 0, вывод: -
# 2. edit
#    человеку показано: {'действие': 'выполнение файла', 'файл': 'db.py'}
#    файл: app.py | код 0, вывод: [('select count(*) from orders', 3)]
# 3. reject
#    человеку показано: {'действие': 'выполнение файла', 'файл': 'db.py'}
#    файл: db.py | не выполнен: db.py ходит в базу
# 4. решения в схеме: ['approve', 'edit', 'reject']
#    обязательные поля: ['type']
# 5. ошибка в поле ('type',) | Input should be 'approve', 'edit' or 'reject'
#    next: ('review_run',) | пауз: 1
#    invoke(None) после неверного ответа: ValidationError
#    исправленный ответ: файл db.py | код 0, вывод: -

Пункты 1-3: во всех трёх thread человек получил одно и то же значение паузы. Решение edit несёт исправление человека: узел записал app.py вместо db.py параметром update, и run_file выполнил исправленный файл. Решение reject записало причину в note и перевело запуск в skip_run, файл не выполнялся. Узел берёт поля ответа как атрибуты, decision.type, потому что interrupt() вернул объект Decision.

Пункт 4: схема ответа перечисляет три решения из Literal. В ней одно обязательное поле, type. У полей file и reason есть значения по умолчанию, поэтому ответ {"type": "approve"} проходит проверку.

Пункт 5: ответ с опечаткой, "approved" вместо "approve", не прошёл проверку. Вызов упал с ValidationError. Текст ошибки можно показать человеку: в нём названы поле и допустимые значения. Задаче ответ не достался: get_state по-прежнему показывает review_run в next и одну паузу. Исправленный ответ с тем же thread_id дошёл до узла, и run_file выполнил db.py. Без схемы узел получил бы словарь с опечаткой и сам решал бы, что с ним делать.

Схема Decision проверяет каждое поле отдельно. Решение edit без file проверку пройдёт, и в file будет None. Тогда run_file получит None вместо имени файла и упадёт. Связь между полями ответа проверяет сам узел. Схему можно передать и словарём JSON Schema. Такой словарь приложение получит в response_schema без изменений, но LangGraph по нему ответ не проверяет.

Документация пишет, что неверный ответ не сохраняется. В langgraph 1.2.16 так только для ответа словарём по id, как в примере 4. Ответ одним значением, как в пункте 5, чекпойнтер записывает до проверки, отдельно от задачи. Поэтому invoke(None, config) после него снова упал с ValidationError. Исправленный ответ заменил записанный и прошёл проверку.

Выполнение файла стоит в отдельном узле после паузы. При возобновлении review_run выполняется второй раз, а run_file только один. Так побочное действие не повторяется, это правило 4 в конце урока.

Исправление бывает нужно и тексту, который подготовил граф: черновику заметки или ответу модели. Тогда узел возвращает исправленный текст в состояние. Если черновик лежит в списке сообщений, верните исправленное сообщение с прежним id. Редьюсер add_messages из урока 2 заменит сообщение с тем же id, и в истории останется один вариант.

HumanInTheLoopMiddleware из урока 17 курса по LangChain даёт эти решения готовыми для агента из create_agent. Он ставит паузу перед вызовом инструмента, сверяет решение со списком разрешённых и при отказе передаёт модели сообщение с причиной. Свой узел нужен, когда пауза стоит перед действием графа без вызова инструмента: проверкой плана или выполнением тестов в графе без модели. Тогда форму значения паузы и ответа задаёте вы.

Несколько пауз в одном шаге

Копии узла из Send выполняются одним шагом, урок 3. Если каждая копия вызывает interrupt(), запуск останавливается сразу на нескольких паузах. Например, человек разрешает выполнение каждого файла стенда отдельно. То же случается с параллельными узлами одного шага, если каждый из них вызывает interrupt().

Пример 04_many_pauses.py

"""Пример 4 урока 6: несколько пауз в одном шаге.

Send запускает по копии узла ask_and_run на каждый файл стенда, все копии
выполняются одним шагом, и каждая останавливается на своей паузе. Одно
значение в Command(resume=...) на четыре паузы не подходит. Ответ даётся
словарём: id паузы и ответ на неё. Сначала человек отвечает на две паузы,
потом на оставшиеся.
"""

import operator
import subprocess
import sys
from pathlib import Path
from typing import Annotated

from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import END, START, StateGraph
from langgraph.types import Command, Send, interrupt
from typing_extensions import TypedDict

REPO = Path(__file__).parent / "mini_repo"
CONFIG = {"configurable": {"thread_id": "many-1"}}
CALLS: list[str] = []


class RunState(TypedDict):
    root: str
    files: list[str]
    runs: Annotated[list[str], operator.add]


class FileState(TypedDict):
    """Состояние одной копии узла ask_and_run: свой файл и корень стенда."""

    root: str
    name: str


def collect_files(state: RunState) -> dict:
    """Собирает имена файлов стенда."""
    names = sorted(path.name for path in Path(state["root"]).glob("*.py"))
    return {"files": names}


def fan_out(state: RunState) -> list[Send]:
    """По одному Send на файл."""
    return [Send("ask_and_run", {"root": state["root"], "name": name}) for name in state["files"]]


def ask_and_run(state: FileState) -> dict:
    """Ставит паузу с вопросом о своём файле и выполняет файл при ответе "да"."""
    CALLS.append(state["name"])
    answer = interrupt({"вопрос": "Выполнить файл?", "файл": state["name"]})
    if answer != "да":
        return {"runs": [f"{state['name']}: пропущен"]}
    done = subprocess.run(
        [sys.executable, state["name"]],
        cwd=state["root"],
        capture_output=True,
        text=True,
        encoding="utf-8",
    )
    return {"runs": [f"{state['name']}: код {done.returncode}"]}


builder = StateGraph(RunState)
builder.add_node("collect_files", collect_files)
builder.add_node("ask_and_run", ask_and_run, input_schema=FileState)
builder.add_edge(START, "collect_files")
builder.add_conditional_edges("collect_files", fan_out, ["ask_and_run"])
builder.add_edge("ask_and_run", END)
graph = builder.compile(checkpointer=InMemorySaver())

# 1. Четыре копии, четыре паузы.
result = graph.invoke({"root": str(REPO), "files": [], "runs": []}, CONFIG)
pauses = result["__interrupt__"]
print("1. пауз:", len(pauses), [pause.value["файл"] for pause in pauses])

# 2. Одно значение на несколько пауз не принимается.
try:
    graph.invoke(Command(resume="да"), CONFIG)
except RuntimeError as error:
    print("2.", str(error).split(".")[0])

# 3. Ответ на две паузы из четырёх: словарь id паузы -> ответ.
by_file = {pause.value["файл"]: pause.id for pause in pauses}
CALLS.clear()
result = graph.invoke(Command(resume={by_file["app.py"]: "да", by_file["config.py"]: "нет"}), CONFIG)
print("3. выполнены копии:", sorted(CALLS))
print("   готово:", result["runs"])
print("   пауз в результате:", len(result["__interrupt__"]))
print("   пауз в get_state:", len(graph.get_state(CONFIG).interrupts))

# 4. Ответ на оставшиеся паузы по id из последнего результата.
CALLS.clear()
rest = {pause.id: "нет" for pause in result["__interrupt__"]}
result = graph.invoke(Command(resume=rest), CONFIG)
print("4. выполнены копии:", sorted(CALLS))
print("   готово:", result["runs"])

# Вывод:
# 1. пауз: 4 ['app.py', 'config.py', 'db.py', 'utils.py']
# 2. When there are multiple pending interrupts, you must specify the interrupt id when resuming
# 3. выполнены копии: ['app.py', 'config.py', 'db.py', 'utils.py']
#    готово: ['app.py: код 0', 'config.py: пропущен']
#    пауз в результате: 2
#    пауз в get_state: 2
# 4. выполнены копии: ['db.py', 'utils.py']
#    готово: ['app.py: код 0', 'config.py: пропущен', 'db.py: пропущен', 'utils.py: пропущен']

Пункт 1: четыре копии дали четыре паузы, у каждой свой id. Пункт 2: одно значение resume на несколько пауз не принимается, вызов падает с RuntimeError. Ответ передаётся словарём. Ключ это id паузы, значение это ответ на неё.

Пункт 3: человек ответил на две паузы из четырёх. В списке выполненных копий стоят все четыре файла. Копии для app.py и config.py получили ответ и закончили работу, их строки уже в runs. Копии для db.py и utils.py ответа не получили: они выполнились заново до interrupt() и снова остановились. Возобновление выполняет заново все задачи шага, которые ещё не записали правку. Поэтому код до interrupt() в такой копии повторяется при каждом частичном ответе.

После частичного ответа и __interrupt__ результата, и get_state показывают по две паузы: те, что ждут ответа. Поэтому id для следующего ответа можно взять и из последнего результата, как в пункте 4, и из get_state по одному thread_id.

Пауза внутри инструмента

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

Модели в примере нет. Узел run_tools выполняет готовый список вызовов по порядку, все в одной задаче.

Пример 05_tool.py

"""Пример 5 урока 6: пауза внутри инструмента.

Инструмент run_file сам ставит паузу вызовом interrupt() и только после
ответа "да" выполняет файл. Узел run_tools выполняет заранее заданный
список вызовов инструментов по порядку. Два вызова в одном узле дают две
паузы подряд в одной задаче. Список EXECUTED показывает, сколько раз файл
выполнялся на самом деле.
"""

import subprocess
import sys
from pathlib import Path

from langchain.tools import tool
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import END, START, StateGraph
from langgraph.types import Command, interrupt
from typing_extensions import TypedDict

REPO = Path(__file__).parent / "mini_repo"
CONFIG = {"configurable": {"thread_id": "tools-1"}}
EXECUTED: list[str] = []


@tool
def run_file(name: str) -> str:
    """Выполняет файл стенда отдельным процессом после ответа "да" на паузе."""
    answer = interrupt({"инструмент": "run_file", "файл": name})
    if answer != "да":
        return f"{name}: отказ"
    EXECUTED.append(name)
    done = subprocess.run(
        [sys.executable, name],
        cwd=REPO,
        capture_output=True,
        text=True,
        encoding="utf-8",
    )
    return f"{name}: код {done.returncode}"


TOOLS = {"run_file": run_file}


class ToolState(TypedDict):
    tool_calls: list[dict]
    results: list[str]


def run_tools(state: ToolState) -> dict:
    """Выполняет вызовы инструментов по порядку."""
    results = [TOOLS[call["name"]].invoke(call["args"]) for call in state["tool_calls"]]
    return {"results": results}


builder = StateGraph(ToolState)
builder.add_node("run_tools", run_tools)
builder.add_edge(START, "run_tools")
builder.add_edge("run_tools", END)
graph = builder.compile(checkpointer=InMemorySaver())

TOOL_CALLS = [
    {"name": "run_file", "args": {"name": "app.py"}},
    {"name": "run_file", "args": {"name": "db.py"}},
]

# 1. Первый вызов инструмента останавливает узел.
result = graph.invoke({"tool_calls": TOOL_CALLS, "results": []}, CONFIG)
first = result["__interrupt__"][0]
print("1. пауза:", first.value, "| выполнены файлы:", EXECUTED)

# 2. Ответ на первую паузу: app.py выполняется, второй вызов встаёт на паузу.
result = graph.invoke(Command(resume="да"), CONFIG)
second = result["__interrupt__"][0]
print("2. пауза:", second.value, "| выполнены файлы:", EXECUTED)
print("   id пауз совпадают:", first.id == second.id)

# 3. Ответ на вторую паузу: узел снова выполняется с начала.
result = graph.invoke(Command(resume="нет"), CONFIG)
print("3. результаты:", result["results"])
print("   выполнены файлы:", EXECUTED)

# Вывод:
# 1. пауза: {'инструмент': 'run_file', 'файл': 'app.py'} | выполнены файлы: []
# 2. пауза: {'инструмент': 'run_file', 'файл': 'db.py'} | выполнены файлы: ['app.py']
#    id пауз совпадают: True
# 3. результаты: ['app.py: код 0', 'db.py: отказ']
#    выполнены файлы: ['app.py', 'app.py']

Пункт 1: первый вызов инструмента остановил узел run_tools. Пункт 2: после ответа "да" узел выполнился с начала. Первый вызов interrupt() вернул "да", и app.py выполнился. Второй вызов инструмента остановил узел на db.py.

Две паузы в одной задаче получили один id: он считается по задаче, и вызовы interrupt() внутри неё он не различает. Ответы на такие паузы LangGraph сопоставляет с вызовами interrupt() по порядку. Первый ответ получает первый вызов, второй ответ второй.

Пункт 3: при втором возобновлении узел снова выполнился с начала. Первый вызов инструмента получил сохранённый ответ "да" и выполнил app.py второй раз. Это риск нескольких вызовов инструмента с паузой в одной задаче: действие одного вызова повторяется из-за паузы следующего.

Чтобы выполнение файла не повторялось, дайте каждому вызову инструмента свою задачу, например копию узла из Send, как в примере 4. Тогда у каждой паузы свой id. Файл в копии выполняется один раз, сколько бы пауз ни оставалось без ответа в соседних копиях. Так устроен агент из create_agent: каждый вызов инструмента из ответа модели уходит в узел tools отдельной копией из Send.

Четыре правила паузы

Узел, который стоит на паузе, при возобновлении выполняется с начала. Сама пауза держится на исключении. Отсюда четыре правила:

1) не ставьте interrupt() внутри try с except Exception

2) при каждом выполнении узла вызывайте interrupt() в одном порядке: не пропускайте вызов по условию и не ставьте его в цикл по списку, который меняется между вызовами графа

3) в значение паузы кладите только то, что пригодно для JSON

4) побочные действия до interrupt() должны выдерживать повтор

При нарушении правил 1, 2 и 4 исключения нет, запуск идёт дальше. Последствия видны только по результату или по журналу. Значение, которое чекпойнтер не умеет сохранить, например функция, роняет вызов сразу на паузе.

Пример 06_rules.py

"""Пример 6 урока 6: четыре нарушения правил паузы.

Каждое нарушение стоит в своём узле, узел собирается в граф из одного узла
и запускается в своём thread:
1) interrupt() внутри try/except Exception;
2) первая пауза задаётся по условию, которое меняется между вызовами;
3) в значении паузы лежит функция;
4) запись в журнал стоит до паузы.
"""

import operator
from typing import Annotated

from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import END, START, StateGraph
from langgraph.types import Command, interrupt
from typing_extensions import TypedDict

ASK_FILE = {"value": True}
JOURNAL: list[str] = []


class AskState(TypedDict):
    answers: Annotated[list[str], operator.add]


def build(node):
    """Граф из одного узла с чекпойнтером."""
    builder = StateGraph(AskState)
    builder.add_node(node.__name__, node)
    builder.add_edge(START, node.__name__)
    builder.add_edge(node.__name__, END)
    return builder.compile(checkpointer=InMemorySaver())


def ask_inside_try(state: AskState) -> dict:
    """Нарушение 1: except Exception перехватывает остановку."""
    try:
        answer = interrupt("Выполнить app.py?")
    except Exception as error:
        answer = f"перехвачено {type(error).__name__}"
    return {"answers": [answer]}


def ask_by_condition(state: AskState) -> dict:
    """Нарушение 2: число пауз зависит от флага, который меняется между вызовами."""
    answers = []
    if ASK_FILE["value"]:
        answers.append("файл: " + interrupt("Какой файл выполнить?"))
    answers.append("аргументы: " + interrupt("С какими аргументами?"))
    return {"answers": answers}


def ask_with_function(state: AskState) -> dict:
    """Нарушение 3: в значении паузы функция."""
    answer = interrupt({"вопрос": "Выполнить app.py?", "проверка": str.isdigit})
    return {"answers": [answer]}


def journal_then_ask(state: AskState) -> dict:
    """Нарушение 4: запись в журнал до паузы."""
    JOURNAL.append("запрошено выполнение app.py")
    answer = interrupt("Выполнить app.py?")
    return {"answers": [answer]}


START_STATE = {"answers": []}

# 1. Остановка перехвачена: паузы нет, запуск закончился без ответа человека.
graph = build(ask_inside_try)
config = {"configurable": {"thread_id": "rule-1"}}
result = graph.invoke(START_STATE, config)
print("1. пауза:", "__interrupt__" in result, "| ответы:", result["answers"])

# 2. Между ответами флаг сменился, и ответ ушёл не в тот вопрос.
graph = build(ask_by_condition)
config = {"configurable": {"thread_id": "rule-2"}}
graph.invoke(START_STATE, config)
graph.invoke(Command(resume="app.py"), config)
ASK_FILE["value"] = False
result = graph.invoke(Command(resume="--verbose"), config)
print("2. ответы:", result["answers"])

# 3. Функцию в значении паузы чекпойнтер не сохраняет.
graph = build(ask_with_function)
config = {"configurable": {"thread_id": "rule-3"}}
try:
    graph.invoke(START_STATE, config)
except TypeError as error:
    print("3.", type(error).__name__, error)

# 4. Узел выполнился дважды, запись в журнале задвоилась.
graph = build(journal_then_ask)
config = {"configurable": {"thread_id": "rule-4"}}
graph.invoke(START_STATE, config)
graph.invoke(Command(resume="да"), config)
print("4. журнал:", JOURNAL)

# Вывод:
# 1. пауза: False | ответы: ['перехвачено GraphInterrupt']
# 2. ответы: ['аргументы: app.py']
# 3. TypeError Type is not msgpack serializable: Interrupt
# 4. журнал: ['запрошено выполнение app.py', 'запрошено выполнение app.py']

Пункт 1: GraphInterrupt наследует от Exception, поэтому except Exception перехватил остановку. Паузы не было: узел подставил свой ответ, и запуск закончился без человека. Ловите конкретные исключения, например OSError вокруг выполнения файла, или ставьте interrupt() вне блока try.

Пункт 2: ответы хранятся списком на задачу и сопоставляются с вызовами interrupt() по номеру. Между ответами флаг ASK_FILE сменился, и первого вопроса не стало. Ответ "app.py" на вопрос о файле достался вопросу об аргументах, а ответ "--verbose" потерялся. Так же ломается цикл с interrupt() по списку, который меняется между вызовами.

Повторный вопрос при неверном ответе ставится без цикла внутри узла. Узел вызывает interrupt() один раз и при неверном ответе записывает в состояние новый вопрос. Условное ребро возвращает запуск в тот же узел, это цикл из урока 4.

Пункт 3: функцию str.isdigit в значении паузы чекпойнтер сохранить не смог, и вызов упал с TypeError. Текст ошибки называет тип Interrupt, имени функции в нём нет. Причину ищите в значении паузы.

InMemorySaver и SqliteSaver сохраняют в значении паузы и типы, которых нет в JSON: datetime, UUID, Path. Правило 3 строже, чтобы значение паузы сохранил любой чекпойнтер, а не только эти два.

Пункт 4: узел выполнился дважды, и запись в журнале задвоилась. Перенесите запись после interrupt(), вынесите её в отдельный узел, как run_file в примере 3, или пишите её по ключу, как в уроке 4.

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

Соберите обход стенда, который выполняет каждый файл только с разрешения человека.

Требования:

1) граф обхода из урока 5: collect_files, pick_file и функция маршрутизации route_next. Вместо scan_file два узла: review_run ставит паузу со значением {"файл": <имя файла>}, run_file выполняет файл отдельным процессом и дописывает в findings строку с кодом возврата

2) review_run возвращает Command. Ответ "да" ведёт в run_file. Любой другой ответ дописывает в findings строку "<имя файла>: пропущен" и ведёт в pick_file. Из run_file ребро ведёт в pick_file

3) чекпойнтер SqliteSaver, открытый методом from_conn_string

4) скрипт отвечает на паузы в цикле, пока в результате есть __interrupt__: "да" для app.py и utils.py, "нет" для остальных файлов

5) после второго ответа скрипт закрывает соединение и даёт оставшиеся ответы графу, собранному заново на новом соединении

6) до первого ответа скрипт один раз вызывает invoke(None, config)

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

1) без учёта вызова invoke(None, config) скрипт получил четыре паузы, по одной на файл, в порядке app.py, config.py, db.py, utils.py

2) invoke(None, config) вернул паузу с тем же файлом, что и первый вызов

3) узел run_file выполнился два раза: для app.py и для utils.py

4) итог содержит четыре находки: две с кодом 0 и две "пропущен"

5) лишний вызов с Command(resume="да") после конца обхода находок не добавляет

Подсказка: route_next из урока 5 ведёт в scan_file, замените цель на review_run.

Итоги урока

interrupt(значение) останавливает запуск внутри узла, состояние сохраняет чекпойнтер. Значение паузы приходит в результате под ключом __interrupt__, в get_state оно лежит в поле interrupts.

Возобновление, это вызов с Command(resume=ответ) и тем же thread_id. Узел с паузой выполняется с первой строки, и interrupt() возвращает ответ. invoke(None, config) ответа не передаёт и снова останавливается на той же паузе. Повтор от checkpoint с паузой ставит паузу заново.

Без чекпойнтера граф на паузе останавливается, но возобновление падает с RuntimeError. С SqliteSaver пауза переживает закрытие соединения.

Решения "выполнить", "исправить" и "отказать" разбирает сам узел и возвращает Command с goto и update. interrupt() с параметром response_schema проверяет ответ по схеме. Вызов с неверным ответом падает с ValidationError, пауза остаётся, и на неё отвечают ещё раз.

Несколько пауз в одном шаге возобновляются словарём из id паузы и ответа. Паузы без ответа есть и в __interrupt__ последнего результата, и в get_state. Паузы внутри одной задачи получают ответы по порядку вызовов interrupt(). Если узел вызывает подряд несколько инструментов с паузой, каждое возобновление повторяет уже одобренные вызовы. Повтора нет, когда у каждого вызова своя копия узла из Send.

Четыре правила:

1) никакого except Exception вокруг interrupt()

2) один порядок вызовов interrupt() в узле

3) значения паузы, пригодные для JSON

4) побочные действия до паузы выдерживают повтор

В уроке 7 разберу подграфы: вынос куска работы в отдельный граф, его состояние, чекпойнтер и паузы внутри него.

Код урока

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


Предыдущий урок: Checkpointer: сохранение, продолжение и возврат назад

Следующий урок: Подграфы: слой как отдельный граф


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

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

0,0

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

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

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

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

Пишите info@aisferaic.ru

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