这一版没有新功能,全是「把自己绊了一跤的地方填平」——从一次线上事故的根因,到三个被门禁当场抓出来的问题。

启动卡在「步骤 4/8」:offline 怎么把主线程堵死的

本地部署时日志停在「步骤 4/8: 初始化规则引擎… 完成」永不继续,Web 端口从不上线。进程还活着、CPU 却只有 0.4%,采样栈显示主线程阻塞在一次文件读取上——它根本没在干活,在等磁盘。

真凶是一处看似合理的写法:

if background and not offline: 走后台加载,else: 走同步加载。

offline=True(用本地缓存、不联网,最常见的配置)恰好让整个条件为假,于是「后台加载」被跳过,主线程被按在地上读模型权重(实测超过 50 秒)。

问题出在把两件正交的事绑成了一个条件:background 管的是「要不要阻塞启动」,offline 管的是「要不要联网」。离线与否,跟能不能异步,毫无关系。现在后台加载与离线解耦:启动秒回,模型照旧在后台约 10 秒就绪,Web 不再被它挡住。

教训:两个语义独立的开关,绝不能用逻辑运算符绑成同一个分支。这不是编译器能拦的错误,只能靠人读代码时多问一句。

首页「决策追踪」停在旧消息

后端按时间正序返回决策,前端组件却直接取前 N 条——于是卡片上永远是最旧的两条。现在排序下沉到组件内部统一处理,最新消息始终在最上方,「意图 & 路由」页也一并受益。

顺带一个概念澄清:决策追踪记录的是「AI 对每条消息做了什么决策」,不是聊天记录。人工回复的消息本就不产生决策记录——某天没有决策,不代表哪里坏了。

安全:三件事

把「静默吞噬」变成「可追溯」

项目内 640 处宽异常中,有 85 处既不记日志也不重新抛出——异常彻底消失,排障时看不到任何线索(上面那个启动卡死被另一条噪音告警淹没,就是活生生的例子)。现在这 85 处全部补上带完整堆栈的日志,并新增 AST 门禁把总数锁定为「只减不增」,防止回潮。

这里有个反直觉的结论:这 640 处宽异常,绝大多数是正确的防御性容错(配置解析失败即回退默认值;日志格式化失败绝不能崩)。所以本版本刻意没有做机械收窄——强行收窄反而会把「可容错」变成「会崩」。真正要治的不是数量,而是静默吞噬。

性能:每次回复最多省 400 次查询

少样本推荐原先对每条候选回复单独查一次「前一条用户消息」,候选多时每次回复产生最多 400 次查询。改为一批取回 + 二分定位后,N+1 降为单次查询,返回值完全不变。

生产事故复盘:索引损坏

此前一轮「持续报 database is locked」的根因既不是并发也不是参数,而是数据文件索引损坏:某分库的一个索引有 49 行缺失,致每次写入退化为全表扫描重建索引、写锁被长期占用,闸门与限流全部失效。现在启动时会检测并自动重建损坏索引(幂等、不动数据),上线后该错误归零。

由此定了一条硬规则:「代码改完还是坏」=数据库文件损坏的强信号,此时应立刻查 integrity_check,而不是继续在代码里找原因(注意 quick_check 对索引不一致会漏检)。

工程门禁

升级注意

无破坏性变更、无新增必填配置。数据库索引自愈与孤儿图片回收参数均自动生效,无需手工迁移。部署建议改用单进程模式:python scripts/run_linkora.py --single-process,Web 与后台轮询同驻一进程,从根上消除 SQLite 跨进程写锁争用(此前该问题曾表现为持续的 database is locked 与轮询丢会话)。

完整变更见 CHANGELOG;代码在 GitHub。