Первый API-вызов должен быть скучным. Если первый вызов уже похож на архитектуру космического корабля, команда просто пытается спрятать отсутствие понимания за слоями абстракции.
Минимальная цель первой интеграции: отправить короткий запрос, получить ответ, измерить latency, посчитать токены, обработать ошибку и сохранить диагностический след. Не «сделать AI-фичу». Не «построить агента». Просто доказать, что backend умеет безопасно разговаривать с моделью.
Ситуация из реальной команды
Команда делает внутреннего помощника для отдела поддержки. Разработчик за вечер собирает прототип: берёт SDK, вставляет API key в .env, пишет endpoint /ask, отправляет вопрос пользователя в модель и возвращает текст. На демо всё красиво. Руководитель доволен. Через неделю начинается прод.
Проблемы появляются сразу:
- запросы иногда висят по 40 секунд;
- при rate limit пользователь видит 500;
- модель возвращает слишком длинные ответы;
- стоимость на тестовой группе неожиданно растёт;
- в логах лежат персональные данные клиентов;
- нельзя понять, какая версия prompt-а дала плохой ответ;
- один и тот же вопрос в разных местах продукта реализован разными кусками кода.
Ошибка была не в SDK. Ошибка была в том, что команда приняла API-вызов за интеграцию.
Сырые вызовы: OpenAI и Anthropic
Прежде чем прятать провайдеров за абстракцией, надо один раз увидеть их без неё. Минимальный вызов OpenAI:
from openai import AsyncOpenAI
client = AsyncOpenAI() # ключ из OPENAI_API_KEY
response = await client.responses.create(
model=SMALL_MODEL, # условные имена — см. Приложение А
instructions="Ты помогаешь оператору поддержки. Пиши черновик ответа.",
input=ticket_text,
max_output_tokens=500,
)
text = response.output_text
usage = response.usage # .input_tokens, .output_tokensМинимальный вызов Anthropic:
from anthropic import AsyncAnthropic
client = AsyncAnthropic() # ключ из ANTHROPIC_API_KEY
message = await client.messages.create(
model=SMALL_MODEL,
max_tokens=500, # у Anthropic параметр обязателен
system="Ты помогаешь оператору поддержки. Пиши черновик ответа.",
messages=[{"role": "user", "content": ticket_text}],
)
text = message.content[0].text # content — список блоков, не строка
usage = message.usage # .input_tokens, .output_tokensУже на двух вызовах видно, что «совместимость» провайдеров — вежливое преувеличение:
| OpenAI | Anthropic | |
|---|---|---|
| Системные инструкции | параметр instructions (или роль в messages) |
отдельный параметр system |
| Лимит ответа | max_output_tokens, опционален |
max_tokens, обязателен |
| Тело ответа | output_text — строка |
content — список блоков разных типов |
| Ошибки | openai.RateLimitError и т.д. |
anthropic.RateLimitError и т.д. |
| Счётчики usage | свои имена полей | свои имена полей |
Многие другие провайдеры и локальные модели предоставляют «OpenAI-совместимый» endpoint — тогда SDK OpenAI работает с ними через base_url. Это удобно, но совместимость обычно кончается на базовой генерации: structured output, tool calling и поведение при ошибках различаются. Отсюда и следующий шаг — свой тонкий слой, который фиксирует, что именно ваше приложение ждёт от любого провайдера.
Минимальный клиент модели
Внутри backend-а нужен тонкий слой, который скрывает детали провайдера, но не врёт, что все модели одинаковые.
from dataclasses import dataclass
from typing import Any
@dataclass(frozen=True)
class LLMUsage:
input_tokens: int
output_tokens: int
total_tokens: int
@dataclass(frozen=True)
class LLMResult:
text: str
model: str
provider: str
usage: LLMUsage | None
raw: dict[str, Any]
class LLMClient:
async def generate_text(
self,
*,
system: str,
user: str,
model: str,
max_output_tokens: int = 800,
temperature: float = 0.2,
timeout_s: float = 30.0,
) -> LLMResult:
raise NotImplementedErrorЭто граница приложения, а не попытка изобрести универсальную абстракцию. За ней остальной код не зависит от конкретного SDK, а каждый вызов проходит через единые правила: timeout, логирование, метрики, лимиты и обработку ошибок.
Что не надо абстрагировать слишком рано
Плохая идея — создать интерфейс UniversalGodModel и притвориться, что OpenAI, Anthropic, Gemini, локальная модель и корпоративный gateway полностью взаимозаменяемы. Они различаются:
- форматом сообщений;
- режимами structured output;
- tool calling;
- лимитами контекста;
- политиками безопасности;
- скоростью;
- стоимостью;
- поведением на длинном контексте;
- качеством на конкретном языке и домене.
Хорошая абстракция скрывает сетевую грязь, но оставляет видимыми важные различия.
Первый FastAPI endpoint
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
router = APIRouter(prefix="/ai", tags=["ai"])
class DraftReplyRequest(BaseModel):
ticket_text: str = Field(min_length=1, max_length=8000)
class DraftReplyResponse(BaseModel):
reply: str
model: str
input_tokens: int | None = None
output_tokens: int | None = None
@router.post("/support/draft-reply")
async def draft_support_reply(
payload: DraftReplyRequest,
llm: LLMClient = Depends(get_llm_client),
) -> DraftReplyResponse:
try:
result = await llm.generate_text(
system=(
"Ты помогаешь оператору поддержки. "
"Пиши черновик ответа. Не утверждай факты, которых нет в обращении."
),
user=payload.ticket_text,
model=settings.SMALL_MODEL, # условное имя; актуальные модели — в Приложении А
max_output_tokens=500,
temperature=0.2,
timeout_s=20,
)
except LLMRateLimitError:
raise HTTPException(status_code=503, detail="AI provider rate limit")
except LLMTimeoutError:
raise HTTPException(status_code=504, detail="AI provider timeout")
return DraftReplyResponse(
reply=result.text,
model=result.model,
input_tokens=result.usage.input_tokens if result.usage else None,
output_tokens=result.usage.output_tokens if result.usage else None,
)Этот код ещё не идеален. Но он уже показывает правильные границы:
- вход ограничен по размеру;
- выход ограничен
max_output_tokens; - модель вызывается через dependency;
- ошибки провайдера не протекают наружу сырым traceback;
- usage возвращается и может логироваться;
- prompt отделён от пользовательского текста.
Правило главы
Первый API-вызов считается успешным не когда модель ответила красиво, а когда вы знаете:
- сколько это стоило;
- сколько заняло времени;
- что будет при таймауте;
- что будет при rate limit;
- где лежит лог;
- какая версия prompt-а использовалась;
- как повторить и отладить сбой.
Если этого нет, вы не интегрировали модель. Вы просто поговорили с ней через backend.
Чеклист главы
- В коде есть тонкий слой клиента, и остальное приложение не импортирует SDK провайдера напрямую.
- Timeout задан явно и меньше терпения пользователя.
- Rate limit и таймаут провайдера превращаются в понятные HTTP-статусы, а не в 500 с traceback.
- Usage (input/output tokens) сохраняется при каждом вызове.
- Имя модели берётся из конфигурации, а не захардкожено по коду.
- Вход пользователя ограничен по длине до отправки в модель.
Практикум
Задача 4.1 — минимальный клиент двух провайдеров. ⭑⭑
Реализуйте интерфейс LLMClient из этой главы для двух провайдеров: OpenAI и Anthropic (или один из них + любой совместимый). Оба клиента должны возвращать одинаковый LLMResult и маппить ошибки провайдера в общие исключения: LLMRateLimitError, LLMTimeoutError, LLMProviderError.
Критерии приёмки:
- Вызывающий код переключается между провайдерами сменой одной настройки, без изменения кода.
- Таймаут проверен принудительно: поставьте
timeout_s=0.001и убедитесь, что получаетеLLMTimeoutError, а не сырое исключение httpx/SDK. -
LLMResult.usageзаполняется у обоих провайдеров, несмотря на разный формат ответа. -
rawсодержит ответ провайдера целиком — по нему можно отладить любой инцидент.
Подсказки:
- Не пытайтесь унифицировать всё: structured output и tool calling у провайдеров разные, в этой задаче достаточно текстовой генерации.
- Rate limit удобно эмулировать: временно подставьте неправильный ключ или замокайте HTTP-ответ 429.
Задача 4.2 — endpoint с диагностическим следом. ⭑⭑
Поднимите FastAPI endpoint /ai/support/draft-reply из главы поверх своего клиента. Добавьте сохранение диагностического следа каждого вызова: use case, модель, версия prompt-а, токены, latency, статус — в лог или таблицу.
Критерии приёмки:
- По одному
request_idможно восстановить: что пришло, что ушло в модель, что вернулось, сколько стоило. - Ответ укладывается в бюджет: p95 latency меньше 10 секунд на коротких тикетах (проверьте хотя бы на 20 запросах).
- При выключенном интернете endpoint возвращает 504 за время таймаута, а не висит.
- В логах нет текста тикета целиком — только его hash и длина (потренируйтесь думать о PII с первого дня).
Подсказки:
- Latency и стоимость проще всего мерить прямо в клиенте, а не в endpoint-е — тогда метрики получат все будущие вызовы бесплатно.