摘要 「排出自己的 Top N」的现有工具是打分制——分数是公众共识,不是个人偏好。本文报告一个把它拆成连续 1v1 取舍的产品(ART/RANK):用户每次只在两件作品之间选一个,系统以四种可换挡的算法档位把取舍序列变成带不确定度的名次。测量结果有四。(1)比较成本可按档换挡:简易档约 1.3 次/件、经典档主动式二分插入 O(n log n)、精确档在验证阶段做主动学习(Top 内从未直接比较过的相邻对主动复测)并给出校准一致率、自适应档由在线 Bradley–Terry 模型 [1] 驱动——信息量判据 p(1−p)(σᵢ²+σⱼ²) 挑最该问的一对,期望名次与 90% 区间全部由决策日志重推而不新增持久化字段;实测可靠边界为 n≤20(n≥30 时未比较对的先验方差主导调度,30/50/100 三组均排错),产品据此在前端诚实提示。(2)偏好回环 A→B→C→A 被当作信号而非噪声:复测不改排序,全部边复测一致才升级为「真实张力」;唯自适应档将复测结论回流模型。(3)大规模个性化可以不牺牲确定性:545 个设计令牌支撑 12 主题,主题包以「重建式注入」(变量名白名单 77 个、必需集 21 个、值字符白名单)保证安全,一次 46 提交的界面焕新做到功能零变更,视觉回归以 270 张截图两轮零差异为基线——并实证「百分比差异会骗人」。(4)695 次提交的真实成本集中在三处:别人的接口(豆瓣降级链、维基消歧)、目标运行时(workerd 禁止模块顶层调用 getRandomValues、PBKDF2 硬帽 100k)、自己的重构(拆 Hook 减 407 行引入三处回归)。LLM 在系统中遵循四原则「key 不内置 / 决策而非生成 / 降级可用 / 非关键路径先行」:快排千件约 $0.002,而实测模型置信度仅 0.44–0.68,直接否决了固定的 0.8 代判阈值。

关键词 成对比较;Bradley–Terry 模型;主动学习;设计令牌;主题系统;Cloudflare Workers

1 问题的提出

「从看过的电影里选出 Top 10」,现有工具几乎都是打分制:豆瓣星级、IMDb 评分、各种榜单。分数回答的是「公众觉得它如何」,回答不了「我更想留下哪一个」。成对比较是心理物理学中测量偏好的经典范式 [1]——它不要求用户给绝对分,只要求在两件作品之间做一次取舍;把无数次取舍组织起来,就能得到一份带不确定度的个人名次。

ART/RANK 把这个范式做成了产品:电影、书籍、音乐、其他四类媒介,每次比较只问「这两件里,你真正想留下的是哪一件」,比较序列经过验证与回环检测,最终输出可保存、可导出、可与他人比较的「艺术人格画像」。本文不评述产品体验,只考察它的三个工程问题:

  • Q1(算法):比较次数与结论质量可以按需换挡吗?每一档的代价与边界在哪里?
  • Q2(设计):12 套主题、可导入的主题包、WebGL 开场特效——个性化做到这个规模,如何不牺牲安全与功能确定性?
  • Q3(工程):695 次提交的钱实际花在了哪里?

工程事实先行:695 次提交、30 个数据库迁移、78 个测试文件约 962 个用例、12 套主题、545 个设计令牌。

2 系统与方法

2.1 排序引擎:四种可换挡的档位

经典档(默认):主动式二分插入——每个新候选与已排序列表的中位比较,逐步收缩插入区间,比较次数 Σ⌈log₂(min(i, topN)+1)⌉,即 O(n log n)。配对冷却 3、作品冷却 4,防止相邻比较被重复问。

简易档:守门员截断——榜单满员后,候选先与榜末「守门员」比一次,输即出局(1 次比较),赢才升级为二分插入且只在守门员之前定位。比较成本估算约为每落选件 1.3 次(模拟拟合的经验系数),适合大清单。

精确档:在经典档上加两类校准。其一,验证数加倍至 max(4, ⌈topN/2⌉);其二,主动学习 [2]——Top 内从未被直接比较过的相邻对(它们的先后关系全部靠二分传递推断)主动入队复测。完成时报告校准一致率 = 复测一致数 / 复测总数。成本估算 ×1.4。

自适应档:不走二分插入,由在线 Bradley–Terry 模型驱动 [1]。批量坐标上升做 MAP 拟合(先验收缩 0.05),强度方差取对角 Laplace 近似;下一问选「探测方差」最大的一对——information = p(1−p)(σᵢ²+σⱼ²),外加 0.35 倍的对手覆盖度(保证每件作品接触过至少两个不同对手,防孤岛);预算 clamp(4n, n−1, n(n−1)/2)。输出不是只有名次:期望名次 = 1 + Σ P(对手排我之上),名次方差做 Delta 传播后给出 90% 名次区间。θ 与 σ 不新增任何持久化字段,全部由决策日志重推——结论可审计,回放即复现。

回环检测与「真实张力」。每次取舍写一条有向边(胜者→败者);新偏好产生时在边图上做 BFS,找到从败者回到胜者、长度 ≥3 的路径即偏好回环(A→B→C→A)。回环中每一对入验证队列复测:全部边复测一致且零冲突,才标记为 persistent——「真实张力」。经典/精确/简易档下,复测不改变排序,只记录偏好证据;唯一例外是自适应档:复测结论回流 BT 模型,全部复测结束后按完整模型重建榜单——「复测不再问了白问」。这是对测量学的诚实:有些偏好本来就不满足传递性,系统的选择是保留张力并标注强度,而不是抹平它。

验证阶段。排序完成后自动复测少量相邻对:经典档 min(4, ⌈topN/3⌉) 对榜首先相邻对,简易档只复测守门员边界(榜单尾部),精确档额外入队上述主动学习对。

2.2 设计系统:令牌、主题包与特效

地基是设计令牌:545 个 CSS 变量、194 处 color-mix。12 套内置主题(经典七套 + 新系列五套)建立在同一令牌集上——新系列里的 editorial 族带组件级结构覆写与条件渲染,atlas 则刻意只用变量就做出辨识度,作为「纯令牌能走多远」的样板。主题与布局正交(data-theme 与 data-layout 各自独立)。

主题包的安全模型是本文认为最值得记录的设计。主题包本质是用户生成的 CSS 变量块,注入方式不是过滤而是重建:变量名白名单 77 个、必需集 21 个缺一拒绝、值字符白名单(拒 ;{}@<> 与 url()、括号平衡校验、单包 ≤16KB、浏览器累计 ≤64KB、只存本浏览器。这与后端「出站请求唯一入口」(2.3)是同一条原则在样式域的复制:安全判定必须有唯一入口。

开场特效走注册表:宿主持有 WebGL 渲染器,四种特效(plasma / halo / blackhole / ribbon)只操作场景;three.js 只允许动态 import——有一条测试守着首屏依赖图,静态引入会被绊线拦截。性能纪律写死:粒子上限 1200、设备像素比钳制 2、additive 混合一律关闭深度写入;prefers-reduced-motion 全局豁免。

无障碍:统一 Modal 组件内嵌 87 行的 FocusTrap(Tab 循环、Esc 关闭、aria-modal),单日为 12 处弹窗接入;44px 触控基线;正文次级色对比度从 4.11:1 修到 4.62:1 过 WCAG AA [3]。

性能:五大视图 React.lazy 拆包,入口 chunk 1072→964 KB(gzip 307→279 KB);Three.js 从主包摘出后再降 28.4% gzip;海报分页懒加载;PWA 构建期预缓存全部哈希产物。视觉回归以 270 张截图 ×2 轮零差异为基线门禁。

2.3 外部依赖管道与安全收口

数据源是外部的,这决定了系统的一半性格。电影走 suggest API → 搜索页 → Top250 索引 → IMDb 备用四级降级;简介走豆瓣官方详情页(零歧义)→ 维基百科消歧打分(标题/类型声明/年份守卫)→ 百度百科兜底;音乐无 suggest API,详情经网易云与一个部署在 Deno Deploy 的透明代理(它存在的唯一理由是 Cloudflare 出口被上游 520)。防限流参数:4 个 Edge UA 轮换、同域串行节流 800ms、403/418 冷却 (尝试+1)×5s 最多 3 次、单请求 15s 超时。海报解析有五层缓存(内存 → sessionStorage → D1 侧表 → isolate LRU → Edge Cache),其中「D1 侧表」是一次事故的产物:海报 URL 曾混进画像数据导致超 512KB 限额,此后海报 URL 绝不进 items、只走侧表。

安全收口只有一处:全部出站请求必须走 fetchBounded——精确 host 白名单、redirect: manual 逐跳按同一策略复核、响应字节上限;另有一条测试只许减不许增地锁住全仓裸 fetch 计数(一轮审查从 21 处降到 17 处)。用户凭证(网易云 Cookie、豆瓣 dbcl2)一律 AES-GCM 加密入库,断开即删。这些都与 2.2 的主题包白名单同构。

2.4 LLM 的位置:决策而非生成

LLM 在本系统有明确边界,写成四条原则:key 不内置(访客自带)、决策而非生成(与姊妹篇棋类系统「模型只做选择」一脉相承)、降级可用(主链路永不依赖模型)、非关键路径先行。已上线三项:AI 快排(≤255 件一次调用、每件一个 0–100 分直接成榜,千件约 $0.002)、AI 代判(置信度 ≥ 阈值自动落位、可撤销、默认关)、AI 点评。未接通的一项更有教益:维基消歧的模型判决端点已上线,前端却零调用——凭证只存在浏览器 localStorage,而批量消歧发生在 Worker 端,架构错位使端点悬空。消歧的需求本身来自一个真实案例:搜索「Journey」时,「道奇 Journey」与游戏「風之旅人」评分只差 1 分——分数不是置信度,判错了用户看到的就是一辆汽车。

3 结果

3.1 换挡的边界:自适应模式的一条诚实声明

信息量选对的成本先被量出来再被优化:300 件清单上,旧实现每步扫描产生约 4.5 万个临时对象、耗时 17.2ms(占单次取舍 20ms 预算的 85%);改为一遍扫描零分配后,选出的对手逐字不变(有 golden 指纹看守),300 件全程从 15.5s 降到 3.8s。

更重要的结果是可靠边界。固定 4n 比较预算、回答完全一致的条件下:n ≤ 20 时 Top N 全对;n ≥ 30 时(30/50/100 三组实测)出现排错——未比较对的先验方差(σ² ≈ 40)主导了调度,边界序对信息不足。产品没有隐藏这条边界:设置页据此提示大清单改用经典档。一个算法档位公开自己的失效条件,比把它藏进 FAQ 便宜得多。

另一个反直觉的实测:逐条在线更新 BT 模型是路径依赖的——12 件清单 50 次比较只命中 3/5 名,而二分插入 26 次就 5/5。这就是自适应档改用批量拟合的原因:同一份数据,喂法不同,结论不同。

3.2 回环是信号不是噪声,撤销的病灶在别处

复测纪律如 2.1 所述:不改排序、全边一致才升级「真实张力」、张力分两级标注。工程上更有趣的是撤销:自适应档 300 件清单撤销一步曾耗时 16.4s。直觉指向回环检测的 BFS——实测改成邻接表几乎没变;真正的病灶是撤销每步都在重新拟合 BT 模型(约 12ms/步 × 1309 步)。改为快照栈后撤销 2ms(约 8500 倍),并在代码里留下一条注释:别再把这条当性能病灶来改。先测后改,两次都是——病灶很少长在直觉指的地方。

3.3 设计工程的三次测量

零变更焕新。一次全站界面焕新用了 46 次提交,功能零变更。流程是工具先行、基线冻结、绊线测试锁死高危耦合;期间发现「百分比差异会骗人」——某网格布局的像素差异只有 0.0014%,实际是 display: block 把 grid-template-columns 整个空化了。视觉回归若只看差异百分比,这次破坏会全绿通过。

可及性按数字修。次级文字对比度实测 4.11:1,未过 WCAG AA;调到 4.62:1 收口 [3]。12 处弹窗的焦点陷阱单日接完,接在统一 Modal 上而不是散在各弹窗里。

拆包先做事实核查。首包优化的第一直觉是「qrcode 库太重」——实测后发现错的;真正的大头在路由未拆分。拆完后入口 gzip 从 307KB 到 279KB。优化前先量,直觉的两头都会错。

3.4 外部接口的账单

09-07 一天 28 次提交,几乎没有一次在加功能:海报 502(改为允许跟随重定向、超时放宽、逐段容错——带重试的完整修复要等 09-17)、搜索评分渲染成 [object Object](接口返回对象不是数字)、管理端鉴权查询漏了 .bind()。09-08 详情抓取被反爬阻断,加「详情失败回落搜索」双路,回落后又冒出同名不同作,再用 subject ID 过滤。09-09 两个经典坑:搜索关键词不能用 + 前缀——URLSearchParams 会把它编码成 %2B,而受害者是维基百科的搜索接口;同天把分享从「整份榜单 base64 塞进 URL」改成短链——base64 链接长到发不出去。

这一段支持一条可推广的判断:依赖外部接口的项目,前期成本与对方接口的整洁程度成正比,与自己的设计水平关系不大。 另有一场典型战役:音乐试听四连误判(CDN 出口被封 → 上游被封 → 网易云出口被封 → 换 Deno Deploy 可达),上线仍全挂,真因是本站 CSP 的 connect-src 缺域名、浏览器静默拦截——症状与上游故障完全一致。跨平台抓取故障要先分清:网络不通、被反爬、被 CSP 拦、被限流,四者的修法互不相干。

顺带一条方法论脚注:仓库时间线文档称 09-12 是「历史上最密集的一天」,git 记录却是 09-09(112 次提交)。文档会漂移,引用前先对 git。

3.5 抽象的账单

09-14 拆分 1000+ 行的根组件:抽工具函数与路由 Hook(−73 行)、抽认证 Hook(−149 行)、抽排序 Hook(−185 行),累计 −407。09-16 三处回归找上门:草稿无限写循环、自动同步无限 PUT、登出保存竞态。仓库的事后归因很朴素——Hook 提取丢失了 effect 依赖数组与守卫;把它综合成一句:原本隐式绑在一起的两件事被拆开后,隐式契约变成了显式契约,而显式契约写错时编译器不会说话。 减掉的 407 行与三条回归,是同一笔交易的两半。

3.6 运行时只有部署才知道的事

10-25 的安全审计里有一条只在目标运行时出现:模块顶层生成随机哑盐,部署直接报 workerd 错误 10021——workerd 禁止在全局作用域调用 getRandomValues。哑盐本来只用于对齐时序,改成固定零盐。同期的平台事实还有一条:PBKDF2 迭代 60 万次在 workerd 全线超时,硬帽实测 10 万;哈希格式自带迭代数字段,此后上调可以平滑进行。加上 3.4 的 CSP 静默拦截,这类错误的共同特征是:本地跑得通、构建通过、类型检查干净,只有部署到目标运行时才暴露。仓库因此有一条成文的铁律:未到线上实机验证不标完成。 另一条同类教训来自「延后写入」:草稿落盘改 500ms 防抖后,切页必须同步 flush——第一次实现里 flush 分支条件互斥、永不可达,成了 P0;修法是给「延后」配一个「不能再延后」的出口,绑在离开排序视图与 pagehide 上,而不是绑在用户的耐心上。

4 讨论

4.1 排序是品味的测量仪器

本文的系统与姊妹篇《不写字的读者》共享一个更深的结构:那边把「一万读者的反应」做成测量仪器,这边把「一个人的偏好」做成测量仪器。两者都在做同一件事——把主观判断变成带不确定度的读数:名次区间、校准一致率、张力分级,都是读数的误差栏。Jev 四原则「决策而非生成」也与《廉价判断与确定性知识》的「模型只做选择」同源:三个系统、同一个模型家族,模型都只被允许在一组结构给出的选项里表达偏好。路线图上的下一步是 Plackett–Luce 品味似然 [4] 与「真实张力 vs 噪声」的模型校准——测量仪器的下一档分辨率。

4.2 主动学习进了消费级产品

精确档的「从未直接比较过的相邻对」与自适应档的信息量选对,本质都是主动学习 [2] 的两个档位:前者探索假设空间里最欠证实的区域,后者按预期信息增益采样。工程上的差别是后者有闭式的信息量(BT 模型给出 p 与 σ),前者只有结构性的判据(从未相遇)。两者在产品里都以「多问几个问题」计价,用户看得见价格——这大概是主动学习文献里少有的一节:被询问者知情。

4.3 设计系统的安全边界

主题包是本系统最「设计」的功能,也是安全上最小心的一处:用户生成的 CSS 走重建式注入而非过滤式清洗,白名单拒绝一切执行语义。这与 fetchBounded 的出站收口是同一条纪律——凡用户可控的输入域,安全判定只留一个入口。设计自由度的价格被显式定价(77 个允许的变量名、16KB 单包上限),而不是靠「相信前端」。

4.4 成本结构:先跑起来,再像样

功能在第二周就基本完成。之后的工作按提交分布看,主要花在三处:别人的接口(降级链、节流、消歧)、目标运行时(workerd 的两条硬约束、CSP)、自己的重构(−407 行与三条回归)。原稿给这篇文的标题「先跑起来,再像样」如今看是对成本结构的压缩表达:先建立可测的完整回路,再让每一层像样——因为只有跑起来,才知道哪里最不像样。

5 威胁效度

其一,提交数不可作进度。 无法区分「必要」与「返工」:拆 Hook 减 407 行、引入 3 处回归是可量化的交换,但更多提交是「上游变了 → 改解析器」式的被动响应。用提交数衡量进度在此严重失真。

其二,回归数量不可外推。 「拆 Hook 引入三处回归」是一个项目的观察,不是一条规律。

其三,审计结论有时效。 3 high / 4 medium / 4 low 是 09-27 那一刻的状态;每加一个接口数字都可能回升。安全审计是周期,不是事件。

其四,两个统计量有定义无实测。 校准一致率与 90% 区间覆盖率在代码中有严格定义,但未发布实测值——本文不给出未经测量的数字,二者是现成的下一步实验。

其五,设计判断来自单用户。 「好听 / 好看」的全部判断不可外推;本文能主张的只有结构与测量层面的结论。

其六,仓库文档自身会漂移。 迁移数在 README 中出现过 29 与 30 两个值,「最密集一天」与 git 矛盾。本文引用处一律以代码与 git 为准。

6 结论

三个问题的答案如下。算法:比较成本可按档换挡(1.3 次/件 → O(n log n) → ×1.4 → 4n),但每一档要公开自己的失效条件——自适应档的 n ≤ 20 是实测出来的边界,不是拍出来的参数;回环与张力说明偏好测量必须容忍非传递性。设计:个性化可以规模化的前提是每一层都有机器可查的契约——令牌地基、重建式注入、绊线测试、零差异基线;「百分比差异会骗人」一例足以说明为什么设计需要工程。工程:695 次提交的钱花在别人的接口、目标运行时与自己的重构上,而不是花在更多功能上;铁律只有一条——未到线上实机验证不标完成。

数据与复现

代码在 https://github.com/tripodxu/film-sort,线上系统 https://sort.logicc.top。排序四档位实现于 src/lib/ranking.ts(简易/经典/精确)与 src/lib/adaptiveRanking.ts、src/lib/preferenceModel.ts(自适应 BT 模型与 90% 区间);回环检测与张力分级同在 ranking.ts。主题系统见 src/lib/theme.ts 与 docs/THEME-PACKS.md(重建式注入的安全模型),特效见 docs/ORB-EFFECTS.md。外部管道与防限流参数见 docs/ARCHITECTURE.md 与 docs/DOUBAN_API.md。历史决策见 docs/memory/DECISIONS.md,战役复盘见 docs/memory/EPISODES.md,发布铁律见 docs/agents/CONVENTIONS.md §8。判定模型在三个姊妹系统中的另两个用法见本博客《廉价判断与确定性知识》与《不写字的读者》。

参考文献

[1] Bradley R A, Terry M E. Rank analysis of incomplete block designs: I. The method of paired comparisons. Biometrika, 1952, 39(3/4): 324-345.

[2] Settles B. Active learning literature survey. Computer Sciences Technical Report 1648, University of Wisconsin–Madison, 2009.

[3] W3C. Web Content Accessibility Guidelines (WCAG) 2.1. W3C Recommendation, 2018. https://www.w3.org/TR/WCAG21/.

[4] Plackett R L. The analysis of permutations. Journal of the Royal Statistical Society: Series C (Applied Statistics), 1975, 24(2): 193-202.