Не «пауза», а воркфлоу
Прерывание из прошлых уроков — это механизм. Approval workflow — это политика поверх механизма: что требует подтверждения, кто подтверждает, что происходит при отказе и как это фиксируется. Две крайности одинаково плохи:
Золотая середина — подтверждение по риску: дешёвые обратимые действия (поиск, чтение) выполняются автоматически, а дорогие необратимые (удаление, платёж, отправка) проходят через человека. Это и проектируем.
Анатомия approval workflow
Полный воркфлоу состоит из пяти стадий. Каждую мы уже умеем реализовать по отдельности — теперь связываем их в граф.
- Классификация риска — узел/условие решает, опасно ли запланированное действие.
- Авто-путь — низкий риск: выполняем сразу, без человека.
- Пауза — высокий риск:
interrupt()с payload (что именно собираемся сделать). - Решение — человек: одобрить / отклонить / поправить; ветвление по исходу.
- Аудит — фиксируем решение в состоянии (кто, что, когда).
Шаг 1: маршрутизация по риску
Сердце воркфлоу — не дёргать человека зря. Условное ребро (привет уроку про conditional edges) направляет на подтверждение только опасные вызовы, а безопасные пускает в обход. Критерий риска — твоя бизнес-логика: имя инструмента, размер суммы, домен получателя.
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.
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. Так три исхода человека превращаются в три маршрута графа.
Полный пример
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): каждое подтверждение дописывает запись.
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]}
Так как журнал — часть состояния, при персистентном checkpointer (SQLite/Postgres) он сохраняется вместе с тредом. Полная история «кто что одобрил» доступна через get_state и get_state_history — отдельная система логирования не нужна, хотя её можно добавить параллельно.
Production-приёмы
Кто и что вправе одобрять — решается на сервере по аутентифицированной роли, а не по данным из resume. Иначе approval превращается в фикцию: злоумышленник пришлёт {"type": "approve"} и обойдёт защиту. Связывай право одобрения с проверенной личностью (как thread_id в уроке про thread management).
Типичные ошибки
Approval на каждом шаге убивает автономность и приучает жать «ок» не глядя. Маршрутизируй на человека только реальный риск.
Если обработан только approve, при отказе граф зависнет или выполнит действие всё равно. Все три исхода (approve/reject/edit) должны вести в явные маршруты.
Без журнала решений невозможно разобрать инцидент: кто одобрил роковое удаление? Пиши аудит в поле с operator.add на каждом подтверждении.
Право одобрять — это авторизация. Проверяй роль аппрувера на сервере; не принимай «approve» только потому, что он пришёл в resume.
Шпаргалка
# Пять стадий воркфлоу подтверждения:
# 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 подтверждением
- Сделай агента с двумя инструментами:
search(безопасный) иtransfer_money(to, amount)(рискованный). Состояние сmessagesиaudit_log: Annotated[list, operator.add]. - Условным ребром маршрутизируй:
searchвыполняется автоматически,transfer_moneyидёт наapproval. - В узле
approvalчерезinterrupt()покажи сумму и получателя; обработайapproveиreject. - Проверь оба сценария: поиск проходит без паузы; перевод встаёт на
approval, и послеCommand(resume={"type":"reject"})деньги НЕ уходят. - Выведи
audit_logи убедись, что каждое решение записано. - Со звёздочкой: добавь исход
edit(человек меняет сумму перед переводом) и порог авто-одобрения: суммы меньше 100 ₽ проходят без подтверждения.