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
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
Решения, на которых всё держится
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.
Что видит оператор
Эскалация приходит к оператору с тем, на чём агент строил решение: переписка, сработавшее правило, классификация с источником уверенности и документы, которые нашёл поиск, в той версии, что видел агент. Черновик ответа можно отправить как есть, поправить или написать с нуля; правка сохраняется вместе с черновиком для разбора качества.
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_cacheisn'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). Уверенность осталась косинусной: порог откалиброван на ней, а не на оценке слияния.
| Vectors | Mode | Recall@5 |
|---|---|---|
| hashing | vector only | 0.73 |
| hashing | hybrid, text + title | 0.83 |
| bge-m3 | vector only | 0.89 |
| bge-m3 | hybrid, text + title | 0.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.
Нефункциональные требования
Отчёт собирается скриптом из артефактов прогонов, числа руками не вносятся.
| NFR | Requirement | Threshold | Measured |
|---|---|---|---|
| NFR1 | Auto-reply latency, p95 | ≤ 8s | 6.30s |
| NFR2 | Complaint/refund recall, macro-F1 | ≥ 0.95 / ≥ 0.85 | 0.96, 1.00 / 0.90 |
| NFR3 | Decision traceability | 100%, ≤ 2s | 100%, 0.01s |
| NFR4 | PII, retention period, another user's ticket | masked, ≤ 90 days, 404 | verified by tests |
| NFR5 | Cost per ticket, p95 | ≤ $0.02 | $0.0013 |
| NFR6 | LLM failure | 3 retries, then escalation | ≤ 10s, escalation |
| NFR7 | Operator edits | 100% with a diff | draft and reply both saved |
| NFR8 | Concurrent tickets | 50 without degradation | 100: p95 6.41s |
| NFR9 | Follow-up cycle | 1, 30 min timeout | verified by tests |
| NFR10 | Prompt injection | 0 successful | 0 of 24 |
| NFR | Требование | Порог | Замер |
|---|---|---|---|
| NFR1 | Задержка автоответа, p95 | ≤ 8 с | 6.30 с |
| NFR2 | Recall жалоб / возвратов, macro-F1 | ≥ 0.95 / ≥ 0.85 | 0.96, 1.00 / 0.90 |
| NFR3 | Трассируемость решений | 100%, ≤ 2 с | 100%, 0.01 с |
| NFR4 | PII, срок хранения, чужой тикет | маска, ≤ 90 дней, 404 | проверено тестами |
| NFR5 | Стоимость на обращение, p95 | ≤ $0.02 | $0.0013 |
| NFR6 | Сбой LLM | 3 повтора, затем эскалация | ≤ 10 с, эскалация |
| NFR7 | Правки оператора | 100% с diff | черновик и ответ сохранены |
| NFR8 | Одновременных обращений | 50 без деградации | 100: p95 6.41 с |
| NFR9 | Цикл уточнений | 1, таймаут 30 мин | проверено тестами |
| NFR10 | Prompt injection | 0 успешных | 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
Материалы