В первой версии сервиса один endpoint собирал prompt, искал документы, вызывал модель и записывал результат. Через полгода смена модели требовала релиза всего приложения, неудачный retry дублировал письмо клиенту, а причину вчерашнего ответа нельзя было восстановить.
Поведение становится явным артефактом
Зрелая система отделяет недетерминированное решение от детерминированного исполнения. Prompt, схема, модель, retrieval corpus и policy образуют версионированный manifest. Вызов получает correlation id, бюджет и deadline. Результат валидируется до побочного эффекта.
API -> use-case orchestrator -> context builder -> model gateway
| | |
policy retrieval providers
v
validator -> action service -> audit log@dataclass(frozen=True)
class Behavior:
prompt_version: str
schema_version: str
model_alias: str
corpus_version: str
policy_version: str
async def run(command, behavior: Behavior):
context = await build_context(command, behavior.corpus_version)
raw = await gateway.generate(command, context, behavior)
decision = validate(raw, schema=behavior.schema_version)
authorize(decision, policy=behavior.policy_version)
return await actions.apply(decision, idempotency_key=command.id)Gateway нормализует транспортные ошибки и usage, но не притворяется, что провайдеры одинаковы. Orchestrator знает use case, а не SDK. Очередь и checkpoint нужны там, где шаги длинные. Outbox связывает запись состояния с отправкой события. Все эти детали скучны ровно до первого инцидента. Скука здесь вообще хороший признак: в зрелой системе интересное происходит только в модели, а всё вокруг неё должно вести себя как водопровод.
Failure modes
«Универсальная» абстракция скрывает важные режимы провайдера. Общий retry повторяет необратимый tool call. Trace хранит секреты. Manifest записали после вызова и потеряли фактическую конфигурацию. Компоненты нарезали раньше, чем появились разные темпы изменений, и получили распределённый монолит.
Четыре границы, которые окупаются
Не обязательно разносить систему по процессам. Достаточно, чтобы в коде существовали четыре шва, и через каждый проходил один вид ответственности:
- Behavior manifest. Всё, что меняет ответ, собрано в один версионированный объект (глава 42). Без этого расследование невозможно.
- Model gateway. Транспорт, лимиты, ретраи, usage, маппинг ошибок. Один на приложение, а не по одному в каждом use case.
- Validator. Ничто не становится решением, пока не прошло схему и семантические проверки.
- Action boundary. Побочные эффекты выполняются в одном месте, с правами, идемпотентностью и аудитом.
Эти четыре шва дают почти всю пользу «зрелой архитектуры». Разделение на сервисы, шина событий и отдельные команды — следующий уровень, и он нужен, когда у компонентов действительно разные темпы изменений и владельцы.
Правило главы
Выделяйте границу там, где различаются ответственность, риск или темп изменения. Само число сервисов зрелости не доказывает.
Чеклист главы
Практикум
Задача 54.1 — четыре шва в существующем endpoint. ⭑⭑⭑
Возьмите один монолитный LLM endpoint и проведите в нём границы, не разнося код по процессам.
Критерии приёмки:
Подсказки:
- Начните с action boundary: она даёт самый заметный эффект и не требует переписывать остальное.
- Список «что мешает воспроизводимости» ценнее самой рефакторинга: это ваш бэклог на следующий квартал.
Задача 54.2 — граница, которую не стоило проводить. ⭑⭑
Найдите в своей системе разделение, которое не окупается, и опишите его честно.
Критерии приёмки:
Подсказки:
- Хороший признак лишней границы: почти каждый pull request меняет обе стороны.
- Обратное движение (слияние компонентов) обсуждают редко, хотя оно так же законно, как разделение.