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

Модульная архитектура как основа работы с LLM

AIPythonYandexGPTArchitectureLLMSCADA

Почему монолитный код ломает LLM-интеграции и как паттерн Module Registry + Tool Executor решает проблему контекста, галлюцинаций и ролевого доступа на примере SCADA.AI.

Почему монолит не работает с языковыми моделями

Большие языковые модели — это вероятностные движки с жёстким ограничением на размер входного контекста. Если скормить системе монолитный код с тысячами строк, десятками функций и размытыми инструкциями, модель начнёт терять фокус. Она путает области ответственности, вызывает несуществующие инструменты и генерирует галлюцинации. В промышленной автоматизации, где каждая команда влияет на физическое оборудование, такая ошибка критична.

В SCADA.AI v3.0.1 мы перешли к строгой модульной архитектуре. Каждый модуль инкапсулирует одну бизнес-задачу, регистрирует свои инструменты и системный промпт. Модель получает контекст маленькими, чётко описанными порциями. Результат: точность вызова нужного инструмента выросла с ~60% до ~95%, время ответа сократилось вдвое, а количество галлюцинаций упало до статистического шума.

Module Registry: центральный хаб системы

Паттерн Module Registry выступает единой точкой регистрации. Он хранит маппинг имён, инструментов и промптов. При старте registry сканирует директорию, загружает модули и собирает JSON Schema для function calling. Модель видит только описания интерфейсов, не исходный код.

# core/module_registry.py
class ModuleRegistry:
    def __init__(self):
        self._modules: dict[str, Module] = {}
        self._tools: dict[str, Callable] = {}
        self._prompts: dict[str, str] = {}

    def register(self, name: str, module: Module):
        self._modules[name] = module
        for tool_name, tool_func in module.get_tools().items():
            self._tools[tool_name] = tool_func
        self._prompts[name] = module.get_prompt()

    def get_tool(self, name: str) -> Callable:
        return self._tools[name]

    def get_all_tools_schema(self) -> list[dict]:
        schemas = []
        for module in self._modules.values():
            schemas.extend(module.get_tools_schema())
        return schemas

    def get_combined_prompt(self, active_modules: list[str]) -> str:
        parts = []
        for name in active_modules:
            if name in self._prompts:
                parts.append(f"## Модуль {name}\n{self._prompts[name]}")
        return "\n\n".join(parts)

Такой подход даёт четыре преимущества:

  1. LLM видит только Schema. Модульность гарантирует чёткие границы.
  2. System prompt собирается динамически. Если пользователь не имеет прав на модуль, его промпт не попадёт в контекст → модель не галлюцинирует.
  3. Runtime-конфигурирование. Модули включаются без перезапуска backend. Это основа ролевой модели.
  4. Независимое тестирование. Каждый набор инструментов тестируется через pytest. CI/CD не падает из-за косвенных зависимостей.

Регистрация модуля и Tool Executor

Каждый модуль реализует интерфейс get_tools(), get_tools_schema() и get_prompt().

# modules/health/__init__.py
from core.module_registry import registry
from . import tools

def register():
    registry.register("health", HealthModule())

class HealthModule:
    def get_tools(self):
        return {
            "analyze_health": tools.analyze_health,
            "get_alarm_journal": tools.get_alarm_journal,
            "analyze_tag": tools.analyze_tag
        }

    def get_tools_schema(self):
        return [
            {
                "function": {
                    "name": "analyze_health",
                    "description": "Анализирует здоровье здания. Возвращает индексы здоровья и жизнеобеспечения.",
                    "parameters": {"type": "object", "properties": {}}
                }
            }
        ]

    def get_prompt(self):
        return "Ты — модуль здоровья здания. Оценивай индексы жизнеобеспечения и выявляй отклонения."

Вызов централизован через ToolExecutor. Единая точка входа логирует вызовы, обрабатывает исключения и возвращает стандартизированный ответ.

# core/tool_executor.py
class ToolExecutor:
    def __init__(self, registry: ModuleRegistry):
        self._registry = registry

    async def execute(self, name: str, args: dict) -> Any:
        tool = self._registry.get_tool(name)
        try:
            log.info("Tool executing", tool=name, args=args)
            result = await tool(**args)
            log.info("Tool completed", tool=name, status="ok")
            return {"status": "ok", "data": result}
        except Exception as e:
            log.error("Tool failed", tool=name, error=str(e))
            return {"status": "error", "error": str(e)}

Грабли из production-опыта

  1. Монолитный system prompt убивает качество. Попытка впихнуть всё в один текст снижала точность до ~60%. Разделение подняло её до ~95%.
  2. Дублирование имён. Два модуля регистрировали одинаковый tool → второй перезаписывал первый. Решение: префиксы или валидация уникальности.
  3. Конфликт промптов. Один требовал всегда вызывать tools, другой — отвечать текстом. Решение: главный координирующий prompt идёт первым, модульные — дополнения.
  4. Циклические зависимости. Модули не должны знать друг о друге. Обмен идёт через registry и общие data collectors.

Когда использовать

Да, если:

  • Интегрируете LLM в систему с ролями или правами
  • Работаете с ограниченным контекстом (32 768 токенов)
  • Требуете стабильного function calling
  • Планируете масштабировать систему

Нет, если:

  • Простой чат-бот с 3-4 командами
  • Критична задержка < 50ms на инициализацию
  • Полностью полагаетесь на промпт-инжиниринг без кода

Итог

Модульная архитектура при работе с ИИ — инженерная необходимость. LLM не понимает «всё сразу», но отлично справляется с изолированными задачами. Паттерн Module Registry + Tool Executor даёт контроль над контекстом, правами и качеством вызова.