Day 06 / 共 20 天 · 阶段2 模型运行时

拆开"原料包":Provider 配置、凭据加解密、和六种模型实体

Day05 我们把 provider_model_bundle 当黑箱用了——今天正式开箱。主角是 core/entities/provider_configuration.pycore/entities/provider_entities.py。弄清三件事:①一个 ProviderConfiguration 到底装了哪些东西(系统配额 / 自定义凭据 / 模型开关 / 负载均衡);②"当前该用哪把凭据"是怎么在"系统内置额度"和"用户自填 key"之间抉择的;③LLM / Embedding / Rerank / TTS / Speech2Text / Moderation 这六类"模型实体"的类型体系怎么统一到一个门面下。这一节把 Day05 的黑箱变透明。

📍 你在 20 天里的位置(阶段2:模型运行时 · D05-07)
D05 模型管理 D06 Provider/实体 D07 Prompt/生成 D08 工作流总览 D09 节点体系 D10 图执行引擎 S4 RAG S6 收官
💡 先用两个类比兜住今天 类比一:ProviderConfiguration一份"某租户在某家供应商的开户档案"——里面有"你是用平台送的试用额度(系统)还是自己充值的账户(自定义)"、"你充值账户绑了哪几张银行卡(多把 API Key)"、"哪些产品被管理员禁用了"。ModelInstance(Day05 的翻译)开工前,必须先查这份档案,才知道该刷哪张卡。类比二:六种模型实体一家翻译公司的六个工种——笔译(LLM 生成)、朗读(TTS)、听写(Speech2Text)、查近义词打分(Rerank)、把词变成坐标(Embedding)、内容审查(Moderation)。它们招聘表(接口)长得一样(都有 invoke),但干的活完全不同。get_model_type_instance 就是"按工种派人"。
L01

痛点:Day05 那个"原料包"到底装了啥

🤔 痛点Day05 说 ModelInstanceprovider_model_bundle 拿到凭据和"技能对象",但那个包我们当黑箱了。可现实里问题全藏在这个包里:同一家 OpenAI,有的租户用平台送的试用额度、有的用自己充的 key,凭据来源完全不同;管理员可能禁用了某个模型;用户可能配了 3 把 key 做负载均衡;而且 key 存进数据库必须加密。这些差异如果泄漏到业务层,Day05 的 ModelInstance 就没法保持"我只管调"的清爽了。
💡 本质:把"配置的所有复杂度"收进一个实体Dify 的答案是 ProviderConfiguration——一个"胖实体"(源码注释自己都写了 lots of logic in a BaseModel)。它把"系统/自定义切换、凭据 CRUD、模型开关、负载均衡配置"全揽进来,对外只暴露两个关键方法:get_current_credentials()(当前该用哪把凭据)和 get_model_type_instance()(给我这类模型的技能对象)。业务层永远只问这两句,脏活全在实体内部。
L02

ProviderConfiguration 全景:一份开户档案的字段

先看这个实体的字段声明(api/core/entities/provider_configuration.py:60):

# api/core/entities/provider_configuration.py:60
class ProviderConfiguration(BaseModel):
    """
    This class handles:
    - Provider credentials CRUD and switch     # 供应商级凭据的增删改和切换
    - Custom Model credentials CRUD and switch  # 模型级凭据
    - System vs custom provider switching       # 系统额度 / 自定义 二选一
    - Load balancing configurations             # 负载均衡
    - Model enablement/disablement              # 模型启用/禁用
    """
    tenant_id: str
    provider: ProviderEntity                    # ① 这家供应商的"说明书"(有哪些模型、怎么配)
    preferred_provider_type: ProviderType       # ② 用户更想用哪种(系统/自定义)
    using_provider_type: ProviderType           # ③ 实际正在用哪种
    system_configuration: SystemConfiguration   # ④ 系统额度配置(平台送的试用)
    custom_configuration: CustomConfiguration   # ⑤ 自定义配置(用户自填的 key)
    model_settings: list[ModelSettings]         # ⑥ 每个模型的开关 + 负载均衡
provider: ProviderEntity"供应商说明书"——这家有哪些模型、需要填哪些字段(API Key?Base URL?)、支持哪几种模型类型。Day05 里 get_model_type_instance 就靠它当 schema。
system_configuration平台内置额度。里面 quota_configurations 管"你还剩多少次免费调用"、restrict_models 管"试用额度只让用哪几个模型"。
custom_configuration用户自己充值/自填的配置,分两层:供应商级(.provider,所有模型共用一把 key)和模型级(.models,某个模型单独配 key)。
model_settings列表,每个元素是 ModelSettingsapi/core/entities/provider_entities.py:153),带 enabled(是否禁用)和 load_balancing_configs(多 key 列表)——这正是 Day05 L06 负载均衡的数据来源!
大白话把这 6 个字段读成一句话:"租户 X 在 OpenAI 这家(provider),现在用的是自己充的账户(using=CUSTOM),充值账户绑了这些 key(custom_configuration),其中 gpt-4o 这个模型还开了 3 把 key 轮询、gpt-3.5 被管理员禁用了(model_settings)。" 一份档案,读完就全懂了。
L03

系统 vs 自定义:get_current_credentials 怎么抉择

Day05 里 ModelInstance 取凭据,最终落到这个方法(api/core/entities/provider_configuration.py:157):

# api/core/entities/provider_configuration.py:157
def get_current_credentials(self, model_type, model) -> dict | None:
    if self.model_settings:                                  # ① 先查"这个模型被禁了没"
        for model_setting in self.model_settings:
            if model_setting.model_type == model_type and model_setting.model == model:
                if not model_setting.enabled:
                    raise ValueError(f"Model {model} is disabled.")   # 禁用 → 直接报错

    if self.using_provider_type == ProviderType.SYSTEM:      # ② 走"系统额度"分支
        restrict_models = []
        for quota_configuration in self.system_configuration.quota_configurations:
            if self.system_configuration.current_quota_type != quota_configuration.quota_type:
                continue
            restrict_models = quota_configuration.restrict_models   # 试用额度限定的模型
        copy_credentials = self.system_configuration.credentials.copy() ...
        return copy_credentials                              # 用平台内置凭据
    else:                                                    # ③ 走"自定义"分支
        credentials = None
        if self.custom_configuration.models:                 #    先看模型级有没有单配 key
            for m in self.custom_configuration.models:
                if m.model_type == model_type and m.model == model:
                    credentials = m.credentials; break
        if not credentials and self.custom_configuration.provider:  # 没有 → 退回供应商级 key
            credentials = self.custom_configuration.provider.credentials
enabled 检查任何取凭据前,先确认这个模型没被管理员禁用。禁用了直接抛 ValueError——所以"某模型突然不能用"的报错可能来自这里。
SYSTEM 分支用平台送的试用额度:从 quota_configurations 里挑出当前额度类型,凭据用平台内置的(用户看不到明文)。restrict_models 限制"试用只能调这几个模型"。
CUSTOM 分支用用户自填的 key,优先级是模型级 > 供应商级:先看 gpt-4o 有没有单独配 key,没有才退回"整个 OpenAI 共用的那把"。这就是"某个贵模型单独用另一个账号"的实现。
credential 合规检查拿到自定义凭据后还会走 runtime_check_credential_policy_compliance,校验这把凭据是否符合租户的合规策略——企业版的凭据治理钩子。
💡 设计取舍:为什么凭据要"两级 + 系统兜底"?只支持"一家一把 key"最简单,但满足不了真实需求:新手想零配置试用(→ 系统额度);进阶用户想整家统一充值(→ 供应商级);高级用户想给贵模型单独开专用账号(→ 模型级)。三层优先级用一点点查找成本换来了从"开箱即用"到"精细计费"的完整光谱。这也是为什么这个方法要写得这么长——复杂度是需求带来的,不是过度设计。
L04

模型类型体系:六个工种一张招聘表

Dify 把模型分成六大类型(ModelType 枚举,来自 model_runtime)。它们的"技能对象"基类各不相同,但 Day05 的 ModelInstance 对每一类都提供了一个 invoke_* 门面方法:

模型类型干什么ModelInstance 门面
LLM对话/文本生成invoke_llm model_manager.py:154
TEXT_EMBEDDING把文本变成向量invoke_text_embedding model_manager.py:214
RERANK给检索结果重排打分invoke_rerank model_manager.py:272
MODERATION内容安全审查invoke_moderation model_manager.py:328
SPEECH2TEXT语音转文字invoke_speech2text model_manager.py:344
TTS文字转语音invoke_tts model_manager.py:360
六种模型实体 · 同一个 ModelInstance 门面 ModelInstance(Day05 的翻译) 按 model_type 分派到对应的技能对象 → LLM对话生成 Embedding文本→向量 Rerank重排打分 Moderation内容审查 Speech2Text语音→文字 TTS文字→语音 六类招聘表(接口)长得一样:都有 invoke() + get_model_schema() → 所以 invoke_llm / invoke_rerank / … 的代码骨架几乎一模一样 差别只在"传什么参数""要求哪个基类"
图注:一个门面(ModelInstance)+ 六个工种。类型防呆 + 委托,模式高度统一。
L05

model_type_instance 是怎么造出来的

Day05 说 model_type_instance 是"真正会调某类模型的技能对象"。它由 ProviderConfiguration.get_model_type_instanceapi/core/entities/provider_configuration.py:1538)产出:

# api/core/entities/provider_configuration.py:1538
def get_model_type_instance(self, model_type: ModelType) -> AIModel:
    if self._bound_model_runtime is not None:
        model_runtime = self._bound_model_runtime           # ① 请求已绑好的运行时,直接复用
    else:
        model_runtime, _ = self._get_runtime_and_provider_factory()  # 否则现查

    provider_schema = self._cached_provider_schema or self.provider  # ② 复用已解析的说明书

    return create_model_type_instance(                       # ③ ★按"运行时+说明书+类型"造技能对象
        runtime=model_runtime,
        provider_schema=provider_schema,
        model_type=model_type,
    )
_bound_model_runtime一个性能优化:一次请求里,"运行时"(跟插件化模型进程通信的通道)会被 bind_model_runtime() 预先绑到 configuration 上。造技能对象时直接复用,不用每次重新解析——这就是字段注释里说的"reuse the caller scope"。
_cached_provider_schema同理,"供应商说明书"也缓存起来复用,避免每次都去插件目录重新拉一遍完整 catalog。
create_model_type_instance★真正的工厂:拿"运行时通道 + 说明书 + 你要的类型",造出一个 AIModel 子类实例(如 LargeLanguageModel)。返回后被塞进 ProviderModelBundle.model_type_instanceapi/core/entities/provider_configuration.py:2071),交给 Day05 的 ModelInstance
注意 Dify 现在的模型实现是插件化的(graphon.model_runtime + 插件进程),所以"技能对象"其实是个跨进程的代理。这也是为什么要有"运行时通道"这层——具体模型代码跑在独立插件里,主进程通过 runtime 调它。
L06

六种 invoke:同一个门面模式的复用

回到 Day05 的 ModelInstance。有了类型体系,你会发现六个 invoke_* 方法长得几乎一样。对比 invoke_text_embeddingapi/core/model_manager.py:214)和 invoke_rerankapi/core/model_manager.py:272):

# api/core/model_manager.py:214
def invoke_text_embedding(self, texts, input_type=EmbeddingInputType.DOCUMENT):
    if not isinstance(self.model_type_instance, TextEmbeddingModel):   # ① 类型防呆
        raise Exception("Model type instance is not TextEmbeddingModel")
    return self._round_robin_invoke(                                    # ② 统一走 Day05 的轮询包装
        self.model_type_instance.invoke,
        model=self.model_name, credentials=self.credentials,
        texts=texts, input_type=input_type,
    )

# api/core/model_manager.py:272
def invoke_rerank(self, query, docs, score_threshold=None, top_n=None):
    if not isinstance(self.model_type_instance, RerankModel):          # ① 换个基类防呆
        raise Exception("Model type instance is not RerankModel")
    return self._round_robin_invoke(                                    # ② 一模一样的包装
        self.model_type_instance.invoke,
        model=self.model_name, credentials=self.credentials,
        query=query, docs=docs, score_threshold=score_threshold, top_n=top_n,
    )
💡 一个骨架六处复用每个 invoke_* 都是三步:①类型防呆(确认技能对象是对的工种)→ ②把 model_type_instance.invoke 当函数传给 _round_robin_invoke → ③各自传各自的参数。差别只有"要求哪个基类"和"传什么参数"。因为所有 invoke 都从 Day05 的轮询包装走,负载均衡 / 故障冷却逻辑就自动对六种模型全部生效——写一份,六处白拿。这就是把"变化点"(参数)和"不变点"(调用流程)分离的威力。
⚠️ 坑:把 Embedding 模型当 LLM 调如果你在配置里给"对话模型"位置误填了一个 embedding 模型,invoke_llmisinstance(..., LargeLanguageModel) 就会失败,抛 "Model type instance is not LargeLanguageModel"。这个防呆看着啰嗦,实则救命——否则错误会一路带到模型 API 才炸,报错信息还看不懂。类型检查在最外层拦下,报错清晰。
L07

串起来 + 今日小结

📝 真实值:一个租户配置的开户档案长啥样 假设租户 t-001 在 OpenAI 的 ProviderConfigurationusing_provider_type=CUSTOM(用自己的账户);custom_configuration.provider.credentials={"openai_api_key":"sk-xxx"}(整家共用这把,加密存库);model_settings=[ModelSettings(model="gpt-4o", enabled=True, load_balancing_configs=[key-A, key-B]), ModelSettings(model="gpt-3.5", enabled=False)]。→ 现在业务调 get_current_credentials(LLM, "gpt-4o"):先查 model_settings 发现 gpt-4o 是 enabled,走 CUSTOM 分支,模型级没单配 key → 退回供应商级 sk-xxx;同时因为 gpt-4o 有 load_balancing_configs,Day05 的 _get_load_balancing_manager 会建轮询器在 key-A/key-B 间转。→ 若换成调 gpt-3.5enabled=False,直接抛 "Model gpt-3.5 is disabled"。一份档案,决定了每一次调用刷哪张卡、能不能刷。

👶 小白:ProviderConfiguration 和 Day05 的 ProviderManager,名字好像,什么关系?

👨‍🏫 老师:一层套一层。ProviderManager(Day05)是"档案室管理员"——它 get_configurations(tenant) 一次性把某租户所有供应商的档案都加载好;ProviderConfiguration(今天)是"其中一家的那份档案"。管理员从数据库读原始记录、解密凭据、组装成一份份 configuration。所以链条是:ProviderManager(管全部)→ 一份 ProviderConfiguration(管一家)→ 打包成 ProviderModelBundle → 交给 ModelInstance(Day05)去调。今天我们钻进了这份"档案"内部。

🧠 今天你应该能回答

  • ProviderConfiguration 装了哪 6 大块?(说明书 / 系统额度 / 自定义凭据 / 模型开关+负载均衡)
  • 系统额度和自定义凭据怎么二选一?(using_provider_typeget_current_credentials 分两支)
  • 自定义凭据两级优先级?(模型级 > 供应商级)
  • Dify 有哪六种模型类型?(LLM/Embedding/Rerank/Moderation/Speech2Text/TTS)
  • 六个 invoke_* 为什么代码几乎一样?(类型防呆 + 统一走 _round_robin_invoke
  • model_type_instance 由谁造?(get_model_type_instancecreate_model_type_instance 插件化工厂)

✋ 10 分钟动手

cd /Users/bitmart/work/codes/github/AI_WORK/dify

# 1. 开户档案的字段
sed -n '60,96p'     api/core/entities/provider_configuration.py   # ProviderConfiguration
sed -n '141,167p'   api/core/entities/provider_entities.py        # ModelSettings / 负载均衡

# 2. 凭据抉择(系统 vs 自定义)
sed -n '157,215p'   api/core/entities/provider_configuration.py   # get_current_credentials

# 3. 技能对象工厂 + 打包
sed -n '1538,1557p' api/core/entities/provider_configuration.py   # get_model_type_instance
sed -n '2071,2081p' api/core/entities/provider_configuration.py   # ProviderModelBundle

# 4. 六种 invoke 对比(看骨架多像)
grep -n "def invoke_" api/core/model_manager.py
明日预告 · Day 07:模型能调了,可"喂给模型的话"是怎么拼出来的?明天进 core/prompt/——SimplePromptTransform / AdvancedPromptTransform 怎么把"系统提示词 + 变量 + 历史对话 + 用户问题"组装成 PromptMessage 列表;再看 core/llm_generator/ 里 Dify 自己怎么用 LLM 干活(自动起对话标题、生成结构化输出)。
← Day 05 模型管理 Day 07 · Prompt 构造与 LLM 生成 →