LLM 动态加载与多用户缓存架构技术文档

LLM 动态加载与多用户缓存架构技术文档

适用口径:LangChain langchain-core 0.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()

原稿写"模型参数哈希",实现时必须明确两条:

  1. 禁止 hash() :CPython 的哈希种子每进程随机化 (PYTHONHASHSEED),

同一份参数重启进程后 key 就变了,跨进程 / 跨重启的缓存完全失效; 而且 hash() 对 dict / list 直接抛 TypeError: unhashable type。

  1. 敏感参数不要明文进 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 存配置,内存存实例; 契约校验在启动,能力探测在加载。

相关推荐
流浪0012 小时前
大模型技术全景(二十):RAG 文本分块策略与语义完整性
llm·rag·文本分块·语义完整性
一木 之林2 小时前
Dify学习笔记 00 · 总览:56集四模块学习地图(从低代码平台到检索底座)
人工智能·计算机视觉·langchain
JWASX2 小时前
【agent 开发】agent 开发学习 - LangChain(2)
python·学习·langchain
小小龙学IT2 小时前
Redis 源码深度解析:从常用命令到内部实现
redis·golang·开源
10年前端老司机12 小时前
你的RAG检索正在“高效地重复废话”:一文彻底搞懂MMR算法
langchain·llm·agent
stereohomology13 小时前
大模型的观点谨:StoryTold 「Crafting Apps」四件套 · 深度总览
人工智能·llm
禾小西14 小时前
07丨Redis 哨兵机制:主库故障后,如何恢复服务?
java·开发语言·redis
浮链序16 小时前
怎么证明"你这个模型是偷我的"——把蒸馏变成取证工具
算法·安全·llm
骉马代驾16 小时前
代驾系统实战(三):抢单池的 Redis 锁 + RabbitMQ 异步消费怎么做
redis