Разработка ИИ-агентов · Модуль 4 · Урок 4.7
Провайдеры LLM и переносимость кода
Почему это важно
Весь курс мы вызывали модель через OpenAI-совместимый шлюз (урок 1.2). Но провайдеров несколько — Anthropic, OpenAI, Google и другие, — и между ними есть отличия в API. Привязав код намертво к одному, вы рискуете: смена провайдера ради цены/качества/доступности превращается в переписывание агента.
Хорошая новость: механика агента провайдеро-независима. Цикл (send → стоп-сигнал → инструменты → дозапись), проектирование инструментов, управление контекстом, guardrails — везде одинаковы. Отличается форма запроса и ответа. Если знать, где проходят швы, изоляция от провайдера стоит дёшево.
Где провайдеры расходятся
Главные точки различий (конкретику и имена полей сверяйте с актуальными доками — они меняются):
- Системный промпт. У одних это сообщение с ролью
systemв общем списке, у других — отдельный параметр запроса, а не элементmessages. - Стоп-сигнал. Поле и его значения называются по-разному:
finish_reason(tool_calls,stop) противstop_reason(tool_use,end_turn). Логика «закончил или зовёт инструмент» одна, имена — разные. - Tool calling. Различаются схема описания инструментов, формат запроса вызова и то, как результат инструмента дозаписывается в историю (роль
toolсtool_call_idпротив блокаtool_resultв сообщении). - Структурированный вывод. Где-то строгий JSON-режим/Structured Outputs, где-то — через tool calling (урок 2.4).
- Мультимодальность, кэш, extended thinking. Форматы блок-изображений, механика prompt caching и режима размышления у каждого свои.
Понятия совпадают, отличается «обёртка». Поэтому переносимость — это вопрос адаптера, а не переписывания логики.
Адаптер: изолируем провайдера за интерфейсом
Практический приём — антикоррупционный слой: ваш агент работает с собственными нейтральными типами (Message, Tool, Reply со стоп-сигналом в общем виде), а конкретный провайдер прячется за интерфейсом ModelClient. Под каждый провайдер — своя реализация, которая переводит ваши типы в его формат и обратно.
Тогда цикл агента (урок 1.4) не знает, с кем говорит, и не меняется при смене провайдера; маппинг стоп-сигналов и форматов инструментов живёт в одном месте — в адаптере. Бонус: такой интерфейс тривиально мокается в тестах (урок 2.6) — это та же абстракция, что нужна для детерминированных тестов агента.
Чего адаптер не скрывает
Не всё прячется за единый интерфейс — и об этом надо знать честно:
- Возможности модели. Extended thinking, computer use, длина окна, мультимодальность есть не у всех и работают по-разному; общий знаменатель ограничен.
- Поведение и промпты. Один и тот же системный промпт даёт разные результаты у разных моделей — промпты приходится подстраивать, а изменения проверять эвалами (это снова повод иметь оценку из урока 2.6).
- Стоимость и лимиты. Цены, rate limits и тарификация кэша/размышления различаются.
Поэтому цель адаптера — убрать механическую привязку (формат запроса/ответа), а не сделать провайдеров волшебно одинаковыми. Переключение всё равно требует пере-прогона эвалов.
flowchart TB
LOOP["Агентный цикл (нейтральные типы)"] --> IFACE{{"interface ModelClient"}}
IFACE --> A["Adapter: Anthropic"]
IFACE --> O["Adapter: OpenAI-совместимый"]
IFACE --> G["Adapter: другой провайдер"]
A --> A1["stop_reason / tool_result"]
O --> O1["finish_reason / role=tool"]// Нейтральные типы агента — не зависят от провайдера.
type StopReason int
const (
StopDone StopReason = iota // модель закончила (end_turn / stop)
StopTools // модель зовёт инструменты (tool_use / tool_calls)
)
type Reply struct {
Text string
ToolCalls []ToolCall
Stop StopReason
}
// ModelClient — единственная точка, которую знает агентный цикл.
// Под каждый провайдер — своя реализация Send.
type ModelClient interface {
Send(ctx context.Context, messages []Message, tools []Tool) (Reply, error)
}
// normalizeStop переводит провайдер-специфичное поле в нейтральный StopReason.
// Логика «закончил или зовёт инструмент» одна — отличаются только строки.
func normalizeStop(raw string) StopReason {
switch raw {
case "tool_use", "tool_calls": // Anthropic / OpenAI-совместимый
return StopTools
default: // "end_turn", "stop", ...
return StopDone
}
}
// Цикл из урока 1.4 работает с ЛЮБЫМ ModelClient и не меняется при смене провайдера.
func runAgent(ctx context.Context, c ModelClient, messages []Message, tools []Tool) (string, error) {
for i := 0; i < 12; i++ { // safety-cap
r, err := c.Send(ctx, messages, tools)
if err != nil {
return "", err
}
if r.Stop == StopDone {
return r.Text, nil
}
messages = append(messages, assistantMessage(r))
messages = append(messages, runTools(r.ToolCalls)...)
}
return "", fmt.Errorf("превышен safety-cap")
}Anti-patterns
| Анти-паттерн | Почему плохо | Как правильно |
|---|---|---|
| Раскидать вызовы конкретного SDK по всему коду | Смена провайдера = переписывание агента | Спрятать провайдера за интерфейсом ModelClient |
| Завязать логику на строковые значения стоп-сигнала | finish_reason vs stop_reason ломают код при переезде | Нормализовать в нейтральный StopReason в адаптере |
| Считать провайдеров взаимозаменяемыми «как есть» | Разные возможности/поведение/цены; промпты не переносятся 1:1 | Адаптер убирает механику; поведение проверять эвалами |
| Хардкодить системный промпт как элемент messages | У части провайдеров это отдельный параметр | Передавать систему через адаптер, он положит куда нужно |
| Менять провайдера без пере-прогона эвалов | Тихая деградация качества | Эвалы (урок 2.6) как гейт при смене модели/провайдера |
Практическое задание
- Опишите нейтральные типы (Message, Tool, Reply, StopReason) и интерфейс ModelClient; перепишите цикл из 1.4 так, чтобы он зависел только от интерфейса.
- Сделайте адаптер под используемый провайдер: маппинг системного промпта, формата инструментов и стоп-сигнала в нейтральные типы.
- Добавьте функцию normalizeStop и покройте её таблицей значений двух провайдеров (tool_use/tool_calls, end_turn/stop).
- Замокайте ModelClient и напишите детерминированный тест цикла без сети (связь с уроком 2.6).
- Выпишите список того, что НЕ переносится автоматически (возможности, поведение промптов, цены), и как вы это проверите при смене провайдера.
Проверка знаний
Что при переносе агента между провайдерами меняется, а что остаётся прежним?
Верный ответ: B
B верно. Логика агента провайдеро-независима; отличается «обёртка» (поля и форматы). Поэтому переносимость — это адаптер, а не переписывание. A — наоборот; C/D — крайности.
В коде агента повсюду напрямую читается resp.finish_reason == "tool_calls" и собирается тело запроса под конкретный SDK.
Как сделать код переносимым?
Верный ответ: B
B верно. Изоляция за интерфейсом + нормализация в одном месте (адаптере) убирает привязку и упрощает тесты. A/D размазывают провайдер-специфику по коду; C — дублирование, которое разъедется.
Почему адаптер не делает смену провайдера полностью бесшовной?
Верный ответ: B
B верно. Адаптер скрывает форму запроса/ответа, но не делает модели одинаковыми: возможности, поведение и цены различаются, поэтому смену проверяют эвалами (урок 2.6). A неверно; C — интерфейсы как раз легко мокать; D — стоп-сигналы различаются.