FastAPI удобен для LLM-интеграций по трём причинам: строгие схемы через Pydantic, естественная async-модель для сетевых вызовов и чистая dependency injection. Но сам FastAPI не спасает от архитектурной грязи. Можно написать аккуратный сервис. Можно написать /ai/do-magic, куда стекается вся боль продукта. Второе встречается чаще.
Базовая структура проекта
app/
api/
routes_ai.py
core/
config.py
logging.py
llm/
clients/
base.py
openai_client.py
anthropic_client.py
prompts/
registry.py
templates/
schemas.py
service.py
cost.py
errors.py
pipelines/
support_triage.py
document_extract.py
workers/
celery_app.py
tasks.py
db/
models.py
session.pyКлючевая мысль: endpoint не должен содержать промпт-инжиниринг, бизнес-логику, retry policy и парсинг ответа одновременно. Endpoint принимает HTTP, проверяет вход, вызывает application service и возвращает результат. Всё.
Слои
- API layer — HTTP, auth, request/response schemas.
- Application service — use case: классифицировать тикет, извлечь данные, создать черновик.
- Pipeline layer — последовательность LLM и non-LLM шагов.
- LLM client layer — провайдеры, timeout, retries, raw usage.
- Persistence layer — PostgreSQL: запросы, результаты, версии prompt-ов, стоимость, статусы.
- Worker layer — Celery-задачи для долгих операций.
Если эти слои смешать, через месяц никто не сможет ответить на простой вопрос: «Почему этот пользователь получил такой ответ?»
Что хранить в PostgreSQL
Минимальная таблица LLM-вызовов:
create table llm_call_log (
id uuid primary key,
created_at timestamptz not null default now(),
use_case text not null,
provider text not null,
model text not null,
prompt_version text not null,
input_hash text not null,
input_tokens integer,
output_tokens integer,
cost_usd numeric(12, 6),
latency_ms integer,
status text not null,
error_code text,
request_json jsonb,
response_json jsonb
);В реальном продукте нельзя бездумно сохранять весь пользовательский ввод: там могут быть персональные данные, коммерческая тайна и токены доступа. Но нельзя и не сохранять ничего. Нужен осознанный компромисс: hash, redaction, limited retention, sampling, отдельные политики для debug-режима.
Когда endpoint должен быть sync, а когда async
Короткая классификация или генерация черновика может жить в HTTP request-response, если укладывается в UX-бюджет: например, 3–8 секунд.
Долгие пайплайны надо выносить в Celery:
- обработка больших документов;
- многошаговый RAG;
- batch-обработка тикетов;
- agent loop;
- генерация отчётов;
- операции с внешними API.
Плохой признак: пользователь держит вкладку открытой 90 секунд, а backend надеется, что провайдер не отвалится. Это не архитектура. Это молитва с progress bar.
Celery pattern
@celery_app.task(bind=True, max_retries=2)
def run_document_pipeline(self, document_id: str) -> None:
try:
pipeline = build_document_pipeline()
pipeline.run(document_id=document_id)
except TransientLLMError as exc:
raise self.retry(exc=exc, countdown=30)Важно: retry должен быть на уровне шага, а не всего мира. Если документ уже распарсен и embeddings посчитаны, не надо делать это заново из-за сбоя на summary. Сохраняйте промежуточные состояния.
Правило главы
LLM-сервис — это не папка utils/ai.py. Это нормальная часть backend-а: со слоями, схемами, логами, задачами, базой и эксплуатационными ограничениями. Чем раньше вы это признаете, тем меньше крови будет в проде.
Чеклист главы
- Endpoint не содержит промптов, retry policy и парсинга ответа — только HTTP-слой.
- LLM-вызовы логируются в таблицу вида
llm_call_logс версией prompt-а, токенами, стоимостью и статусом. - Политика хранения пользовательского ввода осознанна: redaction, retention, sampling.
- Операции длиннее UX-бюджета (3–8 секунд) вынесены в Celery.
- Retry работает на уровне шага, промежуточные состояния сохраняются.
- На вопрос «почему этот пользователь получил такой ответ» можно ответить по данным, а не по памяти.
Практикум
Задача 24.1 — каркас LLM-сервиса. ⭑⭑
Соберите скелет сервиса по структуре из главы: FastAPI + слой клиентов + registry промптов + llm_call_log в PostgreSQL + Docker Compose (app, postgres, redis). Реализуйте один сквозной use case: триаж тикета поддержки.
Критерии приёмки:
-
docker compose upподнимает рабочий стенд с нуля. - После 10 запросов в
llm_call_logлежат 10 строк с заполненными токенами, стоимостью, latency и версией prompt-а. - Промпт живёт в registry с версией, а не в строке внутри endpoint-а; смена версии видна в логе.
- SQL-запросом можно ответить: сколько стоил триаж за сегодня, какой p95 latency, сколько ошибок формата.
Подсказки:
- Не стройте все слои сразу идеально. Цель — правильные границы, наполнение придёт по мере глав.
input_hashвместо сырого текста в основной таблице — хорошая привычка с первого дня; сырой текст, если нужен, кладите в отдельную таблицу со своим retention.
Задача 24.2 — перенос длинной операции в worker. ⭑⭑
Добавьте в сервис обработку документа (возьмите любой PDF/текст на 10+ страниц): извлечение полей по секциям. Синхронный вариант не влезет в UX-бюджет — реализуйте через Celery: endpoint создаёт job, воркер выполняет по шагам, статус доступен по GET /jobs/{id}.
Критерии приёмки:
- Endpoint отвечает мгновенно и возвращает
job_id. - Убийство воркера посреди обработки (
docker kill) не теряет сделанные шаги: после рестарта job продолжается, а не начинается заново. - Повторная отправка того же документа не создаёт дубль работы (подумайте про idempotency key).
- В статусе job-а видно, на каком шаге он находится и сколько токенов уже потрачено.
Подсказки:
- Промежуточные состояния храните в PostgreSQL, а не в памяти воркера — это и есть разница между «retry шага» и «retry всего мира».