Модульная архитектура как основа работы с LLM
Почему монолитный код ломает 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) Такой подход даёт четыре преимущества:
- LLM видит только Schema. Модульность гарантирует чёткие границы.
- System prompt собирается динамически. Если пользователь не имеет прав на модуль, его промпт не попадёт в контекст → модель не галлюцинирует.
- Runtime-конфигурирование. Модули включаются без перезапуска backend. Это основа ролевой модели.
- Независимое тестирование. Каждый набор инструментов тестируется через 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-опыта
- Монолитный system prompt убивает качество. Попытка впихнуть всё в один текст снижала точность до ~60%. Разделение подняло её до ~95%.
- Дублирование имён. Два модуля регистрировали одинаковый tool → второй перезаписывал первый. Решение: префиксы или валидация уникальности.
- Конфликт промптов. Один требовал всегда вызывать tools, другой — отвечать текстом. Решение: главный координирующий prompt идёт первым, модульные — дополнения.
- Циклические зависимости. Модули не должны знать друг о друге. Обмен идёт через registry и общие data collectors.
Когда использовать
Да, если:
- Интегрируете LLM в систему с ролями или правами
- Работаете с ограниченным контекстом (32 768 токенов)
- Требуете стабильного function calling
- Планируете масштабировать систему
Нет, если:
- Простой чат-бот с 3-4 командами
- Критична задержка < 50ms на инициализацию
- Полностью полагаетесь на промпт-инжиниринг без кода
Итог
Модульная архитектура при работе с ИИ — инженерная необходимость. LLM не понимает «всё сразу», но отлично справляется с изолированными задачами. Паттерн Module Registry + Tool Executor даёт контроль над контекстом, правами и качеством вызова.