Skip to content

chore(models): move perception ONNX models out of git to a pinned Release - #492

Open
idootop wants to merge 23 commits into
mainfrom
chore/models-out-of-git
Open

chore(models): move perception ONNX models out of git to a pinned Release#492
idootop wants to merge 23 commits into
mainfrom
chore/models-out-of-git

Conversation

@idootop

@idootop idootop commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

做了什么

感知的 5 个 ONNX 模型(合计 78MB)从 git 移出,改为托管在固定 tag models 的 GitHub Release(已标 prerelease,不顶替 Latest),按 sha256 锁定后由脚本按需拉取。

为什么

模型躺在 git 历史里有两笔持续成本:

  1. CI 的 actions/checkout 默认浅克隆(fetch-depth: 1),每跑一次就白付一遍 78MB;
  2. 以后每换一次模型都在历史里永久叠一份。

改成 Release 资产后这两笔都归零,而且下载源可以走镜像(国内直连 GitHub 常年不稳)。

顺带纠正一个前提:这些模型从来不是 LFS 管理的,是根 commit 里的普通 blob,所以并不存在 LFS 流量超限。真正的收益是上面两条。

怎么拿模型

文件 作用
scripts/models.lock.json 唯一真源:release_tag / base_url / mirrors + 每个文件的 size、sha256、是否必需
scripts/fetch_models.py 纯标准库下载器。原子写(.partos.replace)、Range 续传、每源 3 次退避重试(file:// 源跳过退避——本地读不到不会因为等一会儿就变成读得到)、就绪判据只认 sha256(与落地校验同源,见 _is_ready)。退出码 0 就绪 / 1 必需模型缺失 / 2 用法或 lock 错误。换源变量的取值先规范化再用:裸路径兜底成 file://,非法 scheme 与 lock 里的裸路径都收敛成退 2(而不是穿出 main 打 traceback)
scripts/publish_models.sh 维护者换模型:upload <dir> 上传并同步 lock(上传前先比对文件集,不一致就中止、不动线上资产);refresh-lock 按 Release 现状重算 lock;verify 零下载对账 Release 资产与 lock —— CI lint job 跑的就是它

fetch_models.py 刻意只用标准库 —— 它被 build.sh、CI、install-hermes.sh 直接以 python3 调用,多一个第三方依赖就多一处装不上的可能。面向终端用户的大包下载仍走 install.py 的 httpx 实现,两者互不影响。

python3 scripts/fetch_models.py            # 缺什么拉什么;已就绪的只校验 sha256、不联网
python3 scripts/fetch_models.py --check    # 只校验不下载
python3 scripts/fetch_models.py --dest ~/.openclaw/miloco/models   # 直接补运行时目录

# 内网 / 离线整体换源(独占替换:设了它就只用它,lock 的 base_url + mirrors 不再兜底)
MILOCO_MODELS_BASE_URL=https://mirror.example.com/miloco-models python3 scripts/fetch_models.py
MILOCO_MODELS_BASE_URL=/mnt/nas/miloco-models python3 scripts/fetch_models.py   # 裸路径按 file:// 处理

下载源 = base_url(GitHub 直连)+ mirrors(gh-proxy.com / gh-proxy.org / gh.idayer.com),与 scripts/manifest.jsondownload.sites 同源同序,直连失败自动降级到镜像。测试 test_lock_sources_match_manifest_sites 钉死两边一致,避免以后只改一边。

接入点

  • scripts/build.shpack_models:原来"缺 .onnx 就 FATAL",现在跑 fetch_models.py --strict(可选模型缺失也算失败 —— 不能因一次网络抖动就发出个少了 bge / VAD 的 tarball)。tar 从 -C dir . 改成按 lock 逐条点名models 是可变 tag,Release 上可能残留换代前的旧资产(upload--clobber 同名的、不删旧的),glob 会把它们一并打进安装包 —— 包变胖是小事,某个模型若因质量 / 许可 / 安全原因下线却继续随包分发是大事;顺带也不用再防 README.md*.part 了。该步受 should_build "miloco" 门禁:不含后端的子集构建(如 --packages miloco-cli)跳过,免得只想要个 CLI wheel 却被 --strict 拖去下 78MB。注意别收紧成"仅全量" —— 模型 tar 的消费方除了平台归档还有 install.py 的 dev 通道(直接 glob 仓库 dist/),卡死会让「--packages <子集>install.sh --dev」在 wheel 全装完之后才失败。另外 --help 从写死的 sed -n '5,15p' 改成打到抬头注释块结束(与 publish_models.sh / sync-to-remote.sh 同一写法)—— 本 PR 自己就往那段抬头里插过行,写死行号会把结尾「退出码:」那行无声截掉;顺带修掉 macOS 上一直存在的显示 bug:原来 sed 's/^# \?//'\? 是 GNU 扩展,BSD sed 当字面问号,于是每行帮助都还挂着 #
  • ci.yml / release.ymlactions/cache/restore + 显式下载 + actions/cache/save 拆两步,保存受 cache-hit != 'true' 门禁。一体式 actions/cache 的 post 步骤不看 job 成败,一次只下到 4/5 的运行会把残缺目录固化进那个 key 且再也盖不掉。key 只跟 lock 走;path 只 glob *.onnx/*.json,否则 key 不变时旧缓存会盖掉 checkout 出来的新 README。CI 必须下模型,否则 test_deep_sort_v12.pyrequires_models 那批会静默 skip、等于偷偷少跑一批覆盖。
  • ci.yml 的 lint job 新增零下载对账门禁publish_models.sh verify):models 是可变 tag,资产被换而某分支 lock 未刷新时,构建失败会被缓存变成"同一 commit 今天绿、下周红"的间歇现象;一次 API 调用把它变成确定性失败点。判红范围按"能不能修"分流 —— push 与改了 lock 的 PR 强制,其余 PR 降级为告警(换代窗口内无关 PR 会被判红,而两条修法都要仓库 write 权限,外部贡献者做不到)。
  • plugins/hermes/install-hermes.sh step 4.7:两处印给用户的补齐 / 修法命令都改成「在那个处境下真的能执行」的形式 —— 下载失败分支补上解释器(该文件在 git 里是 100644、没有可执行位,裸路径粘贴是 Permission denied、退 126);--post-install 分支则拆掉 ${FETCH_MODELS:-scripts/fetch_models.py} 这个兜底 —— :- 只在变量为空时取用,而变量为空的充要条件正是「本脚本旁边没有 checkout」,也就是那条相对路径必然解析不到的处境(该分支的唯一调用方 install.py 正是从 tarball 解出的目录调的,那儿只有本脚本和插件目录),兜底值只在它必然错的时候才出场;改成按 FETCH_MODELS 是否可用分叉,不可用时给「重跑 install.sh」(安装包自带 miloco-models-*.tar.gz,解到 $MILOCO_HOME/models/)。两处的共同点是:该文件在 git 里是 100644、没有可执行位,裸路径原样粘贴是 Permission denied(退 126),而这个新错误跟下载失败毫无关系,只会把人往「权限 / 文件损坏」方向带偏,偏偏用户此刻手上只有这一行线索。三级取模型 —— 本地现成 .onnxcp(不联网);没有 → 按 lock 下载到 $MILOCO_HOME/models/;再失败 → warn 但不中断(感知降级,插件其余照装)。两处判据都收紧了:搜目录时从"目录存在"改成"目录里真有 .onnx"(新 clone 出来这目录只剩一个 README);判目标目录是否已装则从"有没有 .onnx"改成"按 lock 齐不齐"(--check --strict,与最后那道门禁复用同一个判据)——按"有没有"的话,目标目录里躺着 1 个 silero_vad.onnx 就会跳过整段 cp,而门禁照样判不齐,于是联网下 ~75MB,可那 4 个文件就在旁边的 checkout 里;断网时更糟,只 warn 不中断、安装报成功、首次 perceive 直接 models_missing
  • scripts/local-ci.sh--check --strict --quiet 非致命提示,防本地覆盖率悄悄比 CI 低。带 --strict 是因为可选模型(bge 去重 / VAD)缺失时用例并不 skip,而是走降级分支——跑的不是 CI 那条路径,不提示就看不出来。校验目录与印出来的补齐命令同源(相对路径写一次、绝对路径由它派生):两者分家时,导出了 MILOCO_MODELS_DEST 的开发者照抄那条裸命令会把 78MB 下到仓库外,而告警下次一字不差地再来一遍、requires_models 那批照旧整批 skip(skip 判据是 __file__ 推出来的包内目录,环境变量够不着),手里唯一的线索恰恰是那条无效命令。
  • .gitignore:忽略该目录、只放行 README.md(目录内 README 说明怎么拿模型)。
  • 文档dev-guide.md(模型怎么拿 / 换源 / git clean 后要重 fetch / 维护者换模型流程)、sdk-onnxruntime.md、包内 perception/models/README.md(这个目录为什么只剩 README)、以及 troubleshooting.mdmodels_missing 行 —— 那行原来只写「重跑 install.sh」,模型移出 git 之后源码开发者照做要为补两个 onnx 装一遍安装包,补上 fetch_models.py --dest 这条直路。

终端用户与运行时不受影响:release 包仍带 miloco-models-*.tar.gzinstall.py 的 download 步解到 $MILOCO_HOME/models/;运行时按两段式解析,两段各判各的 —— 第一段由 directories.models 决定,配了就用它(相对路径按 $MILOCO_HOME 解析),留空则取 $MILOCO_HOME/models,两种情况都不是包内 perception/models/(生产链路上 perception/client.py 总会把这一段的结果填进 perception_model_dir);第二段的包内目录只有完全不传 perception_model_dir 时才走得到(测试 / 临时脚本),而 wheel 里该目录必然是空的(pyproject.toml 已 exclude)。

验证

  • 模型全部移走后跑 build--version 2026.8.4):exit 0,pack_models 从真 Release 拉齐 5 个模型且逐个 sha256 通过,cmp 与原文件 5/5 byte-identical,无残留 .part。产出 miloco-models-2026.8.4.tar.gz(61M),tar -tzf 成员正好 5 个模型(./ 前缀),与改动前产物逐一对齐(旧归档多出来的正是 ././README.md)。
  • 全量 backend pytest(env 对齐 local-ci.sh,跑之前先用 fetch_models.py 把模型拉齐):覆盖含感知链路在内的全量用例,无本改动引入的失败;余下的是 node_monitor smaps 那批 macOS 已知项(local-ci.sh 里显式容忍,macOS 没有 /proc/self/smaps)。这里真正要盯的不是通过数,而是 requires_models 那批有没有被静默 skip —— 模型不在位时 test_deep_sort_v12.py 会整批跳过,覆盖率悄悄比 CI 低却看不出来,local-ci.sh 因此加了 --check --strict --quiet 提示。
  • test_fetch_models.py 41 条契约测试:用 file:// 假 Release 覆盖首下 / 二次离线 / 损坏重下 / env 换源 / hash 不符不留 .part / 可选缺失只降级 / --strict 变硬失败 / --check 不联网 / 用法错误退 2 / 路径穿越被拒;另起本机 HTTP 服务覆盖两条续传语义 —— 干净 FIN 截断不被误报成 hash 不符,以及 .part 活过进程退出、下一次调用带 Range 接着下;另加一条 inode 级断言,钉死"丢弃 .part 内容后 _open_part 拿到的 flock 仍然管用"(flock 锁 inode 不锁路径,unlink 会让锁脱靶)—— 这条把 os.truncate 改回 unlink 就立刻红。另起一台按 RFC 回 416 的本机服务,钉死「续传起点越界必须归零重下」:lock 比线上资产长、目录里又留着落在两者之间的 .part 时,Range 起点会越过资产末尾,把 416 当成「连接断了」保住 .part 会让每次重试、每个源、以及之后每一次调用都原样复现同一个 416,永不自愈。再加一条钉死「两条路径判据必须同源」:lock 里 sizesha256 描述的不是同一份字节时,下载与 --check --strict 必须给同一个结论,否则 CI 上紧挨着的两步一绿一红,而红的那步给的修法正是刚跑成功的上一步 —— 把 _is_ready 里的 size 预判塞回去即变红。另一条钉住「本地源不许干等」:file:// 上读不到就是读不到,退避是纯粹的白等,而唯一会撞上它的调用方(install-hermes.sh 拿旁边 checkout 当源同步)恰好是静默的 —— 源目录只带一部分模型是常态,缺的每个白等 1s+2s,实测表现成安装器无声卡住 12.2s。用例走进程内记 time.sleep 调用(跨进程只能拿墙钟阈值去赌 CI 负载),断言 file:// 不退避、重试次数不变、http 源仍是 [1, 2]。最后三条钉住换源变量的取值契约:裸路径(挂载目录)按 file:// 收下并真的下成功、非 http/https/file 的 scheme 退 2、而 lock 里写裸路径退 2 且文案指向 lock —— 三条都断言 stderr 里没有 Traceback。把 _sources 那段 revert 掉三条一起红。最后八条钉住「lock 能解析、但下载器依赖的键坏了」也必须收敛成一行中文 + 退 2sha256 缺失 / 非字符串 / 空串 / 截断 / 非十六进制、size 是字符串 / 负数,七种参数化各断言退 2 且 stderr 无 Traceback —— 退 1 的话含义是「必需模型缺失」,install-hermes.sh 的四分支门禁会照这个含义让用户「稍后重试」,而重试多少次都没用。第八条反向钉死大写摘要要照常认(certutil -hashfile 的输出就是大写),否则每个文件都判「校验不通过」,表现成永不收敛的重下循环。再加两条钉住「可选键写成 null 必须与整条不写同义」——.get(key, default) 的默认值只在键不存在时生效,键在值是 null 时原样返回 None,两个可选键都从各自的判据下面绕过去,后果还不一样:size: null 漏进 _human() 比大小 → traceback + 退 1;而 required: nullbool(None)False,必需模型被静默降级成可选,少一个必需模型的包能一路退 0 发出去 —— 比炸掉更糟,因为没有任何人收到信号。把 _check_spec 里那段归一化 revert 掉两条一起红。
  • test_publish_models.py 24 条契约测试:假 gh 记录每次调用,覆盖 verify 的全等 / 缺资产 / 多资产 / sha256 不符 / size 短路、digest 缺失与非 sha256 两种降级(都只告警不判红),以及 upload 文件集漂移时在动线上资产之前中止(断言 release upload|create 一次都没发生)。另加两条钉住「cmd_verify 函数体内不许有 exit」这个性质:upload 收尾那次对账是故意非致命的,模拟上传途中 token 过期 / API 502,必须仍打印「别忘了提交 lock」并以 0 退出;同时直接跑 verify(CI lint job 用的就是它)在 gh 不可用时仍须非 0 退出,确保门禁没被顺手摘掉。再补两条给此前零覆盖的 refresh-lock,钉死 required 的保留口径:同名旧条目漏写 required 时保留为必需(与 fetch_models._required 缺键 fail-closed 同口径),显式 false 的原样保留,真正新增的文件仍默认 false。第一条把 prev.get("required", False) 改回去就红 —— 而它红的那条路径「文件集全等、护栏不开火、rc=0 无任何输出」正是最看不见的一条。最后 5 条钉死坏 lock 的报错口径:合并冲突标记这种非法 JSON 在 upload / refresh-lock / verify 每个子命令上都必须是一行中文 + 非 0(不许 traceback、不许走到任何不可逆写操作),release_tag 为空串单独一条,--help 则必须照常打得出来。子命令那一轴是要害:lock 有 4 处读取点,只把顶层那次 TAG= 延后、再按「这个子命令用不用得上 tag」决定要不要校验的话,refresh-lock <dir> 恰好被豁免(它确实不需要 tag),而 refresh_lock_from_dir 内部照样 json.loads 同一份文件 —— 实测那种改法下这一条独红,而「合并完先跑一次 refresh-lock」正是最容易撞上冲突标记的路径。
  • 镜像兜底实测:3 个镜像对资产都返回 206(支持 Range);把 base_url 改成不存在的域名后真跑,直连 3 次失败 → 自动切 gh-proxy.com → sha256 通过 → exit 0。
  • ruff check 通过;改动的 shell 脚本 bash -n 干净,shellcheck -S warning 无本 PR 引入的新告警(build.sh 里 2 处 SC2010 是 main 上既有、且不在本 PR 的 diff 内)。

已知取舍

  1. models 是固定 tag、资产可变:手动换掉资产后,老 commit 里锁的 sha256 就对不上、老 commit 会构建失败。换完请跑 bash scripts/publish_models.sh refresh-lock 同步 lock。若要"老 commit 永远可构建",改用 models-v2 这类不可变 tag 并同步 lock 的 release_tag / base_url
  2. git 历史里的旧 blob 未重写:本 PR 只让未来的 commit 不再带模型,历史里那 78MB 还在,所以仓库不会立刻变小。历史重写(git filter-repo)需要所有人重新 clone,另议。

…ease

感知的 5 个 ONNX 模型(合计 78MB)从 git 移出,改为托管在固定 tag `models` 的
GitHub Release,按 sha256 锁定后由脚本拉取。

## 为什么

模型躺在 git 历史里有两笔持续成本:CI 的 `actions/checkout` 默认浅克隆,每跑一次
就白付一遍 78MB;以后每换一次模型都在历史里永久叠一份。改成 Release 资产后这两笔
都归零,且下载源可以走镜像。

(顺带纠正一个前提:这些模型从来不是 LFS 管理的,是根 commit 里的普通 blob,所以
不存在 LFS 流量超限的问题。)

## 怎么拿模型

- `scripts/models.lock.json` —— 唯一真源:`release_tag` / `base_url` / `mirrors`
  + 每个文件的 size、sha256、是否必需
- `scripts/fetch_models.py` —— 纯标准库下载器(不需要 uv:build.sh、CI、
  install-hermes.sh 都直接 `python3` 调)。原子写(`.part` → `os.replace`)、
  Range 续传、每源 3 次退避重试、size 快筛后再 hash。退出码 0/1/2
- `scripts/publish_models.sh` —— 维护者换模型:`upload <dir>` 上传并同步 lock,
  `refresh-lock` 按 Release 现状重算 lock

下载源 = `base_url`(GitHub 直连)+ `mirrors`(gh-proxy.com / gh-proxy.org /
gh.idayer.com),与 `scripts/manifest.json` 的 `download.sites` 同源同序,
直连失败自动降级到镜像;`MILOCO_MODELS_BASE_URL` 可整体换源(内网 / 离线)。

## 接入点

- `scripts/build.sh` 的 `pack_models`:无条件跑 `fetch_models.py --strict`
  (可选模型缺失也算失败,不能因一次网络抖动就发出少了 bge / VAD 的 tarball);
  tar 改为只打 `*.onnx` + `*.json`,把新增的 README.md 挡在归档外
- `ci.yml` / `release.yml`:`actions/cache`(key 只跟 lock 走)+ 显式下载步骤。
  CI 必须下模型,否则 `requires_models` 那批用例会静默 skip、偷偷少跑一批覆盖
- `install-hermes.sh` step 4.7:本地现成 `.onnx` → cp;没有 → 按 lock 下载;
  再失败 → warn 不中断。判据从"目录存在"改成"目录里真有 .onnx"
- `local-ci.sh`:`--check --quiet` 非致命提示,防本地悄悄比 CI 少跑一批

终端用户与运行时不受影响:release 包仍带 `miloco-models-*.tar.gz`,install.py
解到 `$MILOCO_HOME/models/`;wheel 本来就不含该目录。

## 验证

- 模型全部移走后跑 build:exit 0,5 个模型从真 Release 拉齐并逐个校验,`cmp` 与
  原文件 5/5 byte-identical;tarball 成员与改动前逐一对齐
- 全量 backend pytest:3 failed / 2989 passed / **0 skipped**(3 项是 node_monitor
  smaps 的 macOS 已知失败,local-ci.sh 里显式容忍)
- `test_fetch_models.py` 16 条契约测试;镜像兜底实测:直连域名打不通时自动切
  gh-proxy.com 并通过 sha256

## 已知取舍

`models` 是固定 tag、资产可变:手动换资产后老 commit 锁的 sha256 会对不上,需要跑
`publish_models.sh refresh-lock` 同步。git 历史里的旧 blob 未重写(另议)。
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown

👋 感谢提交 PR @idootop!维护者会尽快 review。

提交前请确认:

  • CI 全绿(test / lint / build)
  • 改动聚焦单一主题,便于审阅
  • 若改动了依赖(lockfile / pyproject.toml / package.json),需维护者评论 /allow-dependencies-change <当前 head SHA> 放行(之后再 push 需重新放行)

pack_models 的 tar 依赖 shell glob 展开 `./*.onnx ./*.json`。上游 fetch_models.py
--strict 已经保证文件齐全,但万一那层被绕过(退出码被吞、步骤被跳过),bash 默认会
把没展开的字面量交给 tar,报 `tar: ./*.onnx: Cannot stat: No such file or directory`
—— set -e 一样会中止构建,只是排查的人得先反应过来那是个没展开的 glob。

改成 nullglob 下先点一次数,空了直接 die,报"模型目录为空 <路径>"。

顺手给同一函数里的 `$models_dir` 补上 `${}`:macOS 自带的 bash 3.2 会把紧跟变量的
全角「)」首字节吃进变量名,set -u 下这条错误路径自己会先炸成
`models_dir?: unbound variable`(C.UTF-8 / en_US.UTF-8 都复现)。

验证(bash 3.2):真实 models 目录 → guard 放行、归档成员与改动前逐字节同名
(5 个文件、`./` 前缀、不含 README.md);空目录 + stub fetch 返回 0 → 按预期
die 1 并打出中文提示。

@ExWang ExWang left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 非阻塞(建议合并前顺手做,都是一行级)

1. build.sh:326 的 --strict 没传 --dest:校验的目录与打包的目录可分离

  • 问题: dest 走 --dest / MILOCO_MODELS_DEST / _DEFAULT_DEST 三级回退,而 :338/:348 的 guard 与 tar 打的是 :319 硬编码的 $models_dir;build.sh 既不传 --dest 也不 scrub 环境。export 了 MILOCO_MODELS_DEST 时:源码 models/ 只剩 README → --strict 在别处判通过 exit 0,:342 die 1 "…--strict 本该已拦截" 把排查方向带反;更糟一支是源码 models/ 留着与 lock 不符的旧 .onnx → --strict 仍在别处通过、guard 数到 5 个放行 → 脏模型进 tarball 并被 bundle sha 背书,而 install.py:963 明写不再逐文件校验。同一 PR 的 install-hermes.sh:730 就显式传了 --dest
  • 建议修法: --strict --dest "$models_dir",并把 :342 的文案打出实际 dest;ci.yml:54 / local-ci.sh:57 一并显式化。

2. test_fetch_models.py 的 _run 全量继承 os.environ:按本 PR 自己的文档 export 换源变量后 10/16 测试失败且会真联网

  • 问题: :42 env={**os.environ, **(env or {})} 没剥 MILOCO_MODELS_*,而 _sources()MILOCO_MODELS_BASE_URL独占替换,fixture 的 file:// 假 Release 会被整体顶掉。该变量正是 models/README.md:25 与 dev-guide.md:390 推荐给内网/离线开发者的逃生口。内网开发者跑全量 pytest 会看到 10 条失败(test_empty_source_list_is_usage_error 从 exit 2 翻成 exit 1、语义反转)、耗时 9.8s→58s,且这批测试会真的对公司镜像发请求,违反本文件自己的「不联网」契约。它还是「把换源逃生口接进 CI」这个缓解手段的前置条件。
  • 建议修法: base = {k: v for k, v in os.environ.items() if not k.startswith("MILOCO_MODELS_")} 再合并;并给 test_dest_env_is_honored 同时传 --dest 并断言 --dest 压过 env(它现在不传,一旦该分支回归会把假模型写进真仓库那个已被 gitignore、git status 看不见的目录,CI 上还会被存进 actions/cache)。

3. ci.yml 缺 --strict(release.yml 有),残缺缓存被不可覆盖的 key 永久钉死

  • 问题: 非 strict 下可选模型失败是「日志一行 + return 0」→ job 绿 → cache 把只有 4 个文件的目录存进 miloco-models-<hash(lock)>;primary key 命中即跳过 save,故该残缺缓存对这个 lock 版本永不自愈(只能等 lock 变更或 LRU 逐出),此后每次都要重下那 ~24MB,:53 步骤名承诺的「缓存命中时不联网」静默失效。今天覆盖率零损失(唯一 requires_models 只看两个必选模型),但一旦给 bge 去重或 VAD 加一条 model-gated 用例,就复活了本 PR 要消灭的静默 skip。边界:必选失败会正确红且不 save,故缓存永远至少含 det_4C + reid;_is_ready 恢复后仍全量 hash,只可能「缺」不可能「错」。
  • 建议修法: --strict 的 help 把语义限定为「打 release tarball 时用」,所以更贴合意图的是:保留非 strict 下载,在 pytest 之前补一步不联网fetch_models.py --check --strict 当门禁 —— 既不让第三方代理抖动把 CI 判红,又杜绝残缺状态过门。

4. install-hermes step 4.7 一级 cp 零校验,下载兜底挂在 elif 上永不执行

  • 问题: 一级判据改成「目录里有任意 .onnx」(:701 compgen),命中即置 MODEL_SRC;:707-723 只 cp,不比 lock 的 sha256、不检查必需模型是否齐,cp 完只打 info 无 warn;:724 的下载兜底是同一链的 elif,MODEL_SRC 非空则永不执行。本 PR 打破了一个不变量:base 判据下该目录由 git 保证恒为 5 件套,现在由 fetch_models.py 填充,而后者部分失败时是「成功的已落地 + exit 1」 —— build.sh:326 一次失败的构建就足以造出部分填充的源目录。结果:cp 1 个 → 「新增 1 个」→ 不下载 → exit 0 零 warn,用户得到「安装成功」但首次 perceive 就 MODELS_MISSING 的系统。
  • 建议修法: :724 的 elif 拆成独立 if,条件从「一个 onnx 都没有」改成 fetch_models.py --check --dest "$MILOCO_HOME/models" 非 0;或 cp 后无条件再 --check 一次。注意光拆 elif→if 不够(cp 完目录里已有 onnx,旧判据仍为假)。

🔵 次要

5. _stream 不校验 written == expected_size:干净 FIN 截断被误报成「sha256 不符」并使续传失效

  • 问题: 读循环 if not chunk: break 收尾,返回前不比较 written 与 total(只喂进度条);http.client 对「Content-Length 未满就 EOF」静默返回 b""(HTTPS 下 suppress_ragged_eofs=True 同样静默)。跨境链路 det_4C 每次到 ~30MB 被干净关闭 → sha256 不符 → :188 删掉那 30MB → 下一次不带 Range 从 0 重来(服务端日志实测:截断场景 3 次请求全 Range=None,超时场景才带 Range=262144 并续传成功)。4 源 × 3 次累计下行 ~360MB、进度永远为 0,诊断说的是「sha256 不符」,把方向指向「镜像被投毒」。docstring 自称「78MB 跨境链路值得」的续传在这条最常见失败类型上没兑现。
  • 建议修法: 不要用 lock 的 expected_size 比 —— 陈旧 lock 场景下 written 会大于 expected_size,会打出反向误导并留超长 .part。应用响应的 Content-Length/Content-Range 校验并抛异常,以保住 .part 让下一轮真正带 Range。

6. actions/cache 把取舍①变成随缓存年龄而定的间歇现象,且零漂移探测

  • 问题: cache key 只由 hashFiles(lock) 决定、无 restore-keys,而 _is_ready 比的是「本地字节 vs 本地 lock」、就绪即完全不联网。资产被换而某分支 lock 未 refresh 时:命中缓存 → 旧字节比旧 lock → 通过 → 零请求 → 绿;缓存 7 天未用被淘汰或换到无缓存 scope → 下到新资产 → sha256 不符 → 红。同一 commit 今天绿下周红。body 写了「老 commit 会构建失败」,没提这个失败被缓存变成时间相关的间歇现象。而对账是免费的:gh api repos/…/releases/tags/models --jq '.assets[]|{name,size,digest}' 一次调用返回 5 条 sha256(实测与 lock 逐条相符,数据一直没被用)。注意供应链完整性并未被绕过 —— 任何绿都只能是「本地字节 == 本 commit lock」。
  • 建议修法: 加一条零下载的对账门禁(lint job 或 daily cron),比对 assets[].{name,size,digest} 与 lock 全等,不一致 ::error::(顺带抓住第 7 条的缩表);若要根治取舍①,按 publish_models.sh:16-18 自己写的路换不可变 tag。

7. publish_models.sh 的 refresh_lock_from_dir 全量重写 lock["files"],无「旧有新无」守卫

  • 问题: :73 lock["files"] = out,用「只放要换的那几个模型」的目录跑 upload 会静默删掉其余条目。blocking 链路不可达(必需模型掉出会被 required 子集断言打红;tokenizer 掉出会让 tar ./*.json 未展开而硬失败),残留危害是静默丢一个可选模型 + 运行时一条 warning。
  • 建议修法:missing = set(old) - names 硬守卫;并补一条 lock ↔ resource_validator.MODELS 的双向等价断言({(s.name, s.optional)} == {(f["name"], not f["required"])}),把「lock 是唯一真源」变成可执行约束。

8. 文档与口径三处不一致

  • 问题: ① models/README.md:34 把 bge 写成「语义检索,缺则降级为关键词」,实际是 suggestion 事件链去重、缺则降级为精确文本匹配,与同 PR 的 lock「事件去重句向量」三方矛盾;silero 那行「缺则不做 VAD 切分」也不准(实际回退能量门控)。② fetch_models.py:21 把 MILOCO_MODELS_BASE_URL 写成「优先于 base_url + mirrors」,实际是独占替换(设了就无兜底,且允许 http:// 明文)。③ install-hermes.sh:752 写的顶层 cfg["models"] 是死键(真键是 directories.models,extra="ignore" 静默丢弃,全仓无消费者),:742 注释也是错的;键错误是既有,但本 PR 把它从 cp 分支提升成独立 if,扩到了 release 装机与下载全路径。
  • 建议修法: 三处按代码实情订正;dedup_embedder.py:7 与两份 knowledge 文档里「包内 perception/models/ 作兜底」的表述已过时,一并改。

9. 两个边界与一处纵深防御

  • 问题: --only <可选> --required-only 得到空选集 → 空循环 → 静默 exit 0;lock 条目缺 required 键时 spec.get("required") fail-open 成可选;dest_dir / spec["name"] 对 name 无路径消毒(--lock 可指任意文件,Path("/a/b") / "../../evil" 会逃出 dest,f"{url}/{name}" 也未 quote)。信任边界目前是「lock 在仓库里」,可接受。
  • 建议修法: 空选集报 usage 错误或至少 warn;required 缺键改 fail-closed;加一句 if "/" in name or name.startswith(".") 守卫。

10. sync-to-remote.sh --remote-build 会删掉远端已有的模型,而换源 env 传不过去

  • 问题: COMMON_EXCLUDES 不含 *.onnx/models/,--remote-buildrsync -az --delete-after。base 时代模型是 tracked 必然带过去;本 PR 之后本地 models/ 合法地可能只有 README → --delete-after 把远端上一轮的 5 个模型删掉 → 远端 build.sh 的 --strict 必须自己拉 78MB,而远端常是网络最差那台;ssh 段只透传 4 个变量,MILOCO_MODELS_BASE_URL 传不过去。
  • 建议修法: COMMON_EXCLUDES 加 --exclude 'backend/miloco/src/miloco/perception/models/',并把 MILOCO_MODELS_BASE_URL 加进 ssh 透传;或在 dev-guide 的 remote-build 段写明先本地 fetch 一次。

11. 三处新增的重复付费/重入面

  • 问题: install-hermes.sh:679 的 --post-install 只挡 banner、step 4.7 正文照跑 → 这个轻量重跑模式现在可能触发 78MB 下载;models/ 变 gitignored 后 git clean -xdf 与新 worktree 都会丢模型、各自重下 78MB;.part 用固定文件名无锁,self-hosted 共享 workspace 或 --post-install 与 build 并行会互相截断(失败是响的、无坏产物)。
  • 建议修法: --post-install 下跳过下载只做 cp/校验;.partf"{name}.{os.getpid()}.part";dev-guide 提一句 git clean 后需重新 fetch。

@Zirconi

Zirconi commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

补一个和现有 comment 不完全重复的维护风险:Release 旧模型 asset 可能残留并被重新写回 lock。

已有第 7 条主要覆盖的是「用不完整本地目录 refresh-lock/upload 会把 lock 缩表」。这里是反方向的问题:models 是固定且可变的 Release tag,而 upload--clobber 同名资产,不会删除本次目录/期望清单里已经没有的旧资产;后续 refresh-lock 又会从 Release 下载当前所有 .onnx/.json 并整体写回 lock["files"],于是废弃 asset 可能重新进入 lock。

触发例子:

  1. 旧 Release 上有 human_body_reid_v2.onnx
  2. 未来模型换名为 human_body_reid_v3.onnx,维护者跑 publish_models.sh upload ./new_models
  3. gh release upload --clobber 会上传/覆盖 v3,但不会自动删除 v2。
  4. 之后有人跑 publish_models.sh refresh-lock,它会下载 Release 上所有 .onnx/.json,v2 + v3 都会被写回 lock。
  5. build.sh 里又是按 ./*.onnx ./*.json glob 打包整个目录,最终安装包也可能继续带上废弃模型。

影响短期通常不是功能崩溃(运行时大多按固定文件名读,额外旧文件可能没人用),但会让 release 包变胖、磁盘/下载成本增加;如果某个模型是因质量/许可/安全/兼容原因下线,也可能继续被分发。更关键的是,models.lock.json 会从“期望模型集合”退化成“Release 上碰巧还挂着的所有模型集合”。

建议至少补一个 allowlist/漂移守卫:

  • refresh-lock 不要盲收 Release 上所有 .onnx/.json,遇到 lock 外 asset 应 fail 或显式报错。
  • upload 后删除 Release 中不在本次期望集合里的旧 .onnx/.json asset,或至少打印阻塞告警。
  • build.sh 打模型 tar 时按 scripts/models.lock.json 的文件名列表打包,而不是 glob 全目录。
  • 如果要彻底规避这类可变资产漂移,考虑用不可变 tag(如 models-vN / models-YYYYMMDD),每次换模型同步更新 lock 的 release_tag/base_url

idootop added 3 commits August 6, 2026 12:42
ExWang 的 11 条 review 意见 + Zirconi 的 Release 资产漂移问题。

修的几个真 bug:

- install-hermes 步骤 4.7 的兜底判据恒为假。旧判据「一个 onnx 都没有才
  下载」挂在 cp 之后,而 cp 只保证「源目录里有的都过来了」、不保证齐。
  模型不再进 git 后源目录改由 fetch_models.py 填充,它部分失败时的形态是
  「成功的已落地 + exit 1」—— 一次失败的构建就能留下只有 1 个 .onnx 的源
  目录,于是「新增 1 个 → 零 warn → 安装成功 → 首次 perceive 就
  MODELS_MISSING」。改成独立 if,用 --check --strict 按 lock 逐个比字节。
  用 strict 是因为可选模型缺了也该补,否则 bge 去重 / VAD 静默降级。

- config.json 写的是死键。顶层 cfg["models"] 全仓无消费者,真实键是
  directories.models;MilocoSettings 的 model_config 为 extra="ignore",
  多出来的顶层键连报错都没有。改写 directories.models 并保留同级子键。

- .part 文件并发截断:flock + pid 兜底,Range 续传在常见情况下保留。

- CI 缓存可能固化不完整状态。actions/cache 的 post-step 无论 job 成败都
  保存,拆成 restore → 下载 → save(if: cache-hit != 'true'),另加一道
  零网络的 --check --strict 门禁。

- sync-to-remote 的 --remote-build 走 rsync --delete-after,本地模型目录
  合法地可能只剩 README,不排除的话会把远端上一轮下好的删掉、逼远端重拉
  ~78MB —— 而远端往往正是网络最差那台。顺带透传 MILOCO_MODELS_BASE_URL。

- 模型目录三处定义不一致(缓存 path / --dest / build.sh 的 pack_models),
  在 release.yml 的 build job 上收敛为 job 级 env.MODELS_DIR。

新增 publish_models.sh verify(Zirconi):零下载对账,一次 API 拿资产清单
与 lock 逐项比 name/size/sha256。它把一类**间歇性**失败变成确定的失败点 ——
models 是固定且可变的 tag,资产被换而某分支 lock 没跟着刷时,构建能不能过
取决于 actions/cache 有没有命中:命中就拿旧字节比旧 lock(绿),缓存一被
逐出就下到新资产、sha256 不符(红)。同一个 commit 今天绿下周红,看日志
完全看不出为什么。同时给 refresh-lock 加文件集漂移门禁(少了会静默缩表、
多了会以 required=false 默默收编),upload 后自动对账一次。

文档三处 claim 逐个对着代码核过:bge 缺失 → 退回精确文本匹配、VAD 缺失 →
退回纯能量 gate、不存在「包内 perception/models/ 兜底」这回事。更正
MILOCO_MODELS_BASE_URL 的语义为独占替换而非「排在前面」。

顺带修 sync-to-remote.sh 的 usage():BSD sed(macOS)不认 BRE 的 \?,
'#' 会原样打出来。scripts/build.sh:50 有同样的 bug,不在本 PR 范围,未动。

后端全量 3 failed / 2985 passed,clean tree 基线 3 failed / 2979 passed
(同样的 macOS node_monitor smaps 预存失败),+6 新增通过,无回归。
- 修正上一轮改错的文档:包内 perception/models/ 兜底确实存在
  (tracking_service.py / detector.py / api.py 三处从 __file__ 上溯),
  两处文档重写为「配了 models_dir 用它,没配才回退包内」的两段式描述
- publish_models.sh: 文件集比对前移到 gh release upload 之前
  (check_fileset_against_lock),避免资产已推上公开 Release 才报错、
  lock 还是旧的、所有人 PR 被对账门禁判红
- 新增 backend/miloco/tests/test_publish_models.py,15 个用例覆盖
  verify 的全等/缺漏/多余/sha256 不符/digest 降级,与 upload 漂移中止
- build.sh: 抽 is_full_build(),子集构建跳过 pack_models —— 否则
  --packages miloco-cli 也会被 fetch_models.py --strict 拖去下 ~78MB,
  且产出的 tar 在子集构建下无人消费
修回上一轮引入的回归 + 两处口径/门禁收尾:

- build.sh: pack_models 的门禁从 is_full_build 放回 should_build "miloco"。
  模型 tar 的消费方不止平台归档,还有 install.py 的 dev 通道(src_dir 直接
  就是仓库 dist/),罩上全量门禁会让「--packages <子集> 再 install.sh --dev」
  在 wheel 全装完之后才 fail。pack_platform_bundles 恢复无条件调用(内部
  已有 is_full_build)。顺手补上 --help 里漏掉的 hermes,并把
  download.models_missing 的中英文案指向 dist/ 而非包内 models 目录
- perception/models/README.md: 与两份知识库同口径,改成两段式解析描述,
  不再说"运行时不是本目录"
- fetch_models.py: 所有源试完后不再无条件删 .part —— 稳定名留着供下一次
  调用带 Range 续传(原实现下跨调用续传只在 Ctrl-C 时有效);pid 名与
  0 字节的壳仍然清掉。新增端到端续传测试锁住该行为
- ci.yml: 对账门禁按"能不能修"分流 —— push 与改了 lock 的 PR 强制,
  其余 PR 降级为告警(models 是可变 tag,换代窗口内无关 PR 会被判红,
  而两条修法都要仓库 write 权限)。verify 失败输出补归属说明
@idootop

idootop commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator Author

/review

@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown

[PR #492]: chore(models): move perception ONNX models out of git to a pinned Release

作者: idootop
范围: chore/models-out-of-git → main(25 文件,+2947 / -21331)

修改方案

要解决的问题

感知链路的 5 个 ONNX 模型(合计 ~78MB)一直存在 git 里:每个人 clone 都得背上,每换一次模型 git 历史里就再压一份,而且这堆二进制还会被打进 wheel。本 PR 把字节搬到 GitHub Release 的固定 tag models 上,仓库里只留一份带 sha256 的清单,并把"谁在什么时候按这份清单把模型取回来"这件事在开发、CI、构建、安装四条链路上分别接好。

整体方案(5 条正交主线)

主线 1 — 仓库里只留一份"谁、多大、什么摘要、必不必需"的清单

  • 5 个模型文件从 git 删除,改成在仓库里放一份清单,逐条记下文件名、字节数、sha256、是否必需,以及去哪儿取——一个 GitHub Release 直链加三个 gh-proxy 镜像(models.lock.json)。这份清单是全链路唯一事实源:下载、就位判定、构建打包、发布对账全部读它,没有第二处硬编码的文件名列表。
  • 原来放模型的那个目录不删掉,改成只留一份 README 解释"为什么这儿是空的、怎么把模型弄回来、运行时是按什么顺序找模型目录的",并在忽略规则里放行这个 README、忽略其余一切(README.md.gitignore)。目录本身必须继续存在,因为运行时在完全没配置模型路径时会回落到这个包内目录。
  • 打 wheel 时把这个目录整体排除,防止以后有人把模型放回本地又被顺手打进发行包(pyproject.toml#L47)。
  • 哪些必需、哪些缺了只降级,跟感知引擎自己那份启动检查表逐字对齐——目标检测和人体重识别是必需,句向量去重、它的 tokenizer、语音活动检测三个是可选(resource_validator.py#L20)。这条对齐由测试钉死,不靠人记。

三份"同一批文件"的事实源及其锁定方式:

事实源 记的是什么 由谁钉住不漂
scripts/models.lock.json 5 个文件的 name / size / sha256 / required —(源头)
resource_validator.MODELS 运行时必需 vs 可选的判定 test_lock_matches_resource_validator_models
manifest.jsondownload.sites 安装器用的镜像站点 test_lock_sources_match_manifest_sites

主线 2 — 一个只依赖标准库的下载器

  • 决定去哪儿下:默认按清单里的直链加三个镜像依次试;一旦设了环境变量指定源,就整体换成这一个,不再回落到清单里的地址——这样"我要用本地/内网源"是个干净的替换而不是多一个候选(_sources)。环境变量里直接写裸目录路径也认,内部转成 file:// URL 再走同一条下载路径(_normalize_env_source)。
  • 判断一个文件"已经就位"只看 sha256,字节数不参与(_is_ready)。理由是字节数在这里没有独立的判别力:sha 对上了 size 必然对,sha 没对上再比 size 也没用;size 留着是给发布侧拿线上资产的真实大小去对账用的。
  • 断点续传按"上次写到哪儿"发 Range 请求;如果本地那个半成品比声明的总长还大(上一版模型的残留),就地截空重下,而不是发一个必然被拒的 Range(_stream)。服务端真回了 416 时同样截空重来,避免"每次都从同一个坏 offset 续、每次都 416"的死循环。
  • 同一台机器上多个进程同时补模型时,靠对半成品文件加锁串行化,拿不到锁的退回一个带 pid 的私有半成品文件,不会互相写坏(_open_part)。丢弃半成品时用截断而不是删除——删了会把锁连同 inode 一起丢掉,后来者以为没人在下(_discard_part)。
  • 落地是原子的:全程写在 .part 上,只有 sha256 对上之后才一步换名到目标路径(_fetch_one)。所以目标目录里的文件要么不存在,要么就是完整正确的,中途断电断网不会留下"看着像模型的半个文件"。
  • 坏清单在读取阶段就一次性拦掉,而不是让 KeyError 穿到下载中途才炸:sha256 必须是 64 位十六进制(顺手统一转小写,Windows 上 certutil 输出的大写摘要不会被判成"永远校验不过"),可选键写成 null 与"整条不写"同义,必需标记缺失时按必需处理(_check_spec_required)。

源选择的分支:

条件 实际用的源
未设 MILOCO_MODELS_BASE_URL(或为空串) 清单的 base_url → 3 个 mirrors,逐个重试
设成 http(s)://file:// URL 只用这一个,不回落清单
设成裸路径(如 /tmp/models 解析成绝对路径转 file://,只用这一个
设成其他 scheme(如 ftp:// 拒绝,退 2

单个文件的下载路径:

sha256 已对上? ──是──> 跳过,一个请求都不发
   │否
   ▼
对 {name}.part 加锁 ──加不上──> 退到 {name}.{pid}.part(私有,不共享续传)
   │加上了
   ▼
.part 存在且 < 声明长度 ──> Range: bytes=<offset>-   ┐
.part 存在且 >= 声明长度 ─> 截空,从头下             ├──> 流式写入 + 边写边算 sha
.part 不存在 ───────────> 从头下                     ┘
   │
   ├─ 服务端 416 ─────────> 截空 .part(不 unlink,保住锁所在的 inode)→ 换下一个源
   ├─ 实收长度 ≠ 声明长度 ─> 保留 .part,换下一个源(下次接着续)
   ▼
sha256 == 清单 ──否──> 删掉 .part(坏字节没有续传价值)+ 报错
   │是
   ▼
os.replace(.part → 目标)     ← 原子,目标要么没有要么完整

退出码是给上游脚本读的契约:

退出码 含义 调用方应有的反应
0 需要的都就位 继续
1 必需模型缺失(--strict 下可选缺失也算) 可重试——网络 / 镜像抖动
2 用法错或清单坏 重试无用,得改清单或命令行

主线 3 — 发布侧脚本:上传、对账、重算清单

子命令 做什么 关键保护
upload 把本地目录里的模型传到 models tag 动 Release 之前先比对"目录里的文件集"与"清单里的文件集",不一致直接停手;确属换代时用 MILOCO_MODELS_ALLOW_LOCK_DRIFT=1 显式放行(check_fileset_against_lock
verify 只调 API 对账,一个字节都不下 分别报"清单有 Release 没有""Release 上残留了清单里没有的""大小不符""摘要不符";Release 没给摘要或给的不是 sha256 时降级成告警而不是判红(cmd_verify
refresh-lock 按目录重算清单 已存在的条目沿用原来的必需标记,全新出现的文件默认按可选,避免重算一次把可选模型悄悄升级成必需(refresh_lock_from_dir

两个值得单说的实现选择:

  • 清单的读取被推迟到子命令分发之后,所以 --help 在清单已经坏掉时仍然能打出来,坏清单也只出一行中文而不是 Python traceback(require_lock)。
  • cmd_verify 内部全程用 return 而不是 exit:它同时被 upload 当作收尾的非致命对账调用,用 exit 会把"别忘了提交 lock"这句提示一起吞掉。

主线 4 — CI / 构建 / 同步链路接线

位置 接了什么 为什么是这个形态
ci.yml backend-test 缓存恢复 → 下载 → --check --strict 校验 → 缓存保存 有 4 个用例在模型缺失时会静默 skip(test_deep_sort_v12.py#L27),不下等于偷偷少跑一批覆盖
ci.yml lint 判定门禁强度 → publish_models.sh verify 零下载的资产对账,见下面「关键设计原则」第 3 条
release.yml build 缓存恢复 → --strict 下载 → 缓存保存 发版必须 5 个全齐;独立成步是为了让"模型没下到"在日志里是个单独的失败点,不用去 build 的长日志里翻
build.shpack_models 先按清单拉齐,再按清单里的名字逐个打进 tar 从前是 tar -C dir . 整目录打包,目录里任何残留都会混进发行包(pack_models
local-ci.sh 跑后端测试前非致命地校验一次 只提示不拦人,本地少个可选模型不该挡住整轮自检(local-ci.sh#L66
sync-to-remote.sh rsync 排除模型目录,并透传源地址环境变量 该脚本用 --delete-after,不排除的话本地这个(合法地)空目录会把远端已下好的 78MB 删掉(sync-to-remote.sh#L99

缓存这一步为什么要拆成 restore + save 两个 action 而不是用一体的 actions/cache

一体 actions/cache:
  restore(miss) → 下载(只成功 4/5,job 红) → post 步骤照样 save 残缺目录
                                                    │
                                                    ▼
                     该 lock 版本的缓存 key 从此再也盖不掉(命中即跳过 save)
                     → 同一个 commit「今天绿、下周红」的间歇现象

拆开 restore / save:
  restore(miss) → 下载 → --check --strict 校验 ──失败──> job 红,save 步不执行
                                             └─成功──> save(干净状态才进缓存)

主线 5 — 安装侧接线(hermes 插件 + 安装器)

  • 判"模型齐不齐"这件事,全脚本只用一个判据:调下载器做一次不联网的严格本地校验;只有在脚本旁边压根没有仓库 checkout(装到插件目录之后)时,才退回旧的弱判据"目录里有没有 .onnx"(models_ready)。
  • 先短路:目标目录已经按清单齐了就跳过后面整段本地拷贝,避免在 Release 装机场景里误报"找不到模型源目录"(install-hermes.sh#L707)。短路判据必须是"齐不齐"而不是"有没有"——按"有没有"的话,目录里躺着一个上次下到一半的文件就会跳过本地拷贝,转而联网下 75MB,而正确的字节此刻就在旁边的 checkout 里。
  • 找本地模型源时,判据是"这个目录里真的有 .onnx"而不是"这个目录存在"——模型出仓之后,新 clone 出来的那个目录里只有一个 README。
  • 本地拷贝那一轮的跳过判据是"同名文件在不在",而换模型走的正是同名覆盖(文件名一个字不变、摘要变了)。所以拷完之后再拿旁边这份 checkout 当 file:// 源跑一趟下载器,把"要不要覆盖"交给 sha256 判:已经对的一个字节都不碰,只有真过期的才就地覆盖(install-hermes.sh#L757)。源 URL 用标准库拼而不是手拼 file://$dir,否则带空格或 # 的 checkout 路径会静默拼出解析错的 URL。
  • 最后独立复判一次(不挂 elif,因为本地拷贝只保证"源目录里有的都过来了"、不保证齐),按四种处境分别给不同的话:
复判结果 / 处境 行为 给用户的话
已齐 什么都不做
不齐 + --post-install 轻量重跑 联网下载 提示重跑 install.sh(安装包自带模型 tar),或在 checkout 目录里手动跑下载器
不齐 + 手边有下载器 按清单联网补齐 失败只 warn 不中断安装,并给一条带解释器前缀、可整行复制的重试命令
不齐 + 手边没有下载器 无法补 提示回 checkout 目录重跑,或手动放模型
  • 模型目录写进配置时,键从原来那个不存在的顶层 models 改成实际生效的 directories.models,并且是合并而不是整段覆盖(install-hermes.sh#L814)。运行时的解析顺序是:配置里的 directories.models → 没配就回落到 $MILOCO_HOME/models → 完全没设过才用包内目录(settings.py#L479settings.py#L523),README 里对这套顺序的描述与代码一致。

关键设计原则

  1. 清单是唯一事实源,不允许出现第二份文件名列表。 下载、就位判定、打包、发布对账、安装器镜像站点全部读它;跟运行时检查表、跟安装器 manifest 的两处一致性各由一个测试钉死。代价是加模型要同时改两三处,收益是"改漏一处"从运行时故障降级成 CI 红。
  2. 判"就位"只认 sha256,字节数只用于线上资产对账。 两个判据分开是有意的:本地校验要的是"字节对不对",而线上对账要的是"我请求的资产是不是我以为的那个"——后者拿得到 size 但不一定拿得到摘要。
  3. CI 门禁的强度按"谁能修"分级,而不是一刀切。 models 是固定且可变的 tag,换代必然有一段窗口——资产已经换上去、刷过清单的 commit 还没合 main。窗口内每个开放 PR 都会红,红的原因跟它的改动毫无关系,而两条修法都要仓库写权限,外部贡献者一条都执行不了。所以 push 和改了清单的 PR 强制判红(那正是换代 PR 本身,作者手上有全部修法,也是"清单写错"唯一能在合入前被拦住的地方),其余 PR 降级为告警。
  4. 拿不到判定依据时倒向最宽松那一档。 列 PR 改动文件的那次 API 调用失败时(二级限流 / 5xx / 网络抖动)按"不强制"兜底并打一条 warning,否则一个只改前端的 PR 会因为"判定门禁强度"这一步失败而整个 lint 变红——贡献者既看不懂也修不了。同一段里还刻意避开了 gh api | grep -q 的写法:grep -q 命中即退,gh 吃到 SIGPIPE 退 141,而默认 shell 带 pipefail,会把"命中"读成失败、恰好反判成不强制。
  5. 模型缺失一律不中断安装,但必须给一条能整行粘贴的命令。 感知会降级报错,插件其余部分照装。相应地,凡是打给用户的补齐命令都带解释器前缀(脚本在 git 里是 644,裸路径粘贴会 Permission denied,而这个新错误跟原故障毫无关系,只会把人带偏),且在 --post-install 这种手边没有 checkout 的处境下,不给相对路径、不提清单文件名——那两样此刻都不在手边。

测试覆盖

主线 测试文件 用例摘要
1 单一事实源 test_fetch_models.py::test_real_lock_is_wellformed / ::test_lock_matches_resource_validator_models / ::test_lock_sources_match_manifest_sites 真实清单结构合法;清单 ↔ 运行时检查表的文件名与必需/可选逐项相等;清单的直链+镜像 ↔ 安装器 manifest 的站点列表一致
2 下载器(41 例) backend/miloco/tests/test_fetch_models.py 首次下载并校验、二次运行零请求、本地文件损坏自动重下、环境变量覆盖源(URL / 裸路径 / 非法 scheme 退 2)、--dest 与环境变量的优先级、摘要不符不留半成品、可选缺失降级 vs --strict 判红、--required-only--check 全程不联网、坏清单一律退 2(缺键 / null required / null size / 大写摘要 / 路径穿越 / 空源列表 / 清单里写裸路径)、截断响应保留半成品供续传、进程退出后半成品仍在、Range 越界 416 自愈、丢弃半成品保住锁所在 inode、file:// 源失败不做退避
3 发布侧(24 例) backend/miloco/tests/test_publish_models.py verify 的六种结论(全等 / 缺资产 / 多余残留 / 摘要不符 / 大小不符时不重复报摘要 / 摘要缺失或非 sha256 时降级)、upload 在文件集漂移时动 Release 之前中止、尾部对账失败不吞"别忘了提交 lock"、无 gh 时 verify 硬失败、环境变量强制放行、refresh-lock 的必需标记继承与新文件默认可选、空目录拒绝、坏清单下三个子命令各出一行中文、坏清单下 --help 仍可用且不被截断
4 / 5 接线 无自动化覆盖(CI 步骤与安装脚本行为),依赖 CI 自身运行验证

上轮 ci-bot review 对账

  • 上轮唯一一条 🔵(install-hermes.sh--post-install 分支用 ${FETCH_MODELS:-scripts/fetch_models.py} 兜底,而这个兜底值只在它必然解析不到的处境下才出场)已在 d919366 修复——现在改成两条不依赖 checkout 的提示。前提也复核过了:hermes tarball 只 stage 了 install-hermes.sh 本身(build_hermes),而 --post-install 正是从解压目录调起的(scripts/install.py:1370-1379),那儿确实没有 scripts/
  • 更早两轮的条目分别落在 c52c3cd(清单里可选键写 null 的语义、下载失败提示补解释器)和 85e301e(坏摘要/坏 size 退 2、--help 不再被 BSD sed 吞掉前缀)。
  • 人类 reviewer Zirconi 提的"Release 上换代后的旧资产残留"已被三处堵住:verify 会单独报"Release 上有、清单里没有"并给出可直接粘贴的 gh release delete-asset 命令;upload 在动 Release 之前先拦文件集漂移;pack_models 改成按清单名字逐个打包,残留资产进不了发行包。

独立复核

除对账 ci-bot 旧条目外,本轮另外跑了这些检查,均未发现问题:

  • 删除符号的反向引用扫描:5 个被删的模型文件名 + perception/models 路径全仓 grep,命中的全是运行时按文件名加载的合法引用,或已在本 PR 内同步更新的文档,无过期引用。
  • PR 描述逐条核对:测试用例数(41 / 24)、--help 里列的 6 个包与 ALL_PACKAGES 的实际取值、wheel 的 exclude 规则、README 里描述的两段式模型路径解析顺序 vs settings.py 实际实现——全部对得上。
  • 跨层一致性:清单 ↔ 运行时检查表 ↔ 安装器 manifest 三方逐项比对一致,且各有测试钉住。
  • --help 提取方式的可移植性:三个脚本改用 awk 之后,逐个数过头部注释块的起止行,输出范围正确,没有提前截断。

静态审查限制(需明示):本轮 review 全部结论来自源码层交叉核对——本 runner 上没有 python3,新增的两个测试文件(1508 行、65 个用例)没有实际执行过。CI 的 backend-test job 会跑到它们,以那次结果为准。

结论

LGTM — 上一轮的 🔵 与人类 reviewer 提的残留资产问题都已闭环,本轮独立扫描(反向引用、PR 描述逐条核对、跨层一致性、脚本可移植性)未发现新问题;唯一保留意见是新增测试未在本地执行,以 CI 结果为准。


由 review-pr skill v1.6 生成

@XiaoMi XiaoMi deleted a comment from github-actions Bot Aug 6, 2026
idootop added 10 commits August 6, 2026 17:20
usage() 原来是 `sed -n '2,26p'`,本 PR 给抬头补 ONNX 说明时正是手工把 24 bump
成 26 才没截断。下次再补一条而忘了同步行号,`-h` 会被静默截短,没有测试或 CI 会拦。

改用与 publish_models.sh 同一种提取方式:跳过 shebang,打到第一个非 # 行为止。
帮助输出与改动前逐字节一致;在抬头追加一行后实测新增行会被打出(旧实现会吞掉)。
文档里的 directories.models_dir 与实际 schema 对不上(🟡):models 才是字段,
models_dir 是 @computed_field 派生只读属性,DirectorySettings 无 model_config、
pydantic v2 默认 extra="ignore",照 dev-guide 写进 config.json 会被静默丢弃、
路径回落 $MILOCO_HOME/models、最终以 MODELS_MISSING 收场且无任何警告。实测
model_validate({"models_dir": ...}) 后 models 仍为 ""。dev-guide / sdk-onnxruntime /
dedup_embedder docstring 三处改成真实键名并点明派生属性只读,与同 PR 内
perception/models/README.md、install-hermes.sh 写入的键统一。

另三条建议:
- local-ci.sh 就绪校验补 --strict,与 ci.yml 门禁同强度;原来可选模型缺失退 0,
  本地静默跑降级路径却自认已对齐 CI。
- ci.yml 下载步注释改写:拆两步买到的是失败归因可分与残缺不进缓存,最终策略
  仍是全严格,原注释的"镜像抖动不判红"会被理解成可选模型下不动不影响 CI。
- sync-to-remote.sh --packages 帮助补上 web,hermes(原样透传、本脚本不校验),
  并注明非全部 6 个时按子集构建、不产平台一体归档。
_open_part 靠对稳定名 .part 加 flock 来保证「同一 dest 下只有一个进程在写同
一个文件」。但 flock 锁的是 inode,不是路径:一旦 unlink 掉目录项,锁就挂在
一个没有名字的 inode 上,别的进程在同一路径新建 inode 后能成功抢到锁 —— 两边
于是并发写同一个 .part,交错出来的字节永远校验不过,表现成「重跑多少次都是
sha256 不符」,把诊断指向「镜像被投毒」,而网络和镜像其实都是好的。

两条 unlink 都在锁的生命周期内:
- _stream 里「已有内容 ≥ 目标大小」那条尤其要紧 —— 目录里留着一份换代后的超长
  .part 就会命中,不需要任何 sha 不符,整轮调用从第一次 _stream 起就无锁。
- _fetch_one 里 sha 不符后紧跟 2^n 秒退避 sleep,正好把窗口拉开到秒级。

改用 os.truncate(path, 0):inode 保住(锁跟着保住),内容清空,对下游完全等价
—— _stream 下一轮 stat 得 0,不带 Range、以 "wb" 整段重写。收尾块里第三处
unlink 保留:它是 finally 释放锁前对该路径的最后一次触碰,且清掉 0 字节空壳有用。

同时:
- ci.yml:列 PR 改动文件失败时降级为告警而非硬失败。这一步自己没有
  continue-on-error,默认 shell 带 -e,二级速率限制会让一个只改前端的 PR 整个
  lint 变红,报的还是「判定对账门禁强度」失败 —— 贡献者看不懂也修不了。
- local-ci.sh:--strict 的提示文案改成覆盖它实际命中的两种情况。原文只提
  requires_models,「只缺可选模型」时这条会打出来而一条 skip 都没有,读的人会
  把它归档成噪音,下次必需模型真缺时也就拦不住人了。
publish_models.sh: cmd_verify 拿资产清单失败时用 return 而非 die。die 是 exit,
在函数里退的是整个 shell,cmd_upload 那句 `if ! cmd_verify` 接不住。而那次对账是
故意设计成非致命的——跑到那儿资产已经推上 Release、lock 也已落盘,一次 API 抖动
不该把「别忘了提交 lock」那行一起吞掉:维护者看到 FATAL + 退出码 1 会读成「上传
失败」,于是要么重跑一遍 upload,要么没提交刷新后的 lock,把 CI 的对账门禁留给
下一个人踩。抬头注释第 44-46 行已为 check_fileset_against_lock 点过同一类坑。
实测:修复前 FATAL/exit 1 且收尾提示不打印;修复后走告警分支、提示照打、exit 0。
直接跑 verify 的退出码不变(实测仍为 1),CI 门禁行为完全一致。

build.sh: 删掉「--help 里印的那行包列表恰好漏了 hermes」这句——本 PR 第一个 hunk
正是把它补全的,补全后与 ALL_PACKAGES 逐字符相同,照抄现在会判**全量**,跟注释
说的正好相反。换成不会过期、且点出真正坑点的说法:is_full_build 是字面全等,
少写一个包或只是换个顺序都算子集。

install-hermes.sh: 4.7 的短路判据从「目标目录有没有 .onnx」改成「按 lock 齐不齐」,
与最后那道门禁复用同一个 models_ready。按「有没有」的话,目标目录里躺着 1 个
silero_vad.onnx(上次下到一半断网 / 手工拷过一个)就会跳过整段 cp,而门禁照样判
不齐,于是从 Release 联网下 ~75MB——那 4 个文件此刻就在旁边的 checkout 里,一次
cp 就够;断网时更糟:下载失败只 warn 不中断,安装报成功,首次 perceive 直接
models_missing,而完整的本地副本从头到尾都在一个目录之外。Release 装机场景不受
影响:tarball 解全了就判齐、照样短路,不多跑那次 import miloco。
cmd_upload 收尾那句 `if ! cmd_verify` 是**故意**非致命的:跑到那儿资产已经
推上 Release、lock 也已落盘,一次抖动不该把「别忘了提交 lock」一起吞掉。
但 cmd_verify 开头的 need_gh 里是 die(= exit),在函数里退的是整个 shell,
调用方接不住 —— 和上一轮修掉的 `gh api` 失败路径是同一个类。

这处更隐蔽:gh auth status 不是纯本地检查,它要发一次请求验 token。从
cmd_upload 开头那次 need_gh 到这里隔着 78MiB 上传 + 78MiB 重算 hash,
通常好几分钟,token 过期或网络抖一下就够了。

前置检查改由调用方做(cmd_upload 已查过,直接跑 verify 由 case 分派处查),
verify 子命令的门禁强度不变。搬出去之后 cmd_verify 函数体内再无任何 exit。

两条回归测试钉住的是「函数体内不许有 exit」这个性质,而不是某一处具体的 die。
本 PR 把 download.models_missing 从「dev 请确认 perception/models/ 下 .onnx
齐全」改成了「dev 请确认 dist/ 下有 miloco-models-*.tar.gz」,但同一条失败路径
上方两行的注释还停在旧语义。

而旧语义对这段代码本来就不成立:_step_download 调的是
_extract_models(_get_src_dir(), ...),dev 通道下 _get_src_dir() 返回仓库
dist/,函数体只 glob miloco-models-*.tar.gz,从头到尾不看包内模型目录。

只影响维护期:后来的人照注释去 perception/models/ 找 .onnx,会发现那个目录
在本 PR 之后按设计就是空的(只剩 README),排查得绕一圈才回到 dist/。
顺手补一句说明那个目录现在的定位,免得下次又有人往那儿找。
_stream 开头那道脏文件守卫只挡 offset >= lock.size 一侧,挡不住
[实际资产长度, lock.size) 这个窗口:lock 比线上资产长(换代后没 refresh)、
目录里又留着一份落在窗口里的 .part 时,Range 起点会越过资产末尾,服务端按
RFC 回 416。而 HTTPError 是 URLError 子类,正好落进"故意不删 part"那个
except —— 偏移量一个字节都没变,3 次重试 × 4 个源全部原样复现同一个 416,
收尾还打"已保留 X 待续传",下一次调用从同一个偏移量再演一遍。永不自愈,
而日志把人指向"网络/镜像有问题",和唯一的解法(丢掉这截字节)正好反向。

416 是第三类失败:"续传起点本身非法"——起因是我们自己攒下的状态,不是
外部原因。处置与 sha256 不符那条同构(都是"本地这截不能要了"):截断归零,
同一个源的下一次重试就不带 Range 从 0 重下。

同类扫过:lock 缺 size 键时守卫根本不触发,同样 416 卡死,本补丁一并覆盖;
服务端不认 Range 回 200 的路径已自愈(offset 归零 + "wb" 整段重写);
404/403/429/5xx 不是自攒状态造成的,重试/换源本就是对的处置。

回归测试起了一台按 RFC 回 416 的本机服务,如实搭出触发条件
(lock 2327 / 资产 2000 / .part 2100),撤掉补丁即红。
三条 review 🔵,逐条核实后都成立:

1. publish_models.sh:31 把 $LOCK 插进 Python 源码串(open('$LOCK')),是全文件
   唯一一处——另外三处调 python3 都是当 argv 传的。clone 到含单引号的路径下
   (/home/o'brien/miloco)展开出来就是语法错误的 Python,而这行在 case 分派
   之前求值,用户拿到一段 SyntaxError traceback,连 --help 都打不出来。实测
   复现并确认修复后两种路径都正常。

2. perception/models/README.md 那段「配了 directories.models(默认
   $MILOCO_HOME/models)就用它…没配时才回退到本目录」把两级判定焊在一个括号里,
   按字面读会得出错的结论:以为不配就用源码树里这个目录。实际上「没配」拿到的是
   $MILOCO_HOME/models,真正落到本目录的条件是构造感知引擎时完全不传
   perception_model_dir。而 wheel 形态下本目录必然为空——正是同段末尾警告的场景。

   同类扫过:这段口径被本 PR 复制到了三处,knowledge/06-dev-guide/dev-guide.md
   与 knowledge/05-external-deps/sdk-onnxruntime.md 同样混淆,一并改成两段各判
   各的写法。

3. test_upload_proceeds_when_fileset_matches_lock 的注释说「让它对上即可」,
   代码却是 set_assets([])。改注释而不是改代码:让代码兑现注释等于重复覆盖
   test_verify_passes_when_assets_match_lock 已经钉住的东西。新注释顺带点明
   这两条路各由哪个用例覆盖。
① _is_ready 只认 sha256,去掉 size 预判

下载落地时校验只看 sha,--check 却先看 size —— lock 里 size 与 sha256 描述的不是
同一份字节时(手改 lock 敲错一位、换模型只更新了 sha),CI 上紧挨着的两步会一绿一红:
fetch_models.py --dest X 打「校验通过」退 0,--check --strict --dest X 立刻判「缺失或
校验不通过」退 1,而它给的修法正是刚跑成功的上一步,重跑多少次都一样。
去掉不多花:happy path 上 size 本来就对,照样要算这一次 hash。lock 的 size 仍供
_stream 的脏文件守卫和进度显示用,它自身对不对由 publish_models.sh verify 拿线上
资产的真实大小去对 —— 那才是该报「size 不符」的地方。
新增 test_download_and_check_agree_on_a_self_contradictory_lock 钉死(把 size 预判
塞回去即变红,已验证)。

② install-hermes.sh:cp 之后追加一趟 file:// 下载

那轮 cp 的跳过判据是文件名,而换模型走的正是同名覆盖(gh release upload --clobber,
sha 变了名字没变),于是老用户升级时旧模型被原样留下:联网时门禁判不齐 → 白下几十
MB,正确的字节就在旁边 checkout 里;断网时下载失败只 warn 不中断,安装报成功,而
resource_validator 只查文件在不在,旧模型被静默加载。
拿 checkout 当 file:// 源再跑一趟下载器,把「要不要覆盖」交给 sha256 判:已经对的
一个字节不碰,只有真过期的才就地覆盖。lock 的 5 个名字与 cp 循环同一批(含 bge
tokenizer 的 .json)。源 URL 走 as_uri() + argv,避免带空格/# 的 checkout 路径拼出
解析错的 URL。整段静默失败,缺什么由下面的门禁和联网那趟去报。
未加 shell 级用例:仓库没有 install-hermes.sh 的测试骨架,为一处三行改动搭一套不
划算;真正的仲裁者是 fetch_models.py 的 sha 覆盖逻辑,已由 27 个用例覆盖。
① 本地源不再干等(scripts/fetch_models.py)

上一版新加的「拿旁边 checkout 当 file:// 源同步一趟」会为源目录里**没有**的模型
白跑退避:下载器不知道这是本地源,对「源上没这个文件」走的是和网络故障一样的路径,
每源 3 次、中间 sleep 1s+2s。env 是独占替换只有一个源,于是每个缺失文件固定 3 秒。
而 fork 的 checkout 只带一部分模型正是常态(那段注释自己写明了),加上整段静默,
实测 5 缺 4 时安装器在两条 info 之间无声卡住 12.2s,对结果毫无贡献——那几个本来
就该由后面的门禁和联网那趟去补。

改在下载器而不是调用点加 --only:判据「等一会儿能不能改变结果」只跟 scheme 有关,
放在这里一处惠及所有本地源调用方(含文档里 MILOCO_MODELS_BASE_URL=file:// 的离线
场景),也不必在 shell 里绕一圈读 lock。重试本身照留(本地源也可能撞 EIO),只是
不再 sleep。实测 12.2s → 0.1s。

新增 test_local_source_failure_retries_without_backing_off:进程内记 sleep 调用,
断言 file:// 不退避、重试次数不变、http 源仍是 [1, 2](撤掉条件即变红,已验证)。
用进程内而非墙钟阈值的理由与 test_discarding_part_keeps_inode… 同:跨进程只能赌
CI 负载。

② 三处复述跟上 _is_ready 的判据变更

上一版把 size 预判从就绪判据里摘掉,但「判据是什么」在仓库里被复述了三遍:
- plugins/hermes/install-hermes.sh(挂在 models_ready 头上,插件链路判齐不齐的唯一入口)
- knowledge/06-dev-guide/dev-guide.md(照它估成本会以为常见情况只花一次 stat)
- PR 描述的「怎么拿模型」表
三处都改成只说 sha256,并把「size 由谁仲裁」指向 publish_models.sh verify,免得
读者以为那个字段没人管了。dev-guide 那处顺带写明「零流量但不是零成本」。
全仓扫过其余 --check 描述(models/README.md、ci.yml、local-ci.sh)都只说
「不联网 / 只校验」,publish_models.sh 那两处说的是 lock 记哪些字段,均无需改。
idootop added 8 commits August 7, 2026 12:38
MILOCO_MODELS_BASE_URL 给裸路径(挂载目录)时,值会原样拼进 URL,
urllib.request.Request 抛的是 ValueError —— 不在下载循环那个
(URLError, OSError, TimeoutError) 里,一路穿出 main:traceback、
退 1(文档定义为"必需模型缺失")而非文件头承诺的 2,剩下的文件不再
尝试,"可用 MILOCO_MODELS_BASE_URL 换源"那句提示永远打不出来 ——
插件安装器接住非 0 后打的是"下载失败(网络?)",而用户明明是刻意离线的。

而两个文档入口都没把"要带 scheme"讲硬(sync-to-remote.sh 说的是
"内网源",README 与 dev-guide 只给了 https 的例子),裸路径正是最自然
的写法。故 env 侧兜底成 file://(scheme 长度为 1 按裸路径处理,
Windows 盘符 C:\models 不会误伤),非 http/https/file 的 scheme 退 2。

同源问题 lock 侧也有一份(base_url / mirrors 写成裸路径,同样的
traceback)。lock 侧刻意**不**兜底:它是提交进仓库、由 publish_models.sh
refresh_lock 生成的产物,写成裸路径就是坏了,而"坏 lock 退 2"是本脚本
已有的契约。跟着兜底会把一眼能定位的配置错误变成"从一个不存在的本地
目录下载失败",把人指向换源,而真正坏的是 lock 自己。

测试 28 → 31,三条均已验证 revert 后失败。
fetch_models._required 缺键 fail-closed 判必需,publish_models.sh
refresh_lock_from_dir 缺键判可选 —— 同一个字段两侧默认值相反。

「lock 条目由人手写/手补」是这套流程文档化的正常路径(refresh 里那句
"新增项记得手工把 required / desc 与 resource_validator.py 对齐")。
一个同名条目只是漏了 required 键时:漏写的那阵子一切是绿的(下载器一直
当它必需,没有任何信号),文件集护栏只比名字集合、看不见"同名但少一个
键"这种漂移,于是下一次 upload / refresh-lock 把它静默写成 required=false。

实测:把 det_4C.onnx 的 required 键删掉后跑 refresh-lock,rc=0、无任何
输出,lock 里该条变成 required=false 并从第 1 行掉到第 4 行(排序按
required 走,diff 看着像纯噪声)。

翻面的代价落在唯一一条不带 --strict 的调用上 ——
install-hermes.sh:787 那趟联网补齐,退出码从 1 变成 0(实测),三条 warn
一条不打、安装报成功,用户第一次 perceive 才拿到 models_missing。
build.sh 与两个 workflow 都带 --strict,不受影响。同时 lock 说可选、
resource_validator 里仍硬编码必需,恰好是 :106 点名要人工对齐的那个字段
自己分了家。

改成:同名旧条目缺键 fail-closed 判必需(= 保留下载器眼中的语义,符合
函数注释承诺的"保留同名文件的 required");真正新增的文件仍默认 false,
那条路径已由护栏显式放行并要求人工对齐。

同类扫描:lock 里 name / sha256 两侧都是硬键(缺了 KeyError,吵),
size 缺了只关掉截断检查、无语义翻面,desc 空即空 —— required 是这一类
里唯一一个两侧默认值相反的。

refresh-lock 此前零覆盖,补两条:缺键必须保留为必需(revert 补丁即红)、
以及反方向的"真正新增的文件仍默认可选"(防止为修这条把所有条目翻成必需)。
顶层那句 `TAG="$(python3 -c ... "$LOCK")"` 在 die 定义之前、也在 case
分派之前求值,于是 lock 不存在 / JSON 坏掉时**任何**子命令都先吐一段
Python traceback 再退 1 —— 连 --help 都一个字打不出来,而人在这个时候
恰恰最需要看一眼用法。典型触发是合并冲突标记:两个分支各自 refresh 过
lock,合出来的工作区里这个文件必然是非法 JSON。CI lint job 里
`publish_models.sh verify` 红出来也是 traceback,读的人第一反应是
"脚本崩了"而不是"清单坏了"。同一份坏 lock 交给 fetch_models.py 则是
一行中文 + 非 0。

改成 require_lock(),延到 case 分派之后调用。三个子命令一律先过这道
校验,**不**按"用不用得上 TAG"分叉:lock 一共 4 处读取点,按需分叉的话
refresh-lock <dir> 恰好被豁免(它确实不需要 tag),而
refresh_lock_from_dir 内部照样 json.loads 同一份文件 —— 实测那种改法下
traceback 原样还在,而"合并完先跑一次 refresh-lock"正是最容易撞上冲突
标记的那条路。

退出码不跟着对齐到 fetch_models 的 2:本脚本的 die 一律退 1,唯一的
自动消费方是 ci.yml 那句 `run:`,只看零/非零,为此另立码段没有收益。

require_lock 排在 need_gh 之前:纯本地、确定性的那道先说话,别让一个
坏 lock 先报成"gh 没登录"(gh auth status 还要发一次请求)。

补 5 条回归:3 个子命令 × 冲突标记、release_tag 空串、--help 仍可用。
完全 revert 则 5 条全红;只按"按需分叉"修则 refresh-lock 那条独红。
publish_models.sh 里 4 处 `:行号` 交叉引用全部指错,两种成因各占一半,
都不是"写的时候不小心"能治的:

  :85  / :106 —— 引入它们的那个 commit 上就已经错了。按缓冲区里的行号
                 写下引用,同一个补丁后面又插了二十来行,提交时它就飘了。
  :136 ×2     —— 写的时候是对的(5abe53f…982cbbd 上 L136 确实是 need_gh),
                 之后两个 commit 把它顶下去,成了 `out.append({`。

所以换成不会飘的符号引用,而不是把行号改对:

  :85   → 「那段 Python 的头一句就是」(refresh_lock_from_dir 里的 json.loads)
  :106  → 「护栏那句『新增项记得手工把 required / desc 与 resource_validator.py 对齐』」
  :136  → 「cmd_upload 开头那句 need_gh」

全文件扫过一遍,剩下的 `:NN` 只有 `[:16]` / `[:12]` 这类 Python 切片。
不为此加 lint:符号引用本身不会漂,而按 `:NN` grep 会误伤合法的跨文件引用
(`ci.yml:187` 之类),为一个已经按构造消掉的问题铺一层误报面不划算。

troubleshooting.md 的 `models_missing` 行只写了"重跑 install.sh",模型移出
git 之后源码开发者照做要绕一大圈(装安装包只为补两个 onnx)。补上
`fetch_models.py --dest`,与 dev-guide.md 里那条同形;两条路都留着。
表格是 prettier 重排的(该文件在 knowledge/**/*.md 的 CI glob 内),除这行
外全是列宽空白。
local-ci.sh 那段就绪检查显式传了 --dest(包内目录),印给人照抄的补齐命令
却是不带 --dest 的裸命令 —— 两套目录。裸命令的目标目录会回退到
MILOCO_MODELS_DEST,而把模型放仓库外共享给多个 worktree 的人常在 profile
里长期 export 它。实测那种环境下的完整闭环:

  $ MILOCO_MODELS_DEST=/tmp/xxx python3 scripts/fetch_models.py --check --strict
  缺少模型:det_4C.onnx, ...
  补齐:python3 scripts/fetch_models.py --dest /tmp/xxx      ← 下到这儿

78MB 落进 $MILOCO_MODELS_DEST,再跑一次 local-ci.sh,告警一字不差地又来一遍:
它校验的仍是包内目录,requires_models 那批也照旧整批 skip(判据是
test_deep_sort_v12.py 里 __file__ 推出来的包内目录,环境变量够不着)。而人
手里唯一的线索就是这条命令 —— 第 57 行注释警告的正是这个陷阱,第 69 行自己
踩了进去。

改法不是"把 --dest 补上"就完事:相对路径提成 models_rel 写一次、绝对路径由它
派生,校验与提示因此不可能再分家。同一个思路上一轮已经用过(行号引用换符号
引用)—— 能按构造消掉的漂移,不必再拿测试去追。不加 grep 式断言的另一层原因
是它只能证明"字符串里有 --dest",证不出"和校验的是同一个目录"。

印相对路径而非绝对路径:命令前半截 `scripts/fetch_models.py` 本来就是仓库根
相对的,绝对 --dest 也救不了从子目录粘贴(前半截照样找不到文件),两截同口径
才是一条能整体粘贴的命令;且这个常量里没有空格 / 引号,不必再考虑转义。

同一个坑的文档侧:dev-guide 里穷举"哪些入口显式传 --dest"的那句漏了本 PR 新加
的 local-ci.sh,读的人会以为环境变量在这里生效,于是把反复报模型缺失当脚本 bug
去查。补进清单并点明它的特殊之处(校验的是包内目录,往别处下不会让告警消失)。

全仓扫过同类:fetch_models.py:481 与 install-hermes.sh:782 两处补齐提示本来就
按各自实际 dest 插值,local-ci.sh 是唯一一处。
两处都是「失败时退错码/静默降级」,各自比 review 点到的范围要大一圈。

fetch_models.py:_check_name 升级成 _check_spec,在 main 读 lock 的 try 里
一次校验完下载器真正依赖的键。原先只有 name 被拦,sha256 缺失会在第一次校验
就 KeyError,穿到下载中途才炸 —— 那时退的是 1,而 1 在本脚本契约里是「必需模型
缺失」,install-hermes.sh 的四分支门禁照这个含义提示「稍后重试」,用户重试多少
次都没用,坏的是本地这份 lock。

顺手补了 review 没提的两条同类:
  - size 类型:_human() 与续传越界判断都拿它做算术,字符串会 TypeError。校验流程
    本身不读 size,所以没有这道拦截时 CI 的 `--check --strict` 会对着坏 lock 亮绿,
    等下载才炸(同样退 1,同样被读成「重试就好」)。
  - sha256 大小写:比对的是 hexdigest() 的小写输出 + ==,lock 里写成大写(Windows
    的 certutil -hashfile 就是大写输出)会让每个文件都判「校验不通过」,表现成永远
    下不完的重下循环,而每一步单看都正常。这里统一归一到小写。

build.sh --help:写死的 `sed -n '5,15p'` 换成打到抬头注释块结束,与
publish_models.sh / sync-to-remote.sh 同一写法 —— 往选项列表插一行就会把结尾
「退出码:」那行无声截掉,本 PR 自己就动过这段抬头。同时修掉一个 macOS 上一直
存在的显示 bug:原来 `sed 's/^# \?//'` 的 \? 是 GNU 扩展,BSD sed 当字面问号,
于是 macOS 上每行帮助都还挂着 "# "。awk 用 ERE,两边一致。

验证:真实 models.lock.json 5 条全部通过 _check_spec 且无一条需要大小写归一;
publish_models.sh 生成侧是 st_size(int) + hexdigest(小写),与新校验同口径。
测试 +8(7 参数化坏键 + 大写摘要照常认),63 passed。
两条都在错误路径上,第一条比 review 点到的范围大一倍。

fetch_models.py:`.get(key, default)` 的默认值只在**键不存在**时生效,键在、值是
null 时原样返回 None,于是每个可选键都从为它设计的判据下面绕过去。review 点的是
size,扫了一遍两个可选键都中,且后果完全不同:

  - size: null   → 下游 spec.get("size", 0) 拿到 None → _human() 比大小 →
                   TypeError → traceback + 退 1。--quiet 救不了,f-string 的参数
                   在调用 _log 之前就求值了。
  - required: null → _required() 里 bool(None) 是 False,必需模型被静默降级成可选。
                   build 的 --strict、CI 门禁、install-hermes 的就绪判据全照
                   required 判,于是少一个必需模型的包能一路退 0 发出去 —— 比炸掉
                   更糟,因为没有任何人收到信号。这正好是 baec217 立起来的
                   fail-closed 契约的反面:漏写键它兜得住,写成 null 反而兜不住。

所以修的不是 size 这一处,而是在 _check_spec 里把"键在、值是 null"统一归一成
"键不存在",两个键一条规则。归一后 required 落到 True(fail-closed,与漏写键同结论),
size 落到"不显示百分比"(本就是合法写法)。required: false 仍然是真可选,没有被
一起收紧。

install-hermes.sh:790:下载失败分支印的修法命令缺解释器,而本文件在 git 里是
100644、没有可执行位,用户原样粘贴会 Permission denied(退 126)—— 这个新错误跟
下载失败毫无关系,只会把人往"权限 / 文件损坏"方向带偏,而用户此刻手上只有这一行线索。
全仓扫了一遍所有 fetch_models.py 的调用点与印出来的命令,这是唯一的漏网。

验证:两条新用例各自 revert 掉归一化即变红;size/required 的 null 与缺键、
required:false 五种组合退出码逐一核对;真实 models.lock.json 不受影响。65 passed,
ruff / bash -n / shellcheck 干净。
`${FETCH_MODELS:-scripts/fetch_models.py}` 的兜底值只在变量为空时取用,而变量为空的
充要条件就是"本脚本旁边没有 checkout"(见 FETCH_MODELS 那两行探测)—— 也就是相对
路径 scripts/fetch_models.py 必然解析不到的那种处境。兜底值只在它必然错的时候才出场。

而 --post-install 的唯一调用方 install.py 正是从 tarball 解出来的目录调的
(build.sh::build_hermes 只把 install-hermes.sh 和插件目录打进去,没有 scripts/),
且 subprocess.run 没给 cwd,相对路径落到用户当初敲 install.sh 的那个目录上。用户照抄
得到的是 "can't open file .../scripts/fetch_models.py",跟"感知模型不齐"毫无关系,
只会把人往"文件损坏 / 路径写错"带偏 —— 正是上一个 commit 在 790 那段注释里刚论证过
要避免的事,同一份文件里犯了两次。

改成按 FETCH_MODELS 是否可用分叉,两条都给能真正执行的动作。与 review 建议的差别是
没提 scripts/models.lock.json:走到这个分支说明手边没有 checkout,那个文件同样不在,
再指过去只是把同一个错误往下挪一层。换成"重跑 install.sh"——安装包自带
miloco-models-*.tar.gz,install.py::_extract_models 解到 $MILOCO_HOME/models/
(models_dest = miloco_home / "models",已核对),而用户既然在跑 --post-install,
这条路他本来就走过。

没加测试:分叉判据用的就是门禁下载那条分支的同一个变量,"能不能跑"与"告诉你跑什么"
不可能再分家,跟 local-ci.sh 那次把路径收敛成单一字面量是同一个道理。仓库也没有
install-hermes.sh 的测试宿主。

全仓扫了其余印给用户的 `:-` 兜底,只有 sync-to-remote.sh 的 ${INSTALL_LIST:-none},
那是展示用占位符、不是让人粘贴的命令,不同类。

@ExWang ExWang left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review:感知模型移出 git、改按 sha256 钉在 Release —— 结论:差一步

方向对、契约设计扎实的一次"模型出库"。清单作单一事实源,下载器纯标准库(安装阶段无第三方依赖即可拉模型),原子落地 / 续传 / 416 自愈 / 环境变量独占换源 / 路径穿越守卫 / 退出码契约都核过,测试也真绑在行为上而不是形状上。上一轮提的十一条里七条已经真闭环,改法多数比建议的更强。差的一步集中在一处:安装侧那条"补齐模型"的链路上,进分支与出分支用了两套判据,于是上一轮那个"模型不齐却报安装成功、零告警"的失效形态在当前 head 仍可复现,且新增了一句在该分支下不成立的成功提示。修法是一个词加一行文案。

角度一:问题是否存在 —— 存在

二进制长期在 git 里,未来每换一次模型历史里再叠一份,且会被打进 wheel。移到 Release 按 sha256 钉版是常规解法。描述里对收益的边界也标得诚实:历史里那份没有重写,所以仓库不会立刻变小,这条只对未来的 commit 生效。

角度二:修改正确性 —— 主体成立,安装侧有一处必须先修

下载器一侧逐条核过:就绪判定只认摘要、字节数不参与;丢弃半成品用截断而非删除以保住锁所在的 inode;截断检测比的是响应的 Content-Length 而不是清单里的 size;换源变量是独占替换、不回落公开镜像;空选集与坏清单一律收敛成用法错。构建与 CI 一侧:校验的目录、逐名断言的目录、打包的目录现在是同一个,打包也从整目录 glob 改成按清单里的名字逐个打,残留文件进不了发行包。清单缩表的守卫落在重算函数体内、双向报,所以"从本地目录重算"和"从 Release 拉全量重算"两条路径都拦得住。

🔴 必须先修(gating)

1. 补齐模型的下载调用漏传严格开关,进出分支判据不一致(plugins/hermes/install-hermes.sh:800

  • 问题:进入补齐分支的判据是 --check --strict(可选模型缺失也算不齐),而分支内真正执行的下载调用没有 --strict。下载器在只有可选模型失败时返回 0(scripts/fetch_models.py:562-575),于是 if ! 永远不成立。
  • 影响:因为三个可选模型缺失而进入补齐分支、这三个又没下下来时,脚本一条告警都不打、退 0。用户看到的是"感知模型不齐,按清单补齐"之后一片沉默,每次重跑完全一样;语义去重与语音活动检测就此静默降级。这与该函数上方 :684-685 自己写的标准("缺任何一个(含可选)都判不齐,否则会静默降级")直接矛盾,也正是上一轮"下载兜底形同虚设、零告警报成功"那条的失效形态。同一份下载器在构建脚本里的调用(scripts/build.sh:351)是传了 --strict 的,这是本次唯一漏传的调用点。
  • 建议方案::800--strict;或下载后重新跑一次就绪判定,不齐则告警。

🟡 值得修(不阻塞)

1. 无法读取清单的分支仍声称"已按清单齐全"

  • 问题:就绪判定在脚本旁边没有 checkout 时会退回弱判据"目录里有没有 onnx"(plugins/hermes/install-hermes.sh:688-693),这在该处境下是无从更强的——清单文件本身也不在手边。但短路分支打印的是"已按 lock 齐全"(:707-708),而这条分支根本没有读过清单。
  • 影响:轻量重跑模式的唯一调用方是从安装包解出的目录调起,那里不含 scripts/,所以该分支恒走弱判据。目标目录里只要有任意一个 onnx(哪怕是零字节、哪怕只有五分之二),就会打印一句不成立的成功提示后退 0。上游有两道护栏收窄了触发面(发行包由严格模式加逐名断言保证齐全,解压出零个模型会硬失败),但部分填充的目录不在这两道之内。比改动前更退一步的地方在于,改动前至少没有这句肯定的断言。
  • 建议方案:弱判据分支改口,例如"未按清单校验(旁边没有下载器)";不必改变行为。

2. 测试环境隔离只剥了自家前缀,代理变量未处理

  • 问题:测试入口现在会剥掉自家的模型相关环境变量,这条闭环了;但上一轮同一条里点名要自查的代理变量没有处理。下载器用的是默认 opener(scripts/fetch_models.py:326-327),默认带代理处理器,而标准库对回环地址没有隐式豁免。
  • 影响:在设了代理的开发机上,三条起本地 HTTP 服务的用例请求会全部出网、本地服务一次都收不到,用例转红——与上一轮描述的症状同形,且只砸在内网开发者身上,托管 runner 上看不到。更隐蔽的是那条进程内直接调下载函数的用例:它连回环端口都连不上才是预期,实测却真发出了三次外网请求,而断言照常通过——"不联网"这条契约被静默违反且无任何信号。另外该平台上代理还会从系统配置读,纯靠剥环境变量堵不住。
  • 建议方案:测试入口连代理变量一起剥,或注入不走代理的设置;更彻底是下载器显式使用不带代理的 opener。

3. 环境变量指定目标目录的用例仍未把写操作关进临时目录

  • 问题:上一轮那条的后半句(把这次写操作关进临时目录)没有落地,该用例与改动前逐字未变,仍然只传清单路径、不传目标目录参数。
  • 影响:新增的"命令行参数压过环境变量"用例只覆盖一个方向——它传了目标目录参数,参数在最前面短路,环境变量分支在不在它都是绿的。把下载器里那个环境变量分支去掉后,实测该新用例依然通过,而旧用例转红并把两个假模型写进了仓库里那个已被忽略、git status 看不见的目录。上一轮建议的写法一次拿到两个方向,现采用的机制只拿到一个。
  • 建议方案:给该用例补上目标目录参数。

4. 资产对账门禁排在下载步骤之后,认证的是下载后的磁盘状态而非缓存状态

  • 问题:零下载对账门禁已经落地,分级强制(推送与改了清单的 PR 强制、其余降级为告警)也有完整理由,这部分对上一轮的意图是合理再解释。但缓存这一段的步骤顺序是"恢复 → 下载 → 门禁 → 保存":下载会先把磁盘补齐,门禁再看到的是补齐后的状态。命中一份残缺缓存时,门禁看到的是完整目录、判绿;而保存步骤又被"缓存命中即跳过"挡掉。
  • 影响:上一轮"残缺缓存被不可覆盖的 key 钉死、命中缓存时不联网的承诺静默失效"这层机制没有变,只是不再影响测试任务的正确性判定(门禁按摘要复核全部五个)。可核查的旁证:当前这个 key 下的缓存条目创建于本 PR 第一个 commit 之后约三分钟,也就是改动前那套一体式缓存、无门禁的配置写下的;清单文件的 blob 在首个 commit 与当前 head 之间完全相同,所以 key 全程没变;当前 head 那次运行的最后访问时间就在该 commit 之后不到一分钟——只做了恢复、命中、跳过保存。也就是说两步式保存在这个分支上一次都没有执行过,CI 全绿不构成新缓存路径可用的证据。合入后目标分支的缓存作用域不同,会新建一条受门禁的条目,所以这是分支范围的现象。
  • 建议方案:认了这条取舍也可以(正确性由门禁兜住),但值得在描述里点明"门禁认证的是下载后状态";若要根治,把就绪判定挪到下载之前、不一致时强制重存,或给 key 加盐。

🔵 minor / 打磨

  • 分块传输或不给 Content-Length 的源上,读取不完整抛出的异常不属于当前捕获的异常族(它不是 OSError 也不是 URLError 的子类),会穿出 scripts/fetch_models.py:411 变成 traceback 加退 1,而退 1 在本文件的契约里是"必需模型缺失"、安装脚本会据此叫用户重试——正是这个文件其余部分极力避免的错误信号。把 http.client.HTTPException 加进该元组即可。
  • 严格开关的帮助文案仍写"打 release tarball 时用"(scripts/fetch_models.py:496),而现在有四处非发布场景在用它(测试任务门禁、本地自检、安装脚本就绪判定、构建脚本)。这是修复缓存门禁那条时顺带产生的口径漂移。
  • 清单里的文件名若被写成非字符串,名字守卫会抛 AttributeError,而主流程的捕获元组不含它,于是变成 traceback 加退 1,而不是既定的用法错退 2。需要一份手工写坏的清单才能触发。
  • 发布脚本里有两处注释仍在断言修复前的行为(说重算会把 Release 上的残留重新收进清单),就写在实现了该守卫的同一个文件里(scripts/publish_models.sh:201:250),会把下一个维护者引向相反的结论。
  • 抢锁失败方走私有半成品文件之前不复查就绪状态,并发两方会各下一份完整体积;跨主机共享挂载上文件锁可能退化成节点本地语义。都不产生坏产物,只是重复付费——现有链路不涉及跨主机共享挂载。

🧪 测试缺口

  • 清单缩表守卫本身没有回归保护,这是最值得补的一条。守卫那十行是同时关掉"用不完整目录重算导致静默缩表"和"把 Release 上的残留盲收进清单"两个口子的唯一机制,而两条重算用例都绕开了它:一条文件集与旧清单全等(守卫不开火),另一条显式开了放行变量(守卫被旁路);上传路径那条在动 Release 之前就中止了,根本到不了重算函数。把那十行删掉,整套测试仍然全绿。建议在文件集漂移那组用例上补一个参数化分支,直接驱动"重算 + 漂移目录 + 不设放行变量"。
  • 换源变量的独占性只被证明了"会被使用",没有被证明"清单里的源不会同时作为回落再试一遍"——把清单里的源文件删掉的写法,对一个"先环境变量、失败再回落清单"的错误实现同样会通过。镜像故障转移也只有手工实测记录,没有自动化用例。
  • 安装脚本那段控制流没有任何自动化覆盖,而上面两条 gating / 值得修都出在这一段。

✅ 确实做得好

  • 就绪判定只认摘要、字节数只用于线上对账,两个判据分开是对的;丢弃半成品用截断而非删除以保住锁所在的 inode,这个易错点有专测真绑。
  • 截断检测比的是响应头而不是清单里的 size,避免了陈旧清单下的反向误导,并且是抛异常而非返回,半成品得以保留、下一轮真的带 Range 续传——用"重试后半成品恰好等于三倍分片"作字节数代理来钉这件事,很扎实。
  • 换源变量是独占替换而不是追加候选,杜绝了内网场景下悄悄回落公开镜像。
  • 清单缩表的守卫落在重算函数体内而不是只挂在上传子命令上,两条调用路径一并覆盖;上传前另有一道更早的守卫,在动线上资产之前开火。
  • 打包从整目录 glob 改成按清单里的名字逐个打,残留资产进不了发行包。
  • 判定门禁强度那段刻意避开了"管道加静默匹配"的写法,因为命中即退会让前一段吃到 SIGPIPE、在默认开启管道失败传递的 shell 里恰好反判——这个坑注意到了。
  • 文档口径这一轮订正得很彻底:可选模型缺失后的真实降级行为、换源变量的独占语义、配置里那个此前写错的键,以及两份知识库文档与去重模块 docstring 里"包内目录作兜底"的过时表述,都改到与代码一致。

结论

差一步。点 Approve 前需要:补齐模型的下载调用补上严格开关(一个词),以及无法读取清单的分支不要再声称"已按清单齐全"(一行文案)——这两处合起来才让上一轮那条真正闭环。🟡 里的测试隔离两条与缓存门禁那条,以及 🧪 里清单缩表守卫的回归保护,建议作为紧接着的 follow-up;🔵 各条随手带上即可。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants