← Назад к блогу

Гибридная AI-архитектура: почему мы перестали доверять LLM все вычисления

AILLMArchitecturePythonYandexGPTSCADAProduction

Как SCADA.AI прошла путь от 'пусть LLM всё посчитает' до трёхслойной архитектуры, где Python отвечает за вычисления, а YandexGPT — только за нарратив. Реальный кейс, метрики и антипаттерны из production.

Провокация: 95% — не преувеличение

Давайте сразу к делу. Если вы разрабатываете AI-продукт и отправляете в LLM запрос типа «посчитай среднюю температуру за последние 24 часа» — вы делаете что-то не так. Если ваш health score вычисляется через GPT-4 — вы сидите на пороховой бочке. Если ваша бизнес-логика живёт в промпте — готовьтесь к сюрпризам в продакшене.

«Любой достаточно продвинутый баг неотличим от фичи» Энди Келли, перефразируя Кларка

В этой статье я расскажу, как мы в SCADA.AI прошли путь от «давайте LLM всё посчитает» до гибридной архитектуры, где LLM отвечает только за narrative (текст для пользователя), а 95% вычислений происходят детерминированно на Python. Покажу реальный код, метрики, подводные камни и кейс, когда LLM вернул пустой JSON и чуть не обрушил систему.

Часть 1: Иллюзия «умного» AI

Как это начинается

Вы — разработчик. Заказчик хочет «умную» систему. Вы слышите:

  • «AI должен анализировать данные»
  • «Система должна понимать, что происходит»
  • «Пусть нейросеть решает»

И вы, вооружённые API, начинаете писать промпты:

Ты — инженер-аналитик SCADA-системы.
Вот данные за последние 24 часа:
- Температура: avg 22.5°C, min 18°C, max 26°C
- CO2: avg 650 ppm, min 400 ppm, max 1200 ppm
- Аварии: 3 high, 5 medium, 12 low
Посчитай индекс здоровья системы от 0 до 100.
Верни JSON: {"score": число, "status": "CRITICAL/WARNING/GOOD/EXCELLENT"}

Кажется элегантно: одна строчка API, одна модель, один ответ. Но это ловушка.

Три фундаментальные проблемы

1. Скорость

LLM-запрос занимает 5-10 секунд. Детерминированная функция на Python — 50 миллисекунд. Разница в 100-200 раз.

Представьте: оператор на промышленном объекте открывает дашборд. Ждёт 10 секунд. Обновляет страницу. Снова ждёт. В критической ситуации (авария, утечка газа) эти 10 секунд — вечность.

2. Стоимость

Каждый запрос к LLM стоит денег. Звучит недорого. Но:

  • 100 операторов × 20 запросов в день × 30 дней = 60 000 запросов
  • 60 000 × $0.03 = $1 800 в месяц только на health score
  • Добавьте narrative, рекомендации, анализ логов — счёт растёт экспоненциально

Детерминированная функция: $0 навсегда.

3. Нестабильность

LLM — это вероятностная модель. Один и тот же запрос может вернуть:

  • {"score": 65, "status": "GOOD"}
  • {"score": 70, "status": "GOOD"}
  • {"score": 60, "status": "WARNING"}
  • А иногда — битый JSON, который ломает ваш парсер

«Определение безумия — делать одно и то же снова и снова, ожидая разных результатов» — Рита Мей Браун

В промышленной автоматизации (SCADA) нестабильность — это не просто неудобство. Это риск для безопасности людей и оборудования.

Часть 2: Анатомия ошибки

Кейс: пустой sub_scores

Это реальная история из нашего проекта SCADA.AI v3.1.0.

Задача: Показать под статусом Health Score детализацию расчёта:

55 = Аварии 18 + Среда 22 + Оборудование 12 + Энергия 3

Наивное решение: Попросить LLM вернуть sub_scores в JSON:

{
  "score": "число",
  "status": "строка",
  "sub_scores": {
    "alarms": {"score": "число", "weight": 35},
    "environmental": {"score": "число", "weight": 30},
    "equipment": {"score": "число", "weight": 25},
    "energy": {"score": "число", "weight": 10}
  }
}

Что произошло:

  • LLM возвращал sub_scores: {} (пустой объект)
  • Frontend пытался отрендерить детализацию
  • Получал undefined вместо данных
  • Детализация не появлялась
  • Пользователь видел только «55» без объяснений

Почему LLM возвращал пустоту:

  1. Мы не дали few-shot examples с sub_scores
  2. LLM «забыл» про это поле
  3. Температура в промпте (0.05) иногда приводила к «лени» модели
  4. Даже если LLM заполнял sub_scores, значения были выдуманными — модель не знала реальных весов

Сколько времени мы потратили на дебаг: 3 часа чтения логов, просмотра API-ответов, добавления console.log во frontend.

Решение: 5 строк кода.

Часть 3: Трёхслойная архитектура

После серии подобных инцидентов мы пришли к гибридной архитектуре, где каждый слой делает то, что умеет лучше всего.

┌─────────────────────────────────────┐
│ Слой 3: LLM Narrative               │
│ (только текст для пользователя)     │
└─────────────────────────────────────┘
↓
┌─────────────────────────────────────┐
│ Слой 2: Deterministic Analyzers     │
│ (формулы, расчёты, валидация)       │
└─────────────────────────────────────┘
↓
┌─────────────────────────────────────┐
│ Слой 1: Data Collectors             │
│ (SQL queries к SCADA БД)            │
└─────────────────────────────────────┘

Слой 1: Data Collectors

Задача: Собрать сырые данные из PostgreSQL (SCADA-база).

Принцип: Минимум логики, максимум I/O.

# modules/health/data_collectors.py
async def collect_all_health_data(period_hours: int = 24) -- dict:
    """
    Собирает сырые данные из SCADA БД.
    Никаких вычислений — только SQL queries.
    """
    pool = await get_db_pool()
    async with pool.acquire() as conn:
        # Аварии за период
        alarms = await conn.fetch("""
            SELECT priority, COUNT(*) as count
            FROM alarm_events_history
            WHERE timestamp - NOW() - INTERVAL '$1 hours'
            GROUP BY priority
        """, period_hours)

        # Параметры среды (температура, CO2, влажность, давление, VOC)
        environmental = await collect_environmental_data(conn, period_hours)

        # Оборудование (битые/офлайн/дребезжащие теги)
        equipment = await collect_equipment_status(conn)

        # Энергоэффективность (освещение)
        lighting = await collect_lighting_data(conn)

        return {
            "alarms": format_alarms(alarms),
            "environmental": environmental,
            "equipment": equipment,
            "lighting": lighting,
            "period_hours": period_hours,
            "collected_at": datetime.now().isoformat()
        }

Почему это важно:

  • Данные собираются один раз за запрос
  • SQL queries оптимизированы (индексы, партиционирование)
  • Если БД недоступна — мы узнаем об этом сразу, а не через 5 секунд LLM-обработки
  • Данные можно кэшировать в Redis на 5 минут

Слой 2: Deterministic Analyzers

Задача: Превратить сырые данные в метрики, индексы, статусы.

Принцип: Чистые функции. Один вход — один выход. Никаких LLM.

# modules/health/analysis.py
from dataclasses import dataclass
from typing import Literal

@dataclass
class HealthReport:
    score: int
    status: Literal["CRITICAL", "WARNING", "GOOD", "EXCELLENT"]
    sub_scores: dict
    issues: list[dict]

def compute_health_report(data: dict) -- HealthReport:
    """
    Детерминированный расчёт здоровья системы.
    50 миллисекунд. 0 рублей. 100% стабильность.
    """
    # === Под-индексы ===
    alarm_idx = _compute_alarm_index(data["alarms"]["by_priority"])
    env_idx = _compute_environmental_index(data["environmental"])
    equip_idx = _compute_equipment_index(data["equipment"])
    energy_idx = _compute_energy_index(data["lighting"])

    # === Композитный score ===
    score = round(
        0.40 * alarm_idx +
        0.35 * env_idx +
        0.25 * equip_idx
    )

    # === Статус (детерминированно, не от LLM!) ===
    if score < 30:
        status = "CRITICAL"
    elif score < 60:
        status = "WARNING"
    elif score < 85:
        status = "GOOD"
    else:
        status = "EXCELLENT"

    # === Sub-scores (для детализации в UI) ===
    sub_scores = {
        "alarms": {"score": alarm_idx, "weight": 40},
        "environmental": {"score": env_idx, "weight": 35},
        "equipment": {"score": equip_idx, "weight": 25},
    }

    # === Issues (топ проблем) ===
    issues = _extract_top_issues(data, sub_scores)

    return HealthReport(
        score=score,
        status=status,
        sub_scores=sub_scores,
        issues=issues,
    )

Реальные формулы (из production-кода):

def _compute_alarm_index(by_priority: dict) -- int:
    """
    score = 100 - min(high * 15, 50) - min(medium * 4, 25) - min(low * 0.5, 10)
    """
    high = by_priority.get("high", 0)
    medium = by_priority.get("medium", 0)
    low = by_priority.get("low", 0)

    score = 100
    score -= min(high * 15, 50)      # Макс -50 за критические аварии
    score -= min(medium * 4, 25)     # Макс -25 за средние
    score -= min(low * 0.5, 10)      # Макс -10 за информационные

    return max(0, round(score))

def _compute_environmental_index(env: dict) -- int:
    """
    Взвешенная сумма статусов 5 параметров.
    OK=100, WARNING=55, CRITICAL=15
    """
    weights = {
        "co2": 0.30,
        "temperature": 0.25,
        "voc": 0.20,
        "humidity": 0.15,
        "pressure": 0.10,
    }
    status_scores = {"OK": 100, "WARNING": 55, "CRITICAL": 15, "NO_DATA": 50}

    total = 0
    for param, weight in weights.items():
        param_data = env.get(param, {})
        status = param_data.get("status", "NO_DATA")
        total += status_scores[status] * weight

    return round(total)

Почему это лучше LLM:

  • 50 мс вместо 5-10 секунд
  • 0 рублей вместо $0.03 за запрос
  • 100% стабильность — один вход всегда даёт один выход
  • Тестируемо — можно написать unit tests
  • Интерпретируемо — инженер видит формулу и понимает логику
  • Версионируемо — изменил формулу, закоммитил, задеплоил

Слой 3: LLM Narrative

Задача: Превратить сухие цифры в понятный текст для оператора.

Принцип: LLM получает уже посчитанные данные и генерирует narrative.

# modules/health/prompts.py
HEALTH_SYSTEM_PROMPT = """
Ты — инженер-аналитик SCADA-системы промышленного здания.
ВХОДНЫЕ ДАННЫЕ (уже посчитаны детерминированно):
- score: {score} (из 100)
- status: {status}
- sub_scores: {sub_scores}
- issues: {issues}
- alarms: {alarms}
- environmental: {environmental}
ЗАДАЧА:
1. Напиши краткое резюме состояния (2-3 предложения)
2. Выдели главную проблему
3. Предложи 1-2 конкретных действия
4. Используй деловой, но не формальный тон
ВАЖНО:
- НЕ пересчитывай score — он уже правильный
- НЕ выдумывай данные, которых нет в input
- Используй только предоставленные числа
- Отвечай на русском языке
"""

Что получает LLM:

{
  "score": 55,
  "status": "WARNING",
  "sub_scores": {
    "alarms": {"score": 45, "weight": 40},
    "environmental": {"score": 70, "weight": 35},
    "equipment": {"score": 60, "weight": 25}
  },
  "issues": [
    {"param": "co2", "impact": -15, "frequency": 12},
    {"param": "temperature", "impact": -8, "frequency": 5}
  ],
  "alarms": {
    "total": 165,
    "by_priority": {"high": 15, "medium": 82, "low": 68}
  }
}

Что возвращает LLM:

{
  "summary": "Состояние системы требует внимания. Основная проблема — повышенный уровень CO2, который негативно влияет на общий индекс здоровья.",
  "main_issue": "Рост CO2 в Зоне 2 (12 превышений за сутки)",
  "recommendations": [
    "Проверьте работу приточной вентиляции в Зоне 2",
    "Откалибруйте датчики CO2 (3 датчика показывают аномалии)"
  ]
}

Почему это работает:

  • LLM не решает математических задач (плохо умеет)
  • LLM интерпретирует уже готовые цифры (хорошо умеет)
  • Если LLM упадёт — система всё равно покажет виджеты с правильными числами
  • Narrative — это «вишенка на торте», а не фундамент

Часть 4: Рендеринг — три канала информации

После того как данные собраны, посчитаны и описаны, мы рендерим их в три канала:

# modules/health/renderers.py
async def render_all(report: HealthReport) -- dict:
    """
    Рендерит отчёт в три канала:
    1. narrative — текст для чата
    2. voice — текст для озвучивания
    3. visual — виджеты для UI
    """
    narrative = await _render_narrative(report)
    voice = _render_voice(report)
    visual = _render_visual(report)

    return {
        "narrative": narrative,
        "voice": voice,
        "visual": visual
    }

Visual — это набор виджетов:

def _render_visual(report: HealthReport) -- dict:
    widgets = []

    # 1. Health Score (круговая диаграмма)
    widgets.append({
        "type": "health_score",
        "data": {
            "score": report.score,
            "status": report.status,
            "status_ru": translate_status(report.status),
            "sub_scores": report.sub_scores,
        },
        "size": "medium"
    })

    # 2. Life Support (параметры среды)
    widgets.append({
        "type": "life_support_card",
        "data": report.life_support,
        "size": "medium"
    })

    # 3. Environmental Panel
    widgets.append({
        "type": "environmental_panel",
        "data": report.environmental,
        "size": "wide"
    })

    # 4. Alarms Panel
    widgets.append({
        "type": "alarms_panel",
        "data": report.alarms,
        "size": "wide"
    })

    # 5. Energy Cost Card (из v3.1.0)
    widgets.append({
        "type": "energy_cost_card",
        "data": report.energy_costs,
        "size": "medium"
    })

    return {"widgets": widgets}

Frontend рендерит виджеты через WidgetRouter:

<!-- frontend/src/components/WidgetRouter.svelte ---
<script-
  import HealthScoreCard from './health/HealthScoreCard.svelte'
  import LifeSupportCard from './health/LifeSupportCard.svelte'
  import EnvironmentalPanel from './health/EnvironmentalPanel.svelte'
  import AlarmsPanel from './health/AlarmsPanel.svelte'
  import EnergyCostCard from './health/EnergyCostCard.svelte'

  const componentMap = {
    'health_score': HealthScoreCard,
    'life_support_card': LifeSupportCard,
    'environmental_panel': EnvironmentalPanel,
    'alarms_panel': AlarmsPanel,
    'energy_cost_card': EnergyCostCard,
  }

  export let widgets = []
</script-

{#each widgets as widget}
  {@const Component = componentMap[widget.type]}
  <Component data={widget.data} /-
{/each}

Результат: Оператор видит красивые виджеты с правильными числами + narrative от LLM + голосовое озвучивание.

Часть 5: Таблица сравнения подходов

Давайте сравним три подхода к архитектуре AI-продукта:

Критерий«Всё через LLM»«Гибрид»«Чисто детерминированно»
Скорость5-10 сек3 сек (50 мс + LLM)50 мс
Стоимость$1800/мес$200/мес (только narrative)$0
СтабильностьНестабильноСтабильно100%
Качество текстаОтличноеОтличноеШаблоны
ТестируемостьСложноЛегко (кроме narrative)Всё легко
ГибкостьЗависит от промптаМожно менять слоиЖёстко
Время на разработкуБыстро (сначала)СреднееДолго
Время на поддержкуАд (отладка LLM)НормальноЛегко
UX при падении LLMВсё сломалосьВиджеты работаютВсё работает

Вывод: Гибрид — это золотая середина. Вы получаете лучшее от обоих миров: скорость и стабильность детерминированных вычислений, качество и гибкость LLM для narrative.

Часть 6: Правило 80/20 — что считать, а что оставить LLM

После года разработки мы вывели простое правило.

Считайте детерминированно (80% работы):

1. Математические формулы

  • Средние значения, min/max
  • Индексы, скоринг, рейтинги
  • Корреляции, регрессии
  • Статистические метрики

2. Бизнес-логика

  • Расчёт стоимости (тарифы × потребление)
  • Валидация данных (границы, пороги)
  • Приоритизация проблем
  • Агрегация по зонам/периодам

3. Статусы и классификация

  • OK / WARNING / CRITICAL
  • HIGH / MEDIUM / LOW приоритеты
  • Битый / Офлайн / Рабочий датчик

4. Фильтрация и поиск

  • Аварии за период
  • Теги по зоне
  • Логи по уровню

Оставьте LLM (20% работы):

1. Narrative (текст для пользователя)

  • «Состояние системы требует внимания…»
  • «Главная проблема — рост CO2…»
  • «Рекомендуем проверить вентиляцию…»

2. Интерпретация сложных паттернов

  • «Эти три аномалии могут быть связаны…»
  • «Тренд похож на инцидент в марте 2025…»

3. Ответы на неструктурированные вопросы

  • «Что мне делать прямо сейчас?»
  • «Почему вчера был скачок температуры?»
  • «Сравни эту неделю с прошлой»

4. Анализ неструктурированных данных

  • Текстовые логи
  • Комментарии операторов
  • Отчёты о ремонтах

«Сделай чтобы работало, сделай правильно, сделай быстро — в таком порядке» — Kent Beck

В AI-контексте это звучит как:

  1. Сделай чтобы работало (детерминированно)
  2. Сделай красиво (добавь LLM для narrative)
  3. Сделай быстро (оптимизируй SQL и кэш)

Часть 7: Performance метрики

Давайте посмотрим на реальные цифры из production SCADA.AI.

Запрос «покажи здоровье здания»

ЭтапВремя% от общего
SQL queries к SCADA БД1.2 сек35%
Детерминированные расчёты50 мс1.5%
LLM narrative2.1 сек62%
Рендеринг виджетов10 мс0.3%
Итого3.4 сек100%

Если бы всё было через LLM:

  • LLM посчитал бы индексы: +5 сек
  • LLM сгенерировал бы виджеты: +3 сек
  • Итого: 11-13 секунд

Разница: 3.4 сек vs 12 сек = 3.5× быстрее

Стоимость за месяц (100 операторов × 20 запросов/день)

ПодходЗапросов к LLMСтоимость
«Всё через LLM»60 000$1 800
Гибрид (наш)10 000 (только narrative)$300
Чисто детерминированно0$0 (но нет narrative)

Экономия: $1 500 в месяц = $18 000 в год

Часть 8: Fallback-логика — когда LLM падает

Одно из главных преимуществ гибридной архитектуры — graceful degradation.

# api/routes/chat.py
async def handle_health_query(message: str) -- dict:
    # 1. Собираем данные (детерминированно)
    data = await collect_all_health_data(period_hours=24)

    # 2. Считаем отчёт (детерминированно)
    report = compute_health_report(data)

    # 3. Пытаемся получить narrative от LLM
    try:
        llm_response = await llm.generate(
            HEALTH_SYSTEM_PROMPT.format(**report.to_dict()),
            timeout=10
        )
        narrative = llm_response.get("summary", "")
        recommendations = llm_response.get("recommendations", [])
    except LLMTimeoutError:
        # Fallback: используем шаблонный narrative
        narrative = f"Здоровье системы: {report.score}/100 ({translate_status(report.status)})"
        recommendations = []
        log.warning("LLM timeout, using fallback narrative")
    except LLMError as e:
        # Fallback: минимальный narrative
        narrative = "Не удалось получить AI-анализ, но виджеты работают."
        recommendations = []
        log.error(f"LLM error: {e}")

    # 4. Рендерим виджеты (детерминированно — всегда работает!)
    visual = render_visual(report)

    # 5. Рендерим voice (детерминированно)
    voice = render_voice(report, narrative)

    return {
        "response": narrative,
        "voice": voice,
        "visual": visual,  # ВСЕГДА есть, даже если LLM упал
        "recommendations": recommendations
    }

Что видит пользователь:

Когда LLM работает:

  • «Состояние системы требует внимания (55/100). Главная проблема — повышенный CO2 в Зоне 2. Рекомендуем проверить вентиляцию.»

Когда LLM упал:

  • «Здоровье системы: 55/100 (Внимание)»

В обоих случаях виджеты работают идеально — оператор видит все цифры, графики, статусы. Narrative — это дополнение, а не основа.

Часть 9: Когда LLM действительно НЕОБХОДИМ

Чтобы не сложилось впечатление, что я против LLM — вот случаи, где она незаменима.

1. Tool Calling для анализа логов

# modules/logs/tools.py
async def analyze_logs(level: str = None, limit: int = 100) -- dict:
    """
    Tool для LLM: читает логи и возвращает для анализа.
    """
    logs = await system_logger.get_logs(level=level, limit=limit)
    return {
        "logs": logs,
        "count": len(logs),
        "level_filter": level
    }

Flow:

User: "проанализируй логи за последний час"
↓
LLM: "Я вызову analyze_logs"
↓
Tool: возвращает 100 строк логов
↓
LLM: "В логах обнаружено 3 ошибки подключения к БД,
2 предупреждения о медленных запросах.
Основная проблема — нестабильное соединение с PostgreSQL
в 14:23-14:27. Рекомендую проверить сетевую инфраструктуру."

Здесь LLM делает то, что невозможно детерминированно — интерпретирует неструктурированный текст и находит паттерны.

2. Ответы на неструктурированные вопросы

User: "Почему вчера был скачок температуры?"
↓
LLM: (анализирует контекст)
"Судя по логам, вчера в 15:42 был отключен приточный агрегат №3
для планового ТО. Это привело к росту температуры в Зоне 2
с 22°C до 28°C. Агрегат включили обратно в 17:15, после чего
температура вернулась в норму в течение 30 минут."

Здесь LLM соединяет данные из разных источников (логи, аварии, параметры среды) и даёт связный ответ.

3. Генерация рекомендаций

LLM получает:

{
  "top_issues": [
    {"param": "co2", "impact": -15, "frequency": 12},
    {"param": "alarms", "impact": -10, "frequency": 8}
  ],
  "trends": {"co2": "rising", "temperature": "stable"}
}

LLM генерирует:

“Рекомендую в первую очередь заняться системой вентиляции:

  1. Замените фильтры в приточной вентиляции (эффект: +10 баллов)
  2. Откалибруйте датчики CO2 в Зоне 2 (эффект: +5 баллов)
  3. Увеличьте кратность воздухообмена ночью с 0.5 до 1.0”

LLM приоритизирует проблемы и даёт конкретные действия — это то, что сложно формализовать в формулах.

Часть 10: Архитектурные принципы

Подытожим правила, которые мы вывели за год разработки.

Принцип 1: «LLM — это UI, а не Backend»

LLM должна быть на границе системы — там, где данные превращаются в человеко-читаемый текст. Внутри системы — только детерминированный код.

Правильно: [SQL] → [Python формулы] → [LLM narrative] → [UI]

Неправильно: [SQL] → [LLM считает score] → [UI]

Принцип 2: «Один запрос = один сбор данных»

Если LLM нужно 5 разных данных — соберите их один раз в Python и передайте в промпт. Не делайте 5 tool calls, если можно сделать 1.

Принцип 3: «Детерминированное ядро + LLM-обёртка»

Ядро системы (бизнес-логика, формулы, валидация) должно работать без LLM. LLM — это надстройка, которую можно отключить без потери функциональности.

Принцип 4: «Тестируй формулы, а не промпты»

Unit tests для Python-функций — обязательны. «Тесты» для промптов — это просто ручная проверка на 10 примерах. Не тратьте время на автоматизацию того, что по своей природе нестабильно.

Принцип 5: «Кэшируй то, что можно кэшировать»

  • SQL queries → Redis на 5 минут
  • Детерминированные расчёты → in-memory на время запроса
  • LLM narrative → не кэшировать (каждый запрос уникален)

Часть 11: Эволюция нашего подхода

v1.0 — «Всё через LLM»

Мы начинали как все:

User: "покажи здоровье"
↓
LLM: (получает сырые данные из БД)
↓
LLM: (считает индексы, генерирует текст, выбирает виджеты)
↓
UI: (рендерит что вернул LLM)

Проблемы:

  • 15 секунд на ответ
  • $3000/месяц на API
  • Постоянные «галлюцинации» в числах
  • Невозможно отдебажить

v2.0 — «Детерминированный слой»

Добавили analysis.py:

User: "покажи здоровье"
↓
Python: (считает индексы)
↓
LLM: (генерирует narrative на основе посчитанных данных)
↓
UI: (рендерит виджеты из Python + narrative от LLM)

Улучшения:

  • 5 секунд на ответ
  • $500/месяц
  • Стабильные числа в виджетах
  • Можно писать unit tests

v3.0 — «Гибридная архитектура»

Текущая версия:

User: "покажи здоровье"
↓
SQL: (собирает данные)
↓
Python: (считает индексы, под-индексы, статусы)
↓
LLM: (только narrative + recommendations)
↓
Renderers: (narrative + voice + visual)
↓
UI: (виджеты + текст + голос)

Результат:

  • 3 секунды на ответ
  • $300/месяц
  • 100% стабильность виджетов
  • Graceful degradation при падении LLM

v3.1.0 — «Модуль Бабло»

Добавили расчёт стоимости ресурсов (электричество, вода, тепло):

  • 100% детерминированно (интервальные тарифы, SQL к тегам ЛЭРС)
  • LLM только объясняет: «В мае вы потратили 114 005 ₽ на электричество»

Часть 12: Антипаттерны, которые мы видели

За год работы с клиентами и другими командами мы собрали коллекцию антипаттернов.

Антипаттерн 1: «LLM как калькулятор»

Prompt: "Посчитай 2 + 2 × 3"
LLM: "Ответ: 8" # Правильно
LLM: "Ответ: 7" # Иногда

Почему плохо: LLM — это языковая модель, а не математическая. Используйте Python для вычислений.

Антипаттерн 2: «LLM как валидатор»

Prompt: "Проверь, что email 'user@example.com' валидный"
LLM: "Да, валидный"
LLM: "Нет, невалидный" # Иногда

Почему плохо: Используйте regex или библиотеки валидации.

Антипаттерн 3: «LLM как классификатор»

Prompt: "Определи приоритет аварии: 'Протечка в серверной'"
LLM: "HIGH" # Обычно
LLM: "MEDIUM" # Иногда

Почему плохо: Используйте правила и keywords в Python.

Антипаттерн 4: «LLM как база данных»

Prompt: "Какая была температура вчера в 14:00?"
LLM: "22.5°C" # Выдумало

Почему плохо: LLM не имеет доступа к вашим данным. Используйте SQL + tool calling.

Антипаттерн 5: «Один промпт на всё»

Prompt: 
Ты — инженер SCADA.
Собери данные из БД
Посчитай индексы
Найди аномалии
Сгенерируй отчёт
Предложи действия
Отформатируй в JSON
Переведи на русский

Почему плохо: LLM делает всё плохо. Разделите на слои.

Часть 13: Практические советы

Если вы только начинаете AI-проект, вот мой чек-лист.

День 1-7: Прототип

  • Используйте LLM для всего (быстрый MVP)
  • Соберите обратную связь от пользователей
  • Определите, что реально нужно

День 8-30: Разделение

  • Выделите бизнес-логику в Python
  • Определите, где LLM действительно нужна
  • Напишите первые unit tests

День 31-90: Оптимизация

  • Добавьте кэширование
  • Оптимизируйте SQL queries
  • Внедрите fallback-логику

День 91+: Полировка

  • A/B тесты промптов
  • Мониторинг стоимости и скорости
  • Добавление новых модулей

Часть 14: Метрики для мониторинга

В production обязательно отслеживайте.

Скорость

  • p50_response_time — медианное время ответа
  • p95_response_time — 95-й перцентиль
  • llm_latency — время LLM-запроса отдельно
  • db_latency — время SQL-запросов

Стоимость

  • daily_llm_cost — дневная стоимость LLM
  • cost_per_request — средняя стоимость запроса
  • monthly_projection — прогноз на месяц

Стабильность

  • llm_error_rate — % ошибок LLM
  • fallback_usage — % запросов с fallback
  • deterministic_accuracy — точность формул

Качество

  • user_satisfaction — оценка пользователей
  • narrative_quality_score — качество narrative
  • false_positive_rate — % ложных срабатываний

Заключение: LLM — это не волшебная палочка

«Распределённая система — это система, в которой поломка компьютера, о существовании которого вы даже не подозревали, может сделать ваш собственный компьютер непригодным к использованию» Leslie Lamport

Перефразируя для AI-систем: «AI-система — это система, в которой галлюцинация LLM, о которой вы даже не подозревали, может сделать ваши виджеты непригодными к использованию».

Гибридная архитектура — это не про экономию денег (хотя это приятный бонус). Это про надёжность, скорость и предсказуемость. LLM — это мощный инструмент, но только тогда, когда вы используете её для того, что она умеет лучше всего: понимать и генерировать естественный язык.

Всё остальное — формулы, валидация, агрегация, классификация — делайте в Python. Ваш продакшен скажет вам спасибо.