一个搜索服务可能需要加载模型,但程序启动后,不一定马上有人搜索。如果创建服务时就加载模型,即使最后没有执行搜索,也已经付出了加载的时间和内存开销。
懒加载,就是先不加载资源,等执行到需要它的地方再加载。 不过,把加载代码放进 @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 文档
排查加载过早时,可以像示例一样,在创建服务、创建工具、调用工具和资源初始化的位置分别打印标记。找到最早触发属性读取的那一行,才能判断需要推迟的是模型创建、工具创建,还是其中的一次属性访问。