技术文档:模型工厂 llm_provider / llm_use 分层设计
适用口径:Python 3.11+ /
langchain-core0.3.x。 本文在原始设计稿基础上做了 4 处修正 ,其中第 1 处会让llm_use通道的伪代码直接AttributeError,详见第 6 节。
1 概述
模型工厂提供两条模型实例构建通道:
| 通道 | 定位 |
|---|---|
llm_provider |
主干道,预注册适配器 |
llm_use |
逃生门,反射动态加载 |
绝大多数场景使用 llm_provider;llm_use 作为兜底扩展入口,仅用于标准化体系无法覆盖的特殊场景。两条通道可以共存,遵循统一的显式意图优先原则。
2 字段定义
| 字段 | 类型 | 说明 |
|---|---|---|
llm_provider |
字符串枚举 | 预注册供应商标识(如 openai、vllm、ollama)。系统启动时,provider 名称与适配器实例预先注册到全局注册表 ADAPTER_REGISTRY |
llm_use |
类全路径字符串 | 格式 module.path:ClassName,用于动态反射导入指定适配器 / 模型类 |
3 优先级规则
显式意图优先级高于默认主干道:
llm_use > llm_provider
- 配置中
llm_use非空:优先走反射通道,忽略llm_provider。使用者显式声明要加载指定类,主动承担反射带来的代价。 - 配置中
llm_use为空:自动回落至主干道,使用llm_provider查表获取预注册适配器。
顶层设计原则:优先级给显式意图,默认值给护栏。
4 两条通道定位与适用场景
4.1 llm_provider:主干道(推荐默认使用)
底层实现:适配器 + 静态注册表,无 if-else 分支。
适用场景:
- 模型兼容 OpenAI HTTP 协议(vLLM、Ollama、各类兼容网关),仅需修改
base_url、model_name、密钥 - 该供应商为系统长期维护的标准模型,需要统一做限流、配额、日志标签、权限、前端下拉选择
- 希望报错信息具备业务语义,故障栈干净,便于运维排查
新增模型接入方式:
- 编写适配器(大多场景直接复用
ChatOpenAI,仅覆盖基础参数) - 将 provider 名称与适配器实例注册进全局注册表
仅新增适配器代码,无需修改工厂核心路由逻辑。
| 优点 | 局限 |
|---|---|
| 报错友好、可审计、可管控 | 必须提前注册,仅支持预定义白名单内的供应商 |
4.2 llm_use:逃生门(兜底扩展通道)
底层实现:动态反射导入模块与类。
适用场景(仅当 provider 体系覆盖不到时才启用):
- 模型接口不兼容 OpenAI 协议 ,使用原生 SDK / gRPC 等异构接口,无法用
ChatOpenAI封装 - 需要深度自定义模型行为:增加特殊前置 / 后置逻辑、自定义 Agent 封装
- 临时实验原型,不想新增 provider、不想加入系统白名单,快速验证自定义适配器
重要 :兼容 OpenAI 协议的自定义模型,不需要
llm_use,直接走llm_provider主干道。
新增模型接入方式:
- 编写继承
BaseChatModel的自定义适配器类 - 在配置中填写
llm_use = "module:ClassName",无需修改注册表
| 优点 | 代价 |
|---|---|
| 不受预注册白名单约束,扩展自由度高 | 反射失败抛出底层 ModuleNotFoundError / AttributeError,堆栈深、丢失业务语义;难以按 provider 做配额、权限分组管控 |
⚠️ 关于"重写
invoke/stream/bind_tools":不应覆写invoke/stream。 LangChain 要求子类实现的是_generate(必需)、_llm_type(必需), 可选_stream/_agenerate/_astream;invoke/stream是基类公开入口,覆写会破坏回调与重试。 另外bind_tools在BaseChatModel中默认抛NotImplementedError,契约校验保不了它,需另行能力探测。
5 判定规则:什么时候走反射层(llm_use)
默认走主干道
llm_provider;反射层llm_use仅在加载不在预注册白名单内 ,且无法通过 OpenAI 协议适配器封装的自定义类时显式启用。 一旦配置llm_use,它以更高优先级覆盖主干道;使用者换取动态扩展能力,同时承担反射带来的诊断成本。
6 执行逻辑伪代码(修正版)
【修正 1】原伪代码两条通道的 adapter 类型不一致,llm_use 分支会直接崩
原稿:
python
def build_chat_model(cfg):
if cfg.llm_use:
adapter_cls = dynamic_import(cfg.llm_use)
assert issubclass(adapter_cls, BaseChatModel) # ← 断言:是 BaseChatModel 子类
adapter = adapter_cls() # ← 于是 adapter 是【模型实例】
else:
adapter = ADAPTER_REGISTRY[cfg.llm_provider] # ← 这里是【适配器实例】
llm_model = adapter.build_model(cfg.params) # ← llm_use 分支:AttributeError
return llm_model, adapter
问题 :issubclass(adapter_cls, BaseChatModel) 之后 adapter_cls() 得到的是模型对象 (如 ChatOpenAI 实例), 而 ChatOpenAI 没有 build_model() 方法 。下一行 adapter.build_model(cfg.params) 在 llm_use 分支必然抛 AttributeError: 'ChatOpenAI' object has no attribute 'build_model'。
根因 :两条通道对 llm_use 的目标类型没有统一契约------它到底指向"适配器类"还是"模型类"?
修正 :显式区分两种情况,并把返回值统一成 (model, adapter):
python
def build_chat_model(cfg):
if cfg.llm_use:
cls = dynamic_import(cfg.llm_use)
if isinstance(cls, type) and issubclass(cls, BaseLlmAdapter):
# 情况 A:llm_use 指向【适配器类】→ 与主干道同构
adapter = cls()
elif isinstance(cls, type) and issubclass(cls, BaseChatModel):
# 情况 B:llm_use 指向【模型类】→ 用通用适配器包一层
adapter = GenericAdapter(model_cls=cls)
else:
raise TypeError(
f"llm_use={cfg.llm_use!r} 必须是 BaseLlmAdapter 或 BaseChatModel 子类,"
f"实际为 {cls!r}"
)
else:
adapter = _lookup_provider(cfg.llm_provider)
llm_model = adapter.build_model(cfg.params)
return llm_model, adapter
更推荐的收口方式 :让
llm_use只接受适配器类 (BaseLlmAdapter子类), 与llm_provider主干道完全同构 ------两条通道最终都返回一个"能build_model()的适配器"。 这样build_chat_model后半段只有一条路径,分支数从 2×2 降到 2×1,也避免"返回的 adapter 到底是什么"的长期歧义。
【修正 2】assert 不能用于生产环境校验
Python 官方语义:assert expr 等价于
python
if __debug__:
if not expr:
raise AssertionError
而 __debug__ 在 -O / PYTHONOPTIMIZE=1 下为 False,编译器此时根本不为 assert 生成任何代码 ------ 校验被静默跳过。官方与主流实践一致:断言只用于检查程序内部不变量,绝不用于校验外部输入/配置。
python
# ❌
assert issubclass(cls, BaseChatModel)
# ✅
if not (isinstance(cls, type) and issubclass(cls, BaseChatModel)):
raise TypeError(f"{cfg.llm_use} 不是 BaseChatModel 子类")
【修正 3】ADAPTER_REGISTRY[...] 直接下标会抛无语义的 KeyError
两个字段都为空时 cfg.llm_provider 为 None,直接 KeyError: None;provider 拼错时也只是 KeyError: 'vlllm',运维看不出该怎么办。
python
def _lookup_provider(provider: str | None):
if not provider:
raise ValueError("未配置 llm_provider,且未指定 llm_use,无法构建模型")
try:
return ADAPTER_REGISTRY[provider]
except KeyError as e:
raise ValueError(
f"未知 llm_provider={provider!r};可用值:{sorted(ADAPTER_REGISTRY)}"
) from e
【修正 4】"反射堆栈深、丢失业务语义"这个代价,其实可以消掉
原稿把"堆栈深、丢语义"列为使用者必须知情承担的代价。这个代价不是必然的 ------用异常链就能同时拿到业务语义和完整技术栈。
Python 官方文档:raise NewError(...) from e 会把原异常挂到新异常的 __cause__ 上, traceback 中两段都会打印,并标注 The above exception was the direct cause of the following exception。
python
def dynamic_import(path: str):
module_path, _, cls_name = path.partition(":")
if not module_path or not cls_name:
raise ValueError(f"llm_use 格式应为 'module.path:ClassName',实际为 {path!r}")
try:
module = importlib.import_module(module_path)
except ModuleNotFoundError as e:
raise LlmProviderError(
f"llm_use 模块未安装:{module_path}(请先 pip install,反射不会自动装包)"
) from e
try:
return getattr(module, cls_name)
except AttributeError as e:
raise LlmProviderError(
f"模块 {module_path} 中不存在类 {cls_name}"
) from e
| 写法 | 结果 |
|---|---|
raise e |
❌ 截断堆栈,丢底层上下文------Code Review 应直接打回 |
raise NewError(msg) |
⚠️ 隐式链(__context__),语义弱 |
raise NewError(msg) from e |
✅ 业务语义 + 完整技术栈,两者兼得 |
这样处理后,
llm_use的"代价"就只剩无法按 provider 做配额 / 权限分组管控这一条(因为它天然不在白名单里), 而"堆栈难读"这条可以从文档里划掉。
7 边界总结
| # | 结论 |
|---|---|
| 1 | llm_provider 负责标准化接入:绝大多数 OpenAI 兼容模型全走这个通道,是系统主力扩展方式 |
| 2 | llm_use 不是用来接入普通 OpenAI 兼容模型的,它是逃出标准化体系的逃生口------异构接口、深度定制、临时原型,日常业务使用频次很低 |
| 3 | 二者不是互相替代,是主通道 + 兜底逃逸通道的互补关系 |
8 常见误区澄清
误区 1 :
llm_use是接入自定义模型的唯一方式。 纠正 :兼容 OpenAI 协议的自定义模型,走llm_provider即可;llm_use仅用于超出 OpenAI 协议封装能力边界的场景。
误区 2 :llm_use优先级更高,所以优先推荐使用llm_use。 纠正 :优先级代表显式配置的意图权重 ,不等于推荐选型。日常开发不填llm_use,默认落在 provider 主干道,享受护栏保护。
补充误区 3 :工厂里用assert做契约校验就够了。 纠正 :-O下 assert 被完全移除,必须raise TypeError。
补充误区 4 :反射的错误堆栈只能忍。 纠正 :用raise ... from e包装成业务异常,语义和栈可以兼得。
9 一句话背诵版
主干道注册、逃生门反射;显式意图压默认,护栏给默认值。 契约校验要
raise不要assert,反射报错要from e不要裸抛。