КОД</>БЕЗ МЕЖ
← Усі статті

LangGraph: мультиагентна система на прикладі

У вступі про LangGraph я обіцяв показати multi-agent передачу роботи на живому прикладі — ось він. Зберемо маленьку мультиагентну систему: супервайзер отримує запит і вирішує, кому його віддати — агенту-досліднику чи агенту-райтеру. Усі троє ділять один стан. Код буде по етапах, короткими шматками, з прозою між ними — щоб ви бачили не «магію фреймворка», а звичайний граф, який ви могли б написати самі.

Якщо ви ще не читали базу — що таке вузли, ребра і стан — почніть з вступу до LangGraph українською → Тут я не повторюю основи, а одразу будую систему з кількох агентів.

Що таке патерн «супервайзер»

Найпростіша мультиагентна архітектура — це не «агенти, що балакають один з одним по колу». Це один вузол-маршрутизатор, який дивиться на стан і вирішує, який спеціаліст потрібен наступним. Спеціалісти не знають один про одного — вони знають лише про спільний стан і про те, що після них керування повертається до супервайзера.

Чому саме так, а не «кожен агент сам вирішує, кому передати»? Бо децентралізована передача швидко перетворюється на той самий клубок if-ів, від якого ми тікали. Супервайзер — це одна точка рішення. Один вузол, один промпт, одне місце, де ви дивитесь у трейсі «чому пішло сюди, а не туди». Для системи з 2-5 агентів це майже завжди правильний старт.

Етап 1: стан, який тече крізь усіх

Перше і найважливіше рішення — стан. Це те, що відрізняє мультиагентну систему від купи окремих викликів LLM. Замість того щоб передавати між агентами рядки («ось тобі текст, зроби щось»), ми описуємо спільну структуру, яку кожен агент читає і доповнює.

from typing import TypedDict, Annotated, Literal
from langgraph.graph import add_messages
from langchain_core.messages import BaseMessage

class AgentState(TypedDict):
    # історія діалогу; add_messages дописує, а не перетирає
    messages: Annotated[list[BaseMessage], add_messages]
    # куди супервайзер вирішив віддати наступний крок
    next: str
    # робочі артефакти спеціалістів
    research: str
    draft: str

Зверніть увагу на Annotated[list, add_messages]. Це не косметика. Без редюсера кожен вузол, що повертає messages, перетер би історію. З add_messages LangGraph розуміє: нові повідомлення треба дописати до наявних. Це і є та сама причина, чому стан кращий за передачу рядків — він має правила злиття, а не просто «останній переміг».

Поля research і draft — це робочі артефакти. Дослідник пише в research, райтер читає його і пише в draft. Ніхто нікому нічого не «передає» вручну — всі просто працюють з однією дошкою.

Етап 2: вузли-спеціалісти

Тепер самі агенти. Кожен — це звичайна функція: приймає стан, робить свою роботу, повертає оновлення стану (не весь стан, лише те, що змінилось). Усередині — той самий виклик LLM, що й на голому SDK.

def researcher(state: AgentState) -> dict:
    topic = state["messages"][-1].content
    notes = llm.invoke(
        f"Збери 4-5 ключових фактів по темі: {topic}. "
        "Лише факти, без вступів."
    )
    return {
        "research": notes.content,
        "messages": [notes],
    }

def writer(state: AgentState) -> dict:
    text = llm.invoke(
        "Напиши короткий абзац на основі цих нотаток:\n"
        + state["research"]
    )
    return {
        "draft": text.content,
        "messages": [text],
    }

Це і вся «спеціалізація». Дослідник нічого не знає про райтера. Райтер не знає, звідки взявся research — він просто читає поле стану. Кожен вузол повертає dict лише з тими ключами, які оновлює — LangGraph сам зллє це з рештою стану за правилами з AgentState.

Етап 3: супервайзер як маршрутизатор

Серце системи. Супервайзер дивиться на стан і повертає рішення — рядок з іменем наступного вузла. Це рішення може бути детермінованим (як тут) або через виклик LLM з structured output, якщо логіка складніша.

def supervisor(state: AgentState) -> dict:
    # просте правило: спершу дослідити, потім написати, потім стоп
    if not state.get("research"):
        return {"next": "researcher"}
    if not state.get("draft"):
        return {"next": "writer"}
    return {"next": "FINISH"}

def route(state: AgentState) -> Literal["researcher", "writer", "__end__"]:
    nxt = state["next"]
    if nxt == "FINISH":
        return "__end__"
    return nxt

Я навмисно розділив supervisor (приймає рішення, пише його в стан) і route (читає рішення і повертає мітку для ребра). Так рішення супервайзера лишається у стані — ви бачите його в трейсі й можете відмотати. Якби логіка була складнішою, тіло supervisor просто кликало б LLM: «ось стан, що робити далі — researcher, writer чи FINISH?». Сам каркас графа не змінюється.

Етап 4: збираємо граф

Тепер з'єднуємо все. Ключова деталь: після кожного спеціаліста ребро веде назад до супервайзера. Це й робить його супервайзером — він отримує керування знову і знову, поки не скаже FINISH.

from langgraph.graph import StateGraph, START, END

graph = StateGraph(AgentState)
graph.add_node("supervisor", supervisor)
graph.add_node("researcher", researcher)
graph.add_node("writer", writer)

graph.add_edge(START, "supervisor")

# супервайзер -> один зі спеціалістів або кінець
graph.add_conditional_edges("supervisor", route, {
    "researcher": "researcher",
    "writer": "writer",
    "__end__": END,
})

# кожен спеціаліст завжди повертається до супервайзера
graph.add_edge("researcher", "supervisor")
graph.add_edge("writer", "supervisor")

app = graph.compile()

Подивіться на форму графа: це зірка. Супервайзер у центрі, спеціалісти по краях, керування завжди повертається в центр. Додати третього агента (наприклад, редактора) — це один add_node, один add_edge назад до супервайзера і один рядок у словнику маршрутів. Жодного переписування того, що вже працює.

Етап 5: запуск

Запускаємо так само, як будь-який StateGraph — передаємо початковий стан.

from langchain_core.messages import HumanMessage

result = app.invoke({
    "messages": [HumanMessage(content="LangGraph для мультиагентних систем")],
    "next": "",
    "research": "",
    "draft": "",
})

print(result["draft"])

Один invoke — і всередині відбувся цілий цикл: супервайзер → дослідник → супервайзер → райтер → супервайзер → кінець. Ви не писали жодного while і жодного лічильника кроків. Логіка «хто наступний» вся в графі, а не розмазана по тілу циклу. Це головна різниця з тим, як це виглядало б на голому SDK.

Чому стан, а не передача рядків

Найпоширеніша помилка новачків — будувати мультиагентну систему як ланцюжок: агент A повертає рядок, ви вручну пхаєте його в агента B, той повертає інший рядок. Працює на демо, ламається на третьому агенті. Ось що дає спільний стан натомість:

Де сюди вписуються чекпоінти

Поки що граф живе в пам'яті: відпрацював invoke — стан зник. Для справжньої мультиагентної системи цього мало, особливо якщо між агентами є пауза на людину або робота триває довго. Тут вступає checkpointer — і це майже безкоштовно за кодом.

from langgraph.checkpoint.postgres import PostgresSaver

checkpointer = PostgresSaver.from_conn_string(POSTGRES_URL)
app = graph.compile(checkpointer=checkpointer)

# кожен прогін прив'язаний до thread_id
config = {"configurable": {"thread_id": "user-42"}}
app.invoke({"messages": [HumanMessage(content="...")],
            "next": "", "research": "", "draft": ""}, config)

Один аргумент при compile — і граф пише стан після кожного вузла. Тепер система переживає рестарт сервера, ви можете поставити паузу між дослідником і райтером на підтвердження людини, а потім продовжити з того ж місця. thread_id ізолює стан кожного користувача чи сесії. Саме чекпоінти перетворюють «гарне демо» на щось, що можна тримати в продакшні.

Коли мультиагент — це перебір

Чесно, як завжди: більшості задач кілька агентів не потрібні. Розбиття на дослідника й райтера має сенс, коли ролі справді різні — різні промпти, різні інструменти, різні моделі під кожну роль. Якщо ж усе робить один промпт, то «мультиагентність» — це три виклики LLM там, де вистачило б одного, плюс зайва латентність і втричі більше токенів.

Лишайтеся на одному агенті, коли:

Я кажу це клієнтам прямо: спочатку один агент з кількома інструментами. Розбивайте на кілька, лише коли один промпт почав тягнути на себе надто багато несумісних ролей. Деталі про те, коли система дійсно стає мультиагентною — у статті про multi-agent системи →

Поширені помилки в мультиагентних графах

  1. Спеціалісти знають один про одного. Щойно агент A вирішує, що далі йде агент B, ви втратили єдину точку рішення. Хай вирішує лише супервайзер.
  2. Стан без редюсерів. Якщо два вузли пишуть у те саме поле без правила злиття — один затре інший. Для історій повідомлень завжди add_messages.
  3. Роздутий стан. Не кидайте у стан усе підряд «про всяк випадок». Стан має бути мінімальним і явним, інакше граф читається так само важко, як роздутий промпт.
  4. Немає умови виходу. Якщо супервайзер ніколи не повертає FINISH, зірка крутиться вічно. Завжди має бути шлях у END.

Як це виглядає в реальному проєкті

У продакшні цей самий каркас обростає деталями: супервайзер кличе LLM зі structured output, щоб обирати маршрут за змістом, а не за порожніми полями; під дослідника я ставлю модель з пошуком і tool-calling, під райтера — сильнішу на тексті; чекпоінтер у Postgres тримає стан і дає паузи на людину. Зірка лишається тією самою — росте лише начинка вузлів.

Такі мультиагентні системи під ключ — від проєктування графа до деплою, чекпоінтів і моніторингу — я будую в рамках розробки multi-agent систем →

Підсумок

Мультиагентна система в LangGraph — це не магія, а зірка з супервайзером у центрі та спеціалістами по краях, що ділять один стан. Стан із правилами злиття замінює ручну передачу рядків, чекпоінтер додає persistence одним аргументом, а вся логіка «хто наступний» живе в графі, а не в циклі. Але пам'ятайте головне: беріть кілька агентів тоді, коли ролі справді різні — інакше один агент із набором інструментів зробить те саме дешевше.

Не впевнені, чи ваша задача дозріла до кількох агентів? Напишіть — за 30 хвилин дзвінка чесно скажу, де у вас один агент, а де справді граф.

Написати @tribeofdanel →