LangGraph: почему Command запускает лишнюю ветку графа
Заявка на возврат должна попасть либо на ручную проверку, либо сразу в подготовку ответа. Разработчик добавляет Command(goto="review"), запускает тест и видит оба узла в трассировке. Ветка review действительно выбрана, но прежний переход route → answer тоже сработал. Если answer отправляет сообщение клиенту, проверка человеком уже не удерживает операцию.
Такую ошибку легко принять за повторный запуск агента, сбой очереди или странное решение модели. Начните с более узкого вопроса: какие переходы разрешены после узла route? Если он одновременно возвращает Command и имеет обычное исходящее ребро, граф получил два указания продолжить работу. Они не образуют условие «иначе».
У каждого перехода должен быть один источник решения
Обычное ребро add_edge описывает безусловное продолжение. Его удобно читать как «после этого узла всегда выполнить следующий». Command позволяет узлу выбрать направление во время исполнения. Поле goto добавляет динамический переход; оно не удаляет уже заданное безусловное ребро. Поэтому после route могут запуститься review и answer в одном следующем шаге.
Для узла, выбирающего одну ветку, оставьте один механизм маршрутизации. Когда узел должен одновременно записать результат решения и выбрать следующий шаг, подходит Command(update=..., goto=...). Когда он только выбирает направление, можно использовать add_conditional_edges. Оба варианта пригодны, но дополнительный add_edge с того же узла требует отдельного смысла.
Название ошибки тоже стоит уточнить: лишняя ветка не обязательно означает два вызова одного узла. Если два пути назначают один и тот же узел в одном шаге, поведение отличается от ромба, где они достигают его в разные моменты. Надёжный тест должен проверять конкретные посещённые узлы вашего графа, а не общий лозунг «Command всегда удваивает выполнение».
Проверьте маршрутизацию без внешних действий
Выполните python -m pip install langgraph в отдельном окружении, сохраните пример и запустите его. visited собирает имена выполненных веток. Вместо отправки письма или создания возврата узлы оставляют только запись в состоянии, поэтому ошибочную топологию можно воспроизвести локально.
from typing import Annotated, Literal, TypedDict
import operator
from langgraph.graph import StateGraph, START, END
from langgraph.types import Command
class State(TypedDict):
needs_review: bool
visited: Annotated[list[str], operator.add]
def route(state: State) -> Command[Literal["review", "answer"]]:
destination = "review" if state["needs_review"] else "answer"
return Command(goto=destination)
def review(state):
return {"visited": ["review"]}
def answer(state):
return {"visited": ["answer"]}
def build(with_static_edge):
b = StateGraph(State)
b.add_node("route", route)
b.add_node("review", review)
b.add_node("answer", answer)
b.add_edge(START, "route")
if with_static_edge:
b.add_edge("route", "answer")
b.add_edge("review", END)
b.add_edge("answer", END)
return b.compile()
bad = build(True).invoke({"needs_review": True, "visited": []})
assert sorted(bad["visited"]) == ["answer", "review"]
good = build(False)
for needs_review, expected in [(True, "review"), (False, "answer")]:
result = good.invoke({"needs_review": needs_review, "visited": []})
assert result["visited"] == [expected]
print(needs_review, result["visited"])
Первый assert подтверждает неисправную схему: при needs_review=True выполнились обе ветки. Затем тот же вход проходит через исправленный граф и даёт только review. Второй случай проверяет обратное решение: при False выполняется только answer. Вывод вычисляется по состоянию, которое вернул настоящий LangGraph.
Аннотация Command[Literal["review", "answer"]] сообщает допустимые назначения для отображения графа. Сам выбор делает тело функции route. Если в большом проекте диаграмма выглядит правильно, всё равно оставьте проверку исполнения: рисунок не доказывает, что сохранённое add_edge не создаёт дополнительный маршрут.
Держите проверку visited рядом с построителем графа. При добавлении нового пути решите, должен ли он выполняться вместе с существующими ветками или вместо них. Например, независимый аудит может идти параллельно, тогда как подготовка ответа до ручного решения нарушает смысл процесса.
Как найти оставшийся переход в проекте
Найдите место регистрации узла и все вызовы add_edge с его именем в первом аргументе. Отдельно найдите add_conditional_edges и Command(goto=...), возвращаемые самим узлом и его инструментами. Это важнее поиска по тексту «review»: имя может встречаться в логах, описаниях и схемах без влияния на исполнение.
После удаления лишнего ребра проверьте не только нормальный ответ. Возьмите вход с обязательной проверкой, вход без неё и случай, когда решение сформировать не удалось. Для последнего заранее задайте маршрут ошибки или уточнения. Иначе исправленный граф может завершиться без нужного результата либо принять отсутствующий флаг за разрешение продолжить.
Если выбор делает LLM, сохраняйте её решение в ограниченном формате и проверяйте допустимые значения до маршрутизации. Тест топологии можно запускать с готовыми решениями, как в примере, а качество классификации модели проверять на отдельном наборе заявок. Тогда падение теста покажет, где проблема: в выборе категории или в исполнении уже выбранного маршрута.
Граница перед реальной операцией
Узел ручной проверки должен остановить исполнение и получить решение, если этого требует сценарий. Переименование узла в review само по себе ничего не приостанавливает. После восстановления маршрута отдельно проверьте механизм ожидания, передачу подтверждённой операции и поведение при отказе. Полезно сопоставить его со связью плана и исполнения агента.
В платном курсе AI Agents PRO: LangGraph, AutoGen и LLMOps работа с графом продолжается в RAG-сервисе с API, тестированием и наблюдаемостью. Вы сможете отрабатывать переходы вместе с обработкой ошибок и контролем действий, а затем видеть результат в трассировке приложения. Откройте программу курса: она показывает, какие части сервиса предстоит собрать вокруг графа.
