Триаж заявок о поддержке с участием нескольких агентов с использованием LangGraph и Ollama
Создайте граф триажа заявок с трьмя агентами с использованием LangGraph: классификатор, ответчик и человек-контролер, работающие на локальном Ollama или через API с одной измененной строкой.
Источники: Building a Multi-Agent Support Ticket Triage With LangGraph
Каждый, кто работал с почтовым ящиком для обработки запросов поддержки, знает утреннюю рутину: кто-то читает каждый новый запрос, сортирует заявки на сброс паролей и вопросы, связанные с оплатой, а также находит те немногие сообщения, которые действительно срочны. Это полностью механический процесс, и именно такую работу может взять на себя небольшая команда сотрудничающих агентов.
LangGraph помогает создать такую команду. Один агент классифицирует каждый запрос, маршрутизатор определяет дальнейшие действия, а второй агент составляет ответ. Срочные случаи обходят автоматизацию и передаются человеку. Граф строится один раз и работает либо локально на Ollama, либо через облачный API, без изменений в агентах или механизмах маршрутизации.
Почему нужно более одного агента
Одинокий агент похож на сотрудника, который никогда не общается ни с кем, и в настоящей компании такой подход быстро препятствует развитию. При добавлении новых задач запрос становится длинным, хрупким и сложным для отладки, поскольку одной модели требуется одновременно классифицировать, принимать решения, писать тексты и оценивать риски. Разделение задач позволяет сохранять каждую из них небольшой, поддающейся тестированию и заменяемой. Система с несколькими агентами напоминает команду специалистов, у каждого из которых есть определенная роль, и они согласованно планируют следующие действия.
Метод сортировки заявок соответствует этой идее, поскольку работа естественным образом делятся на части, а конвейер, обрабатывающий все сообщения одинаково, будет неправильным по крайней мере в одном из следующих случаев:
- Рутинный вопрос о счетах можно ответить автоматически.
- Неопределенная просьба о новой функции получает предварительный ответ, который позже дорабатывается человеком.
LangGraph обрабатывает именно такие ситуации, когда следующий шаг зависит от текущего состояния. В команде три члена:
- Агент-классификатор, который читает заявку и присваивает ей категорию и приоритет.
- Агент-ответчик, который составляет ответы на заявки, которые можно обработать автоматически.
- Человеческий контроль — узел, который передает срочные заявки человеку вместо того, чтобы отвечать сам.
Между ними находится логика маршрутизации, которая фактически выполняет роль надзирателя. Она смотрит на приоритет и выбирает путь обработки.
Как работает LangGraph
Представьте схему на доске. LangGraph состоит из четырех частей:
- State — это буфер обмена данными, передаваемый от одной станции к другой. В нем хранится всё, что известно на данный момент о билете: исходный текст, категория, приоритет, возможный проект ответа и статус.
- Node — это станция, функция, которая берёт состояние, выполняет задачу и возвращает обновления.
- Edge — это фиксированная стрелка от одной станции к другой.
- Conditional edge — это стрелка, выбираемая во время выполнения. Небольшая функция-маршрутизатор читает состояние и решает, куда двигаться, вместо того чтобы всегда направляться в одно и то же место.
Таким образом, состояние передаётся через узлы, стрелки их соединяют, а условные стрелки позволяют пути разветвляться в зависимости от текущего состояния. Если вы только начинаете знакомство с этим, состояние — это просто словарь на 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, которая возвращает соответствующую модель на основе одного параметра. Единственная задача фабрики — вернуть готовый объект. Представьте кофемашину с кнопкой «локально» и другой с кнопкой «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. Заявка имеет высокий приоритет, поэтому маршрутизатор направляет её на рассмотрение человека. Статус возвращается как «передано на рассмотрение человеком», и автоматический ответ не генерируется.
Попробуйте вторую, обычную заявку:
# 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 описывают инструменты, используемые здесь.