# work_orders **Repository Path**: zf_victor/work_orders ## Basic Information - **Project Name**: work_orders - **Description**: 智能工单 - **Primary Language**: Unknown - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2026-09-23 - **Last Updated**: 2026-09-24 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # 智能客服工单秒级自动路由与优先级分发系统 工单文本进来,一次前向出**业务类别 / 子类别 / 优先级**三个标签,直接落到对应的 队列、技能组与 SLA。核心是**双轨级联**:FastText 用几毫秒拦掉有把握的流量, 没把握的才交给 BERT 精判;再不确定的可以继续升级到 LLM + 检索历史工单。 ``` 工单文本 │ ├─ ① FastText 多标签初筛(业务类别 + 置信度 / 优先级 + 置信度) │ 业务 conf ≥ 0.80 且 优先级 conf ≥ 0.70 ──► 直通,直接出三标签 │ 否则 ├─ ② BERT 多任务精判(主:业务类别 / 辅:子类别 / 辅:优先级) │ conf ≥ 0.60 ──► 采纳 │ 否则 └─ ③ LLM + 检索历史工单(RAG,可插拔,默认离线兜底) ``` 三层的取舍是**用精度换时延**:FastText 单条 0.24 ms,BERT 1.99 ms(约 8 倍), 所以判据不是「谁的准确率高」,而是「哪些流量可以安全地不过 BERT」。 --- ## 快速开始 ### Docker(推荐) ```bash docker compose up -d --build # 起服务,监听 8000 open http://localhost:8000 # 演示台 + 指标看板 ``` 两类模型的处理方式不同,这是刻意的: * **训练产物**(BERT 三头权重、FastText、RAG 向量索引)走 volume 挂载,不进镜像 ——它们每轮训练都变、几百 MB,打进镜像会让每次改代码都重建几个 G。 * **固定的公开预训练权重**(查询侧的 `BAAI/bge-small-zh-v1.5`)在**构建期**预下载 进镜像。它不是本项目训出来的,钉在镜像里才能开箱即用。 ```bash docker run --rm -p 8000:8000 \ -v "$PWD/models:/app/models:ro" \ -v "$PWD/data/data_pre:/app/data/data_pre:ro" \ -v "$PWD/reports:/app/reports" \ ticket-router:latest ``` > 镜像里 `HF_HUB_OFFLINE=1` 是**默认且必须**的:容器没有外网,任何一次 > 「顺手连一下 HuggingFace」都会让服务卡死在 TCP connect 上(见下文「工程踩坑」)。 > 需要临时下模型时 `-e HF_HUB_OFFLINE=0`——但**同时要保留 `HF_HUB_DISABLE_XET=1`**, > 否则走镜像站会 401(原因见「工程踩坑」第 2 条)。 > > 第三级检索是**可降级**的,而且降级可观测:镜像里没预下载成功、或换了 > `WO_EMBED_MODEL` 却本地没有时,检索会自动退回 TF-IDF, > `curl localhost:8000/healthz` 的 `layers.retrieval_backend` 会显示 `tfidf`。 > 要跳过一次预下载:`docker build --build-arg EMBED_MODEL= ...`。 ### 本地(从零跑通) ```bash pip install -r requirements.txt # 或 conda env create -f environment.yml # ① 预处理:原始 CSV → 清洗 + 槽位掩码 + 模板化 + 去重切分(一步到位) # 产出 data/data_pre/{train,val,test_seen,test_unseen}.csv、FastText 训练行、 # 以及 data_stats.json(含泄漏自检,三个模板交集必须全为 0) python -m data_pipeline.prepare # ② 一级:FastText 三个头(各约 5 秒)+ 温度标定 → models/fasttext/、models/calibration.json python -m train.fasttext_train # ③ 二级:BERT 多任务(GPU 约 4 分钟/轮,CPU 约 40 分钟/轮,默认 3 轮) python -m train.bert_train # ④ 三级:检索索引(默认 dense,需要联网下 bge-small-zh-v1.5;下不到会自动退回 TF-IDF) python -m rag.build_index # ⑤ 评测:三层 × 两个评测集 → reports/evaluation.json python -m scripts.evaluate ``` > **注意:切分没有单独的入口。** `data_pipeline/split.py` 是**库模块**, > 只提供 `build_splits()`,由 ① 的 `prepare` 调用。直接跑 > `python -m data_pipeline.split` 会**退出码 0、零输出、什么都不做** > (Python 只是导入并执行了模块顶层),很容易被当成「跑成功了」。 跑完 ①~④ 后启动服务: ```bash python -m app.api # 默认 0.0.0.0:8000,内部用 waitress curl localhost:8000/healthz # 三层状态 + retrieval_backend ``` 再打开 是演示台与指标看板。 ### 演示与压测 ```bash python -m scripts.demo_cli --text "物流信息3天没更新了" --trace # 单条,看完整链路 python -m scripts.benchmark # QPS / 时延分位 python -m scripts.benchmark --compare-batching # 量化攒批收益(见「性能」) python -m scripts.tune_threshold # 闸门阈值扫描 python -m scripts.probe_offline # 离线自检(不起服务) pytest tests/ -q # 192 个用例,约 17s ``` --- ## 结果 ### 三层对比(`reports/evaluation.json`) `test_unseen`:154 条**训练时没见过**的模板,56338 行——衡量「换种说法还能不能判对」。 | 方案 | 业务准确率 | 业务 macro-F1 | 优先级准确率 | 三标签全对 | **严重漏判率** | P0 召回 | 摊薄时延 | QPS | |---|---|---|---|---|---|---|---|---| | ① FastText | **0.6571** | **0.6699** | 0.7415 | 0.1761 | **0.2322** | 0.1262 | 0.242 ms | 4126 | | ② BERT | 0.6538 | 0.6531 | **0.9235** | **0.4273** | **0.0000** | **1.0000** | 1.987 ms | 503 | | ③ 级联 | 0.6538 | 0.6531 | **0.9235** | **0.4273** | **0.0000** | **1.0000** | **1.884 ms** | **531** | `test_seen`:397 条**见过**的模板 + 没见过的槽位,5740 行——线上的稳态情形。 三个方案全部 1.0000,级联时延 0.297 ms(纯 BERT 的 **14.4%**)。 ### 三个必须讲清楚的发现 **1. 级联的价值完全取决于闸门能拦下多少流量,而这一点在两种数据上差 7 倍。** | 评测集 | 直通率 | 级联时延 / 纯 BERT | 全对率差 | |---|---|---|---| | `test_seen` | 100.0% | **14.4%** | 0.0000 | | `test_unseen` | 17.6% | **94.8%** | 0.0000 | 稳态(见过的模板)下级联是纯 BERT 的 1/7 时延且精度无损;换成没见过的说法, 闸门只敢放 17.6% 过去,**加速几乎消失**。只报 `test_seen` 那一行会得出 「级联快 7 倍」的结论,那是被数据挑出来的幻觉。 **2. 级联与纯 BERT 的指标逐位相同,这不是巧合,是闸门确实选对了。** `cascade` 与 `bert` 在 `test_unseen` 上所有指标完全一致,看起来像级联没起作用。 按路径拆开就清楚了: | 路径 | 占比 | 该子集上 FastText 准确率 | 该子集上 BERT 准确率 | |---|---|---|---| | 直通(FastText 出结果) | 17.6% | **1.000000** | **1.000000** | | 升级(BERT 出结果) | 82.4% | 0.583877 | 0.579870 | **被放行的 9919 行,FastText 一条都没判错**——闸门挑出的正是两者都稳的那部分。 所以级联省下的是时延,不是精度;反过来说,唯一会让级联掉点的情形 (放行了一个 FastText 判错的样本)在这份数据上一次都没发生。 **3. FastText 单独上线的风险集中在 P0,这正是不能只用它的原因。** `test_unseen` 上 FastText 的 **P0 召回只有 0.1262**、严重漏判率 **0.2322**—— 把 P0 紧急单判成 P2 意味着资金安全问题被压 4 小时。BERT 把这两项做到 1.0000 / 0.0000。业务类别准确率两者只差 0.3 个点(0.6571 vs 0.6538), **但业务代价差了一个数量级**——只看准确率会完全看不出来。 ### 标定:为什么优化目标是 NLL 而不是 ECE(`reports/calibration_objective.json`) 闸门阈值要可读(「阈值 0.8」得约等于「80% 判对」),那是 ECE 的定义。 但在这份数据上**两个指标会选出方向相反的温度**: | 任务头 | NLL 最优 T | NLL | 等宽 ECE | 等频 ECE | |---|---|---|---|---| | `biz` | 3.25 | 2.0587 → **1.1616** | 0.3224 → **0.1931**(好) | 0.3301 → 0.3426(**差**) | | `prio` | 3.55 | 1.1332 → **0.5312** | 0.1782 → 0.2681(**差**) | 0.1839 → 0.2329(**差**) | | `sub` | 3.10 | 4.3781 → **2.4820** | 0.3839 → 0.2835(好) | 0.3952 → 0.3148(好) | `biz` 上**两种 ECE 口径自己就打架**。根因是这份数据的置信度高度离散: 验证集 27761 行只对应 **146 个不同的置信度取值**(= 146 条模板), 最大并列组 4704 行占 **16.9%** 流量——分箱边界在这种分布上极不稳定。 NLL 是严格正常评分规则(strictly proper scoring rule),对概率的每一处调整都有 确定的好坏方向,不依赖任何分箱约定。**ECE 仍然是报告标定效果的指标,只是不能 拿来优化**。附带结论:温度缩放是单调变换,**不改变任何一条样本的排序**, 所以它修不好闸门的台阶效应(阈值卡在并列组中间时,直通率会整块跳变)。 --- ## 性能:QPS 与时延分位(`reports/benchmark.json`) 端到端 HTTP,压测容器与路由容器**跑同一个镜像、同一个 Docker 网络**, 避免「在宿主机压、在容器里跑」导致两个数字没有可比性。 篇幅有限,这里只放关键列,完整分位(P90/P95/P99/max)在报告里。 | 并发 | QPS | P50 (ms) | P90 (ms) | P99 (ms) | 失败率 | |---|---|---|---|---|---| | 1 | 91.7 | 10.8 | 11.2 | 13.1 | 0% | | 4 | **331.2** | **11.9** | 12.7 | 15.0 | 0% | | 16 | **354.9** | 44.6 | 46.1 | 49.9 | 0% | | 64 | 349.8 | 180.8 | 188.7 | 198.1 | 0% | **1. 吞吐在并发 16 封顶(≈355 QPS),再加并发只买到时延。** 并发 64 时 QPS 反而略降,P50 却从 44.6 涨到 180.8 ms(**4 倍**)。 这是典型的饱和点:请求开始排队,等待时间全部转成时延。 过饱和时继续加并发是无效的,要么加 worker/机器,要么在入口限流。 **2. 并发 1 时的 P50 是 10.8 ms,而 BERT 单条前向只要 1.99 ms。** 差的这 8 ms 不是模型慢,是**攒批窗口本身**(`WO_BATCH_WINDOW_MS=8`)—— 低并发下没有批可攒,窗口就变成纯粹的时延地板。 并发 4 是甜点:窗口被摊薄到几乎看不见(P50 仍只有 11.9 ms),QPS 却到了 331。 **3. 攒批买到的主要不是吞吐,是尾延迟(同进程对照,并发 32):** | 配置 | QPS | P50 (ms) | P99 (ms) | |---|---|---|---| | 不攒批(`window=0`) | 2319.5 | **1.94** | 44.6 | | 攒批(`window=8ms`) | **3889.8** | 6.25 | **13.1** | QPS **×1.68**,同时 **P99 从 44.6 ms 降到 13.1 ms(3.4 倍)**。 尾延迟变好这件事一开始反直觉,但原因很直接:不攒批时每个请求各自排队做一次前向, 队伍一长尾巴就炸;攒批把 N 次前向合并成一次,排队长度直接除以批大小。 代价是 P50 从 1.94 涨到 6.25 ms——**低并发付窗口,高并发收利息**。 所以 `WO_BATCH_WINDOW_MS` 该按目标并发来定,没有普适最优值。 对「秒级」SLA 而言 8 ms 的窗口完全可接受,而它买到的 P99 改善恰恰是 SLA 真正考核的东西(SLA 通常卡的是尾部,不是中位数)。 > **怎么读这些数字。** 同一张表内可比,**跨表不可比**:in-process 那组绕过了 > Flask/JSON/网络,载荷条数与预热状态也不同,两张表之间不要横向对比。 > 机器是 24 逻辑核的桌面机,容器配 `WO_WORKERS=4` × `WO_TORCH_THREADS=4` > (两者相乘别超物理核数,超订时 QPS 反而下降——见「配置」一节)。 > 这些数字应当当作**量级与形状**来读,而不是一台专用服务器的性能承诺。 --- ## 数据 `data/电商工单数据_20万条.csv`(20 万行)→ 清洗 + 槽位掩码后: | 指标 | 值 | |---|---| | 原始行数 | 200,000 | | 去重后不同文本 | 77,971 | | **不同模板数** | **971**(模板压缩率 99.51%) | | 业务类别 / 子类别 / 优先级 | 7 / 29 / 3 | **槽位掩码**是预处理的核心:订单号、金额、天数、百分比替换成 `` / `` / `` / ``。它们对分类没有信息量, 却是词袋模型过拟合的主要来源——训练时见过的订单号会被当成强特征。 掩码后同一模板的不同工单**文本逐位相同**,模型被迫学模板语义而不是记数字。 ### 双评测集:这个项目最容易含糊过去的地方 朴素随机切分会让测试集里 **99.98% 的行落在训练时见过的模板上**——因为 200k 行 其实只有 971 个模板。那样测出来的不是泛化,是记忆。 所以按**模板**分组切分,且 `test_seen` 与 `test_unseen` 回答的是两个不同问题: | 评测集 | 行数 | 模板数 | 与训练模板重叠 | 回答的问题 | |---|---|---|---|---| | `val` | 27,761 | 146 | 0 | 早停 / 标定用 | | `test_seen` | 5,740 | 397 | 模板见过,槽位没见过 | 线上稳态:老问题换个数 | | `test_unseen` | 56,338 | 154 | **完全没见过** | 鲁棒性:老问题换个说法 | `reports/data_stats.json` 里记录了可验证的泄漏检查,三项交集全为 0: ```json "leakage_check": {"train∩val": 0, "train∩test_unseen": 0, "val∩test_unseen": 0} ``` ### 一个必须记住的统计事实:有效样本量是 146,不是 27761 同模板的行在掩码后文本完全相同,FastText 对它们输出**逐位相同**的概率。 所以模板级指标才是真实分辨力:行级准确率会把精度高估约 7 倍。 `evaluation/metrics.py::template_level_metrics` 专门输出模板级准确率与标准误—— 在 146 个独立模板上,**标准误约 3.5 个百分点**,任何小于这个幅度的「提升」 都不该当成结论。 --- ## 工程踩坑 按「坑有多隐蔽」排序,都是实际调试出来的。 ### 1. 一次重构把 BERT 微调变成了从零训练(最隐蔽) 为了复用「拼装三个分类头」的代码,`MultiTaskBert.from_pretrained` 被改成转调 `from_config`——而后者用的是 `AutoModel.from_config`,**权重是随机初始化的**。 后果:日志正常、参数量正常、loss 正常下降,只有指标悄悄掉了。 `val biz acc 0.7596 → 0.6040`,训练 loss `0.6061 → 0.7449`。 发现方式是对比两次训练报告:同一份数据、同一个种子、同一段训练代码, 训练 loss 却不一致——而**训练 loss 不受验证集影响**,所以差异只能来自训练本身。 现在两条路径共用 `_assemble` 拼装但**各自构造 encoder**,不允许互相调用, 并由 `tests/test_bert_model.py` 用离线小模型逐位对照守住: 预训练权重必须真的被加载进来。 ### 2. 走镜像站不关 Xet,权重根本下不来,但报错说「连不上镜像站」 容器化部署后第三级整层瘫掉,日志只有一句「预热失败」。追下去是 `snapshot_download` 失败,报错为: ``` OSError: We couldn't connect to 'https://hf-mirror.com' to load the files, and couldn't find them in the cached files. ``` 于是去查网络——**但 `curl https://hf-mirror.com` 返回 200**,DNS、防火墙、代理全都正常。 真正的原因完全在别处: * 小的 json/txt 文件**全部下载成功**,只有 95MB 的权重文件失败 (这是从逐个文件的下载进度里看出来的,报错本身完全没提); * HuggingFace 的新传输协议 **Xet** 会先把**元数据**请求发给 endpoint, 再把**权重**重定向到 `cas-server.xethub.hf.co`; * 那个地址**不经过镜像站**,于是没有镜像的访问凭证,直接返回 **401**; * huggingface_hub 把 401 包装成了「连不上 hf-mirror」——排查方向从第一步就被带偏了。 修法是 `HF_HUB_DISABLE_XET=1`,退回普通 LFS over HTTPS,镜像就能正常代理。 `common/hf_setup.py::_disable_xet_for_mirror` 会在检测到非官方 endpoint 时自动设置 (官方站点仍保留 Xet,因为它更快)。 > 这个坑的教训不是「记得设某个变量」,而是**报错信息指向的子系统未必是出问题的子系统**。 > 「连不上 X」至少有三层可能:DNS 真不通、连上了但被重定向到别处、以及连上了但凭证不足。 ### 3. 离线模式必须是**进程级**的 `local_files_only=True` **拦不住** transformers 后台的 `safetensors_conversion.auto_conversion` 线程去连 Hub。那个线程会让服务 启动时挂在 TCP connect 上,而且报错信息指向完全无关的地方。 正确做法是设 `HF_HUB_OFFLINE=1` 环境变量(见 `common/hf_setup.py`), 且必须在 import transformers **之前**生效。`scripts/probe_offline.py` 可离线自检。 ### 4. Windows 上 numpy 大矩阵乘法直接崩进程 `np.ndarray @ np.ndarray` 在 (671, 512) 上抛 `Windows fatal exception: code 0xc06d007f`, 小矩阵却正常。根因不是代码:直接跑 `python.exe` 而没把 conda 的 `Library/bin` 加进 `PATH` 时,numpy 延迟加载的 BLAS DLL 找不到。 ```bash export PATH="/d/anaconda/envs/:/d/anaconda/envs//Library/bin:$PATH" ``` 这也是镜像用 `python:3.11-slim` + pip wheel 而不是 conda 的原因之一。 ### 5. 构建镜像时的两个网络/体积问题 * `deb.debian.org` **80 端口出不去**(`Connection refused`),而 Debian 官方源的 URI 就是 `http://`,于是 `apt-get update` 失败、`libgomp1` 装不上。 镜像里换成了可达的 https 镜像,可用 `--build-arg APT_MIRROR=...` 覆盖。 * Linux 上 PyPI 默认的 `torch` 是 CUDA 版,会顺带拖进整套 `nvidia-*` (约 2.5GB),而这是纯 CPU 推理的服务。镜像里 torch 单独走 CPU 源安装。 ### 6. `.env.example` 曾被 `.gitignore` 静默吞掉 `.env.*` 这条规则连模板一起匹配了。后果是新克隆的人看不到任何配置说明。 已加 `!.env.example` 放行——该文件只有变量名和注释,**不含任何密钥**。 --- ## 第三级为什么会「静默降级」,以及它如何被看见 第三级(LLM + 检索)是全链路里唯一**允许降级**的一层,因为它依赖外部资源 (向量模型、LLM API)。设计目标是:**降级必须可用,且必须可见**。 这里踩过一个反过来的坑:原实现的降级只写在 `Retriever.build()` 里 (建索引时向量模型不可用 → 退回 TF-IDF),但**服务启动走的是 `load()`**—— 它只从 `index_meta.json` 读回 `backend="dense"` 和那份 `vectors.npy`, 查询侧的编码器要等第一次检索才惰性加载。于是「建索引时能降级、线上检索时不能」: ``` 离线容器 + 没挂 HF 缓存 → search() 里 encode() 抛 OSError → 未捕获 → 500 → 且每条工单复现一次(_load_encoder 只在成功后缓存) ``` 索引里向量完好、671 条文本也在 meta 里(足够现拟合一份 TF-IDF), 这条路却一次都没走。现在降级放在**查询路径**上,并且只降一次: | 情形 | `layers.retrieval_backend` | 行为 | |---|---|---| | 正常(镜像已含向量模型) | `dense` | 语义检索 | | 向量模型不可用 | `tfidf` | 字符 n-gram 兜底,**仍然返回结果** | | 索引文件缺失 | `None` | 第三级退化为规则兜底 | `/healthz` 里 `llm_rag` 这个布尔值**不足以反映上述区别**——索引加载成功它就为 `true`,哪怕查询侧编码器加载不出来、dense 检索条条报错。所以额外暴露 `retrieval_backend`,让「降级」这件事在探针上就看得见,而不是等业务方发现工单判错。 同一个坑还有一处**措辞**问题:预热失败时日志写的是「预热失败(不影响服务)」。 预热走的就是真实链路(含第三级检索),它失败意味着**同一条路径上的真实请求也会失败**, 只是那里没人看到 500。这句话把一个「第三级整层 500」的故障盖成了无害告警。 现在如实说明失败位置,并把排查方向指向 `/healthz`。 `.env.*` 这条规则连模板一起匹配了。后果是新克隆的人看不到任何配置说明。 已加 `!.env.example` 放行——该文件只有变量名和注释,**不含任何密钥**。 --- ## 配置 所有参数集中在 `common/config.py`,用 `WO_` 前缀的环境变量覆盖,不改代码即可切换。 **每一项都有默认值,`.env.example` 里没有一行是必填的**。常用几项: | 变量 | 默认 | 说明 | |---|---|---| | `WO_BIZ_CONF` / `WO_PRIO_CONF` | 0.80 / 0.70 | 直通闸门(架构图规定的双条件) | | `WO_ESCALATE_CONF` | 0.60 | BERT 低于此值升级到 LLM+RAG | | `WO_ENABLE_LLM` | 1 | 设 0 则第三级完全不启用 | | `WO_BATCH_WINDOW_MS` / `WO_BATCH_MAX_SIZE` | 8 / 32 | 微批攒批;0 表示关闭 | | `WO_WORKERS` / `WO_TORCH_THREADS` | 4 / 4 | **两者相乘别超物理核数**,超订时 QPS 反而下降 | | `WO_FASTTEXT_THREAD` | 16 | 设 1 可复现(多线程异步 SGD 不可复现,见下) | > **FastText 不可复现,报告里的绝对值要带 ±0.03 理解。** 它用多线程异步 SGD, > 同一份数据同一个种子,两次训练的 biz macro-F1 在 0.61~0.67 之间浮动。 > 追求逐位复现设 `WO_FASTTEXT_THREAD=1`(慢一个数量级)。 > 结论只看方案间的相对差异——级联与纯 BERT 的差距远大于这个波动。 ### LLM 层默认关闭,且涉及数据合规 第三级会把**工单原文与检索到的历史工单**发往第三方 API。密钥只从环境变量读、 永不硬编码,`.env` 已 gitignore。**不填 key 也能完整跑通**:会自动走离线检索投票, 结果里明确标注「未经 LLM」。生产启用前请先确认合规,或改用私有部署 (如 vLLM)并把 `WO_LLM_BASE_URL` 指过去。详见 `rag/llm_client.py` 的模块说明。 --- ## 目录 ``` common/ 配置、标签体系、文本归一化、HF 离线装载 data_pipeline/ prepare 一条命令做完清洗 + 模板化 + 切分;split 是它调用的库模块 train/ FastText 训练与温度标定、BERT 多任务训练 serving/ FastText / BERT 路由器、级联闸门、温度标定 rag/ 向量/稀疏检索、LLM 客户端(可插拔) app/ Flask 接口、微批处理器、在线指标 evaluation/ 分类指标、优先级风险指标、模板级指标 scripts/ 评测 / 压测 / 阈值调优 / 标定对照 / 离线自检 / CLI 演示 front/ 单页演示台 + 指标看板(无构建步骤,原生 ESM) deploy/ Dockerfile reports/ 评测报告(入库,指标可见) ``` `front/` 没有构建步骤:原生 ES module + 手写 SVG 图表原语。 配色(分类色板、深色模式、对比度)经 `dataviz` skill 的校验脚本验证, 图表都配了等价表格视图,保证不依赖颜色也能读数。 --- ## 测试 ```bash pytest tests/ -q # 192 passed ``` 覆盖文本归一化、标签体系的确定性映射、无泄漏切分、温度标定的不变量 (含**跨样本排序反转**这类容易写错的边界)、级联闸门逻辑、微批处理器、 在线指标与全部接口。**所有用例都不加载真实模型**——模型替身只替换「模型」 那一层,闸门与受限解码的逻辑是真的被测的,这样才能精确构造出 「置信度刚好压在阈值两侧」这种真实模型很难稳定复现的边界。 三处单独的回归防线,都对应上文一个真实踩过的坑: * `tests/test_bert_model.py` —— 用离线玩具模型逐位对照预训练权重是否真的被加载 (「工程踩坑」第 1 条)。 * `tests/test_hf_setup.py` —— 钉住离线/联网开关的**状态机**: 失败后不允许把进程留在「允许联网」状态,镜像站必须关 Xet (第 2、3 条)。用例直接断言 `HF_HUB_OFFLINE` 的首尾取值, 而不是只看函数有没有抛异常。 * `tests/test_retriever.py` —— 构造「索引是 dense、但编码器加载不出来」这个 真实环境里要靠拔网线才能复现的状态,断言检索**仍然返回结果**且只降级一次 (见「第三级为什么会静默降级」)。 另外 `tests/test_service.py` 专门盯 `/healthz` 的诚实性:降级之后 `retrieval_backend` 必须跟着变,探针不能报绿。