В сервисе был универсальный LLMClient.complete(prompt). Когда понадобились citations и tool calls, команда стала засовывать JSON в строку prompt, а результат разбирать регулярным выражением. Интерфейс оставался красивым. Production вокруг него уже нет.
Абстрагировать полезно политику приложения: классификацию обращения, извлечение полей, подготовку ответа. Формат 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Не следует возвращать наружу «универсальный ответ» из десятков optional-полей. Общие поля становятся пустыми, а важная провайдерская информация теряется. Храните raw response в защищённом артефакте и нормализуйте только то, что действительно использует use case.
Где проходит граница
Хорошая абстракция позволяет заменить SDK без изменения доменной логики, централизует timeout, учёт usage и классификацию ошибок. Плохая обещает бесшовно заменить модель, хотя prompt и evals зависят от неё. Особенно опасен автоматический fallback внутри клиента: вызывающий код не знает, что изменились качество, цена и политика данных.
Failure modes заметны по условным веткам if provider == ... во всех use case, строковым JSON, потере request ID, общему исключению LLMError и тестам только на mock. Mock подтверждает интерфейс, но не совместимость с живым API.
Правило главы
Абстрагируйте стабильную потребность приложения и повторяющуюся эксплуатационную механику. Различия, меняющие качество, безопасность или цену, должны оставаться видимыми.
Практикум
Найдите один существующий LLM-вызов и выпишите поля, которыми пользуется вызывающий код. Создайте task-specific port и две реализации. Добавьте contract tests на схему результата, ошибки, usage и request ID, затем integration test с реальными sandbox API. Попробуйте включить tool calling: если интерфейс приходится обходить строкой, граница выбрана неверно.