一个只接入 OpenAI 的应用,模型创建代码通常很短:

from langchain_openai import ChatOpenAI
 
model = ChatOpenAI(
    model="example-model",
    api_key="<OPENAI_API_KEY>",
)

加入第二家 Provider 后,看起来只要换掉 API Key:

model = ChatOpenAI(
    model="provider-model",
    api_key="<PROVIDER_API_KEY>",
)

这段代码还缺少一个决定请求去向的值:base_url。继续加入 DeepSeek、Kimi、豆包、GLM 和本地 Ollama 后,差异也不再只有 Key 和 URL,还包括 SDK 类、模型名、参数名称、Tool Calling、结构化输出以及响应中的扩展字段。

因此,“多 Provider 接入”包含两件不同的事:

  1. 模型列表中能够展示和选择 Provider
  2. 每一条实际调用链都能根据选择创建正确的 LangChain ChatModel

只完成第一件事,页面上虽然出现了新模型,聊天、工作流或后台任务仍可能继续创建原来的 ChatOpenAI。统一模型工厂解决的是第二件事。

先分清五个容易混在一起的概念

Provider

Provider 是提供模型运行服务的平台,例如 OpenAI、DeepSeek 或本地 Ollama。它通常决定:

  • 请求发送到哪个 API 地址
  • 使用哪一种认证方式
  • 哪些模型可以调用
  • 支持哪些参数和高级能力
  • 使用哪个 LangChain 集成类

Provider 不是模型。一个 Provider 可以托管多个模型,同一个开源模型也可能由多个 Provider 托管。

Model

Model 是一次调用中选中的具体模型标识。provider="deepseek" 只说明服务来自哪一方,仍要用 model="..." 指定调用哪个模型。

不同 Provider 的模型名处在各自的命名空间中。只替换 Provider,不同步检查模型名,请求可能在发送前就因模型不存在而失败。

ChatModel

LangChain 的 ChatModel 是应用代码实际调用的 Python 对象,例如:

ChatOpenAI
ChatDeepSeek
ChatOllama

这些类把统一的 invoke()stream() 等接口转换成各家 Provider 的请求。

model_config

model_config 是业务保存的一次模型选择:

{
  "provider": "zhipu",
  "model": "provider-model-name",
  "parameters": {
    "temperature": 0.7,
    "max_tokens": 4096
  }
}

它回答“选择谁、选择哪个模型、这次生成怎样控制”,不保存 API Key、Base URL 或 Python 类。

模型目录与模型工厂

模型目录记录 Provider 和模型的静态信息;模型工厂读取业务配置与目录,创建真正参与调用的 ChatModel:

model_config               Provider / Model Catalog
业务选择                    连接信息、能力和参数规则
       │                              │
       └──────────┬───────────────────┘

          LanguageModelManager


      ChatOpenAI / ChatDeepSeek / ChatOllama

目录是数据,工厂是执行逻辑。把两者分开后,业务 Service 不需要知道每家 SDK 的构造参数。

OpenAI-compatible 解决了哪一层

许多 Provider 提供兼容 OpenAI Chat Completions 的端点。标准文本对话可以继续使用 ChatOpenAI,只改变模型名、Key 和 Base URL:

from langchain_openai import ChatOpenAI
 
model = ChatOpenAI(
    model="provider-model-name",
    api_key="<PROVIDER_API_KEY>",
    base_url="https://provider.example/v1",
)
 
message = model.invoke("解释一下什么是线程")
print(message.content)

这段代码中的数据流是:

"解释一下什么是线程"


ChatOpenAI 把消息转换成 OpenAI Chat Completions 请求


base_url 决定请求发送到第三方兼容端点


Provider 返回兼容格式


LangChain 转换成 AIMessage

LangChain 的 Chat model integrations 将这种方式限定为基础 Chat Completions 兼容。ChatOpenAI 以 OpenAI 官方 API 规范为目标,第三方增加的 reasoning_contentreasoning 等字段不一定被提取或保留。

因此,OpenAI-compatible 不等于“所有能力完全相同”:

能力兼容端点通常能否统一仍要单独核对什么
普通消息输入输出通常可以消息类型和返回字段
流式文本通常可以usage 和结束事件
Tool Calling取决于模型与端点Tool Schema、tool_choice
结构化输出取决于具体实现JSON Schema、JSON Mode、Function Calling
推理内容常有厂商扩展专属响应字段
多模态差异较大图片格式、大小与模型能力

需要 Provider 扩展字段时,专属集成类更合适。例如 ChatDeepSeek 使用 langchain-deepseek 包,并单独标注不同模型的 Tool Calling 与结构化输出能力。

同一套工厂可以同时容纳两种接法:

Provider 类型工厂创建的类适用范围
OpenAI 官方ChatOpenAIOpenAI API
OpenAI-compatibleChatOpenAI + base_url标准兼容能力
有专属集成的 ProviderChatDeepSeek保留厂商能力和字段
本地原生协议ChatOllama使用本地 SDK 参数与响应

一次模型选择由哪些数据组成

模型创建时需要的数据可以分成四类:

数据示例谁负责
业务选择Provider、模型、温度、输出长度应用或工作流配置
Provider 连接API Key 环境变量名、Base URLProvider 目录
模型能力上下文、输出上限、参数范围、结构化策略模型目录
运行实现ChatOpenAIChatDeepSeekChatOllamaProvider 适配层

业务配置只保存第一类:

from typing import Any
 
from pydantic import BaseModel, ConfigDict, Field
 
 
class LanguageModelConfig(BaseModel):
    model_config = ConfigDict(extra="forbid")
 
    provider: str
    model: str
    parameters: dict[str, Any] = Field(default_factory=dict)

extra="forbid" 会拒绝意外字段。API Key 不进入这份对象,可以避免模型配置序列化进数据库或接口响应后泄露凭据。

Provider 目录保存连接与实现信息:

- name: deepseek
  label: DeepSeek
  api_key_env: DEEPSEEK_API_KEY
  base_url: https://api.example.com
  supported_model_types:
    - chat
 
- name: zhipu
  label: GLM
  api_key_env: ZHIPU_API_KEY
  base_url: https://provider.example/v1
  supported_model_types:
    - chat

这里保存的是环境变量名称,不是 Key 的值。进程启动后,工厂才从服务端环境读取对应凭据。SDK 类可以由 Provider 名称和模型类型按约定加载:deepseek + chat 找到 DeepSeek 的 Chat 适配类,zhipu + chat 找到基于 ChatOpenAI 的兼容适配类。这样 YAML 不需要保存 Python 类对象,Provider 差异仍然留在适配目录中。

模型目录保存单个模型的规则:

model: provider-model-name
model_type: chat
context_window: 128000
max_output_tokens: 8192
 
parameters:
  - name: temperature
    type: float
    default: 1
    min: 0
    max: 2

目录中的 context_windowmax_output_tokens 是应用侧能力声明,用于展示、校验和预算计算。它们不会自动修改远端 Provider 或本地推理引擎。

模型工厂怎样创建一次真实调用

下面的示例省略缓存和展示字段,但包含完整的创建路径:

import os
from dataclasses import dataclass
from typing import Any
 
 
FORBIDDEN_PARAMETERS = {
    "api_key",
    "base_url",
    "model",
}
 
 
@dataclass
class ProviderSpec:
    model_class: type
    api_key_env: str | None
    base_url: str | None
    models: dict[str, "ModelSpec"]
 
 
@dataclass
class ModelSpec:
    attributes: dict[str, Any]
    parameter_rules: dict[str, dict[str, Any]]
 
 
class LanguageModelManager:
    def __init__(self, providers: dict[str, ProviderSpec]) -> None:
        self.providers = providers
 
    def create_chat_model(self, raw_config: dict[str, Any]):
        config = LanguageModelConfig.model_validate(raw_config)
 
        provider = self.providers[config.provider]
        model_spec = provider.models[config.model]
        parameters = self._validate_parameters(
            config.parameters,
            model_spec.parameter_rules,
        )
 
        if FORBIDDEN_PARAMETERS & parameters.keys():
            raise ValueError("业务参数不能覆盖连接字段")
 
        init_kwargs = {
            **model_spec.attributes,
            **parameters,
        }
 
        if provider.api_key_env:
            api_key = os.getenv(provider.api_key_env)
            if not api_key:
                raise ValueError(
                    f"missing environment variable: {provider.api_key_env}"
                )
            init_kwargs["api_key"] = api_key
 
        if provider.base_url:
            init_kwargs["base_url"] = provider.base_url
 
        return provider.model_class(**init_kwargs)
 
    def _validate_parameters(
        self,
        parameters: dict[str, Any],
        rules: dict[str, dict[str, Any]],
    ) -> dict[str, Any]:
        validated = {}
 
        for name, value in parameters.items():
            if name not in rules:
                raise ValueError(f"unknown model parameter: {name}")
            validated[name] = value
 
        for name, rule in rules.items():
            if name not in validated and "default" in rule:
                validated[name] = rule["default"]
 
        return validated

假设输入是:

raw_config = {
    "provider": "zhipu",
    "model": "provider-model-name",
    "parameters": {"temperature": 0.7},
}

工厂内部会产生这些中间状态:

1. Pydantic 校验
   provider = "zhipu"
   model = "provider-model-name"
   parameters = {"temperature": 0.7}
 
2. 查 Provider 目录
   model_class = ChatOpenAI
   api_key_env = "ZHIPU_API_KEY"
   base_url = "https://provider.example/v1"
 
3. 查模型目录并校验参数
   temperature 是已声明的 float 参数
 
4. 合并构造参数
   {
     "model": "provider-model-name",
     "temperature": 0.7,
     "api_key": "<从进程环境读取>",
     "base_url": "https://provider.example/v1"
   }
 
5. 创建 ChatOpenAI

这条链保证 modelapi_keybase_url 来自同一个 Provider。业务传入 parameters={"base_url": "..."} 时会在第三步前被拒绝,不能绕过服务端目录把请求改发到任意地址。

工厂的意义也不只是减少重复代码。未知 Provider、未知模型、参数类型错误、参数越界和缺少凭据都在“创建模型”这个边界暴露,不必等到业务链运行到一半才失败。

模型列表接入不等于运行链路接入

一个应用常有多条 LLM 调用入口:

应用聊天
工作流 LLM 节点
应用调试
已发布 API
会话摘要
会话标题
候选问题
提示词优化
健康检查或调试 Ping

如果其中一处仍然写着:

model = ChatOpenAI(model="example-model")

这条入口就绕过了 Provider 选择。页面列表可以显示 GLM,应用聊天也能使用 GLM,但后台标题生成仍然会读取 OPENAI_API_KEY

判断完整接入不能只看 Provider 列表,而要看调用图:

每一个业务入口


LanguageModelManager


Provider 对应的 ChatModel

项目测试还可以反向扫描业务目录,禁止在 Provider 适配层之外直接导入 langchain_openailangchain_deepseeklangchain_ollama。这样新增 Service 时,直接实例化 SDK 会立刻让测试失败。

应用模型与系统辅助模型

同一个系统通常存在两类模型选择:

调用场景配置来源生命周期
应用聊天应用保存的 model_config跟随应用
工作流节点节点自己的 model_config跟随节点
会话标题、摘要系统默认模型跟随平台部署
候选问题、提示词优化系统默认模型跟随平台部署

系统模型可以用独立变量表达:

SYSTEM_LLM_PROVIDER=zhipu
SYSTEM_LLM_MODEL=provider-model-name

工厂把它们重新组装成同一种 LanguageModelConfig

def create_system_chat_model(
    manager: LanguageModelManager,
    parameters: dict[str, Any] | None = None,
):
    return manager.create_chat_model({
        "provider": os.getenv("SYSTEM_LLM_PROVIDER", "openai"),
        "model": os.getenv("SYSTEM_LLM_MODEL", "default-model"),
        "parameters": dict(parameters or {}),
    })

两类配置共用创建逻辑,但不共用选择来源。修改系统默认模型不会改写已发布应用的模型;应用切换 Provider 也不会让平台辅助任务跟着变化。

load_dotenv() 默认不会覆盖进程里已经存在的同名变量。修改系统模型环境变量后,如果 Flask、Celery 或其他 Worker 没有完整重启,旧进程仍然保留旧选择。此时出现 OPENAI_API_KEY 错误,不一定是 Key 填错,也可能是运行中的进程仍在创建默认 OpenAI 模型。

模型目录还要处理兼容性

模型目录既服务于新建配置,也要解析数据库里的历史配置。某个旧模型停止展示时,直接删除目录项会让已有应用无法加载。

model: legacy-model-name
visible: false
deprecated: true

visible: false 只影响新选择列表,解析能力仍然保留。Provider 的存储 ID 也应保持稳定:页面标签可以从 “Moonshot” 改成 “Kimi”,数据库中的 provider="moonshot" 不必跟着迁移。

目录中的稳定 ID、展示标签和远端模型名是三件事:

字段用途是否适合频繁修改
Provider ID数据库存储和代码查找
Label页面显示可以
Model ID业务配置选择需要兼容历史
SDK attribute真正传给 Provider随适配层转换

参数也有同样的层次。业务统一使用 max_tokens,OpenAI 新接口可能使用 max_completion_tokens,Ollama 原生类使用 num_predict。字段转换属于 Provider 适配层,完整路径见 Ollama 模型参数适配:从 max_tokens 到 num_predict

lc_versions 是谁加进去的

调试模型对象时,有时会看到:

{
  "lc_versions": {
    "langchain": "<version>",
    "langchain-core": "<version>",
    "langchain-openai": "<version>"
  }
}

这是 LangChain 在模型初始化和序列化过程中加入的追踪元数据,不是 Provider API 返回的模型内容,也不是 LLM 生成的文本。它适合用于定位依赖版本,但业务接口没有必要把模型对象的全部 metadata 原样返回。

结构化输出的 method 也属于运行时适配信息。它不必出现在模型选择页面,却必须由模型工厂按 Provider 能力使用。具体链路见 LangChain 跨 Provider 结构化输出与降级

怎样验证工厂真的完成了接入

第一层验证不需要发送真实请求。给环境变量放入测试值,只构造模型对象,就能检查大部分接线:

def test_factory_builds_provider_model(monkeypatch, manager):
    monkeypatch.setenv("ZHIPU_API_KEY", "test-key")
 
    model = manager.create_chat_model({
        "provider": "zhipu",
        "model": "provider-model-name",
        "parameters": {"temperature": 0.5},
    })
 
    assert model.model_name == "provider-model-name"
    assert model.temperature == 0.5
    assert str(model.openai_api_base) == "https://provider.example/v1"

离线测试可以覆盖:

  • Provider 和模型是否能解析
  • 工厂选择了哪一个 Python 类
  • Base URL 是否正确注入
  • 缺少哪一个环境变量时会失败
  • 参数默认值、类型和范围是否生效
  • 业务参数能否覆盖连接字段
  • 旧模型是否隐藏但仍可解析
  • SDK 导入是否只存在于适配层

它不能证明:

  • API Key 真实可用
  • 远端模型当前在线
  • Provider 完整实现了兼容协议
  • Tool Calling 或结构化输出在真实模型上表现正确
  • 网络、限流和计费配置正常

后一层需要明确开启的真实 Provider smoke test。离线构造通过只能说明“应用把正确参数交给了正确的 LangChain 类”,不能写成端到端调用已经成功。

回到最初的切换问题,一次真正的 Provider 选择包含完整链路:

应用保存 provider + model + parameters


Provider 目录补充 Key 来源、Base URL 和 SDK 类


模型目录补充能力、默认值和校验规则


统一工厂创建 ChatModel


所有聊天、工作流和辅助任务都从工厂取得模型

只换 Key、只增加模型 YAML 或只修改列表接口,都还没有走完整条链。