Cherry Studio 本地 Embedding 模型下载链路重构:应用自管下载、SHA-256 校验与断点续传
2026/9/20 12:24:52 网站建设 项目流程
  • 人工智能
  • 大模型
  • AI 应用
  • 交互助手
  • 本地部署

【免费下载链接】cherry-studio

🍒 Cherry Studio 是一款支持多个 LLM 提供商的桌面客户端

项目地址:https://gitcode.com/CherryHQ/cherry-studio
点击查看免费下载

本文围绕 Cherry Studio 开源仓库中的一份 breaking-change 记录(v2-refactor-temp/docs/breaking-changes/2026-08-27-embedding-model-download-moved.md)展开,解读知识库本地 Embedding 模型(Qwen3-Embedding-0.6B)下载方式的一次关键变更:模型文件不再由加载它的机器学习库(transformers.js)负责下载,而是改由应用自身完成。文章将结合仓库源码,说明这次变更背后的下载引擎实现、镜像与代理策略、旧目录自动迁移机制,以及它对普通用户和发布管理者的实际影响。

变更概览:一次 notice 级别的行为调整

该文档的 frontmatter 标记为category: changedseverity: notice,即这是一次行为变更类、低影响提示的改动,发布于 2026-08-27。核心变化只有一句话:知识库 Embedding 模型现在由应用自己下载,而不是由加载它的机器学习库下载

具体到当前仓库,这条链路对应的是src/main/ai/localModel/目录下的"本地模型子系统":Embedding 模型(qwen3-embedding-0.6b)和 OCR 模型(pp-ocrv6-medium)的权重、分词器等文件,统一由应用侧的下载引擎(acquisition/目录)拉取,再由installation/LocalModelStorageService.ts管理磁盘状态。文档提到的新行为包括三点:

  • 逐文件校验:每个文件到达时都做 SHA-256 校验;
  • 断点续传:被中断的下载会保留已完成的部分,下次只补缺失文件,而不是全部重下;
  • 代理一致性:下载走应用自身配置的代理,与 App 内其他下载行为完全一致。

为什么这样做:三个问题的闭环

文档的 "Why this matters to the user" 小节阐述了这次变更要解决的三个实际问题,每一处都能在源码中找到对应设计。

1. 代理设置不再失效

此前模型由机器学习库(transformers.js)自行下载,走的是库内部自己的网络栈,不经过应用配置的代理。结果是:用户在应用里配置的代理对其他地方都有效,唯独 Embedding 模型下载失效。

现在所有下载都经由src/main/ai/localModel/acquisition/downloadEngine.ts中的net.fetch——这是 Electron 主进程的网络模块,天然继承应用级代理配置。源码注释明确写道:

"The one way bytes enter the local-model directories"(这是字节进入本地模型目录的唯一途径)。

即模型文件与共享运行时 tarball 全部经过withMirrorFallbackstreamToFileVerified两个函数,网络层统一、行为一致。

2. 损坏或被篡改的下载当场拒绝

文档指出,过去"被损坏或被拦截的下载"可能在下载时不被察觉,直到加载模型失败时才暴露。现在,校验发生在字节流经的途中,而不是事后。

streamToFileVerified(downloadEngine.ts)的实现要点:

const response = await net.fetch(url, { signal }) if (!response.ok || !response.body) throw new Error(`HTTP ${response.status} for ${url}`) const total = Number(response.headers.get('content-length')) || 0 const hash = crypto.createHash('sha256')

数据通过一个Transform流边下载边更新 sha256 摘要,写盘目标是一个${dest}.tmp临时文件;当整个流结束后:

const digest = hash.digest('hex') if (digest !== sha256) { await prepared.abort() throw new Error(`sha256 mismatch for ${url}: expected ${sha256}, got ${digest}`) } await prepared.commit()

只有摘要匹配才把临时文件原子性地重命名到最终位置。任何截断响应、LFS 指针、强制门户(captive portal)页面都会在校验环节被拒绝,且不会留下一个让就绪探测误判为"已安装"的假文件

3. 失败后只补缺的部分

文档举例说明恢复下载"只获取仍然缺失的内容,而不是重新下载完整的约 614MB"。

这在两个层面实现。其一,LocalModelStorageService.pendingBundleFiles会逐文件比对磁盘现状,只返回缺失清单;其二,下载引擎对单个文件同样使用临时文件 + 原子提交,已完整落盘的文件不会再次抓取。614MB 这个数字也对应 catalog 中的权重文件描述:qwen3-embedding-0.6bonnx/model_quantized.onnxminBytes为 100,000,000(100MB)以上,配合weight: 585,可推知权重文件约占整个 bundle 绝大部分体积。

下载引擎的实现细节

下载引擎位于src/main/ai/localModel/acquisition/,由四个文件组成,职责清晰:

文件职责
modelSource.ts定义下载镜像源(HuggingFace / ModelScope)与区域偏好
downloadEngine.ts底层网络原语:镜像回退、流式校验写盘、文本抓取
bundleDownload.ts按 bundle 编排多文件下载与进度条
tarballArtifact.ts共享原生运行时(onnxruntime-node)npm tarball 的获取与安装

镜像回退:一个坏镜像不能拖垮整个下载

withMirrorFallback(downloadEngine.ts)会依次尝试每个 URL,直到其中一个成功。关键设计是:不可达的镜像和返回损坏字节的镜像在此处失败方式完全相同——因为校验在attempt内部(streamToFileVerified抛出的 sha256 不匹配异常同样会被withMirrorFallback捕获并尝试下一个镜像),所以"活着但数据是坏的"镜像永远不可能让下载变成终态错误,只要另一个镜像还有好字节就能继续。

同时,取消(abort)不算镜像失败:被取消的下载必须停下来,而不是遍历剩余镜像列表重新发出也注定被取消的请求。

派生文件:先校验文本再改写落盘

并非所有文件都是原样落盘。bundleDownload.ts中的writeBundleFile区分两种情形:

if (!derivation) { await streamToFileVerified(url, dest, { sha256, signal, onProgress }) return } const fetched = await fetchTextVerified(url, { sha256, signal }) signal.throwIfAborted() await writeFileAtomic(dest, applyDerivation(derivation, fetched))

derivation的文件(目前只有 OCR 的paddle_dict_from_inference_yml)体积很小,是下载后需要改写再写入的配置类文件,因此走fetchTextVerified(抓取文本后校验 sha256)加writeFileAtomic(临时文件 + 原子写入,崩溃不会留下"看起来已安装"的半成品)。Embedding 模型本身不使用派生,四个文件全部直接流式校验落盘。

进度条:按文件权重加权

bundleDownload.ts的进度回调按BundleFile.weight加权(约等于文件 MB 数),因此进度条反映的是真实字节进度而非文件个数。对于跨多个文件的 bundle,权重还用于"已完成部分 + 当前文件内进度"的累计:

const totalWeight = files.reduce((sum, file) => sum + file.weight, 0) // ... onProgress?.((doneWeight + file.weight * fraction) / totalWeight)

镜像源与区域策略

modelSource.ts把下载镜像建模为一张地址表,而非单一 base URL,原因在于 HuggingFace 与 ModelScope 的寻址差异:

const SOURCES: Record<ModelSourceId, ModelSource> = { huggingface: { remoteHost: 'https://huggingface.co', remotePathTemplate: '{model}/resolve/{revision}', revision: 'main' }, modelscope: { remoteHost: 'https://www.modelscope.cn', remotePathTemplate: 'models/{model}/resolve/{revision}', revision: 'master' } }

两个镜像都暴露 HF 兼容的/<repo>/resolve/<revision>/<file>路由;区别是 ModelScope 把仓库嵌套在models/下、默认分支叫master(HuggingFace 叫main)。

区域偏好DownloadSourcePreference'china-first' | 'global-first'两个值:china-first默认 ModelScope(国内访问 HuggingFace 困难),global-first默认 HuggingFace。偏好只在管理边界处依据出口区域(egress region)解析一次,而不是依据显示语言。镜像顺序则把区域默认源放在第一位、另一个作为回退:

export function modelSourceOrder(preference: DownloadSourcePreference): [ModelSourceId, ...ModelSourceId[]] { return defaultModelSourceId(preference) === 'modelscope' ? ['modelscope', 'huggingface'] : ['huggingface', 'modelscope'] }

源码注释特别强调:推理从不查询这张表——模型按绝对路径加载,这正是"推理完全离线"的保证。这一点与"应用自管下载"的变更互为表里:下载是唯一联网环节,下载完成后的一切都发生在本地。

目录布局与旧布局自动迁移

当前布局与遗留布局

catalog 中 Embedding bundle 的目录定义(catalog.ts):

'qwen3-embedding-0.6b': { id: 'qwen3-embedding-0.6b', capability: 'embedding', installDirKey: 'feature.embedding.models', installSubdir: 'onnx-community/Qwen3-Embedding-0.6B-ONNX', legacyInstallSubdir: 'onnx-community/Qwen3-Embedding-0.6B-ONNX/master', requires: ['onnxruntime-node'], runtime: { dtype: 'q8' }, // 4 files... }

两个要点值得展开:

  • installSubdir之所以以仓库名命名,是因为 transformers.js 从存放 config.json 的目录加载模型,而早期版本允许它把目录命名为仓库名。保持同样的布局,是"已安装的模型无需重新下载 614MB"的关键。
  • legacyInstallSubdir末尾多了一层master/,这正是文档中"从 ModelScope 镜像下载的用户,模型多了一层master/目录"的由来:ModelScope 的默认分支是master,旧版 transformers.js 会把非main的 revision 追加进目录层级。

自动迁移:尽力而为,绝不半途

LocalModelStorageService.resolveInstalledDir(LocalModelStorageService.ts)的判定逻辑:当前布局完整 → 返回当前目录;当前布局不完整但遗留布局完整 → 触发liftLegacyInstall把遗留目录提升到当前布局;提升后再读一次两种布局,避免"提升中回滚也失败导致丢文件"的目录被交给调用方。

liftLegacyInstall(同文件 L127-L167)是严格"尽力而为"的:如果文件正被存活的推理 worker 占用导致移动失败,会记录警告并留在原地继续使用(fallback 零成本);但绝不会处于半完成状态——凡是已经移动的文件都会移回去,因为"一个横跨两种布局的安装会让两边都不完整,从而重新下载一个其实完全在磁盘上的模型"。

这与文档中发布管理说明完全吻合:

"如果移动无法完成(例如文件暂时被占用),模型会继续从原位置正常工作,应用稍后重试。任何情况下都不会删除或重新下载任何内容。"

断点续传与未完成下载的清理

只下载缺失文件

missingFilesInstatSync检查每个文件的存在性与最小字节数minBytes),pendingBundleFiles据此返回还需抓取的清单:

private missingFilesIn(bundle: ModelBundle, dir: string): BundleFile[] { return bundle.files.filter((file) => { const stat = fs.statSync(this.bundleFilePath(bundle, file, dir), { throwIfNoEntry: false }) return !stat?.isFile() || stat.size < file.minBytes }) }

minBytes的语义值得注意:它是磁盘扫描的下限,足够捕获旧版(无校验时代)下载留下的截断文件,又不会因为上游修订号变动而强制重下。源码注释特别说明磁盘扫描永远不做 sha256 校验——每次状态查询都对约 700MB 权重做哈希会让查询慢到不可用;校验只发生在字节到达的下载路径上。

启动时的陈旧临时文件清扫

pendingBundleFiles之外,sweepStaleDownloads处理另一种残留:崩溃/强杀会让写入方来不及删除自己的临时文件。它清理两类东西:

  1. 每个 bundle 文件旁形如<file>.tmp-<uuid>的残留(每次重试都写新的 uuid,不清理会不断累积);
  2. 每个所需共享运行时的 staging 目录。

清扫只在启动时执行——下载进行中时,临时文件属于写入方,不能动。

共享运行时:onnxruntime-node 的按需获取

Embedding 与 OCR 两个 bundle 都声明requires: ['onnxruntime-node']。onnxruntime-node 是共享原生运行时,被设计为按需下载而非随安装包分发:npm 包携带所有平台的二进制,打包进安装器会给从不使用本地模型的用户凭空增加数百 MB(见 catalog.ts 的注释)。

它的获取走tarballArtifact.ts

  • 从 npm 注册表(registry.npmjs.org/registry.npmmirror.com)下载整个 tarball,用tarballSha256整个 tarball校验——因此解压出的平台文件无需各自的校验和;
  • 只解压当前平台tarballPrefix下的文件(如package/bin/napi-v6/linux/x64/),拍平到安装目录;
  • 安装顺序是支持文件先于入口文件,保证isArtifactInstalled永远不会看到"绑定文件在而它依赖的库还没到"的状态;
  • 删除时对 Windows 的EPERM/EBUSY做重试退避(50/100/200/400ms)。

LocalModelStorageService.ensureArtifact还做了并发合并:同一 artifact 的多个并发安装共享同一次下载(artifactInstallsMap),"Embedding 和 OCR 下载竞速同一个运行时"时,二者等待同一个请求而非同时写同一批文件。删除(removeArtifactIfUnused)则通过预留计数(artifactReservations)保证"只用引用计数降到 0 才删"。

推理侧:绝对路径、完全离线

变更的另一半是推理。EmbeddingInferenceService.embed通过resolveModel()拿到安装目录:

private resolveModel(): { modelDir: string; dtype: string } { const bundle = bundleForCapability('embedding') const modelDir = localModelStorageService.resolveInstalledDir(bundle) const artifactsReady = bundle.requires.every((id) => localModelStorageService.isArtifactReady(id)) if (!modelDir || !artifactsReady) throw new Error('the local embedding model is not fully downloaded') return { modelDir, dtype: bundleDtype(bundle) } }

注意这里的防御逻辑:目录不完整或共享运行时未就绪时直接抛错,而不是"试图从网络补"。下载与加载被严格分层。

推理侧的处理函数inferenceEmbeddingHandlers.ts进一步保证离线:传给 transformers.js 的modelDir绝对目录路径,而 transformers.js 会把绝对路径当非法 repo id 拒绝,其文件发现机制因此无法触网:

"The model id is an absolute directory, which transformers.js rejects as a repo id (isValidHfModelId) — and every remote branch in its resolver is gated on that check, so file discovery cannot reach the network no matter whatrevision/local_files_onlyits internal stages default to."

这段话还解释了旧问题的根因:transformers.js 4.2.0 在发现阶段(get_pipeline_files → get_files → get_config / get_tokenizer_files)会丢弃revision/local_files_only两个选项,这正是旧版"仅 ModelScope 缓存无法离线使用"的原因——ModelScope 的masterrevision 无法被正确寻址。新的绝对路径加载方式从根本上绕开了这一缺陷。

用户与发布管理要点

文档的收尾部分对两类读者给出了明确的行动指引,本文照录并补充说明:

对普通用户——什么都不用做,全部自动:

  1. 已下载的 Embedding 模型保持已安装状态,不会被重新下载resolveInstalledDir对当前布局直接返回,pendingBundleFiles返回空);
  2. 若文件位于被取代的旧布局(ModelScopemaster/目录),应用会在首次使用时自动移动它们(liftLegacyInstall惰性触发);
  3. 移动失败不影响使用——模型继续从原位置加载,应用稍后重试;
  4. 任何情况下都不会删除或重新下载任何内容。

对发布管理者——需要知晓的边界情况:

  1. 从 ModelScope 镜像下载的用户,文件多一层master/目录,该副本会被自动迁移;
  2. 迁移是尽力而为的:文件被占用时留在原地、稍后重试(对应liftLegacyInstall的警告日志could not lift a legacy local model install; using it in place);
  3. 本次变更不引入任何删除或重下行为。

测试与验证路径

仓库为这套机制提供了完整测试,可作为阅读与验证的入口:

  • modelSource.test.ts:镜像寻址表、区域默认与回退顺序;
  • downloadEngine.test.ts:镜像回退、sha256 校验、原子写盘;
  • bundleDownload.test.ts:多文件编排、加权进度、派生文件改写;
  • LocalModelStorageService.test.ts:安装状态扫描、遗留布局提升与回滚、陈旧临时文件清扫、并发安装合并;
  • inferenceEntryOffline.test.ts:验证离线推理路径(ModelScope 缓存离线可用性问题)。

总结

这次变更把本地 Embedding 模型的下载从"机器学习库内部行为"提升为"应用自治的基础设施":网络层统一走应用代理(net.fetch),完整性由流式 SHA-256 校验兜底,失败恢复由"临时文件 + 原子提交 + 只补缺失文件"三件套支撑,旧布局迁移则被设计为"尽力而为、可回滚、不重下不删除"的低风险操作。对用户而言它是一次无感的自动化改进;对开发者而言,它是一份值得借鉴的"下载可靠性"实现样本——镜像回退、边下载边校验、断点续传与旧目录迁移,四件事在 acquisition/ 与 installation/ 两个目录中做到了职责分明、可测可验证。

  • 人工智能
  • 大模型
  • AI 应用
  • 交互助手
  • 本地部署

【免费下载链接】cherry-studio

🍒 Cherry Studio 是一款支持多个 LLM 提供商的桌面客户端

项目地址:https://gitcode.com/CherryHQ/cherry-studio
点击查看免费下载

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询