LLM 动态加载与多用户缓存架构技术文档
适用口径:LangChain
langchain-core0.3.x /langchain-openai;Python 3.9+ 缓存装饰器。 本文在原始复盘稿基础上做了 4 处修正 + 3 处工程补充,分别标注在对应小节。
一、文档概述
本文档针对 Agent 项目的动态 LLM 模型加载机制 、类 / 实例缓存策略 、多用户并发隔离方案 、本地缓存与 Redis 分层设计做系统性总结。
核心解决的问题:
| # | 问题 |
|---|---|
| 1 | 如何通过配置动态插拔任意 LLM 模型,无需修改业务代码 |
| 2 | 区分「模型类」与「模型实例」的缓存边界 |
| 3 | 解决多用户场景下的模型配置隔离、内存溢出、并发性能问题 |
| 4 | 明确本地内存缓存与 Redis 的分工与使用场景 |
二、核心机制:LLM 动态反射加载原理
2.1 整体流程
项目通过 llm_use(模型类路径)+ llm_use_params(模型实例参数)实现完全配置化加载:
python
import importlib
from langchain_core.language_models.chat_models import BaseChatModel
def load_llm_class(llm_use: str) -> type[BaseChatModel]:
module_path, _, cls_name = llm_use.partition(":") # "langchain_openai:ChatOpenAI"
module = importlib.import_module(module_path) # ① 运行时动态导入
cls = getattr(module, cls_name) # ② 按变量名取类对象
if not (isinstance(cls, type) and issubclass(cls, BaseChatModel)):
raise TypeError(f"{llm_use} 不是 BaseChatModel 子类") # ③ 契约校验
return cls # ④ 返回类,交给上层实例化
| 步骤 | 动作 | 说明 |
|---|---|---|
| 1 | 读取配置 | AGENT_LLM_USE 获取模型类路径(如 langchain_openai:ChatOpenAI) |
| 2 | 动态反射 | importlib.import_module 运行时导入模块 + getattr 取类 |
| 3 | 契约校验 | 强制 issubclass(cls, BaseChatModel) |
| 4 | 实例化 | 通过 llm_use_params 传入 base_url / api_key / model / temperature 生成实例 |
工程补充 A :反射只能加载已安装在环境中的模块 ,不会自动下载第三方包。 换一个没装过的厂商包,必须先在镜像里
pip install,否则import_module直接ModuleNotFoundError。
2.2 关键设计:类与实例完全解耦
| 对象类型 | 说明 | 是否含用户配置 | 复用策略 |
|---|---|---|---|
| 模型类(Class) | 如 ChatOpenAI、自定义适配器类,属于代码模板 |
❌ 无任何用户私有数据 | 全局单例永久缓存 |
| 模型实例(Instance) | new 出来的 llm 对象,承载请求配置 |
✅ 含 base_url、api_key、用户超参 |
多用户隔离、可 TTL 缓存、用完释放 |
2.3 契约校验的意义(issubclass 校验)
校验规则:动态加载的类必须是 BaseChatModel 子类。
核心价值:
- 不是限制模型厂商,是统一接口契约
- 启动 / 加载阶段快速失败,避免业务运行时深度报错
- 原生厂商 SDK 不继承
BaseChatModel,无法直接加载;OpenAI 协议模型统一复用ChatOpenAI实现适配
三、两类模型接入方式(全覆盖场景)
3.1 通用场景:OpenAI 兼容协议模型(零代码接入)
绝大多数本地 / 第三方模型(Ollama、vLLM、SGLang、OneAPI)均支持 OpenAI 标准 HTTP 协议。
- 固定使用模型类:
langchain_openai:ChatOpenAI - 仅通过
llm_use_params差异化配置:base_url、api_key、model名称 - 无需新增任何代码、无需适配器
3.2 私有场景:无 OpenAI 协议的厂商原生 SDK
厂商仅提供私有 Python SDK、无标准 HTTP 接口时,需要自定义适配器。
【修正 1】自定义适配器要实现的是「下划线方法」,不是 invoke / stream
原稿写"实现标准接口(invoke/stream 等)"------这在 LangChain 里是错的 。 invoke / stream / batch 是基类提供的公开入口 (自带回调、重试、配置注入等逻辑),子类不应该 覆写它们。 真正需要子类实现的是下面这张表(LangChain 官方 BaseChatModel 文档):
| 方法 / 属性 | 说明 | 是否必需 |
|---|---|---|
_generate |
由 prompt 生成 ChatResult |
必需 |
_llm_type(property) |
唯一标识模型类型,用于日志 | 必需 |
_identifying_params(property) |
描述模型参数化信息,用于 tracing | 可选 |
_stream |
实现流式输出 | 可选 |
_agenerate |
原生异步方法 | 可选 |
_astream |
_stream 的异步版本 |
可选 |
python
from langchain_core.language_models.chat_models import BaseChatModel
class MyVendorChatModel(BaseChatModel):
@property
def _llm_type(self) -> str:
return "my-vendor"
def _generate(self, messages, stop=None, run_manager=None, **kwargs):
# 内部封装厂商原生 SDK
return ChatResult(generations=[...])
def _stream(self, messages, stop=None, run_manager=None, **kwargs):
... # 不实现也可以:基类会降级为「调 _generate 后整块 yield」
配置填写项目内部适配器路径,即可动态加载。适配器只需编写一次,后续切换模型仅改配置。
【修正 2】issubclass 校验不能 保证 bind_tools 可用
原稿写"保证所有动态加载的模型,都拥有 invoke / stream / bind_tools 等 Agent 依赖的标准方法"------这条不成立。
LangChain 官方口径:bind_tools 在 BaseChatModel 中的默认实现直接抛 NotImplementedError,由各厂商 partner 包各自覆写。
| 方法 | BaseChatModel 是否提供可用默认实现 |
|---|---|
invoke / ainvoke |
✅ 有(内部转调 _generate) |
stream / astream |
✅ 有(未实现 _stream 时降级为「_generate 后整块输出」) |
bind_tools |
❌ 默认 NotImplementedError,需厂商实现 |
with_structured_output |
有默认 function-calling 实现,但部分厂商会自行覆写 |
工程结论 :Agent 若是 tool-calling 强依赖,光靠 issubclass 不够,应在加载后做一次能力探测 (如 hasattr(model, "bind_tools") 并在启动阶段跑一次最小工具调用),把"模型不支持工具调用"这个失败前移到启动期,而不是等用户聊到一半才炸。
四、缓存架构设计(核心重点)
4.1 模型类缓存(全局永久缓存)
| 项 | 值 |
|---|---|
| 缓存对象 | 解析完成的 LLM 模型类 |
| 缓存 Key | llm_use 类路径字符串 |
| 缓存策略 | 进程内全局单例、永久缓存、无需过期 |
原因:
- 模型类是只读模板,无用户状态、无配置污染风险
- 避免重复反射导入、重复基类校验
- 底层复用 Python
sys.modules模块缓存,上层业务二次加固缓存
工程补充 B(别高估这层缓存的收益) :
importlib.import_module本身就是走sys.modules缓存的 , 重复 import 不会重复执行模块代码。所以这层类缓存真正省掉的,只有partition(":")+getattr+issubclass这一小段------量级在微秒 。 它的主要价值是统一入口 + 集中做契约校验与错误提示,而不是性能。 不要为了这点开销去设计复杂的失效机制。
4.2 模型实例缓存(多用户隔离 TTL 缓存)
核心结论 :多用户 Web 场景下,禁止全局单例 LLM 实例,会导致用户配置互相污染。
| 项 | 值 |
|---|---|
| 缓存对象 | LLM 实例(携带用户专属 base_url、api_key、超参) |
| 缓存 Key | user_id + llm_use + 模型参数摘要 |
| 缓存策略 | 本地内存 TTL + LRU(maxsize 限制 + 空闲过期) |
核心收益:
- 复用 HTTP 长连接池,减少 TCP / TLS 握手开销,提升并发效率
- 避免高频用户重复创建实例的冗余开销
- 过期自动销毁、上限限流,杜绝内存溢出
【修正 3】@cache / functools.lru_cache 没有 TTL
原稿 5.1 写"全局单例缓存(@cache)"、4.2 写"本地内存 TTL+LRU 缓存"------这两个用的不是同一个东西,别混为一谈:
| 方案 | LRU 上限 | TTL 过期 | 说明 |
|---|---|---|---|
functools.lru_cache(maxsize=N) |
✅ 有 | ❌ 没有 | 只在缓存满时按 LRU 淘汰;不满则永不过期 |
functools.cache(Python 3.9+) |
❌ 无上限 | ❌ 没有 | 等价于 lru_cache(maxsize=None),永不淘汰 |
cachetools.TTLCache(maxsize=N, ttl=T) |
✅ 有 | ✅ 有 | 同时支持上限 + 时间过期,生产级正解 |
aiocache(异步) |
✅ 有 | ✅ 有 | 异步服务可换这个 |
所以 4.2 的「TTL + LRU + maxsize」三件套,标准库做不到 ,必须引入 cachetools:
python
from cachetools import TTLCache, cached
import threading
llm_cache = TTLCache(maxsize=256, ttl=600) # 最多 256 个实例,空闲 10 分钟回收
_cache_lock = threading.RLock() # ⚠ cachetools 默认非线程安全,Web 并发必须加锁
@cached(cache=llm_cache, lock=_cache_lock, key=lambda u, p: make_key(u, p))
def get_llm(user_id: str, params: dict):
return load_llm_class(params["llm_use"])(**params["llm_use_params"])
⚠️ 线程安全 :
cachetools的TTLCache本身不是线程安全的 , Web 并发下必须通过@cached(lock=...)传锁,或自己用threading.RLock包一层, 否则会出现「同一 key 被并发重复创建实例」甚至字典竞态。
【修正 4】缓存 Key 的参数摘要不能用 Python 内置 hash()
原稿写"模型参数哈希",实现时必须明确两条:
- 禁止
hash():CPython 的哈希种子每进程随机化 (PYTHONHASHSEED),
同一份参数重启进程后 key 就变了,跨进程 / 跨重启的缓存完全失效; 而且 hash() 对 dict / list 直接抛 TypeError: unhashable type。
- 敏感参数不要明文进 Key :
api_key明文出现在缓存 key 里,可能被日志、APM、cache_info()打出去。
正确做法(与本项目「缓存 Key 规范」一致):
python
import hashlib, json
def make_key(user_id: str, params: dict) -> str:
payload = {
"llm_use": params["llm_use"],
"base_url": params["llm_use_params"].get("base_url"),
"model": params["llm_use_params"].get("model"),
"temperature": params["llm_use_params"].get("temperature"),
# ⚠ api_key 不进 key:换 key 不应命中旧实例,用其摘要代替
"key_fp": hashlib.sha256(
params["llm_use_params"].get("api_key", "").encode()
).hexdigest()[:16],
}
return f"{user_id}|" + hashlib.sha256(
json.dumps(payload, sort_keys=True, ensure_ascii=False).encode()
).hexdigest()
4.3 为什么 LLM 实例不存 Redis?
很多人会混淆本地缓存与分布式缓存,此处明确边界。
工程补充 C(把理由说精确) :严格讲 Redis 能存任何
bytes------用pickle也能塞进 Python 对象。 真正的问题是:LLM 实例持有不可序列化的资源 ------httpx.Client连接池、已建立的 socket、 线程锁、openai.OpenAI客户端内部的异步资源。pickle这些会直接报TypeError: cannot pickle '_thread.lock' object,硬绕过去也会得到一堆指向已失效连接的对象。
- Redis 只能可靠地缓存可序列化的数据,无法承载"带连接池、带上下文的活对象"
- 反序列化对象存在大量隐性 bug,稳定性极差
- LLM 实例是进程内临时资源,天然不需要跨机器共享
Redis 的正确分工 :仅缓存用户配置数据、会话状态、限流计数、密钥配置,不缓存实例对象。
五、两种运行场景的最终方案选型
5.1 单机个人场景
| 层 | 方案 |
|---|---|
| 模型类 | 全局缓存 |
| 模型实例 | 全局单例缓存(@cache) |
特点:极简、无隔离需求、性能最优。 (@cache 无上限无 TTL,在单用户、模型集合固定的前提下是安全的。)
5.2 Web 多用户并发场景(生产标准)
| 层 | 方案 |
|---|---|
| 模型类 | 全局永久缓存(所有用户共用模板) |
| 模型实例 | 本地 TTL + LRU 缓存(cachetools.TTLCache + 锁) |
配套约束:
- 设置最大缓存数量,防止内存暴涨
- 设置空闲过期时间,自动回收低频实例
- 用户配置变更自动生成新实例,无缓存脏数据
- 低频用户:请求时临时创建实例,用完释放,零常驻内存压力
工程补充 D(释放不是免费的) :CPython 引用计数为 0 时会立即回收对象, 但底层连接池不会随之立即关闭 ------
httpx.Client的 socket 要等close()或 gc 终结器。 实例从 TTL 缓存淘汰时,建议显式close()(LangChain 侧通常有client.close()或 通过with上下文管理),否则短时大量创建 / 淘汰会堆积TIME_WAIT连接。
六、关键误区纠正
| # | 误区 | 纠正 |
|---|---|---|
| 1 | 动态导入 = 自动安装依赖 | 反射只能加载已安装在环境中的模块,不会自动下载第三方包 |
| 2 | LLM 实例很重,并发会爆内存 | LangChain ChatModel 仅存储参数,不加载模型权重,内存开销极低;真正的资源占用是底层 HTTP 连接池 |
| 3 | 全局实例单例通用 | 只适合单机;多用户共用实例会导致配置串扰,是严重生产 bug |
| 4 | 缓存实例是为了省对象创建开销 | 核心价值是复用连接池 (省 TCP / TLS 握手),不是省 new |
| 5 | issubclass(BaseChatModel) = 工具调用可用 |
bind_tools 默认抛 NotImplementedError,需厂商实现(见 3.2 修正 2) |
| 6 | 自定义适配器要覆写 invoke / stream |
应实现 _generate / _llm_type,可选 _stream / _agenerate / _astream(见 3.2 修正 1) |
七、最终架构总结:一层配置、两层缓存、三级隔离
| 层 | 内容 |
|---|---|
| 配置层 | llm_use 控制模型类,llm_use_params 控制实例参数 |
| 缓存层 1(全局) | 模型类永久缓存,复用模板、集中做契约校验 |
| 缓存层 2(用户级) | 模型实例 TTL + LRU 缓存,复用连接、隔离用户配置、控内存 |
| 数据层 | Redis 缓存用户配置,进程内存缓存业务实例,分工明确 |
一句话背诵版:
类全局、实例用户级;类永久、实例 TTL; Redis 存配置,内存存实例; 契约校验在启动,能力探测在加载。