网罗开发 (小红书、快手、视频号同名)

  大家好,我是 展菲,目前在上市企业从事人工智能项目研发管理工作,平时热衷于分享各种编程领域的软硬技能知识以及前沿技术,包括iOS、前端、Harmony OS、Java、Python等方向。在移动端开发、鸿蒙开发、物联网、嵌入式、云原生、开源等领域有深厚造诣。

图书作者:《ESP32-C3 物联网工程开发实战》
图书作者:《SwiftUI 入门,进阶与实战》
超级个体:COC上海社区主理人
特约讲师:大学讲师,谷歌亚马逊分享嘉宾
科技博主:华为HDE/HDG

我的博客内容涵盖广泛,主要分享技术教程、Bug解决方案、开发工具使用、前沿科技资讯、产品评测与使用体验。我特别关注云服务产品评测、AI 产品对比、开发板性能测试以及技术报告,同时也会提供产品优缺点分析、横向对比,并分享技术沙龙与行业大会的参会体验。我的目标是为读者提供有深度、有实用价值的技术洞察与分析。

展菲:您的前沿技术领航员
👋 大家好,我是展菲!
📱 全网搜索“展菲”,即可纵览我在各大平台的知识足迹。
📣 公众号“Swift社区”,每周定时推送干货满满的技术长文,从新兴框架的剖析到运维实战的复盘,助您技术进阶之路畅通无阻。
💬 微信端添加好友“fzhanfei”,与我直接交流,不管是项目瓶颈的求助,还是行业趋势的探讨,随时畅所欲言。
📅 最新动态:2025 年 3 月 17 日
快来加入技术社区,一起挖掘技术的无限潜能,携手迈向数字化新征程!


问题背景

你用的是本地 BAAI/bge-base-zh-v1.5 模型,通过 AutoTokenizer + AutoModel 做嵌入,且把模型封装成单例类(BAAIEmbeddingEngine)并在项目启动时初始化。
但是每次执行向量化(lancedb 操作)时,日志都会出现:

sentence_transformers.SentenceTransformer INFO Load pretrained SentenceTransformer: BAAI/bge-base-zh-v1.5

你怀疑:这是不是每次都重新加载了模型?导致每次调用都有卡顿?单例不是已经只加载一次了吗?

结论(先给结果)

那条日志来自 sentence_transformers.SentenceTransformer.from_pretrained(...) 的内置日志。出现这行日志 说明在某处确实创建了 SentenceTransformer 实例(或以 model name 调用了 from_pretrained)。如果这条日志每次都出现,则说明在每次向量化调用流程中确实触发了 SentenceTransformer 的构造/加载操作 —— 也就是模型被重复“实例化/加载”了(至少在当前进程/上下文中每次都会走到加载路径)。

但“为什么会重复加载”有几种常见原因(下面详细解释并给出验证与解决办法):

可能的根因与排查要点(逐项说明)

  1. 有其它库/代码在内部每次创建 SentenceTransformer

    • 即使你用 AutoModel/AutoTokenizer,项目中可能有第三方库(例如 lancedb、embedding 注册器、某些 util)在内部收到 model name 字符串("BAAI/bge-base-zh-v1.5")时会调用 SentenceTransformer(model_name) 来构建 embedding 模型。
    • 排查方法:grep / search 项目中是否有 SentenceTransformerfrom_pretrained 被调用;或启用更详细日志,打印创建时的栈信息(稍后给方法)。
  2. 你的单例并不是真的被复用(跨请求/进程/worker 不生效)

    • 单例在一个进程中有效,但如果你的服务是以多进程(gunicorn/uvicorn workers)或 multiprocessing 执行,那么每个子进程都会独立加载模型。
    • 在 Windows 或 spawn 启动模式下,子进程会重新执行初始化逻辑(单例不能跨进程共享)。
    • 排查方法:在 __init__ 里打印 id(self.__model)os.getpid(),观察每次调用时 PID 与对象 id 是否一致。
  3. lancedb / EmbeddingFunctionRegistry 的注册机制导致每次创建新实例

    • 你注册的是一个类,lancedb 在序列化/反序列化或每次调用时可能会 new 一个实例(例如在 worker 或远端执行)。
    • 排查方法:查看 lancedb 的 embedding function 使用方式,是否把你的对象 pickled 传给 worker,从而导致重建。
  4. 你在 encode 的路径中某处重复尝试创建 SentenceTransformer(代码逻辑问题)

    • 例如在 generate_embeddings__call__ 内部无意间每次都 new 一个模型(也许间接调用了其他工具函数)。
    • 排查方法:在 SentenceTransformer 的构造处(或在 BAAIEmbeddingEngine.__init__)打 log,观察调用频率。
  5. 日志并不代表真正的“磁盘/网络权重加载”

    • 有时 SentenceTransformer 只是在 wrapper level 打了一条 log(例如加载配置/tokenizer)而不一定重新下载权重。但通常它会初始化模型权重,启动会有开销。
    • 排查方法:看加载时间(打印时间戳)来判断是否为完全加载。

调试/验证步骤(实操清单)

你可以按照下面步骤定位到底是谁在重新加载:

  1. BAAIEmbeddingEngine.__init__ 打详细日志

    import time, os, logging
    logger.info(f"Init BAAIEmbeddingEngine pid={os.getpid()} timestamp={time.time()}")
    

    如果 __init__ 只在应用启动时调用一次,那说明你的封装确实只初始化一次。

  2. 在每次 encode 前后打印 model id 与 pid

    logger.debug(f"encode called pid={os.getpid()} model_id={id(self.__model)}")
    

    如果每次调用 model_id 变化或 pid 变化,那就说明模型并没有在同一进程中被复用。

  3. 搜索项目代码中 SentenceTransformer 的使用

    grep -R "SentenceTransformer" -n .
    grep -R "from_pretrained(" -n .
    

    若发现第三方或 utils 在别处创建了该对象,那就找到了 culprit。

  4. 打印堆栈(在加载位置)来追踪是谁触发的(可以短暂在 sentence_transformers 日志里改为 DEBUG 并在加载附近捕获 stack):

    import traceback, logging
    logging.getLogger('sentence_transformers').setLevel(logging.DEBUG)
    # 或者在你怀疑的地方: print('stack', ''.join(traceback.format_stack()))
    
  5. 注意进程/worker 模式

    • 如果你用 gunicorn/uvicorn ,每个 worker 都有独立进程,会各自加载模型(这是正常且必要的)。
    • 如果你想减少启动延迟,可以使用 --preload 让主进程先加载模型再 fork(Linux 下的 Copy-On-Write 优化)。

常见解决方案与建议(按优先级)

下面是有针对性的、能解决多数情况的办法:

A. 如果确实是 “某处每次新建 SentenceTransformer”,修复代码(最佳)

  • 找到并改成复用实例(不要在每次调用时 SentenceTransformer(...))。
  • 推荐把 embedding engine 作为 模块级全局单例,并把该实例传入需要的注册/调用处,而不是让框架每次 new。

示例(改造你的注册逻辑,传入实例而非类):

# module-level single instance
EMBEDDING_ENGINE = BAAIEmbeddingEngine()  # model加载在这里只发生一次/每个进程

# 注册时直接把实例包裹成 function,不让框架再 new
@registry.register("local-baai-transformers")
class BAAITransformerEmbeddings(TextEmbeddingFunction):
    def __init__(self, engine: BAAIEmbeddingEngine = EMBEDDING_ENGINE, **kwargs):
        super().__init__(**kwargs)
        self.__fun = engine

    def generate_embeddings(self, texts):
        return self.__fun.encode(texts)

关键点:确保 EMBEDDING_ENGINE 在模块 import 时创建一次(每个进程一次)。

B. 如果是多进程/worker 导致(不可避免地每个进程加载),采用预加载 + fork 优化(Linux)

  • 使用 gunicorn --preloaduvicorn--worker-class preload 选项,使父进程先加载模型,然后 fork 出多个子进程(子进程共享内存页面直到写入)。这可以借助 Linux 的 Copy-On-Write 降低内存占用与加载延迟。
  • 注意:Windows 下没有 fork,因此每个 worker 都会重新加载模型(不可避免)。

示例(gunicorn):

gunicorn -k uvicorn.workers.UvicornWorker --preload -w 4 app:app

preload 的前提是你的初始化代码(模型加载)在全局模块层,而不是在 worker 启动后的回调里。

C. 如果 lancedb/框架在序列化/远程执行时重新实例化

  • 有些框架会把你的 embedding function 序列化给远端 worker,worker 反序列化时会在子进程中重新 run __init__。解决方法是 不要把函数对象直接序列化,而是把可调用包装成引用到已存在单例的 wrapper,或在 worker 启动后再从全局单例获取模型。
  • 更稳妥的做法是把 embedding 服务化(独立进程/微服务),其它进程通过 RPC/HTTP 调用该服务获取 embedding。这样模型只在 embedding 服务进程里加载一次。

D. 抑制日志(临时降噪)

如果你确认模型只加载一次,但日志仍然显得频繁(比如别的 lib 打了一条类似日志),可把 sentence_transformers 日志等级调高,降低噪声:

import logging
logging.getLogger("sentence_transformers").setLevel(logging.WARNING)

这只“隐藏”日志,不解决重复加载问题。推荐先排查重复加载再考虑隐藏日志。

可运行的调试 / 演示代码

下面给出一段最小可运行的 Python 示例,展示三件事:

  1. 当你每次都新建 SentenceTransformer 时,日志会重复打印(模拟你的卡顿)。
  2. 用模块级单例只在进程启动时构建一次。
  3. 如何查看 PID / model id 来验证是否重复加载。

说明:示例使用 sentence-transformers/paraphrase-MiniLM-L6-v2(轻量)代替 BAAI 模型,方便本地运行。如果你要用 BAAI 本地模型,把名字换成本地路径或模型名称即可。

# demo_sentence_transformer_singleton.py
import time, os, logging
from sentence_transformers import SentenceTransformer

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# 控制 sentence_transformers 的日志级别(示例中保留 INFO)
logging.getLogger("sentence_transformers").setLevel(logging.INFO)

MODEL_NAME = "sentence-transformers/paraphrase-MiniLM-L6-v2"  # 替换成你的模型名或本地路径

# 方式1:每次都创建(BAD)
def encode_bad(texts):
    logger.info("encode_bad: creating SentenceTransformer at pid=%s", os.getpid())
    model = SentenceTransformer(MODEL_NAME)  # 每次都会打印 Load pretrained ...
    emb = model.encode(texts)
    return emb

# 方式2:模块级单例(GOOD)
class EmbeddingSingleton:
    _instance = None

    def __init__(self):
        logger.info("EmbeddingSingleton.__init__ pid=%s time=%s", os.getpid(), time.time())
        self.model = SentenceTransformer(MODEL_NAME)
        logger.info("model id=%s", id(self.model))

    @classmethod
    def get_instance(cls):
        if cls._instance is None:
            cls._instance = EmbeddingSingleton()
        return cls._instance

def encode_good(texts):
    inst = EmbeddingSingleton.get_instance()
    logger.info("encode_good using model id=%s pid=%s", id(inst.model), os.getpid())
    return inst.model.encode(texts)

if __name__ == "__main__":
    texts = ["hello world", "sentence transformers demo"]
    logger.info("Call bad 1")
    encode_bad(texts)
    logger.info("Call bad 2")
    encode_bad(texts)

    logger.info("Now call good 1")
    encode_good(texts)
    logger.info("Now call good 2")
    encode_good(texts)

运行这个脚本你会看到:

  • encode_bad 每次都会因 SentenceTransformer(MODEL_NAME) 打出 Load pretrained SentenceTransformer: ...
  • encode_good 只在第一次初始化单例时打印“Load pretrained …”,后续调用复用同一实例。

如果你用的是 lancedb:如何把单例和注册结合(示例)

下面示例演示把模块级单例传给 lancedb 的 embedding registry(伪代码,具体接口按你实际 lancedb 版本调整):

# registry_wrapper.py
from sentence_transformers import SentenceTransformer
import logging
logger = logging.getLogger(__name__)

class BAAIEmbeddingEngine:
    def __init__(self, model_name="./HF/models/BAAI/bge-base-zh-v1.5"):
        logger.info("Loading embedding model in pid=%s", os.getpid())
        self.model = SentenceTransformer(model_name)  # or your AutoModel wrapper
    def encode(self, texts):
        return self.model.encode(texts)

# module-level shared instance
EMBEDDING_ENGINE = BAAIEmbeddingEngine()

# lancedb registration: pass a wrapper that uses EMBEDDING_ENGINE
def my_embedding_function(texts):
    return EMBEDDING_ENGINE.encode(texts)

# register in lancedb: make sure lancedb stores the NAME or callable reference
# and that during execution it does NOT re-create SentenceTransformer by name.
# Example (pseudocode):
# db.register_embedding_function("local-baai", my_embedding_function)

要点:不要把 SentenceTransformer 的模型名作为字符串传给 lancedb 让其内部去 from_pretrained;而应把已经构建好的实例的 callable 传入。

额外说明:多进程/多线程与加载行为

  • 多线程:线程共享同一进程内存,模块级单例在多线程里可复用(无需重复加载),但要注意线程安全(你的 lockRIns 很必要)。
  • 多进程:每个进程都会单独执行模块级初始化,所以每个进程会单独加载模型(不可避免),除非使用 fork 之后共享(--preload)。
  • 使用 Fork 优化:在 Linux 下,先在主进程加载模型再 fork 出多个 worker,可以节省物理内存(利用 COW)并减少每个 worker 单独加载时的 I/O 延迟。

最后给出一个“排查清单”便于快速定位问题

  1. BAAIEmbeddingEngine.__init__ 里打印 pidtimeid(self.__model),观察是否只在进程启动时打印一次。

  2. 确认 lancedb 的注册方式:不要传 model name 字符串给 lancedb 让它内部加载;传实例或外部函数。

  3. 搜全项目 SentenceTransformer / from_pretrained 的调用位置,确认没有别处重复创建。

  4. 若使用多进程(uvicorn/gunicorn/multiprocessing),理解每个进程需要加载一次模型是正常的;考虑用 --preload 或拆分为单独 embedding 服务。

  5. 临时把 sentence_transformers 日志等级调高以查看更详细的加载栈(便于定位是谁触发加载)。

    import logging
    logging.getLogger("sentence_transformers").setLevel(logging.DEBUG)
    
  6. 若确认是“重复加载”但难以改造,短期减缓可以把 sentence_transformers 日志隐藏:

    logging.getLogger("sentence_transformers").setLevel(logging.WARNING)
    

总结

  • 那条 Load pretrained SentenceTransformer: ... 日志就表示某处在构造 SentenceTransformer。如果在每次向量化都看到,说明确实有重复构造/加载发生。
  • 单例在单进程内有效,但无法跨进程复用;很多框架/运行模式(尤其 Windows 或 spawn 模式)会导致每个进程都重新加载模型。
  • 最稳妥的做法是 确保实例只在进程内加载一次(模块级单例 / 预加载 + fork / 服务化 embedding),并让 lancedb 等库调用该实例的 encode,不要在运行时反复通过模型名去 from_pretrained
  • 同时可以通过打印 pid/model id、检查日志堆栈来准确定位重复加载的触发点。
Logo

加入「COC·上海城市开发者社区」,成就更好的自己!

更多推荐