Skip to content

Routing & default

Ongrid запускает N providers параллельно и диспетчирует каждый LLM-вызов на ровно одного. Эта страница покрывает, как этот dispatch работает на трёх слоях:

  1. MultiClient — wire-level router, используемый legacy llm.Chat путём (translate, knowledge search).
  2. RoutingChatModel — обёртка eino model.ChatModel, используемая graph-kernel ReAct-агентом.
  3. DefaultResolver — dynamic-default hook, который позволяет настройке default_provider вступать в силу mid-process.

MultiClient

Низ router-стека живёт в internal/pkg/llm/router.go.

go
// router.go:67
type MultiClient struct {
    // map: provider id -> sub-Client built from a ProviderConfig
    // ...
}

func (m *MultiClient) Chat(ctx context.Context, req ChatReq) (*ChatResp, error)

Каждый provider config производит один sub-клиент при конструировании:

go
// cmd/ongrid/main.go:492
providerCfgs := []llm.ProviderConfig{}
if cfg.OpenAI.APIKey != "" {
    providerCfgs = append(providerCfgs, llm.ProviderConfig{
        ID: "openai", Label: "OpenAI",
        APIKey: cfg.OpenAI.APIKey,
        Model: firstNonEmpty(cfg.OpenAI.Model, "gpt-5.4"),
        BaseURL: cfg.OpenAI.BaseURL,
        Models: dedupeModels(...),
    })
}
// ...same for each provider...
llmRouter := llm.NewMultiClient(providerCfgs, cfg.LLM.Default, openaiClient)

Chat диспетчирует на req.Provider:

  • Непустой → искать sub-клиент; 404 → ErrUnknownProvider.
  • Пустой → fall back на defaultProvider.

Wiring MultiClient.SetProvidersResolver(r) накладывает DB-backed resolver сверху — на каждый вызов активный sub-клиент set ре-разрешается (кэшируется 60с через SetResolveTTL).

RoutingChatModel

Graph kernel использует интерфейс eino model.ChatModel, не llm.Chat. RoutingChatModel (eino_routing.go:89) оборачивает N внутренних ChatModel и диспетчирует через impl-specific опцию:

go
type RoutingChatModel struct {
    inner           map[string]model.ChatModel
    defaultProvider string
    defaultResolver func(context.Context) (provider, mdl string)
}

func WithProvider(provider string) model.Option {
    return model.WrapImplSpecificOptFn(func(o *providerOpts) {
        o.provider = provider
    })
}

Использование с места вызова:

go
resp, err := chatModel.Generate(ctx, msgs,
    model.WithModel("glm-4.7-flash"),
    llm.WithProvider("zhipu"),
)

pick() разрешает внутренний:

go
// eino_routing.go:173
func (r *RoutingChatModel) pick(opts ...model.Option) (model.ChatModel, string, error) {
    po := model.GetImplSpecificOptions(&providerOpts{}, opts...)
    prov := po.provider
    if prov == "" {
        prov = r.defaultProvider
    }
    inner, ok := r.inner[prov]
    if !ok {
        return nil, prov, fmt.Errorf("%w: %s", ErrUnknownProvider, prov)
    }
    return inner, prov, nil
}

DefaultResolver — dynamic default

Баг, который это чинит: админ переключает default_provider с Anthropic на GLM в /settings/llm. UI chat-picker немедленно уважает новый дефолт (он ре-fetch'ит /v1/aiops/models на каждой загрузке). Но RCA investigator worker — который process-internal и связывает свой provider при загрузке — продолжает маршрутизировать на Anthropic до рестарта.

Фикс: RoutingChatModelConfig.DefaultResolver.

go
var defaultResolver func(context.Context) (string, string)
if resolver != nil {
    defaultResolver = func(rctx context.Context) (string, string) {
        provCfgs, resolvedDefault, rerr := resolver.ResolveProviders(rctx)
        if rerr != nil || resolvedDefault == "" {
            return "", ""
        }
        for _, pc := range provCfgs {
            if pc.ID == resolvedDefault {
                return resolvedDefault, pc.Model
            }
        }
        return resolvedDefault, ""
    }
}
chatModel, err := llm.NewRoutingChatModel(llm.RoutingChatModelConfig{
    Inner:           innerModels,
    DefaultProvider: defProv,
    DefaultResolver: defaultResolver,
})

withDynamicDefault инъектит output resolver'а как WithProvider + WithModel для вызовов, которые не закрепили ни того, ни другого — chat-picker закрепляет provider per-message и обходит resolver полностью; default-routed вызовы (investigator, translate, knowledge fan-out) теперь следуют live конфигурации.

Per-call provider в Chat

Для пути MultiClient.Chat (non-graph-kernel вызывающие), установите ChatReq.Provider явно:

go
resp, err := llmClient.Chat(ctx, llm.ChatReq{
    Provider: "openai",
    Model:    "gpt-5.5",
    Messages: msgs,
})

Пустой Provider → MultiClient разрешает дефолт во время вызова. Пустая Model → разрешённый sub-клиент использует свой сконфигурированный дефолт.

Per-call provider в graph kernel

Agent runtime инъектит llm.WithProvider всякий раз, когда chat send envelope содержит непустой provider. Persona-реестр может также закрепить provider для целой персоны (например, дешёвая extractor-персона закрепляет на Anthropic Haiku). См. справочник формата персоны агента.

Подводные камни

  • Забыть default_provider — resolver выбирает первый отсортированный provider id; вы пошлёте ваш model id на неправильный эндпоинт. Всегда ставьте ONGRID_LLM_DEFAULT_PROVIDER (или пишите DB-строку).
  • Закрепление provider, у которого нет внутренней ChatModel — случается, когда resolver возвращает provider id, который не был зарегистрирован при загрузке И не был pre-registered. Custom slot pre-registered ровно по этой причине; всё остальное гейтится на cfg.LLM.*.APIKey != "".
  • Hot-swap timing — cache TTL — 60с на resolver и 60с на MultiClient. Worst case 120с до того, как admin-правка вступит в силу. Invalidate-путь выставлен, но ещё не подключён к save- действию SPA.

См. также

  • Обзор моделей — сборка []llm.ProviderConfig.
  • Budget — ортогонально routing; тот же cap применяется к каждому provider.
  • RCA — маршрутизация investigator-воркера.