Django часто живёт там, где уже есть зрелый монолит: пользователи, админка, права, ORM, Celery, платежи, письма, legacy и бизнес, который не хочет переписывать систему ради модного AI-сервиса. И правильно не хочет. Переписывать рабочий монолит на микросервисы только потому, что появилась LLM-фича, — слабая инженерная воля, замаскированная под архитектурную смелость.
Где Django хорош для LLM
Django удобен, когда LLM-фича должна встроиться в существующие бизнес-процессы:
- админка оператора;
- обработка заявок;
- внутренние copilot-функции;
- модерация;
- генерация черновиков писем;
- извлечение данных из загруженных файлов;
- back-office workflows.
У Django уже есть то, что часто приходится собирать вокруг FastAPI-сервиса отдельно: auth, admin, ORM, migrations, permissions, forms, management commands.
Рекомендуемая структура приложения
project/
ai/
models.py
services/
llm_client.py
prompts.py
support.py
documents.py
tasks.py
admin.py
views.py
schemas.py
evals/Не кладите всё в models.py или views.py. Django это позволяет. Именно поэтому надо держать себя за руку.
Pydantic внутри Django
Django models хороши для хранения. Pydantic хорош для контрактов LLM-ответа. Их не надо противопоставлять.
from pydantic import BaseModel, Field
class TicketTriageResult(BaseModel):
category: str
confidence: float = Field(ge=0, le=1)
needs_human: bool
operator_note: str = Field(max_length=500)Django model может хранить результат:
class AITriage(models.Model):
ticket = models.ForeignKey("support.Ticket", on_delete=models.CASCADE)
prompt_version = models.CharField(max_length=100)
model_name = models.CharField(max_length=100)
result = models.JSONField()
input_tokens = models.PositiveIntegerField(null=True, blank=True)
output_tokens = models.PositiveIntegerField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)Pydantic валидирует форму ответа. Django хранит и связывает с доменной моделью. Спорить, кто из них «главнее», бессмысленно. Это разные инструменты.
Celery в Django
Для LLM-фич в Django почти всегда нужен Celery. Не потому, что модно. Потому что LLM-вызовы медленные, внешние и периодически падают.
@shared_task(bind=True, max_retries=2)
def triage_ticket(self, ticket_id: int) -> None:
ticket = Ticket.objects.get(id=ticket_id)
service = SupportAIService()
try:
result = service.triage(ticket.message)
except TransientLLMError as exc:
raise self.retry(exc=exc, countdown=20)
AITriage.objects.create(
ticket=ticket,
prompt_version=result.prompt_version,
model_name=result.model,
result=result.data.model_dump(),
input_tokens=result.input_tokens,
output_tokens=result.output_tokens,
)Ошибка команды
Команда добавляет кнопку «Сгенерировать ответ» прямо во view Django admin. View вызывает LLM синхронно. На тесте работает. В проде оператор нажимает кнопку, запрос висит, nginx режет соединение, оператор нажимает ещё раз, создаются два разных черновика, один из них уходит клиенту после ручного копирования. Потом никто не может восстановить, какой prompt и какой вход использовались.
Правильное решение:
- кнопка создаёт
AIJob; - Celery выполняет задачу;
- результат сохраняется;
- админка показывает статус;
- оператор видит diff/черновик;
- отправка письма остаётся человеческим действием;
- все LLM-вызовы логируются.
Правило главы
В Django LLM-интеграция должна уважать монолит, а не пытаться его унизить. Используйте ORM, admin, permissions и Celery. Но держите LLM-контракты и сервисный слой отдельно, иначе AI-код превратит зрелый проект в кашу из view, signals и надежды.
Чеклист главы
- LLM-логика живёт в отдельном app с сервисным слоем, а не в
views.pyи signals. - Контракты ответов — Pydantic; хранение и связи — Django models. Границы не смешаны.
- Никакой LLM-вызов не выполняется синхронно внутри admin view или request-response цикла дольше UX-бюджета.
- Кнопки в админке создают job, а не блокируют оператора.
- Отправка результата клиенту (письмо, публикация) остаётся человеческим действием, пока не доказано обратное.
- Результаты LLM привязаны к доменным объектам через ForeignKey — историю можно поднять из админки.
Практикум
Задача 30.1 — AI-триаж в существующем Django-проекте. ⭑⭑
Возьмите любой свой Django-проект (или стандартный туториал-проект с моделью Ticket). Встройте триаж тикетов по схеме главы: app ai, сервисный слой, Pydantic-контракт, Celery-задача, модель AITriage для результатов.
Критерии приёмки:
- Создание тикета ставит Celery-задачу; падение LLM-провайдера не ломает создание тикета.
- Результат триажа виден в админке рядом с тикетом: категория, confidence, версия prompt-а, токены.
-
TransientLLMErrorприводит к retry с countdown; постоянная ошибка — к статусуfailed, который виден оператору. - В коде нет импорта SDK провайдера нигде, кроме
services/llm_client.py.
Подсказки:
- Не заводите отдельный FastAPI-сервис «для AI» — вся ценность задачи в том, чтобы встроиться в монолит аккуратно.
Задача 30.2 — кнопка «Сгенерировать ответ» без инцидента. ⭑⭑⭑
Реализуйте сценарий из «ошибки команды» правильно: в админке тикета кнопка «Сгенерировать черновик ответа». Кнопка создаёт AIJob, Celery выполняет генерацию, страница показывает статус, оператор видит черновик и вручную решает, отправлять ли.
Критерии приёмки:
- Двойное нажатие кнопки не создаёт два job-а (защита на уровне БД, а не только UI).
- Статус job-а обновляется на странице без перезагрузки или через явную кнопку «Обновить» — но оператор всегда видит актуальное состояние.
- Черновик нельзя отправить, пока job не в статусе
done; отправка логируется с указанием, какой job и какая версия prompt-а породили текст. - Тест: nginx-таймаут (или искусственная задержка в 120 секунд) не приводит к потере результата.
Подсказки:
- Уникальный constraint
(ticket_id, job_type, status="pending")через partial index решает дубли надёжнее любого JS. - Это учебная версия паттерна human-in-the-loop из главы 35 — здесь достаточно ручной отправки и лога.