Приклад AI‑агента підтримки (теорія + production-практика)

Практичний агент підтримки, який готує чернетку відповіді, але не надсилає її автоматично: класифікація ризику, перевірка правил, аудит і погодження людиною.
На цій сторінці
  1. Суть прикладу (коротко)
  2. Чому агент підтримки часто ламається
  3. Рішення: безпечний процес для агента підтримки
  4. Важливо
  5. Структура повної реалізації
  6. Як запустити
  7. Вирізки коду з поясненнями
  8. 1) Класифікація ризику до генерації відповіді (policy.py)
  9. 2) Межа політик і список дозволених інструментів (gateway.py)
  10. 3) Основний сценарій роботи агента (main.py)
  11. 4) Перевірка тверджень і посилань (policy.py)
  12. 5) LLM генерує структурований JSON, а не "вільний текст" (llm.py)
  13. Що отримує команда підтримки на виході
  14. Типові помилки, яких цей підхід уникає
  15. Компроміси
  16. Коли цей підхід не підходить
  17. Повний код на GitHub

Суть прикладу (коротко)

Продакшен-готовий агент підтримки клієнтів — це не модель, яка напряму відповідає клієнтам.

Це керований процес:

  • читає тікет і контекст
  • робить класифікацію ризику
  • готує чернетку
  • перевіряє правила і посилання на політики
  • передає на погодження людиною
  • не надсилає відповідь автоматично

Цей приклад поєднує кілька патернів в одному практичному сценарії:

  • ReAct Agent - рішення крок за кроком
  • Routing Agent - маршрутизація ризикових звернень на людину
  • Guarded Policy Agent - шар політик перед діями із наслідками

Чому агент підтримки часто ламається

У зверненнях до підтримки одночасно є:

  • емоції користувача
  • неповний контекст
  • ризики білінгу, безпеки та юридичних зобовʼязань
  • внутрішні правила, які не можна "вигадувати"

Найнебезпечніша помилка: змішати "зробити чернетку" і "відправити клієнту" в один крок.

Тому надсилання завжди має бути окремим етапом з явним підтвердженням людини.

Аналогія: уяви оператора кол-центру-стажера. Він може підготувати відповідь, але натиснути "Відправити" може тільки черговий лід. Це повільніше, зате різко зменшує інциденти.

Why this matters in production

Автоматичне надсилання LLM-відповідей клієнтам може:

  • пообіцяти повернення коштів, яке ви не можете легально надати
  • витекти внутрішні нотатки розслідування
  • зафіксувати SLA, яких насправді не існує
  • некоректно ескалювати інциденти безпеки

У продакшені агент підтримки має чітко розділяти етапи:

  • підготовка чернетки
  • погодження
  • відправка

У цьому прикладі ця межа зафіксована явно.


Рішення: безпечний процес для агента підтримки

  1. Прочитати тікет (лише інструменти читання).
  2. Зробити класифікацію ризику: низький чи високий.
  3. Для високого ризику одразу зробити передачу на людину.
  4. Для низького ризику зібрати контекст з бази знань і політик.
  5. Попросити LLM зробити чернетку та структуровані твердження з посиланнями.
  6. Перевірити правила (без небезпечних обіцянок і з повними посиланнями).
  7. Зберегти артефакт і подію аудиту.
  8. Передати у requires_human_approval=true.

Важливо

  • Агент не має email.send у списку дозволених інструментів.
  • Якщо в чернетці небезпечні твердження без джерел - чернетка блокується.
  • Якщо категорія ризикова - клієнту нічого не надсилається, тільки внутрішня передача на команду.

Структура повної реалізації

TEXT
examples/
└── support-agent/
    └── python/
        ├── main.py
        ├── llm.py
        ├── gateway.py
        ├── policy.py
        ├── tools.py
        ├── requirements.txt
        └── README.md

Повний код реалізації для цього матеріалу лежить окремо в examples-репозиторії саме в цій директорії.


Як запустити

BASH
cd examples/support-agent/python
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt

export OPENAI_API_KEY="sk-..."
# optional
# export OPENAI_MODEL="gpt-4.1-mini"
# export OPENAI_TIMEOUT_SECONDS="60"

python main.py

Вирізки коду з поясненнями

1) Класифікація ризику до генерації відповіді (policy.py)

PYTHON
HIGH_RISK_CATEGORIES = {"security", "billing_refund", "legal", "outage"}


def classify_ticket_risk(ticket: dict[str, Any]) -> tuple[str, str]:
    text = f"{ticket.get('subject', '')} {ticket.get('body', '')}".lower()

    if any(word in text for word in ["refund", "charged", "chargeback"]):
        return "billing_refund", "contains_refund_or_chargeback"
    if any(word in text for word in ["hacked", "2fa", "compromised", "password reset"]):
        return "security", "contains_security_signal"
    if any(word in text for word in ["gdpr", "legal", "lawyer", "contract"]):
        return "legal", "contains_legal_signal"
    if any(word in text for word in ["outage", "down", "incident", "500"]):
        return "outage", "contains_incident_signal"

    return "general", "no_high_risk_signals"

Що це дає:

  • не пускаємо модель у ризикові кейси "на автоматі"
  • одразу передаємо на людину там, де ціна помилки найвища
  • зменшуємо кількість небезпечних чернеток ще до виклику LLM

2) Межа політик і список дозволених інструментів (gateway.py)

PYTHON
class ToolGateway:
    def __init__(
        self,
        *,
        allow: set[str],
        registry: dict[str, Callable[..., dict[str, Any]]],
        budget: Budget,
    ):
        self.allow = set(allow)
        self.registry = registry
        self.budget = budget
        self.tool_calls = 0
        self.started = time.monotonic()
        self.seen_signatures: dict[str, int] = {}

    def call(self, name: str, args: dict[str, Any]) -> dict[str, Any]:
        if time.monotonic() - self.started > self.budget.max_seconds:
            raise StopRun("max_seconds")

        self.tool_calls += 1
        if self.tool_calls > self.budget.max_tool_calls:
            raise StopRun("max_tool_calls")

        if name not in self.allow:
            raise StopRun(f"tool_denied:{name}")

        signature = f"{name}:{args_hash(args)}"
        hits = self.seen_signatures.get(signature, 0) + 1
        self.seen_signatures[signature] = hits
        if hits > 2:
            raise StopRun("loop_detected")

        tool = self.registry.get(name)
        if tool is None:
            raise StopRun(f"tool_missing:{name}")

        return tool(**args)

Що це дає:

  • модель не може викликати довільні інструменти
  • контроль бюджету і часу під навантаженням
  • захист від циклів і спаму викликів інструментів

Для бази: Tool Calling, Tool Permissions, Step Limits.


3) Основний сценарій роботи агента (main.py)

PYTHON
def run_support_agent(ticket_id: str) -> dict[str, Any]:
    gateway = ToolGateway(allow=ALLOWED_TOOLS, registry=TOOL_REGISTRY, budget=BUDGET)

    ticket_payload = gateway.call("tickets_get", {"ticket_id": ticket_id})
    ticket = ticket_payload["ticket"]

    risk_category, risk_reason = classify_ticket_risk(ticket)
    customer = gateway.call("customers_get", {"customer_id": ticket["customer_id"]})["customer"]

    if should_force_manual_review(risk_category=risk_category, customer=customer):
        handoff = build_handoff_note(ticket=ticket, risk_category=risk_category, risk_reason=risk_reason)
        gateway.call("tickets_add_internal_note", {"ticket_id": ticket_id, "note": handoff})
        artifact = gateway.call("artifacts_put", {"ticket_id": ticket_id, "kind": "handoff", "payload": handoff})
        gateway.call("audit_emit", {"event_type": "support.handoff.created", "details": {"ticket_id": ticket_id, "artifact_id": artifact["artifact_id"]}})
        return {
            "status": "success",
            "outcome": "handoff",
            "requires_human_approval": True,
            "send_allowed": False,
            "risk_category": risk_category,
        }

    kb = gateway.call("kb_search", {"query": ticket["subject"], "k": 3})
    policy = gateway.call("policy_search", {"query": "refund policy sla", "k": 3})

    safe_customer = redact_customer(customer)
    draft = generate_support_draft(ticket=ticket, customer=safe_customer, kb_matches=kb["matches"], policy_matches=policy["matches"])

    violations = []
    violations.extend(validate_no_commitments(draft["customer_reply"]))
    violations.extend(validate_citations(claims=draft["claims"], citations=draft["citations"]))

    if violations:
        return {"status": "blocked", "stop_reason": "unsafe_draft", "violations": violations}

    artifact = gateway.call("artifacts_put", {"ticket_id": ticket_id, "kind": "draft", "payload": draft})
    gateway.call("audit_emit", {"event_type": "support.draft.created", "details": {"ticket_id": ticket_id, "artifact_id": artifact["artifact_id"]}})

    return {
        "status": "success",
        "outcome": "draft_ready",
        "requires_human_approval": True,
        "send_allowed": False,
        "artifact_id": artifact["artifact_id"],
    }

Що тут ключове:

  • ризикові категорії не проходять у чернетку для клієнта
  • перед збереженням чернетка проходить перевірку правил
  • результатом є "чернетка для перевірки", а не автоматичне надсилання

4) Перевірка тверджень і посилань (policy.py)

PYTHON
REQUIRED_CITATION_KINDS = {"refund", "credit", "sla", "timeline"}


def validate_citations(
    *,
    claims: list[dict[str, Any]],
    citations: list[dict[str, Any]],
) -> list[str]:
    errors: list[str] = []
    cited_ids = {
        str(item.get("id", "")).strip()
        for item in citations
        if isinstance(item, dict) and str(item.get("id", "")).strip()
    }

    for claim in claims:
        kind = str(claim.get("kind", "")).strip().lower()
        citation_id = str(claim.get("citation_id", "")).strip()
        if kind in REQUIRED_CITATION_KINDS and not citation_id:
            errors.append(f"missing_citation:{kind}")
            continue
        if citation_id and citation_id not in cited_ids:
            errors.append(f"unknown_citation:{citation_id}")

    return errors

Це не гарантує ідеальну правду, але гарантує перевірюваність чернетки перед погодженням.


5) LLM генерує структурований JSON, а не "вільний текст" (llm.py)

PYTHON
SYSTEM_PROMPT = """
You are a support drafting assistant.
Return only JSON with keys:
- customer_reply: string
- internal_note: string
- claims: [{"kind": string, "text": string, "citation_id": string}]
- citations: [{"id": string, "title": string}]

Rules:
- Do not promise refunds or credits.
- If policy claim is made, include citation_id.
- Keep customer_reply concise and professional.
""".strip()

Чому так:

  • структурований формат простіше валідувати
  • менше шансів пропустити небезпечні твердження
  • легше будувати стабільний інтерфейс погодження

Що отримує команда підтримки на виході

  • чернетка відповіді клієнту
  • внутрішню нотатку для передачі
  • твердження + посилання для перевірки
  • artifact_id для історії
  • подію аудиту для відстеження
  • явний прапорець requires_human_approval=true

Типові помилки, яких цей підхід уникає

  • "автоматично відправили не те"
  • "пообіцяли повернення коштів без права на повернення"
  • "витекли внутрішні нотатки у лист клієнту"
  • "не зрозуміло, чому агент прийняв саме це рішення"

Детальніше про ці сценарії збоїв:


Компроміси

  • погодження людиною додає затримку
  • повністю автоматичних резолюцій стане менше
  • зате падає кількість інцидентів і репутаційних помилок

Для продакшену це зазвичай правильний обмін.

Production note

Запуск агента підтримки в продакшені зазвичай потребує:

  • списку дозволених інструментів
  • бюджетів виконання
  • перевірки політик перед діями
  • журналів аудиту
  • процесу людського погодження

Без цих контролів агент може:

  • надмірно обіцяти повернення коштів
  • витікати внутрішні нотатки
  • діяти поза політиками компанії

Інфраструктура на кшталт OnceOnly надає ці контролі як окремий шар політик і керування.


Коли цей підхід не підходить

Не варто запускати агента підтримки в такому вигляді, якщо у вас немає:

  • розділення інструментів читання і запису
  • місця для зберігання артефактів та аудиту
  • процесу людського ревʼю

Тоді краще почати з простішого сценарію:


Повний код на GitHub

У репозиторії лежить повна runnable-версія цього прикладу: класифікація ризику, перевірка політик, аудит, артефакти й обовʼязкове погодження людиною.

Переглянути повний код на GitHub ↗
Інтегровано: продакшен-контрольOnceOnly
Додай guardrails до агентів з tool-calling
Зашип цей патерн з governance:
  • Бюджетами (кроки / ліміти витрат)
  • Дозволами на інструменти (allowlist / blocklist)
  • Kill switch та аварійна зупинка
  • Ідемпотентність і dedupe
  • Audit logs та трасування
Інтегрована згадка: OnceOnly — контрольний шар для продакшен агент-систем.

Автор

Микола — інженер, який будує інфраструктуру для продакшн AI-агентів.

Фокус: патерни агентів, режими відмов, контроль рантайму та надійність систем.

🔗 GitHub: https://github.com/mykolademyanov


Редакційна примітка

Ця документація підготовлена з допомогою AI, із людською редакторською відповідальністю за точність, ясність і продакшн-релевантність.

Контент базується на реальних відмовах, постмортемах та операційних інцидентах у розгорнутих AI-агентних системах.