Создание умного саппорт-бота с RAG, классификацией тикетов и интеграцией в Helpdesk
AI-агент для техподдержки — это конвейер обработки обращений клиентов, состоящий из четырёх ключевых компонентов. Каждый компонент решает свою задачу и передаёт результат следующему звену. Разберём архитектуру подробно.
Классификатор (Classifier) — первый рубеж обработки. Он анализирует входящий запрос и определяет: тему обращения (billing, technical, account), срочность (low, medium, high, critical), язык и тональность. На основе этих метрик принимается решение о маршрутизации: отправить в RAG-пайплайн, дать быстрый FAQ-ответ или эскалировать на живого оператора.
RAG Pipeline (Retrieval-Augmented Generation) — ядро системы. Здесь происходит семантический поиск по базе знаний (документация, статьи Help Center, база тикетов). Запрос клиента векторизуется, ищутся топ-K релевантных чанков, затем применяется реранкинг для повышения точности. Найденные документы передаются в генератор как контекст.
Генератор ответов (Generator) — LLM-компонент, который на основе запроса клиента и найденных документов формирует финальный ответ. Используются тщательно спроектированные промпты с шаблонами для разных категорий обращений, что гарантирует консистентность стиля и tone-of-voice бренда.
Эскалация (Human Escalation) — важнейший компонент безопасности. Если confidence классификатора ниже порога, запрос помечен как critical или клиент явно просит оператора — тикет автоматически передаётся в Helpdesk с полным контекстом диалога и предложенным AI-драфтом ответа.
Качество ответов саппорт-агента напрямую зависит от базы знаний. Подготовка включает три этапа: сбор и парсинг документации, разбиение на чанки, векторизация и загрузка в векторную БД. Используем ChromaDB — легковесную и быструю векторную базу, идеальную для прототипирования.
# 1. Парсинг документации из Markdown-файлов import os, glob from langchain_community.document_loaders import TextLoader from langchain_text_splitters import RecursiveCharacterTextSplitter docs = [] for path in glob.glob("docs/*.md"): loader = TextLoader(path, encoding="utf-8") docs.extend(loader.load()) print(f"Загружено документов: {len(docs)}") # 2. Разбиение на чанки с перекрытием для сохранения контекста splitter = RecursiveCharacterTextSplitter( chunk_size=512, chunk_overlap=64, separators=["\n\n", "\n", ". ", " "] ) chunks = splitter.split_documents(docs) print(f"Чанков создано: {len(chunks)}")
Ключевой параметр здесь — chunk_size. Для техподдержки оптимален размер 256–512 токенов: достаточно мал для точного поиска, но достаточно велик для сохранения контекста ответа. Перекрытие в 64 токена предотвращает разрыв предложений на границе чанков.
# 3. Эмбеддинги и загрузка в ChromaDB from langchain_openai import OpenAIEmbeddings from langchain_chroma import Chroma embeddings = OpenAIEmbeddings( model="text-embedding-3-small", openai_api_key=os.environ["OPENAI_API_KEY"] ) vectorstore = Chroma.from_documents( documents=chunks, embedding=embeddings, persist_directory="./chroma_support_db", collection_name="support_docs" ) print("✅ База знаний готова к поиску")
После загрузки базы знаний реализуем непосредственно retrieval — поиск релевантных документов по запросу клиента. Здесь критичны два аспекта: качество первичного поиска и реранкинг результатов для отсеивания шума. Базовый similarity search возвращает топ-K по косинусной близости, но часто требует доработки.
# Базовый retrieval с фильтрацией по категории def retrieve_context(query: str, category: str | None = None, k: int = 5): """Поиск релевантных чанков по запросу""" search_kwargs = {"k": k} if category: search_kwargs["filter"] = {"category": category} retriever = vectorstore.as_retriever( search_type="similarity", search_kwargs=search_kwargs ) docs = retriever.invoke(query) return docs # MMR-ретривер — баланс релевантности и разнообразия retriever_mmr = vectorstore.as_retriever( search_type="mmr", search_kwargs={"k": 5, "fetch_k": 20, "lambda_mult": 0.7} )
MMR (Maximal Marginal Relevance) особенно полезен для техподдержки: он предотвращает ситуацию, когда все 5 возвращённых чанков — это разные абзацы одной и той же статьи. Параметр lambda_mult=0.7 означает 70% веса на релевантность и 30% на разнообразие.
# Реранкинг с CrossEncoder для повышения точности from sentence_transformers import CrossEncoder reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2") def rerank_docs(query: str, docs, top_n: int = 3): """Реранкинг чанков кросс-энкодером""" pairs = [(query, doc.page_content) for doc in docs] scores = reranker.predict(pairs) ranked = sorted(zip(docs, scores), key=lambda x: x[1], reverse=True) return [doc for doc, score in ranked[:top_n]] # Пример использования query = "Как сбросить пароль администратора?" candidates = retrieve_context(query, k=10) top_docs = rerank_docs(query, candidates, top_n=3) print(f"Отобрано чанков после реранкинга: {len(top_docs)}")
Классификатор — это «диспетчер» агента. Он принимает текст обращения и возвращает структурированную разметку: категорию, срочность, язык и решение о маршрутизации. Реализуем через вызов LLM с JSON-выходом — это обеспечивает гибкость без необходимости обучать отдельную модель.
import json from openai import OpenAI client = OpenAI() # Системный промпт классификатора CLASSIFIER_PROMPT = """Ты — классификатор обращений в техподдержку. Определи по тексту запроса: - category: billing | technical | account | general | feature_request | bug_report - urgency: low | medium | high | critical - language: ru | en - sentiment: positive | neutral | negative | angry - needs_human: true | false (true если critical, angry, или клиент просит оператора) - confidence: 0.0–1.0 Верни ТОЛЬКО JSON, без markdown-обёртки.""" def classify_ticket(text: str) -> dict: response = client.chat.completions.create( model="gpt-4o-mini", messages=[ {"role": "system", "content": CLASSIFIER_PROMPT}, {"role": "user", "content": text} ], temperature=0.0, max_tokens=150 ) result = json.loads(response.choices[0].message.content) return result # Пример классификации ticket = "У меня не проходит платёж уже третий раз! Деньги списались, а подписка не активирована. Срочно решите!" classification = classify_ticket(ticket) print(json.dumps(classification, ensure_ascii=False, indent=2))
Обратите внимание на temperature=0.0 — для классификации нужна детерминированность, никакой креативности. Использование gpt-4o-mini оптимально: высокая скорость и низкая стоимость при достаточном качестве классификации.
# Маршрутизация на основе классификации def route_ticket(classification: dict) -> str: if classification["needs_human"]: return "human_escalation" if classification["confidence"] < 0.7: return "human_escalation" if classification["category"] in ["general", "account"]: return "faq_fast_path" return "rag_pipeline"
Финальный этап — генерация ответа клиенту. Здесь критичен промпт-инжиниринг: модель должна опираться на найденные документы, не выдумывать информацию и соблюдать tone-of-voice. Используем структурированный промпт с чёткими инструкциями и примерами.
# Промпт генератора ответов SUPPORT_PROMPT = """Ты — агент техподдержки QantCore. Твоя задача — помочь клиенту, используя ТОЛЬКО информацию из предоставленных документов. ПРАВИЛА: 1. Отвечай на русском, кратко и по делу. 2. Если ответа нет в документах — честно скажи и предложи эскалацию. 3. НЕ выдумывай информацию. Если не уверен — признай это. 4. Структура ответа: приветствие → решение → доп.инфо → закрытие. 5. Для technical-запросов давай пошаговые инструкции. 6. Для billing-запросов уточняй детали, не давай обещаний по возвратам. Контекст из базы знаний: {context} Вопрос клиента: {query} Тема: {category} | Срочность: {urgency} Ответ:""" def generate_response(query: str, context_docs, classification: dict) -> str: context = "\n\n---\n\n".join( f"[Источник: {doc.metadata.get('source', 'N/A')}]\n{doc.page_content}" for doc in context_docs ) prompt = SUPPORT_PROMPT.format( context=context, query=query, category=classification["category"], urgency=classification["urgency"] ) response = client.chat.completions.create( model="gpt-4o", messages=[{"role": "user", "content": prompt}], temperature=0.3, max_tokens=600 ) return response.choices[0].message.content
Ключевые элементы промпта: явный запрет на галлюцинации («НЕ выдумывай информацию»), привязка к контексту через {context}, дифференциация стиля ответа по категориям. Параметр temperature=0.3 даёт лёгкую вариативность формулировок при сохранении фактологической точности.
# Полный пайплайн обработки тикета def process_support_ticket(ticket_text: str) -> dict: # Шаг 1: Классификация classification = classify_ticket(ticket_text) route = route_ticket(classification) # Шаг 2: Маршрутизация if route == "human_escalation": return { "action": "escalate", "reason": f"needs_human=True, confidence={classification['confidence']}", "classification": classification } if route == "faq_fast_path": return { "action": "faq_response", "response": generate_response(ticket_text, [], classification), "classification": classification } # Шаг 3: RAG — поиск + реранкинг + генерация docs = retrieve_context(ticket_text, category=classification["category"], k=10) top_docs = rerank_docs(ticket_text, docs, top_n=3) return { "action": "ai_response", "response": generate_response(ticket_text, top_docs, classification), "sources": [doc.metadata for doc in top_docs], "classification": classification }
Последний шаг — интеграция AI-агента с реальной Helpdesk-системой. Рассмотрим два сценария: приём вебхуков от Zendesk/Intercom и отправка ответов обратно через API. Агент должен работать асинхронно, не блокируя интерфейс оператора.
# FastAPI-сервер для приёма вебхуков from fastapi import FastAPI, Request, BackgroundTasks from pydantic import BaseModel import httpx app = FastAPI() class WebhookPayload(BaseModel): ticket_id: str subject: str body: str customer_email: str source: str # zendesk | intercom | api async def process_and_reply(payload: WebhookPayload): """Фоновая обработка тикета и отправка ответа в Helpdesk""" result = process_support_ticket(payload.body) if result["action"] == "ai_response": # Отправка AI-ответа как внутренней заметки await send_internal_note(payload.ticket_id, result["response"], result["sources"]) elif result["action"] == "escalate": # Эскалация — назначаем на дежурного оператора await escalate_to_human(payload.ticket_id, result["reason"]) async def send_internal_note(ticket_id: str, ai_response: str, sources: list): """Отправка внутренней заметки в Zendesk""" async with httpx.AsyncClient() as client: await client.put( f"https://{ZENDESK_DOMAIN}.zendesk.com/api/v2/tickets/{ticket_id}", auth=(ZENDESK_EMAIL, ZENDESK_API_KEY), json={"ticket": {"comment": { "body": f"🤖 AI-драфт ответа:\n\n{ai_response}\n\n---\nИсточники: {sources}", "public": False }}} )
Ключевой архитектурный паттерн — AI не отправляет ответ клиенту напрямую. Вместо этого он создаёт внутреннюю заметку с драфтом ответа, которую оператор проверяет и отправляет. Это стратегия «human-in-the-loop»: она исключает риск неверных ответов и постепенно выстраивает доверие к агенту.
# Эндпоинт вебхука @app.post("/webhook/support") async def support_webhook(payload: WebhookPayload, background: BackgroundTasks): # Мгновенный ответ — тикет принят в обработку background.add_task(process_and_reply, payload) return {"status": "processing", "ticket_id": payload.ticket_id} # Автосоздание тикета через Intercom API async def create_intercom_ticket(customer_id: str, subject: str, body: str): async with httpx.AsyncClient() as client: resp = await client.post( "https://api.intercom.io/tickets", headers={"Authorization": f"Bearer {INTERCOM_TOKEN}"}, json={ "contacts": [{"id": customer_id}], "ticket_type_id": 12345, "ticket_attributes": { "subject": subject, "ai_processed": True } } ) return resp.json()
Мы прошли полный цикл создания AI-агента для техподдержки — от архитектуры до интеграции с реальными Helpdesk-системами. Агент умеет классифицировать обращения, искать ответы в документации через RAG, генерировать драфты ответов и эскалировать сложные кейсы операторам.
ChromaDB + OpenAI embeddings + CrossEncoder реранкинг
gpt-4o-mini, JSON-вывод, 6 категорий + срочность
Структурированные промпты, anti-hallucination правила
FastAPI вебхуки, Zendesk/Intercom API, human-in-the-loop
Ключевые выводы: