Не «пауза», а воркфлоу

Прерывание из прошлых уроков — это механизм. Approval workflow — это политика поверх механизма: что требует подтверждения, кто подтверждает, что происходит при отказе и как это фиксируется. Две крайности одинаково плохи:

❌ Подтверждать всё
Человека дёргают на каждый чих — автономность теряется, агент бесполезен. Пользователь устаёт и жмёт «ок» не глядя.
❌ Не подтверждать ничего
Полная автономия на необратимых действиях — рано или поздно агент удалит не то или отправит не туда.

Золотая середина — подтверждение по риску: дешёвые обратимые действия (поиск, чтение) выполняются автоматически, а дорогие необратимые (удаление, платёж, отправка) проходят через человека. Это и проектируем.

Анатомия approval workflow

Полный воркфлоу состоит из пяти стадий. Каждую мы уже умеем реализовать по отдельности — теперь связываем их в граф.

100%
колёсико — масштаб  ·  зажать и тянуть — перемещение
agent риск? классификация execute выполнить действие END ⏸ approval interrupt(payload) решение человека abort отменить + лог low risk → авто high risk approve ✓ reject ✗ Audit log (operator.add): время · кто · действие · решение — пишется на каждом подтверждении
  1. Классификация риска — узел/условие решает, опасно ли запланированное действие.
  2. Авто-путь — низкий риск: выполняем сразу, без человека.
  3. Пауза — высокий риск: interrupt() с payload (что именно собираемся сделать).
  4. Решение — человек: одобрить / отклонить / поправить; ветвление по исходу.
  5. Аудит — фиксируем решение в состоянии (кто, что, когда).

Шаг 1: маршрутизация по риску

Сердце воркфлоу — не дёргать человека зря. Условное ребро (привет уроку про conditional edges) направляет на подтверждение только опасные вызовы, а безопасные пускает в обход. Критерий риска — твоя бизнес-логика: имя инструмента, размер суммы, домен получателя.

Маршрутизатор: опасное — на approval, безопасное — мимо
python
RISKY_TOOLS = {"delete_files", "send_money", "send_email", "deploy"}

def needs_approval(state: State) -> str:
    """Решает, требует ли запланированный вызов подтверждения."""
    last = state["messages"][-1]
    if not last.tool_calls:
        return "end"                       # агент закончил
    name = last.tool_calls[0]["name"]
    return "approval" if name in RISKY_TOOLS else "execute"   # риск → человек

builder.add_conditional_edges(
    "agent", needs_approval,
    {"approval": "approval", "execute": "execute", "end": END},
)
Критерий риска — конфигурация, не код

Список опасных инструментов и пороги (например, «суммы выше 10 000 ₽») выноси в конфиг, а не зашивай в функцию. Тогда политику подтверждений можно менять без правки графа — и она становится частью аудита: видно, по какому правилу действие ушло на ревью.

Шаг 2: узел подтверждения и три исхода

Узел approval использует динамический interrupt() (из урока про dynamic interrupts): отдаёт человеку payload с описанием действия и ждёт решения. Решение — структура с типом исхода, чтобы развести три ветки: approve / reject / edit.

Узел approval: пауза, решение, аудит
python
from langgraph.types import interrupt

def approval(state: State) -> dict:
    call = state["messages"][-1].tool_calls[0]

    # Останавливаемся и показываем человеку, ЧТО собираемся сделать
    decision = interrupt({
        "action": call["name"],
        "args": call["args"],
        "prompt": "Подтвердить выполнение?",
    })
    # decision приходит из Command(resume=...): {"type": "approve|reject|edit", ...}

    audit = {"action": call["name"], "decision": decision["type"]}

    if decision["type"] == "approve":
        return {"approved": True, "audit_log": [audit]}
    if decision["type"] == "edit":
        # человек поправил аргументы — подменяем их в запланированном вызове
        call["args"] = decision["args"]
        return {"approved": True, "audit_log": [audit]}
    # reject
    return {"approved": False, "audit_log": [audit]}

После approval — ещё одно условное ребро: одобрено (approved == True) → в execute, отклонено → обратно к agent (пусть предложит иной план) или в abort. Так три исхода человека превращаются в три маршрута графа.

Полный пример

Risk-based approval workflow целиком
python
from typing import Annotated, TypedDict
import operator
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
from langgraph.checkpoint.memory import MemorySaver
from langgraph.types import interrupt, Command
from langchain_openai import ChatOpenAI

RISKY = {"delete_files"}

def delete_files(pattern: str) -> str: return f"Удалены: {pattern}"
def search(query: str) -> str:        return f"Результаты: {query}"

class State(TypedDict):
    messages: Annotated[list, add_messages]
    audit_log: Annotated[list, operator.add]   # аудит копится

llm = ChatOpenAI(model="gpt-4o-mini").bind_tools([delete_files, search])

def agent(state): return {"messages": [llm.invoke(state["messages"])]}

def approval(state):
    call = state["messages"][-1].tool_calls[0]
    decision = interrupt({"action": call["name"], "args": call["args"]})
    audit = [{"action": call["name"], "decision": decision["type"]}]
    return {"approved": decision["type"] == "approve", "audit_log": audit}

def execute(state):
    call = state["messages"][-1].tool_calls[0]
    fn = {"delete_files": delete_files, "search": search}[call["name"]]
    out = fn(**call["args"])
    return {"messages": [{"role": "tool", "content": out,
                          "tool_call_id": call["id"]}]}

def after_agent(state):
    last = state["messages"][-1]
    if not last.tool_calls: return "end"
    return "approval" if last.tool_calls[0]["name"] in RISKY else "execute"

def after_approval(state):
    return "execute" if state.get("approved") else "agent"

builder = StateGraph(State)
builder.add_node("agent", agent)
builder.add_node("approval", approval)
builder.add_node("execute", execute)
builder.add_edge(START, "agent")
builder.add_conditional_edges("agent", after_agent,
    {"approval": "approval", "execute": "execute", "end": END})
builder.add_conditional_edges("approval", after_approval,
    {"execute": "execute", "agent": "agent"})
builder.add_edge("execute", "agent")

graph = builder.compile(checkpointer=MemorySaver())   # checkpointer обязателен
cfg = {"configurable": {"thread_id": "ops-1"}}

# Безопасный search выполнится сам; delete_files встанет на approval
graph.invoke({"messages": [{"role": "user", "content": "Удали *.tmp"}]}, cfg)
print(graph.get_state(cfg).next)                      # ('approval',)

# Человек одобряет
graph.invoke(Command(resume={"type": "approve"}), cfg)
print(graph.get_state(cfg).values["audit_log"])       # [{'action': 'delete_files', ...}]

Шаг 3: аудит решений

В проде мало принять решение — его нужно зафиксировать: что предлагалось, кто подтвердил, когда, каков исход. Это требование комплаенса и спасение при разборах инцидентов. Реализуется тривиально — полем-журналом с reducer'ом operator.add (урок про reducers): каждое подтверждение дописывает запись.

Журнал аудита накапливается через reducer
python
from datetime import datetime, timezone

class State(TypedDict):
    messages: Annotated[list, add_messages]
    audit_log: Annotated[list, operator.add]    # неуничтожимый журнал

def approval(state) -> dict:
    call = state["messages"][-1].tool_calls[0]
    decision = interrupt({"action": call["name"], "args": call["args"]})
    record = {
        "ts": datetime.now(timezone.utc).isoformat(),
        "action": call["name"],
        "args": call["args"],
        "decision": decision["type"],
        "by": decision.get("user", "unknown"),     # кто решил
    }
    return {"approved": decision["type"] == "approve", "audit_log": [record]}
ℹ️ Аудит живёт в checkpoint'е — и переживает рестарт

Так как журнал — часть состояния, при персистентном checkpointer (SQLite/Postgres) он сохраняется вместе с тредом. Полная история «кто что одобрил» доступна через get_state и get_state_history — отдельная система логирования не нужна, хотя её можно добавить параллельно.

Production-приёмы

allow-list
Доверенные действия (или конкретные пользователи) одобряются автоматически — меньше шума, та же безопасность.
timeout / эскалация
Нет ответа N часов — отклонить по умолчанию или эскалировать другому аппруверу. Тред ждёт в checkpoint'е сколько нужно.
роли аппруверов
Кто может одобрять — проверяй на сервере по роли пользователя, а не доверяй полю из запроса.
⚠️ Подтверждение — это контроль доступа

Кто и что вправе одобрять — решается на сервере по аутентифицированной роли, а не по данным из resume. Иначе approval превращается в фикцию: злоумышленник пришлёт {"type": "approve"} и обойдёт защиту. Связывай право одобрения с проверенной личностью (как thread_id в уроке про thread management).

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

Ошибка 1: подтверждать всё подряд

Approval на каждом шаге убивает автономность и приучает жать «ок» не глядя. Маршрутизируй на человека только реальный риск.

Ошибка 2: нет ветки «отклонить»

Если обработан только approve, при отказе граф зависнет или выполнит действие всё равно. Все три исхода (approve/reject/edit) должны вести в явные маршруты.

Ошибка 3: нет аудита

Без журнала решений невозможно разобрать инцидент: кто одобрил роковое удаление? Пиши аудит в поле с operator.add на каждом подтверждении.

Ошибка 4: доверять решению из запроса

Право одобрять — это авторизация. Проверяй роль аппрувера на сервере; не принимай «approve» только потому, что он пришёл в resume.

Шпаргалка

Approval workflow — всё в одном месте
python
# Пять стадий воркфлоу подтверждения:

# 1. КЛАССИФИКАЦИЯ риска — условное ребро (только опасное → на человека)
def after_agent(state):
    name = state["messages"][-1].tool_calls[0]["name"]
    return "approval" if name in RISKY else "execute"

# 2. ПАУЗА — узел approval через interrupt() с payload
def approval(state):
    call = state["messages"][-1].tool_calls[0]
    d = interrupt({"action": call["name"], "args": call["args"]})
    return {"approved": d["type"] == "approve", "audit_log": [record(call, d)]}

# 3. РЕШЕНИЕ — ещё одно условное ребро по исходу
def after_approval(state):
    return "execute" if state["approved"] else "agent"   # approve/edit vs reject

# 4. АУДИТ — поле-журнал с operator.add (кто/что/когда/решение)
audit_log: Annotated[list, operator.add]

# 5. checkpointer ОБЯЗАТЕЛЕН; resume:
graph.invoke(Command(resume={"type": "approve"}), cfg)   # interrupt()

# Правила:
#  • маршрутизируй по риску, не подтверждай всё
#  • обрабатывай ВСЕ исходы (approve/reject/edit)
#  • аудит — в состоянии (operator.add)
#  • право одобрять проверяй на сервере, не из resume

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

Собери production-воркфлоу:

Задание: агент с risk-based подтверждением

  1. Сделай агента с двумя инструментами: search (безопасный) и transfer_money(to, amount) (рискованный). Состояние с messages и audit_log: Annotated[list, operator.add].
  2. Условным ребром маршрутизируй: search выполняется автоматически, transfer_money идёт на approval.
  3. В узле approval через interrupt() покажи сумму и получателя; обработай approve и reject.
  4. Проверь оба сценария: поиск проходит без паузы; перевод встаёт на approval, и после Command(resume={"type":"reject"}) деньги НЕ уходят.
  5. Выведи audit_log и убедись, что каждое решение записано.
  6. Со звёздочкой: добавь исход edit (человек меняет сумму перед переводом) и порог авто-одобрения: суммы меньше 100 ₽ проходят без подтверждения.

Что дальше