不会报错的错误:一个背单词工具的内容管线、同步正确性与设计系统
摘要 背单词工具的核心不是题目,是记忆;而记忆类应用的正确性几乎不由「写入」决定——写入错了用户会立刻发现,删除错了只会让某个词在另一台设备上原地复活,换账号错了会让数据静默串号。本文以一个雅思刷词工具(22 章 / 3568 词;Cloudflare Worker + D1 + 无框架前端;46 天、82 次提交)为对象,考察三条工程线。其一,内容质量是管线属性:题源由 LLM 分片生成,经「规范即提示词」的单一事实源、先校验后写入的合并、四档演进的校验器(v1.0→v1.2,其中 v1.2 由一次规范与校验器脱节的事故催生)与逐题缺陷扫描驱动三轮整改共 3710 条次——校验错误 199→0、模板化解析 892→0、硬歧义 49→0、万能干扰项 63→0,另有 2876 条截断损伤按三种形态补全;机器不可约残留(约 180 条长度比、24 处软错位、12 词无反向题)被如实标注而非掩饰。其二,学习状态的正确性由「删除」与「换账号」决定:整章 JSON blob 重写为按词存储并以自动迁移过渡,删除以墓碑持久化并按条 LWW 收敛(客户端时间戳、同毫秒删除优先),重置以服务端 cutoff 防复活,迁移完成单独建表(行数不可用于判定已迁移);「延后写入」的微任务优化(下一题主线程 p50 10.4→2.8 ms)必须同时回答「什么时候不能再延后」——三个卸载出口与切账号前的强制 flush。其三,设计与性能同样门禁化:双主题 + 六色调色盘之上,「组件样式裸写十六进制即门禁失败」;axe 四页 × 明暗八次扫描;性能改动先测后改。全仓无 CI,以「push 即部署(约 1 分钟)+ 部署后 36 项线上只读核验」替代,并两次在真实端到端中抓到静态检查与单测不可见的缺陷。
关键词 LLM 内容管线;墓碑同步;LWW;数据正确性;设计令牌;PWA
1 问题的提出
背单词产品的常见投入方向是内容与题型:更多词、更多花样。本项目最初也这样起步——第一天搭出能跑的界面,题源是「能出题」的水平。真正决定这个产品可信度的,是三类与题目无关的问题。它们共享同一个名字,也就是标题里那个悖论——不会报错的错误:失效不抛异常、不弹红字,只在未来的某台设备、某一次导出、某一天凌晨的临界时刻安静地显形。它们拆成三个可检验的问题:
- Q1(内容):LLM 生成的教育内容,质量能不能靠「把判据写成可执行的检查」守住,而不是靠人工抽查?
- Q2(数据):记忆类应用的正确性为什么由「删除」和「换账号」决定?靠什么机制守住?
- Q3(前端):单人维护的产品,设计与性能的底线如何机器化?
工程事实先行:46 天、82 次提交、9 个数据库迁移、22 章 / 3568 词题源全量精编、六层验证(218 项单测 + 69 项静态一致性 + 69 项接口端到端 + 105 项浏览器冒烟 + axe 八次扫描 + 36 项线上只读核验)。
2 系统与方法
2.1 内容管线:规范即提示词,判据即可执行
题源由 LLM 分片生成(每片 40–60 词),但质量约束不在提示词的措辞里,在管线的结构里。规范即提示词:出题提示词从规范文档的标记区段提取,规范与提示词是同一份文件——单一事实源。合并先校验:分片合并前先跑校验器,有错不写入。校验器即规格:v1.0 只查四选一与解析长度;v1.1 加入互换测试法、义项覆盖、词性 / 反义词配额的机器判定与长度比 0.5–2.0;v1.1b 补反向题规范;v1.2 把解析上限提到 120 字并禁止硬截断——这一版不是规划出来的,是一次事故的产物:文档写 40 字上限、代码早已放宽到 120,规范与校验器脱节,由此确立「改版本必须同步规范、22 个分片 JSON 与校验器」的常青约定。
逐题缺陷扫描输出十个标签(错误 / 歧义 / 错位 / 模板 / 英文解析 / 同因重复 / 通用解析 / 长度越界 / 万能项 / 无备注),配合逐章修复清单做约束式逐题修复。管线里还沉淀了两条带血的红线:其一,「禁止任何脚本事后只替换 text」——历史上有 916 条辨析因脚本只换文本不换依据而错位;其二,截断扫描的豁免判定必须用「相等」而非「前缀匹配」——词库释义自带省略号,前缀匹配的逻辑必然漏报假绿。
2.2 同步与数据正确性:删除比写入难
第二天(09-17)就把第一版数据模型重写了:同步原本按整章一个 JSON blob 存,两台设备一起答题,后写的整章覆盖先写的——这不是 bug,是数据模型选错了。重写为按词存储(主键:用户、章、词),旧 blob 在首次拉取时自动迁移为按词行。
之后的所有正确性设计都围绕两类「不会报错的错误」。删除:生词取消不物理删除,写 starred = 0 的墓碑;整章重置在服务端写 cutoff 时间戳,旧客户端的延迟上传不能复活已重置的数据;备注与配图删除各有墓碑表——共四张墓表 / cutoff 表。墓碑本身按条 LWW 收敛 [1]:两台设备各自删同一个词,结果必须收敛为「已删」,不能来回翻。换账号:本地存储的键一律带用户后缀——历史上发生过换账号互看备注的事故;切换账号时,排队中的本地写入若不先落盘,会用新账号的键算出存储位置,把数据写进别人的命名空间——所以切换函数的第一行必须是强制 flush。
LWW 的细节值得交代:时间戳是客户端本地毫秒(学习状态用 seen_at,生词用 updated_at);学习状态同时间戳时按掌握等级定序;生词同毫秒删除优先。两个容易被忽略的实现事实:迁移完成与否不能看行数——重置会暂时删空行,若因此重新导回旧 blob,用户刚做的重置会被撤销,所以迁移完成单独建表;同步队列有防饿死上限(防抖 800ms,但最长 4s 必须放行),关页补发被截断时不清脏集合——读不到响应,下次全量重发。
2.3 设计与 PWA:每一屏只有一个视觉主角
第六期界面「单词即海报」的设计命题是一句话:每一屏只有一个视觉主角(当前词)。双主题(暖纸 / 墨舱)——暖纸的纸纹用 SVG 噪声滤镜生成,零图片请求;三层设计令牌(primitive → semantic → component),组件样式裸写十六进制即门禁失败;词面用衬线栈并禁止中文 webfont(兆级文件会毁掉离线与首屏),计时数字一律等宽;图标全部内联 SVG sprite 且 sprite 单源——四页原先各内联一份 28 符号的重复块,已收编;六色主题调色盘与 z 层级七级阶梯(新增浮层「只许挂档、不许写数字」)。动效走 View Transitions,全部受 prefers-reduced-motion 约束。
PWA 侧:词库与界面离线可用,Service Worker 现为 v23。HTML 与代码网络优先——从 stale-while-revalidate 改过来的理由写在注释里:SWR 会把旧代码先喂给页面,发版后要多刷一两次;带内容指纹的资源 cacheFirst,连 304 都省;/api/* 一律不拦截,后台页不进缓存。
2.4 验证体系:六层门禁与无 CI 的替代
仓库没有 CI,验证纪律由六个独立层级替代:218 项单测(判分、出题、D1 接口、认证、生词 LWW、会话守卫、前端守卫、备份导入)、69 项静态一致性检查(含设计 lint:裸十六进制 / emoji / 移除焦点轮廓 / 外部字体源)、69 项真实 workerd 接口端到端、105 项浏览器冒烟、axe 四页 × 明暗八次扫描(critical/serious 非零即失败)、36 项线上只读核验。push 到 main 自动部署,约 1 分钟,部署后跑线上核验——线上是验证的一层,不是最后一步;这与姊妹篇 ART/RANK 写成成文铁律的「未到线上实机验证不标完成」是同一纪律的两种写法。无障碍扫描的注入本身有个坑:必须经页面上下文求值而非脚本标签注入,后者会被 CSP 静默拦截,门禁会「长期跑不过而无人发现」。
3 结果
3.1 内容质量:判据可执行化之后的修复量
09-20 一天内,3568 道题从原始模型分片重建,并完成第一轮逐题整改;此后三轮整改共 3710 条次:校验错误 199→0,模板化解析 892→0,硬歧义(义项覆盖)49→0,万能干扰项 63→0,备注覆盖 2777→3568(100%)。截断损伤单独成役:扫描器按三种形态(句末省略号、无省略号的静默截断、开头缺词名)共补全 2876 条。
诚实的另一半是残留:约 180 条干扰项长度比在 2.0–2.5 之间(判据内合格、听感偏长)、约 24 处软错位(辨析落在近义词上)、12 个词找不到任何形近或同根词而无法出反向题(同章兜底,rev 覆盖 3555/3568)。这些是判据之外的质量,只能靠人,且不可规模化——把它们如实标注出来,比假装门禁全绿诚实。
3.2 「看起来在工作」的五类缺陷
10 月下旬的全量审查抓到五个缺陷,其中三个同构:报告页分模式正确率的分母只累加答错数(正确率可显示 >100%);题源校验器的「兼容版本集合」漏了当前版本,22 章题源全部误报不一致——噪声把真信号埋了;注册接口的限流只查不记,拦截过期时间恒为零,等于可无限枚举邮箱。它们都是代码看起来在工作,其实从未生效——不报错,只让门禁变绿。第四个更隐蔽:后台「近 7 天报错」拿 ISO 时间里的 T 与 SQLite 时间戳里的空格做字典序比较,空格小于 T,临界那一天整天算错。第五个:登录表单写了 novalidate: "false"——布尔属性存在即生效,原生校验全被关掉,一个语法合法的「关闭关闭」。
同期的两次「上线即坏」级别事故同样有教育意义。其一,引入构建工具当天起,线上产物缺失、整站无样式,靠真实截图走查才发现——修法是产物入库、缺产物时回落源码、线上核验逐个校验资源可取。其二,应用自己导出的备份 100% 被自己的导入器拒绝——写入端与校验端的模式漂移;修法除接受运行时实际形状外,加了一条防漂移单测:把导出函数的真实输出直接喂给导入校验器。而最早的 09-17,第一次真实端到端就抓到两个单测不可见的缺陷:注册按钮实际打的是登录接口;新设备登录后恢复进度但不恢复章节。这件事沉淀为习惯:功能写完先跑真实路径,别信单测的绿——单测只验证「代码按我写的逻辑跑」,不验证「我的逻辑是不是对的」。
3.3 性能:先测后改,病灶不在直觉处
重度用户存档 2600 词 / 376KB,每次点「下一题」的主线程同步处理 p50 为 10.4ms——其中约 8ms 是两次 376KB 的 JSON.stringify(切题函数与渲染函数末尾各一次),而 setItem 本体只有 0.3ms。测量基础设施本身有个插曲:本机构建工具起不来(其自身把路径当参数的缺陷),改用 Node 静态服务器 + 浏览器自动化直连源码目录自建探针。修法很小:落盘函数置脏后排队到微任务,同一任务只写一次最终状态;p50 降到 2.8ms,写入 2→1 次。
但它立刻引出新问题:微任务能赶上关页吗?不能保证。于是必须另开同步出口,在 beforeunload / pagehide / visibilitychange 三处现场落盘,并在切换账号前强制执行;还有一条可靠性专测:点完立刻刷新,进度必须在盘上。**任何「延后写入」的优化,都必须同时回答「什么时候不能再延后」。**这个问题答不上来,优化就是把丢数据的时间窗口从零扩大到一次任务。同构的问题在姊妹篇 ART/RANK 里也出现过,两处修法一致。其余性能数字同源:构建迁移后入口脚本 141→77KB、讲义脚本 77→36KB;词库缓存从「localStorage 只留 2 章」迁到 IndexedDB 全 22 章驻留;空闲预取下一章;指纹化资源配合缓存策略,二次切章零网络请求。
3.4 设计的门禁化
设计规范自称「界面改动唯一事实源」,它的执行靠四项 lint:裸十六进制、界面 emoji、移除焦点轮廓、外部字体源,任何一项出现即检查失败。无障碍不是自查:axe 实测抓出三个语义色在次级面板上不达 AA,改值后把新值写进门禁。z 层级的七级阶梯把「弹层被弹层盖住」这类问题从品味问题变成挂档问题。这一层的共同点是:设计的每条规矩都能失败——不能失败的规范只是愿望。
4 讨论
4.1 教育内容的质量是管线属性,不是模型属性
同一批模型分片,第一版「能出题」,重建后「能考人」——差别不在模型,在判据。把质量判据写成可执行检查之后,质量工作从「抽查这一次」变成「拦住下一批」:三轮整改的 3710 条次全部由机器定位、按约束修复、再由机器复核。这与姊妹篇钢琴系统的「承诺—实现对账」、ART/RANK 的「先测后改」是同一个方法论:测量先于改进,判据先于测量。人的位置也被如实界定:机器不可约的残留(180 条长度比、24 处软错位)留给人工,并且永远会有——管线的目标不是零人工,是把人工从「大海捞针」压缩到「已捞出的针堆」。
4.2 删除与换账号:记忆应用的两道真正难题
写入错误的暴露是即时的,用户自己就是测试;删除错误的暴露是延迟且跨设备的——那个词在另一台设备上复活时,用户不会报错,只会失去信任。本文的做法是用三层机制把「删除」变成与「写入」同等级的一等公民:墓碑让它可见,cutoff 让它不可被旧数据推翻,同毫秒删除优先让它收敛 [1][2]。换账号则是同一问题的命名空间版本:键不带用户后缀、flush 不在切换前执行,数据就会跨账号串号——这类错误同样不报错。数据系统文献把这概括为一句话:正确性很少败给写入路径,多败给副本之间的协议 [3];单用户产品也不例外。
4.3 性能优化的次序
两遍序列化的病灶不在渲染、不在算法,在「同一任务内写了两次」——直觉很少指向这里。本文的经验次序是:先建探针、再找病灶、最后回答「延后之后什么时候不能再延后」。三步里最贵的是第二步之前的那个问题:如果不测,10.4ms 的主线程占用会被「感觉不卡」掩盖;如果不回答第三个问题,优化本身就制造了一次 P0。
4.4 无 CI 的验证纪律
没有 CI 不是缺陷的借口,但需要替代物:push 即部署(约 1 分钟)+ 部署后 36 项线上只读核验,把「线上」变成验证的一层而不是最后一步。这个体系两次抓住静态检查与单测不可见的缺陷(注册按钮打错接口、备份导出被自家导入器拒绝),也付出过代价——最新一轮线上核验 36 项中有 1 项未通过,事后定位是本机网络中断而非产品缺陷,如实记录而不粉饰。
5 威胁效度
**其一,LWW 假设客户端时钟大致可比。**收敛基于客户端本地毫秒时间戳,设备时钟被手动改过,结果会偏向「更晚」的那台;同毫秒删除优先只堵「复活」,不堵时钟偏移。逻辑时钟是已知未做项 [1]。
**其二,性能数据样本极小。**p50 10.4→2.8ms 来自一台机器、一份伪造的 376KB 存档;JSON.stringify 的开销与结构相关,不只是字节数,词量翻倍不能线性外推。
**其三,「重写」没有对照。**第二天重写数据模型是对的,但无法证明「在整章 blob 上打补丁」一定更差——这是一次被结果支持的直觉判断,不是实验结论。
**其四,内容质量的「机器不可约残留」意味着判据覆盖不完备。**约 180 条长度比、24 处软错位在现行判据下合格,质量上限由判据决定;判据的演进(v1.0→v1.2)本身依赖事故驱动,是反应式的。
**其五,文档会漂移。**README 的单测计数(217)落后实际(218)——引仓库文档时以代码与门禁输出为准。
6 结论
三个问题的答案:内容——LLM 生成的教育内容,质量可以是管线属性;规范即提示词、判据即可执行检查之后,质量从抽查问题变成门禁问题,人的位置被压缩到机器不可约的残留上。数据——记忆类应用的正确性确实由删除与换账号决定:墓碑、cutoff、同毫秒删除优先、切号前 flush,四件小事各守一道不会报错的错误。前端——单用户产品的设计与性能可以门禁化,前提是每条规矩都能失败;性能优化必须先测后改,并且回答「延后之后什么时候不能再延后」。
46 天 82 次提交,功能只占其中一部分;另一半花在让「不会报错的错误」无处藏身。这与四篇姊妹篇的结论同形:系统的可信度不在它做对了什么,在它错了的时候会发出声音。
数据与复现
代码在 https://github.com/tripodxu/vocab,线上系统 https://vocab.logicc.top(push 即部署)。题源管线在 scripts/(quiz.mjs 生成与合并、quiz-lib.mjs 校验器、quiz-truncation-scan.mjs 截断扫描、quiz-flag.mjs 逐题缺陷扫描),生成规范与提示词同源在 docs/选择题资料生成规范.md。同步与正确性:迁移 migrations/0003(按词存储)、0006–0008(墓碑 / cutoff / 迁移标记),LWW 规则在 worker/index.js,客户端队列在 public/sync.js,落盘与 flush 在 public/storage.js。设计系统见 docs/设计规范.md 与 public/tokens.css。项目记忆在 MEMORY.md(最新在上)。
参考文献
[1] Lamport L. Time, clocks, and the ordering of events in a distributed system. Communications of the ACM, 1978, 21(7): 558-565.
[2] Shapiro M, Preguiça N, Baquero C, et al. Conflict-free replicated data types. Proceedings of the 13th International Symposium on Stabilization, Safety, and Security of Distributed Systems (SSS), 2011: 386-400.
[3] Kleppmann M. Designing Data-Intensive Applications. Sebastopol: O’Reilly Media, 2017.
[4] W3C. Web Content Accessibility Guidelines (WCAG) 2.1. W3C Recommendation, 2018. https://www.w3.org/TR/WCAG21/.
[5] W3C. Service Workers. W3C Working Draft. https://www.w3.org/TR/service-workers/.