Endpoint /summarize работал за 4 секунды. После смены модели медиана выросла до 7 секунд, p95 до 28, а часть запросов стала завершаться после таймаута клиента. Клиенты повторяли POST, сервис дважды платил за генерацию и иногда сохранял две разные сводки. Провайдер здесь только показал, что у API не было собственного контракта.
Внешний API не должен повторять интерфейс SDK модели. Клиенту нужны операция, статус и результат. Название модели, устройство prompt-а, число ретраев и формат provider-specific ошибок остаются внутри сервиса.
Контракт операции
Для короткой предсказуемой обработки допустим синхронный ответ. Всё, что регулярно выходит за 2–3 секунды или требует нескольких шагов, удобнее оформить как ресурс операции:
POST /v1/documents/{document_id}/summaries
Idempotency-Key: 7f0c...
202 Accepted
{
"operation_id": "op_123",
"status": "queued",
"status_url": "/v1/operations/op_123"
}
GET /v1/operations/op_123
{
"status": "succeeded",
"result": {"summary": "...", "warnings": []},
"schema_version": "summary.v2"
}Idempotency-Key относится к намерению клиента. Один ключ плюс один пользователь плюс один тип операции должны возвращать ту же операцию. Если payload с прежним ключом изменился, отвечайте 409, а не угадывайте.
DTO отдельно от модели провайдера
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
class SummaryOptions(BaseModel):
model_config = ConfigDict(extra="forbid")
language: Literal["ru", "en"] = "ru"
max_chars: int = Field(default=1200, ge=200, le=5000)
class CreateSummary(BaseModel):
document_id: str
options: SummaryOptions = SummaryOptions()
class SummaryResult(BaseModel):
summary: str
warnings: list[str] = []
schema_version: Literal["summary.v2"] = "summary.v2"В DTO нет temperature, model и max_tokens. Если отдать их каждому клиенту, через месяц невозможно сравнить качество и стоимость. Управляемые параметры задаются серверной политикой; исключения оформляются версионированными профилями вроде quality=fast|balanced.
Ошибки, которые понимает клиент
Верните стабильный код и request_id, а исходную ошибку провайдера оставьте в журнале:
{
"error": {
"code": "temporarily_unavailable",
"message": "Обработка временно недоступна",
"retryable": true,
"request_id": "req_abc"
}
}Полезный минимум: invalid_request, content_rejected, quota_exceeded, temporarily_unavailable, operation_failed. HTTP 429 вашего продукта и 429 провайдера имеют разный смысл. Первый сообщает клиенту о его квоте. Второй обычно превращается во внутренний retry или 503.
Бюджеты и отмена
У операции должны быть лимиты до вызова модели: размер входа, максимальная стоимость, deadline и квота владельца. Проверка после генерации похожа на проверку билета после поезда.
Отмена DELETE /operations/{id} прекращает ещё не начатые шаги. Уже отправленный запрос провайдеру часто нельзя остановить и не заплатить за него. API должен честно различать cancel_requested и cancelled.
Failure modes
Если endpoint возвращает сырой provider response, смена поставщика ломает клиентов. Если синхронный POST не имеет idempotency key, сетевой retry создаёт дубли. Если API обещает точный процент прогресса, а шаги различаются по времени, пользователь видит 90% двадцать минут. Показывайте завершённые этапы и текущий этап.
Версионируйте продуктовый контракт, а не prompt. Изменение формулировки prompt-а без изменения полей остаётся внутренним релизом. Переименование поля или новая семантика enum требует новой версии либо совместимого периода миграции.
Правило главы
Проектируйте API вокруг устойчивой операции и её результата. Модель является заменяемой зависимостью. Клиент не обязан знать, какой провайдер сегодня отвечает и сколько внутренних попыток потребовалось.
Чеклист главы
- Долгие операции отвечают
202и имеют endpoint статуса. - Повтор POST защищён idempotency key и хешем payload.
- DTO не содержит случайных параметров SDK.
- Ошибки сведены к стабильной продуктовой таксономии.
- До запуска проверяются квота, deadline и бюджет стоимости.
- Версии схемы результата и prompt-а учитываются отдельно.
Практикум
Задача 12.1. API извлечения полей. Реализуйте FastAPI-сервис с POST /extractions, GET /operations/{id} и отменой.
Критерии приёмки:
- Два POST с одним ключом создают один LLM-вызов.
- Тот же ключ с другим payload возвращает
409. - Таймаут и 429 провайдера не просачиваются наружу в исходном виде.
- Клиент получает версию схемы, предупреждения и
request_id. - Контрактные тесты проходят с двумя моками провайдеров.