В сервисе был универсальный LLMClient.complete(prompt). Когда понадобились citations и tool calls, команда стала засовывать JSON в строку prompt, а результат разбирать регулярным выражением. Интерфейс оставался красивым. Production вокруг него уже нет.
Судьба этого интерфейса типична. Сначала он гордость архитектора: одна функция, любой провайдер, всё под контролем. Через полгода вокруг него нарастает слой переходников, в котором complete вызывают с промптом, собранным из шаблона, содержащего инструкцию собрать JSON, который потом разбирают регулярным выражением, чтобы отдать в функцию, которая снова вызывает complete. Абстракция цела. Никто не решается её тронуть, потому что она везде.
Абстрагировать полезно политику приложения: классификацию обращения, извлечение полей, подготовку ответа. Формат HTTP-запроса конкретного SDK действительно можно спрятать. Нельзя без потерь объявить одинаковыми разные semantics tools, streaming, caching и structured output.
Порт по задаче
from typing import Protocol
from pydantic import BaseModel
class TicketRoute(BaseModel):
queue: str
urgency: int
reason: str
class TicketRouter(Protocol):
async def route(self, subject: str, body: str) -> TicketRoute: ...Так backend зависит от результата бизнеса, а реализация владеет prompt, схемой и провайдером. Для общих эксплуатационных правил ниже может жить тонкий transport adapter.
API -> use case -> TicketRouter port
-> OpenAIRouter / AnthropicRouter
-> provider transportРазница между двумя подходами видна по тому, что происходит при смене модели. У complete(prompt) меняется всё, до чего дотянулся промпт, то есть весь код. У TicketRouter меняется одна реализация, а тесты use case остаются теми же, потому что они проверяют маршрутизацию тикетов, а не текст.
Не следует возвращать наружу «универсальный ответ» из десятков optional-полей. Общие поля становятся пустыми, а важная провайдерская информация теряется. Храните raw response в защищённом артефакте и нормализуйте только то, что действительно использует use case.
Два слоя, а не один
Практика показывает, что уровней абстракции должно быть ровно два, и они отвечают на разные вопросы.
Верхний, доменный. «Классифицируй обращение», «извлеки поля из письма», «сформулируй ответ по источникам». Здесь живут бизнес-типы, здесь пишутся evals, сюда смотрит остальной код. Провайдер отсюда не виден вообще.
Нижний, транспортный. Таймауты, ретраи, лимитер, circuit breaker, учёт usage, единый лог вызова, маппинг ошибок в свои типы. Здесь провайдер виден полностью, включая его особенности.
Между ними ничего нет. Именно попытка вставить третий слой — «универсальный клиент модели» — и создаёт тот самый complete(prompt), который сначала удобен, а потом обходится строками.
Где проходит граница
Хорошая абстракция позволяет заменить SDK без изменения доменной логики, централизует timeout, учёт usage и классификацию ошибок. Плохая обещает бесшовно заменить модель, хотя prompt и evals зависят от неё. Особенно опасен автоматический fallback внутри клиента: вызывающий код не знает, что изменились качество, цена и политика данных.
Про автоматический fallback скажу прямее. Клиент, который при ошибке тихо уходит на другую модель, — это подмена ответственности: решение продуктового уровня («мы согласны на худшее качество ради доступности») принимается библиотекой, в цикле ретраев, без ведома того, кто отвечает за результат. Fallback нужен, но он должен быть виден: другой маршрут, другой лог, другая метрика, при необходимости другой ответ пользователю (глава 27).
Failure modes заметны по условным веткам if provider == ... во всех use case, строковым JSON, потере request ID, общему исключению LLMError и тестам только на mock. Mock подтверждает интерфейс, но не совместимость с живым API.
Диагностический вопрос, который экономит месяцы: что придётся изменить, чтобы добавить tool calling в один сценарий? Если ответ — «прокинуть новое поле через универсальный клиент, а пока обойти строкой», граница проведена не там.
Правило главы
Абстрагируйте стабильную потребность приложения и повторяющуюся эксплуатационную механику. Различия, меняющие качество, безопасность или цену, должны оставаться видимыми.
Чеклист главы
Практикум
Задача 26.1 — вскрытие одного вызова. ⭑⭑
Найдите один существующий LLM-вызов и выпишите поля, которыми реально пользуется вызывающий код.
Критерии приёмки:
Подсказки:
- Начните с самого старого вызова в проекте: он лучше всех показывает, какие поля оказались нужны на самом деле.
- Неиспользуемые поля удаляйте сразу — они возвращаются в код только вместе с новым требованием, и тогда их видно.
Задача 26.2 — тест границы через tool calling. ⭑⭑
Попробуйте включить tool calling в существующий сценарий, не меняя доменный порт.
Критерии приёмки:
Подсказки:
- Обход строкой — не позор, а сигнал. Важно не спрятать его, а записать и исправить границу.
- Если после правки границы понадобилось изменить десятки файлов, значит, старый универсальный клиент успел прорасти в use case; это отдельная задача и её лучше делать явно.