Багатоагентне сортування заявок про підтримку за допомогою LangGraph та Ollama
Створіть граф для сортування заявок із трьома агентами за допомогою LangGraph: класифікатор, оператор та людський контролер, які працюють на локальному Ollama або через API з однією зміненою рядком.
Джерела: Building a Multi-Agent Support Ticket Triage With LangGraph
Кожен, хто працював у відділі підтримки, знає ранкову рутину. Хтось читає кожен новий запит, сортує запити на скасування паролів та питання щодо оплати в окремі групи та шукає ті кілька повідомлень, які справді термінові. Це суто механічна робота, і саме її може взяти на себе невелика команда співпрацюючих агентів.
Далі ми створюємо таку команду за допомогою LangGraph. Один агент класифікує кожен запит, маршрутизатор вирішує, що робити далі, а другий агент складає відповідь. У термінових випадках автоматизація не застосовується, і справа передається людині. Граф створюється один раз та працює або локально на Ollama, або через хмарний API, без змін у агентах чи механізмах маршрутизації.
Чому потрібно більше одного агента
Одинокий агент схожий на працівника, який ніколи не спілкується з іншими, і в реальній компанії це швидко уповільнює розвиток. Якщо додати більше завдань, один запит стає довгим, крихким та складним для відлагодження, адже від однієї моделі вимагають одночасно класифікувати, приймати рішення, писати та оцінювати ризик. Розділення завдань дозволяє кожній частині залишатися невеликою, піддається тестуванню та може бути замінена. Система з кількома агентами нагадує команду спеціалістів, кожен з яких має чітко визначену роль та координує наступні кроки.
Система сортування заявок підходить до цієї ідеї, оскільки робота природним чином ділиться на частини, а конвеєр, який обробляє кожне повідомлення однаково, був би неправильним принаймні у одному з наступних випадків:
- Звичайне запитання щодо оплати можна відповісти автоматично.
- Нечітке запитання щодо функцій отримує попередню відповідь, яку людина уточнює пізніше.
LangGraph обробляє саме такі ситуації, коли наступний крок залежить від поточного стану. У команди є троє учасників:
- Агент-класифікатор, який читає заявку та присвоює їй категорію та пріоритет.
- Агент-відповідач, який готує відповіді на заявки, які можна автоматично обробити.
- Людський контроль – вузол, який передає термінові заявки людині замість того, щоб відповідати сам.
Між ними знаходиться логіка маршрутизації, яка фактично виступає керівником. Вона бере до уваги пріоритет та обирає шлях обробки.
Як думає LangGraph
Уявіть собі діаграму потоків на дошці. LangGraph складається з чотирьох частин:
- Стан — це буфер, який передається від однієї станції до іншої. Він містить усю інформацію про квиток, відому на даний момент: оригінальний текст, категорію, пріоритет, запропоновану відповідь, якщо вона є, та статус.
- Вузол — це станція, функція, яка бере стан, виконує певну роботу та повертає оновлення.
- Ребро — це фіксована стрілка від однієї станції до іншої.
- Умовне ребро — це стрілка, яка обирається під час виконання програми. Невелика функція-маршрутизатор читає стан та вирішує, куди рухатися, замість того щоб завжди йти в одне й те саме місце.
Отже, стан проходить через вузли, ребра їх з’єднують, а умовні ребра дозволяють шляху розгалужуватися залежно від того, що зараз вказує стан. Якщо ви новачок у цьому, стан — це просто словник у Python із визначеною структурою. TypedDict описує, які ключі має цей словник.
Технологічна стека
LangGraph є шаром оркестрації. Він визначає вузли, стан та маршрутизацію між агентами.
Ollama з Llama 3 запускає все локально, безкоштовно, на вашому власному пристрої. Заявки на підтримку часто містять особисті дані клієнтів, тому в діловому середовищі є справжня перевага у зберіганні цих даних на власній інфраструктурі замість надсилання їх до стороннього API.
Модель API, тут gpt-4o-mini, є альтернативою, коли у вас немає додаткового обладнання або потрібна більша продуктивність, ніж забезпечує невелика локальна модель. Оскільки граф складається один раз, ви можете почати роботу локально та пізніше перейти на API, не переписуючи логіку.
Створення крок за кроком
1. Налаштування
Встановіть три пакети: langgraph для графів, langchain-ollama як місток до Ollama та langchain-openai як місток до API-моделі.
# Install LangGraph and the two model integrations
pip install langgraph langchain-ollama langchain-openai
Чисте віртуальне середовище зберігає пакети цього проекту окремо від інших. У Windows створіть його за допомогою команди python -m venv yourname, а потім активуйте його через папку Scripts, яка знаходиться всередині. У Mac або Linux використайте команду python3 -m venv venv, а потім source venv/bin/activate. Після активації команда встановлення, наведена вище, впливатиме лише на цей проект.
Якщо Ollama ще не встановлений, завантажте його з офіційного сайту ollama.com/download. Потім завантажте локальну модель з терміналу:
# Download the Llama 3 model so we can run it locally
ollama pull llama3
2. Агенти
Відтепер усе потрапляє до одного файлу під назвою triage.py, який заповнюється зверху вниз у наведеному нижче порядку.
Фабрика для моделі. Почніть з елемента, який уможливлює роботу обох бекендів — невеликої функції-фабрики get_llm, яка повертає відповідну модель на основі одного параметра. Єдине завдання фабрики — повернути готовий об’єкт. Уявіть кавомашину з кнопкою під назвою „local“ та іншою під назвою „API“: натисніть одну, отримайте відповідну чашку, не намагаючись зрозуміти, що відбувається всередині.
Функція приймає параметр backend, значення за замовчуванням якого — „local“, тому його виклик без аргументів використовує локальну модель.
# A factory that returns the model we want, so the rest of the code
# never has to care which backend we are using.
def get_llm(backend: str = "local"):
if backend == "local":
from langchain_ollama import ChatOllama
# temperature=0 keeps classification stable and repeatable
return ChatOllama(model="llama3", temperature=0)
elif backend == "api":
from langchain_openai import ChatOpenAI
return ChatOpenAI(model="gpt-4o-mini", temperature=0)
else:
raise ValueError("backend must be 'local' or 'api'")
# Choose your backend here: "local" or "api"
BACKEND = "local"
llm = get_llm(BACKEND)
Ланцюг if/elif/else перевіряє параметр та повертає або llama3, або gpt-4o-mini, або повідомлення про помилку. Імпорти навмисно розташовані всередині гілок. Той, хто працює лише локально, ніколи не потребує langchain-openai, і навпаки.
temperature=0 контролює ступінь випадковості відповідей моделі. Вищі значення додають різноманітності, тоді як 0 робить відповіді максимально стабільними та передбачуваними. Для класифікації це ідеально, адже одна й та сама заявка має надійно отримувати одну й ту саму категорію. BACKEND = „local“ визначає вибір, а наступний рядок викликає фабрику.
Класифікатор. Цей агент читає заявку та повертає категорію та пріоритет. Щоб обробити результат програмно, від моделі вимагають надавати відповіді у форматі JSON.
Функція-допоміжник parse_json перетворює відповідь на справжній словник Python. Вона використовує find, щоб спочатку знайти відкриваючі та закриваючі дужки, адже локальні моделі не завжди повертають чистий JSON. Вони люблять оточувати його додатковим текстом на кшталт „Звісно, ось JSON: ... Сподіваюся, це допоможе.“
Функція classify_ticket — це сам агент. Вона отримує поточний стан та повертає оновлення. Її інструкція чітко визначають роль моделі як асистента з сортування запитів, обмежують відповідь чотирма категоріями та трьома пріоритетами та вимагають чистого JSON без додаткових коментарів. Інструкції розроблені навмисно суворо, щоб відповідь була чистішою, а parse_json мав менше роботи. Виклик llm.invoke(prompt) надсилає їх до моделі.
Зверніть увагу, що повертається: лише два поля, які заповнює цей агент — категорія та пріоритет. Це основна риса LangGraph. Вузол не повертає всю становість, а лише ті частини, які він хоче змінити, а LangGraph об’єднує їх у спільний стан. Це робить вузли компактними, полегшує їх окреме тестування, а також запобігає ситуації, коли один агент випадково перезапише поле, яке належить іншому.
import json
def parse_json(text: str) -> dict:
# Local models sometimes wrap their JSON in extra text,
# so we grab everything between the first { and the last }.
start = text.find("{")
end = text.rfind("}") + 1
return json.loads(text[start:end])
def classify_ticket(state: dict) -> dict:
prompt = f"""You are a support triage assistant.
Classify the following support ticket.
Respond with ONLY a JSON object with two keys:
"category": one of ["billing", "technical", "account", "general"]
"priority": one of ["low", "medium", "high"]
Ticket:
{state['ticket']}"""
response = llm.invoke(prompt)
result = parse_json(response.content)
# We return only the keys we want to update in the shared state
return {"category": result["category"], "priority": result["priority"]}
Відповідач. Другий агент складає відповіді для заявок, які можна автоматизувати. Функція draft_reply отримує стан заявки, формує запит до моделі, пропонуючи їй грати роль дружелюбного та лаконічного співробітника підтримки, та встановлює обмеження: модель не може давати обіцянки, які не може виконати. Вона повертає два поля — сформований текст у draft_reply та статус „auto-drafted“, щоб пізніше було видно, що заявка була оброблена автоматично та не передана на вищий рівень. Запит до моделі є навмисно стриманим. Відповідь співробітника підтримки, написана моделлю, має бути короткою та чесною, а обіцянки щодо терміну повернення грошей чи дати випуску функції — це те, що має вирішувати людина.
def draft_reply(state: dict) -> dict:
prompt = f"""You are a friendly and concise support agent.
Write a short, helpful reply to this {state['category']} ticket.
Do not make promises you cannot keep.
Ticket:
{state['ticket']}"""
response = llm.invoke(prompt)
return {"draft_reply": response.content, "status": "auto-drafted"}
Людський посередник. Третій агент ніколи не викликає модель. Він лише позначає заявку як передану на розгляд, щоб нею зайнявся людина. Він повертає порожній draft_reply, оскільки автоматичної відповіді не має існувати, та статус „передано на людський розгляд“. Це чітко вказує на те, що заявкою керує людина. Для того, щоб вузол вважався агентом у графі, йому не обов’язково використовувати LLM. Будь-що, що бере на себе обробку стану, виконує завдання та повертає оновлення, може знаходитися на одній сцені, незалежно від того, чи воно викликає модель, базу даних чи взагалі нічого.
def escalate_to_human(state: dict) -> dict:
# No auto-reply for urgent tickets. We flag them for a person.
return {
"draft_reply": "",
"status": "escalated to human review",
}
3. Підключення графа
Кожен з трьох агентів має свою функцію. Тепер їх потрібно поєднати, і це починається з опису форми спільного стану. Клас TicketState є тим самим „буфером обміну“, про який йшлося раніше, і визначає, які поля існують та який їх тип.
Ці п’ять полів також відстежують шлях заявки через систему. У полі ticket зберігається початковий текст. Класифікатор заповнює поля category та priority. Отримувач відповіді заповнює поле draft_reply, або залишає його порожнім після передачі на вищий рівень. Поле status фіксує, де зрештою опинилася заявка.
from typing import TypedDict
class TicketState(TypedDict):
ticket: str # the original ticket text
category: str # filled in by the classifier
priority: str # filled in by the classifier
draft_reply: str # filled in by the responder (or cleared on escalation)
status: str # where the ticket ended up
Далі йде роутер, тобто керівник процесу. Функція route_by_priority читає пріоритет, визначений класифікатором, та повертає рядок з інструкціями для LangGraph щодо обраного шляху. Якщо пріоритет дорівнює „високий“, заява надсилається до людини-оператора. Інакше функція повертає значення „responder“, і заява отримує автоматичну відповідь.
def route_by_priority(state: TicketState) -> str:
# Urgent tickets skip the automatic reply and go to a human.
if state["priority"] == "high":
return "human_review"
return "responder"
Тепер складіть граф:
- Створіть порожній граф за допомогою
StateGraph(TicketState), передавши форму стану, щоб граф знав, які поля знаходяться у буфері обміну. - Зареєструйте три агента за допомогою
add_node. Кожен виклик поєднує коротку назву з функцією. Назва „classifier“ належить доclassify_ticket, „responder“ — доdraft_reply, а „human_review“ — доescalate_to_human. Усі наступні кроки посилаються на вузли за цими назвами, тому обирайте їх ретельно та дотримуйтесь їх послідовності. - Додайте
add_edge(START, „classifier“), щоб кожен тикет починався з класифікатора.START— це вбудована точка входу.
add_conditional_edges — ця функція приймає вузол, де відбувається розгалуження („classifier“), функцію маршрутизації (route_by_priority) та таблицю пошуку.END, оскільки виконання завершується незалежно від того, чи було запитання вирішене, чи передано на подальше розглядання.graph.compile() — ця функція перетворює визначення на щось виконуване. Результат зберігається у app, і саме він обробляє запитання.from langgraph.graph import StateGraph, START, END
# Create the graph and tell it the shape of our state
graph = StateGraph(TicketState)
# Register each agent as a node
graph.add_node("classifier", classify_ticket)
graph.add_node("responder", draft_reply)
graph.add_node("human_review", escalate_to_human)
# Every ticket starts at the classifier
graph.add_edge(START, "classifier")
# After classifying, the router decides where to go next
graph.add_conditional_edges(
"classifier",
route_by_priority,
{
"responder": "responder", # normal tickets get an auto-draft
"human_review": "human_review", # urgent tickets get escalated
},
)
# Both paths finish the run
graph.add_edge("responder", END)
graph.add_edge("human_review", END)
# Compile turns our definition into something runnable
app = graph.compile()
Словник, який передається до функції add_conditional_edges, є таблицею пошуку. Її ключі — це рядки, які може повернути маршрутизатор, а її значення — це вузли, на які вказують ці рядки. Коли маршрутизатор повертає значення „human_review“, LangGraph переходить до вузла human_review. Саме це зіставлення є основою того, як граф розгалужується. Якщо пізніше додати нову категорію обробки, наприклад спеціаліста з билінгу, це призведе до появи одного нового вузла, одного нового ключа в таблиці та однієї гілки в маршрутизаторі.
4. Запуск: локально чи через API
Спочатку надішліть терміновий запит через граф:
# A clearly urgent billing issue
urgent_ticket = (
"I was charged twice for my subscription this month "
"and I need a refund today. This is unacceptable."
)
result = app.invoke({"ticket": urgent_ticket})
print("Category:", result["category"])
print("Priority:", result["priority"])
print("Status: ", result["status"])
print("Reply: ", result["draft_reply"])
Запустіть скрипт за допомогою python triage.py. Очікуйте, що точні слова надрукованої відповіді трохи будуть відрізнятися між запусками та між моделями, але категорія, пріоритет та статус мають залишатися однаковими — саме тому використовується параметр temperature=0. Заява має високий пріоритет, тому роутер надсилає її на перевірку людині. Статус повертається як „escalated to human review“, і автоматичної відповіді не створюється.
Спробуйте другу, звичайну заяву:
# A routine, low-stakes question
routine_ticket = "How do I change the email address on my account?"
result = app.invoke({"ticket": routine_ticket})
print("Category:", result["category"])
print("Priority:", result["priority"])
print("Status: ", result["status"])
print("Reply: ", result["draft_reply"])
Цей механізм передає запит відповідачу та повертає проєкт відповіді, який людина може переглянути перед надсиланням. Робота обох запитів одночасно дозволяє чітко побачити різницю: ідентична структура, ідентичний код, два різні шляхи вирішення, визначені виключно за пріоритетом, який призначив класифікатор.
Перехід на модель API
Виконання тієї самої функції через API замість локально вимагає лише одного API-ключа та однієї зміненої рядка завдяки функції-фабриці.
Увійдіть на платформу API OpenAI, створіть новий ключ та негайно скопіюйте його, адже він відображається лише один раз. Для безпеки не вставляйте ключ у скрипт. Зберігайте його як змінну середовища та дозвольте коду читати його звідти. Виконайте команду для вашої системи в терміналі, замінивши your-key-here на ваш ключ:
# On Windows (PowerShell)
setx OPENAI_API_KEY "your-key-here"
# On Mac/Linux
export OPENAI_API_KEY="your-key-here"
Виконуйте ці команди в терміналі, а не у скрипті, і бажано в тому самому терміналі, де активне віртуальне середовище. Змінна встановлюється на рівні сеансу терміналу, а не всередині venv. Після цього ключ зберігається в одному місці поза вашим кодом, ніколи не з’являється у скрипті та не може випадково потрапити на GitHub.
Потім змініть одну рядок у скрипті та знову запустіть python triage.py:
# Switch from local to API without touching the graph or the agents
BACKEND = "api"
llm = get_llm(BACKEND)
Що він показує та куди рухатися далі
Приклад відтворює реальний щоденний робочий процес. Мета не в тому, щоб замінити команду підтримки, а в тому, щоб автоматизувати механічну сортувальну процедуру, тоді як важливі та термінові справи потрапляють до людини. Оскільки граф, агенти та механізм направлення працюють як з Ollama, так і з API, ви можете створити прототип за допомогою Ollama та перейти на модель API, коли знадобиться краща продуктивність.
Є багато можливостей для розширення. Агенти могли б мати власну пам’ять, щоб наступний заявок від того самого клієнта містила необхідний контекст. Могли б приєднатися ще спеціалісти, наприклад агент з обробки оплати з доступом до справжніх даних про рахунок. Граф міг би підключитися до реальної системи обробки заявок через її API, перетворюючи скрипт на частину справжнього потоку обробки. Крім того, належний крок з участю людини міг би помістити підвищені за складністю заявки у чергу на перевірку із вже доданою класифікацією.
Для подальшого читання документація LangGraph та LangChain за адресою https://docs.langchain.com/oss/python/langgraph/overview, а також посібник з швидкого старту Ollama за адресою https://docs.ollama.com/quickstart описують інструменти, які використовуються тут.