一个搜索服务可能需要加载模型,但程序启动后,不一定马上有人搜索。如果创建服务时就加载模型,即使最后没有执行搜索,也已经付出了加载的时间和内存开销。

懒加载,就是先不加载资源,等执行到需要它的地方再加载。 不过,把加载代码放进 @property,只会把触发点改成“访问这个属性时”,并不自动保证“第一次搜索时才加载”。

下面用一个带打印信息的小例子,观察模型到底在创建服务、创建工具,还是调用工具时加载。示例不下载模型,也不依赖 LangChain;它只演示 Python 的执行顺序。

1. 创建服务,不等于加载模型

新建一个示例目录,在里面保存 services.py:

class DemoModel:
    def __init__(self):
        print("加载模型")
 
    def search(self, query):
        return f"模拟搜索:{query}"
 
 
class SearchService:
    def __init__(self):
        self._model = None
        print("服务已创建")
 
    @property
    def model(self):
        print("访问 model 属性")
        if self._model is None:
            self._model = DemoModel()
        return self._model

这里有两个不同的对象:

  • SearchService 是服务对象,用来管理模型。创建它时,__init__() 只把 _model 设为 None,表示还没有模型。
  • DemoModel 代表要加载的资源。这个演示类只打印“加载模型”,它的 search() 也只返回一段模拟文字,不做真实检索。

因此,执行 SearchService() 时不会执行 DemoModel()。创建服务之后,状态是:

服务对象:已经存在
服务对象里的 _model:None
模型对象:还不存在

接着在同一目录保存 demo.py:

from services import SearchService
 
service = SearchService()
print("准备第一次取模型")
first = service.model
 
print("准备第二次取模型")
second = service.model
 
print("两次取到同一个对象:", first is second)

在终端切到这个目录,用 Python 3.11 运行,不需要安装额外依赖:

python3 demo.py

输出是:

服务已创建
准备第一次取模型
访问 model 属性
加载模型
准备第二次取模型
访问 model 属性
两次取到同一个对象: True

从这几行可以分清两个动作:属性被访问了两次,模型只创建了一次。

2. @property 负责触发,_model 负责保存结果

平常调用方法会写 service.get_model(),带着括号。使用 @property 后,可以写成 service.model,但这不代表它只是读取一个已经存好的值:Python 会执行 model() 里的代码,取它的返回值。这个取值函数也叫 getter。Python property 文档

第一次执行 first = service.model 时,发生的是:

读取 service.model
    ↓
进入 model() 的函数体
    ↓
发现 self._model 是 None
    ↓
执行 DemoModel(),得到模型对象
    ↓
把对象存到 self._model,再返回它

第二次读取时,仍会进入函数体,所以又打印了“访问 model 属性”。但此时 _model 已经有值,if 条件不成立,程序直接返回保存的对象。

因此,这段代码同时做了两件事:

代码作用
@property读取 service.model 时执行取值函数
if self._model is None 与赋值尚未加载时才创建,并把结果留给后续使用

@property 本身不缓存结果。 如果函数体每次都执行 return DemoModel(),每次读取属性都会创建一个新模型。

这里的 _model 只是我们选用的普通实例属性名,用来存放结果;前面的下划线是内部使用的命名约定,不会让 Python 自动执行加载或缓存。

3. 为什么放进 property 后,仍然在创建服务时加载

看下面这个变体。假设把 services.py 中 SearchService 的 __init__() 换成:

def __init__(self):
    self._model = None
    self.model
    print("服务已创建")

虽然创建模型的代码仍然在 property 中,但初始化方法里多了一行 self.model。即使没有接收返回值,这一行也已经读取了属性。

于是,执行 SearchService() 时的顺序变成:

进入 __init__()
    ↓
读取 self.model
    ↓
触发 property,创建模型
    ↓
__init__() 结束

它会先打印“访问 model 属性”和“加载模型”,最后才打印“服务已创建”。资源并没有等到搜索时才加载。

这不是 property 失效了,而是第一次属性访问就在初始化方法里。判断懒加载的时机,需要找到谁先读取了属性,不能只看加载代码写在哪个函数中。

4. 创建工具时,也可能提前触发加载

下面回到第一节的原始 services.py,保持初始化方法只设置 _model = None 并打印信息。把 demo.py 的全部内容替换为:

from services import SearchService
 
 
def create_tool(service):
    model = service.model
    return model.search
 
 
service = SearchService()
print("准备创建工具")
tool = create_tool(service)
print("工具已创建,还没有调用")
 
print("准备调用工具")
print(tool("来访的人怎样上网?"))

这里用一个可调用的函数代表“工具”:tool 接收问题,返回搜索结果,没有引入额外的工具框架。

return model.search 没有写调用括号,所以它只是把这个模型的搜索方法交给调用者,留待以后调用。但是,在拿到这个方法之前,上一行的 service.model 已经被求值了。

再次运行 python3 demo.py,输出是:

服务已创建
准备创建工具
访问 model 属性
加载模型
工具已创建,还没有调用
准备调用工具
模拟搜索:来访的人怎样上网?

“加载模型”出现在“工具已创建,还没有调用”之前。搜索方法确实没有提前执行,但模型已经在创建工具时加载。

即使合并成 return service.model.search,结果也一样:Python 需要先取得 service.model,才能找到它的 search 方法。不写搜索方法的括号,只能推迟搜索,不能推迟前面的属性访问。

5. 把资源访问放到工具执行时

如果希望创建工具时也不加载模型,就把 service.model 放进以后才执行的函数里。

保持 services.py 不变,把 demo.py 替换为:

from services import SearchService
 
 
def create_tool(service):
    def run(query):
        return service.model.search(query)
 
    return run
 
 
service = SearchService()
print("准备创建工具")
tool = create_tool(service)
print("工具已创建,还没有调用")
 
print("准备第一次调用工具")
print(tool("来访的人怎样上网?"))
 
print("准备第二次调用工具")
print(tool("来访的人怎样上网?"))

这次 create_tool() 只定义并返回 run,没有执行 run() 的函数体。返回的函数保留了对 service 的引用,后面执行 tool(...) 时,才通过它访问模型。

运行后输出:

服务已创建
准备创建工具
工具已创建,还没有调用
准备第一次调用工具
访问 model 属性
加载模型
模拟搜索:来访的人怎样上网?
准备第二次调用工具
访问 model 属性
模拟搜索:来访的人怎样上网?

对照上一节,变化只在于属性访问移到了哪里:

阶段创建工具时读取属性在 run() 内读取属性
创建服务不加载不加载
创建工具加载模型不加载
第一次调用工具使用已有模型加载模型,再搜索
后续调用工具使用已有模型使用已有模型

如果这个工具最终没有被调用,后一种写法就不会创建模型。前提是没有其他代码提前访问同一个服务的 model 属性。

用在检索服务中也是同样的道理:假设 faiss 是加载向量库的 property,那么创建工具时执行 self.faiss.as_retriever(),就已经读取了 self.faiss。它只能保证此前不加载,不能保证一直等到工具被调用才加载。要达到后者,需要把这次属性访问也推迟到工具的执行路径中。

6. 延迟加载,也会延迟等待和错误

懒加载改变的是加载的时间,不会消除加载所需的工作。如果模型加载需要几秒,第一次使用它的调用仍然要等待这几秒;文件缺失或配置错误,也可能到第一次使用时才暴露。

本例的缓存属于一个 SearchService 实例。再次创建 SearchService(),新对象的 _model 又是 None,不会自动共享前一个服务里的模型。程序退出后,这个内存对象也不会保留到下次启动;模型文件是否已经下载到磁盘,是另一回事。

示例按单线程顺序执行。在多个线程同时首次访问时,if self._model is None 并不能保证只创建一次:一个线程开始加载但尚未完成赋值,另一个线程仍可能看到 None。如果业务要求并发时也只初始化一次,需要为检查、创建和赋值这一段增加同步保护。

Python 还提供 functools.cached_property,可以省去手动保存属性计算结果的一部分代码。但它同样由属性读取触发,换成它也不会改变“创建工具时访问属性,就在创建工具时计算”的关系。Python cached_property 文档

排查加载过早时,可以像示例一样,在创建服务、创建工具、调用工具和资源初始化的位置分别打印标记。找到最早触发属性读取的那一行,才能判断需要推迟的是模型创建、工具创建,还是其中的一次属性访问。