Разработка ИИ-агентов · Модуль 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).
  • Выпишите список того, что НЕ переносится автоматически (возможности, поведение промптов, цены), и как вы это проверите при смене провайдера.

Проверка знаний

Что при переносе агента между провайдерами меняется, а что остаётся прежним?

  • A Меняется механика агентного цикла; форматы запросов одинаковы
  • B Механика (цикл, инструменты, контекст, guardrails) остаётся; меняется форма запроса/ответа — системный промпт, имена стоп-сигналов, формат tool calling
  • C Меняется всё — переносимость невозможна
  • D Ничего не меняется, провайдеры идентичны

В коде агента повсюду напрямую читается resp.finish_reason == "tool_calls" и собирается тело запроса под конкретный SDK.

Как сделать код переносимым?

  • A Добавить if на каждое место под второй провайдер
  • B Ввести интерфейс ModelClient и нейтральный StopReason; нормализовать стоп-сигнал и формат запроса в адаптере, цикл оставить на нейтральных типах
  • C Скопировать весь агент во вторую версию под другой провайдер
  • D Захардкодить оба формата в цикле

Почему адаптер не делает смену провайдера полностью бесшовной?

  • A Адаптеры всегда медленные
  • B Провайдеры различаются возможностями, поведением на одинаковый промпт и ценами; адаптер убирает механику, но поведение нужно проверять эвалами
  • C Потому что интерфейсы в Go нельзя мокать
  • D Потому что стоп-сигналы у всех одинаковые