В первой версии сервиса один 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 записали после вызова и потеряли фактическую конфигурацию. Компоненты нарезали раньше, чем появились разные темпы изменений, и получили распределённый монолит.
Правило: выделяйте границу там, где различаются ответственность, риск или темп изменения. Само число сервисов зрелости не доказывает.
Практикум 54.1
Возьмите один монолитный LLM endpoint. Нарисуйте путь данных и побочных эффектов, затем выделите behavior manifest, gateway и action boundary без обязательного разнесения по процессам. Добавьте idempotency и trace. Повторите старый вызов по сохранённому manifest и перечислите всё, что всё ещё мешает воспроизводимости.