Case study · 2026 · Systems analysis · architecture · AI agent

Кейс · 2026 · Системный анализ · архитектура · AI-агент

Support Agent

Support-агент

An AI support agent for an online store: it classifies the incoming message, searches the knowledge base, and decides whether to answer directly, ask a follow-up, or hand off to a human operator. The route is chosen by code, not by the model. The design document came first, the code came second. Every number on this page comes from real run artifacts in the repository.

AI-агент поддержки интернет-магазина: классифицирует обращение, ищет ответ в базе знаний и решает: ответить самому, уточнить или передать оператору. Решение принимает код, а не модель. Сначала был проектный документ, потом код. Каждое число на этой странице взято из артефактов прогонов в репозитории.

FastAPILangGraphPostgreSQL + pgvectorRabbitMQReact

10 / 10
NFR metNFR выполнено
12
ADRADR
306
testsтестов
6
CI jobsзадач CI

Task

Задача

Where the agent's autonomy ends

A support agent earns its keep by answering the simple cases on its own and staying out of the ones where a mistake is expensive. A complaint, a refund request, or a question the knowledge base has no answer for has to go to a human, even when the model claims to be confident. So this project isn't about generation quality; it's about the boundary: where the agent is allowed to act, who checks that, and what happens when something breaks.

Scope: the service runs in Russia (foreign LLM APIs aren't reachable, personal data handling follows 152-FZ), a budget of $0.02 per ticket, a human kept in the loop, and every decision traceable.

What got built: a design document, requirements, use cases, a decision table, an ERD, API contracts, 12 ADRs, a test strategy, and SLIs. Then an implementation built from it: FastAPI, LangGraph, Postgres with pgvector, RabbitMQ, an operator console in React. And measurements that checked both the document and the code.

Где граница автономности агента

Агент поддержки полезен, пока отвечает сам на простое и не отвечает на то, где ошибка дорога. Жалобу, возврат денег или вопрос без ответа в базе знаний нужно отдать человеку, даже если модель «уверена». Поэтому проект не про качество генерации, а про границу: где агенту можно действовать, кто и как это проверяет, и что происходит, когда что-то ломается.

Рамки: сервис в России (зарубежные LLM API недоступны, обработка персональных данных согласно 152-ФЗ), бюджет $0.02 на обращение, человек в контуре, каждое решение трассируемо.

Что сделано: проектный документ, требования, use cases, decision table, ERD, API-контракты, 12 ADR, стратегия тестирования, SLI. Затем реализация по нему: FastAPI, LangGraph, Postgres с pgvector, RabbitMQ, консоль оператора на React. И замеры, которые проверяли и документ, и код.

Architecture

Архитектура

The decisions everything else rests on

Решения, на которых всё держится

Message PII redaction Classification LLM + cross-check Search pgvector + full-text Decision Engine rules R1 through R10 · pure function Reply to customer auto-reply or follow-up Escalation Postgres ticket + escalation + outbox RabbitMQ Console
The message passes through PII redaction, classification, and search, then the Decision Engine makes the call. An escalation is written to Postgres in one transaction together with the queue event (dashed line); RabbitMQ delivers it to the operator console.
Обращение PII-редакция Классификация LLM + сверка Поиск pgvector + полнотекстовый Decision Engine правила с R1 по R10 · чистая функция Ответ клиенту автоответ или уточнение Эскалация Postgres тикет + эскалация + outbox RabbitMQ Консоль
Обращение проходит редакцию персональных данных, классификацию и поиск, затем решение принимает Decision Engine. Эскалация пишется в Postgres одной транзакцией с событием очереди (пунктир), RabbitMQ доставляет её в консоль оператора.

Code chooses the route, not the LLM ADR-001

Маршрут выбирает код, а не LLM ADR-001

Decision
A decision table: a pure function, rules R1 through R10, the rule that fired gets written to the log.
Why
The message text can't change the route, so prompt injection never produces an auto-reply. An input combination the rules don't cover doesn't produce a random answer; it produces escalation and an alert. Tests exhaustively cover all 144 combinations.
Решение
Decision table: чистая функция, правила с R1 по R10, сработавшее правило пишется в журнал.
Почему
Текст обращения не может изменить маршрут, поэтому prompt injection не приводит к автоответу. Непокрытая комбинация входов даёт не случайный ответ, а эскалацию и алерт; тесты перебирают все 144 комбинации.

Postgres holds the source of truth, RabbitMQ is just transport ADR-004, ADR-007

Postgres хранит источник истины, RabbitMQ служит транспортом ADR-004, ADR-007

Decision
The ticket, the escalation, and the event are written in one transaction (transactional outbox).
Why
The consumer is idempotent, and the message is only acknowledged after commit. An escalation can't be lost between the database and the broker.
Решение
Тикет, эскалация и событие пишутся одной транзакцией (transactional outbox).
Почему
Consumer идемпотентен, сообщение подтверждается только после commit. Эскалацию нельзя потерять между базой и брокером.

The LLM provider is chosen by measurement ADR-009

Провайдер LLM выбирается замером ADR-009

Decision
Adapters for GigaChat and OpenAI-compatible APIs, with the choice made by a run against the golden set.
Why
A hard requirement for a provider with logprobs would have left only outdated models on the 2026 market. A criteria table wouldn't have caught that.
Решение
Адаптеры к GigaChat и OpenAI-совместимым API, выбор по прогону на golden set.
Почему
Жёсткое требование «провайдер с logprobs» на рынке 2026 года оставило бы только устаревшие модели. Таблица критериев такое бы не поймала.

Document versions instead of deletion ADR-011

Версии документов вместо удаления ADR-011

Decision
The decision log shows the knowledge base document version the agent actually saw, not the current one.
Решение
Журнал решения показывает ту версию документа базы знаний, которую видел агент, а не текущую.

Console

Консоль

What the operator sees

The escalation reaches the operator carrying exactly what the agent based its decision on: the conversation, the rule that fired, the classification with its confidence source, and the documents search found, in the version the agent actually saw. The draft reply can be sent as is, edited, or replaced from scratch; the edit is saved alongside the draft for quality review.

Что видит оператор

Эскалация приходит к оператору с тем, на чём агент строил решение: переписка, сработавшее правило, классификация с источником уверенности и документы, которые нашёл поиск, в той версии, что видел агент. Черновик ответа можно отправить как есть, поправить или написать с нуля; правка сохраняется вместе с черновиком для разбора качества.

Conversation Draft reply Classification type + confidence source Documents found 5 documents · version + relevance
A diagram of the escalation card, not a screenshot: the conversation, the draft reply, and classification with confidence source on the left; five retrieved documents with version and relevance on the right.
Переписка Черновик ответа Классификация тип + источник уверенности Найденные документы 5 документов · версия + релевантность
Схема карточки эскалации, не скриншот: переписка, черновик ответа, классификация с источником уверенности слева; пять найденных документов с версией и релевантностью справа.

The demo stand runs a baseline: a dictionary classifier and a templated draft. It comes up with one command:

Демо-стенд, базовая линия: словарный классификатор и шаблонный черновик. Стенд поднимается одной командой:

docker compose --profile demo up -d --build

Measurements

Замеры

What the measurements found

The most valuable parts of this project are the places where a measurement overturned a decision that looked correct on paper.

Что показали замеры

Самое ценное в проекте составляют места, где замер опроверг решение, которое на бумаге выглядело правильным.

Model confidence

Уверенность модели

Hypothesis
If the provider doesn't return logprobs, confidence is computed from five model responses (k-sampling), and a 0.85 threshold filters out doubtful cases.
Measurement
Alice AI Flash, 359 tickets from the golden set: five votes out of five on 357 of them, including 34 of the 35 errors. The threshold filtered out nothing; "Hello" got an auto-reply.
Decision
Cross-check against a local dictionary classifier: on disagreement, confidence drops below the threshold. The cross-check costs nothing and caught 29 of the 35 errors. ADR-012
Result
Zero incorrect auto-replies and zero auto-replies on ambiguous tickets, with recall and F1 unchanged; classification got 5 times cheaper. The cost: 30% fewer auto-replies.
Гипотеза
Если провайдер не отдаёт logprobs, уверенность считается по пяти ответам модели (k-sampling), и порог 0.85 отсекает сомнительные случаи.
Замер
Alice AI Flash, 359 обращений golden set: пять голосов из пяти у 357, в том числе у 34 из 35 ошибок. Порог не отсекал ничего; «Здравствуйте» получило автоответ.
Решение
Сверка с локальным словарным классификатором: при расхождении уверенность падает ниже порога. Сверка ничего не стоит и поймала 29 из 35 ошибок. ADR-012
Результат
Ноль ошибочных автоответов и ноль автоответов на неоднозначные обращения при прежних recall и F1; классификация в 5 раз дешевле. Цена: на 30% меньше автоответов.

Search threshold

Порог поиска

Hypothesis
The design document records a search relevance threshold of 0.7, calibrated for bge-m3.
Measurement
The golden set wasn't fit for calibration: every one of its tickets has an answer in the knowledge base, so any threshold would have looked safe. A set of 36 questions outside the knowledge base was added. At 0.7, only 39% of the questions that do have an answer got an auto-reply.
Decision
0.60: the share of auto-replies with no supporting document is 3.2% against a 5% tolerance, with 93% coverage. The minimum threshold the procedure allows is 0.57, but there the share sits right against the 5% line on only 36 negative examples. A fabricated answer is worse than one extra escalation.
Гипотеза
В документе порог релевантности поиска 0.7 записан как откалиброванный под bge-m3.
Замер
Golden set для калибровки не годился: у всех его обращений ответ в базе есть, и любой порог выглядел бы безопасным. Добавлен набор из 36 вопросов вне базы знаний. При 0.7 автоответ получали только 39% вопросов, на которые ответ есть.
Решение
0.60: доля автоответов без опоры на документ 3.2% при допуске 5%, покрытие 93%. Минимальный порог по процедуре составляет 0.57, но там доля лежит вплотную к 5% при всего 36 негативных примерах. Выдуманный ответ хуже лишней эскалации.
Threshold τGet an auto-reply,
of those with an answer in the knowledge base
Share of auto-replies
with no supporting document
0.70 (previous)39%n/a
0.60 (adopted)93%3.2% (5% tolerance)
0.57 (procedure minimum)right at the line~5% on 36 examples
Порог τПолучают автоответ
из тех, у кого ответ в базе есть
Доля автоответов
без опоры на документ
0.70 (было)39%н/д
0.60 (принято)93%3.2% (допуск 5%)
0.57 (минимум по процедуре)вплотную к границе~5% на 36 примерах

Load

Нагрузка

The load test found three capacity defects. At 50 concurrent tickets, the auto-reply p95 was 12.98s against an 8s budget. The causes weren't in the model:

  • lru_cache isn't thread-safe: the first wave of requests created a database engine per thread, and connections climbed to 56;
  • a request held its database connection for the entire time it waited on the LLM;
  • anyio's default thread pool is 40, against 50 concurrent tickets.

After the fixes: 6.30s at 50 concurrent and 6.41s at 100, with no more than 22 database connections.

Нагрузочный тест нашёл три дефекта ёмкости. При 50 одновременных обращениях p95 автоответа был 12.98 с при бюджете 8 с. Причины оказались не в модели:

  • lru_cache не потокобезопасен: первая волна запросов создавала движок базы на каждый поток, соединений стало 56;
  • запрос держал соединение с базой всё время ожидания LLM;
  • пул потоков anyio по умолчанию составляет 40 при 50 одновременных тикетах.

После исправлений: 6.30 с при 50 и 6.41 с при 100 одновременных, соединений с базой не больше 22.

Search

Поиск

ADR-010 set the rule in advance: if Recall@5 falls below 0.9, turn on hybrid search. Recall came in at 0.73, so hybrid search was turned on: Postgres full-text search with Russian morphology, plus vectors, merged by rank (RRF). Confidence stayed cosine-based: the threshold is calibrated on that score, not on the fused one.

ADR-010 заранее определил: если Recall@5 ниже 0.9, включить гибридный поиск. Recall был 0.73, гибрид включён: полнотекстовый поиск Postgres с русской морфологией плюс векторы, слияние по рангам (RRF). Уверенность осталась косинусной: порог откалиброван на ней, а не на оценке слияния.

VectorsModeRecall@5
hashingvector only0.73
hashinghybrid, text + title0.83
bge-m3vector only0.89
bge-m3hybrid, text + title0.92
ВекторыРежимRecall@5
хеширующиетолько вектор0.73
хеширующиегибрид, текст + заголовок0.83
bge-m3только вектор0.89
bge-m3гибрид, текст + заголовок0.92

Stop-word filtering added another 2 points, but its threshold would have had to be tuned on the same set the measurement runs against. Rejected as fitting the test.

Отсечение частых слов давало ещё +2 п.п., но его порог пришлось бы подбирать на том же наборе, на котором идёт замер. Отклонено как подгонка под тест.

The document versus the code

Документ и код

Building the implementation from the document also became a way of checking it. Discrepancies found and closed:

  • the schema was described as "designed for hybrid search from the start," though it had no full-text index;
  • the code referenced a runbook twice that didn't exist;
  • the "end-to-end trace_id" was actually different across the agent, the error, and the queue event;
  • escalation on LLM unavailability bypassed the agent entirely, with no log entry, contradicting the traceability requirement;
  • workers crashed when Postgres restarted, and a connection through the Docker proxy hung indefinitely with no timeout.

Реализация по документу стала ещё и его проверкой. Расхождения, найденные и закрытые:

  • схема «с самого начала рассчитана на гибридный поиск», хотя полнотекстового индекса в ней не было;
  • код дважды ссылался на runbook, которого не существовало;
  • «сквозной trace_id» был разным у агента, у ошибки и у события в очереди;
  • эскалация при недоступности LLM шла в обход агента, без записи в журнал, вопреки требованию трассируемости;
  • воркеры падали при перезапуске Postgres, а подключение через прокси Docker висело бесконечно, без таймаута.

NFR · 10 / 10

Non-functional requirements

The report is assembled by a script from run artifacts; no number is entered by hand.

Нефункциональные требования

Отчёт собирается скриптом из артефактов прогонов, числа руками не вносятся.

NFRRequirementThresholdMeasured
NFR1Auto-reply latency, p95≤ 8s6.30s
NFR2Complaint/refund recall, macro-F1≥ 0.95 / ≥ 0.850.96, 1.00 / 0.90
NFR3Decision traceability100%, ≤ 2s100%, 0.01s
NFR4PII, retention period, another user's ticketmasked, ≤ 90 days, 404verified by tests
NFR5Cost per ticket, p95≤ $0.02$0.0013
NFR6LLM failure3 retries, then escalation≤ 10s, escalation
NFR7Operator edits100% with a diffdraft and reply both saved
NFR8Concurrent tickets50 without degradation100: p95 6.41s
NFR9Follow-up cycle1, 30 min timeoutverified by tests
NFR10Prompt injection0 successful0 of 24
NFRТребованиеПорогЗамер
NFR1Задержка автоответа, p95≤ 8 с6.30 с
NFR2Recall жалоб / возвратов, macro-F1≥ 0.95 / ≥ 0.850.96, 1.00 / 0.90
NFR3Трассируемость решений100%, ≤ 2 с100%, 0.01 с
NFR4PII, срок хранения, чужой тикетмаска, ≤ 90 дней, 404проверено тестами
NFR5Стоимость на обращение, p95≤ $0.02$0.0013
NFR6Сбой LLM3 повтора, затем эскалация≤ 10 с, эскалация
NFR7Правки оператора100% с diffчерновик и ответ сохранены
NFR8Одновременных обращений50 без деградации100: p95 6.41 с
NFR9Цикл уточнений1, таймаут 30 минпроверено тестами
NFR10Prompt injection0 успешных0 из 24

Limits

Границы

What this project doesn't prove

  • The golden set is synthetic. The dictionary classifier the cross-check relies on was written against those same phrasings, so real tickets will show more disagreements and fewer auto-replies.
  • The load test simulates LLM latency. Against the real provider with no load, processing p95 is 1.9s; it hasn't been checked under load or against the provider's own rate limits.
  • There were no real tickets or operators: the share of operator edits and the quality of the drafts haven't been measured.

Each of these limits is recorded in its corresponding ADR and in the NFR report, not just here.

Чего проект не доказывает

  • Golden set синтетический. Словарный классификатор, на котором держится сверка, писался под те же формулировки, поэтому на реальных обращениях расхождений будет больше, а автоответов меньше.
  • Нагрузочный тест имитирует задержку LLM. На реальном провайдере без нагрузки p95 обработки составляет 1.9 с; под нагрузкой и с лимитами провайдера не проверено.
  • Реальных тикетов и операторов не было: доля правок оператора и качество черновиков не измерены.

Каждое из этих ограничений записано в соответствующем ADR и в отчёте по NFR, а не только здесь.

Resources

Материалы

Code, the design document, and run artifacts

Код, документ и артефакты прогонов