我做了一个多人结构化讨论系统,叫 Arena。
它最核心的一条设计不是功能,是一条**禁止**:**AI 不许当裁判。**
不许排序、不许打分、不许判谁赢、不许说「A 更正确」。AI 在这套系统里只做三件事:
**切分**(把你的一句话切成若干命题)、**组织**(放进结构里)、**发现分歧**(指出哪里吵起来了)。
投票也有,但记的是**公共偏好**,不是真理。四个统计量之间**没有任何算子**能把它们连起来 ——
没有合计、没有总分、没有「A 61% 所以 A 更对」。
听起来像是个很虚的立场。但我这篇想讲的是:**这条立场是可以用代码守住的,而且守住它的成本比想象中高。**
---
### 一、把「声明」写成「可执行的否证检查」
一般的项目 README 会写「本项目不做 X」。问题是,**这种句子没有任何东西在守它** ——
半年后有人顺手加了一个 `total_score` 字段,没人会发现。
所以这个项目的每一条立场,都配一条**可执行的否证检查**:跑一段 `grep`,
在产物代码里找**不许出现的东西**。命中就是声明不成立。
一共 13 条,编号 B1–B13。举几条:
| 编号 | 盯的是什么 | 怎么查 |
|---|---|---|
| B1 | 不引入锁 | 扫 `lock` / `FileLock` / `flock`(但 `blocks` 这种子串巧合要排除) |
| B5 | 无标量分 | 产物里不许有「把多个量合成一个数」的代码 |
| B6 | 观测点只记不算 | 不许拿观测点的值和任何数字比较(不设阈值) |
| B7 | 第三方依赖为零 | 扫所有 import,非标准库即命中 |
| B10 | 发号器不携带判断 | 号码只能出自存储层,出不了 `scaffold.py` |
| B12 | 命题类型不许写死 | `confirm.py` 里不许出现类型字面量(写死就推翻不了) |
跑法就是 `python checks.py`,退出码 0 = 13 条全部成立。
**但真正难的不是写这 13 条,是让它们不空转。**
---
### 二、这个项目最花力气的地方:「检查失效」和「检查通过」长得一模一样
这是我做这个项目最大的收获,也是我觉得最值得分享的一点。
**一条坏掉的检查,输出和一条通过的检查完全一样:都是「过」。**
这不是抽象担忧。同一个形状在这个项目里**真的出现了三次**:
**第一次:靠标记触发的检查。**
有一条检查是扫样本文件的:如果样本里有个「待标注」标记,那标注区就必须是空的。
问题在于,这条检查是**靠那个标记来决定扫不扫的**。于是标记一改名,检查就**永久空转** ——
它什么都不扫,然后报「过」。而输出看起来完全正常。
修法:把判据从「有没有这个标记」换成「类型是 Assumption 且覆盖率是 0」—— 不再依赖一个可以被改名的东西。
**第二次:目标文件不存在时返回「暂不适用」。**
有一条检查是盯并发装置的。它开头是这样的:如果目标文件不存在,就 `return []`。
而它的测试用例正好断言 `== []`。
于是「**装置是干净的**」和「**根本没有装置**」长得一模一样。
当时正好要给那个文件改名 —— 只要漏改一处路径,这条检查就会**静默变成空转**,
而且测试**照样全绿**。
修法:把路径提成一个常量,并让测试**单独断言「这个文件真的在」**。
**第三次:分子根本产生不出来。**
这个最有意思。系统里有个指标叫「改判率」:机器做的结构化,用户拒绝了多少。
分子 = 被用户拒绝的边数,分母 = 机器产出的边总数。
问题是:**产品里根本没有「拒绝一条边」这个入口。** 那个拒绝函数存在,但没有任何命令调它。
所以从产品表面看,分子**恒为 0**。而 `0 / 5` 这个读数,和「AI 很准」**长得一模一样**。
它其实在说「**没人能拒**」,不是「没人拒绝」。
修法不是去加一个界面(那样成本高,而且不一定需要),而是让**读数诚实**:
```
0 / 2 → 算不出 —— 产品里没有「拒绝一条边」这个动作,
分子产生不出来,所以这个 0 的含义是「没人能拒」,不是「没人拒绝」
```
---
### 三、由此提炼出的两条判据
这两条我觉得可以搬到任何项目里:
**判据一:一个数如果它的「0」和「没有」分不开,就不许印成 0。**
观测点里所有比率,分母为 0 时一律显示「算不出」,而不是 `0.0000`。
因为「还没开始记」和「记了,结果是零」是两件完全不同的事,
混起来等于给自己造一份**看起来很正常的空数据**。
而且这个错我犯过两次 —— 第二次更隐蔽:
有个指标的分母,我第一版写的是「总条目数」。结果是:一个什么都没分类过的库,
会显示 `0 / 2 → 0.0000`。**「还没开始记」被渲染成了「比率是零」。**
防 `0/0` 伪装成 0,不能只防除法那一步 —— **选错分母是一样的病,而且更隐蔽**,
因为除法本身看着没问题。
**判据二:检查的「适用范围不成立」,不能返回和「范围内没违规」一样的值。**
这就是上面第二次那个洞。返回 `[]` 表示「没问题」,
但「文件不存在」也返回 `[]` —— 两种完全不同的情况,一个返回值。
**空、退化、没有**,都不许长得像**零**。
---
### 四、顺手记一个真实的并发 bug
项目里有个环节:起 N 个真进程,无协调地操作同一份 SQLite 状态。
第一次跑,**id 撞车**:两个进程同时发号,发出了同一个号。
修法**不是加锁**。加锁是最容易想到的,但它会引入一个新的共享点,
而且这个项目明确禁止引入锁组件。
改成让 **SQLite 自己原子发号**:把「读最大值 + 1」换成一条原子写。
修完再跑,同样的参数从「每次都撞」变成 0 次异常,加压到 8 进程 × 12 轮仍然是 0。
**但这里有个坑值得单说**:这种「没出事」的结论**必须带分母**。
如果 4 个进程其实是**排队**跑完的,那一条异常也不会有 —— 而那样的「没出事」什么都证明不了。
所以这个装置的输出**先印换手次数,再印异常数**。换手是 0 的话,
那句「未暴露问题」就是一句**没有分母的结论**。
顺带说:我在 CI 里也没有写「跑一下看它崩不崩」。因为那个装置的退出码**永远是 0**
(「本轮未暴露」是有效结果,不是失败),所以「跑通了」等于什么都没证明。
**恒绿的断言也是绿的。** 真正的断言是「换手次数 > 0」。
---
### 五、一个零依赖的终端折线图
投票结果要能看。查了一圈成熟方案,终端画图的正路是 **Unicode Braille(U+2800 区)点阵** ——
`plotille` / `uniplot` / `brailleplot` 都是这个路子。
一个 Braille 字符是 **2×4 个可独立寻址的点**,所以 80×20 的终端等于 160×80 的位图,
分辨率是普通字符画的 8 倍。而且**零依赖**。
自己写了两百来行,没引库。几个细节:
- **一个点不画。** 只有 1 票时不画折线 —— 一个点不是折线,画出来会让人以为「票数就是这样」。
- **图例永远带数。** 不做成「有颜色才显示」。因为 Braille 字符**本身不带颜色**,
两条线叠在同一格时字符是**合并**的,重定向到文件之后几条线在字符层面就是分不开的 ——
而那份输出看上去还是一张正常的图。
- **横轴退化要说出来。** 如果所有票是同一时刻投的,横轴退化成票序号,那就在图上写明。
---
### 六、说清楚没做的
- **合并(merge)表达不出来。** 版本表是**单亲**的 —— 一棵树,不是有向无环图。
真正的合并要两个父版本,现在这个列装不下。要改就得动 schema,我没动。
- **投票只接了「看」,「投」没接。** 要接得先定「选项怎么呈现给人」——那是产品判断,不是实现细节。
- **「拒绝一条边」没有界面。** 上面说过,改成让读数诚实了。
- **没有前端、没有账号、没有部署、没有数据库服务。**
- **样本只有 1 条原文。** 这是最要命的一条 —— 需要用户长期使用才能长出来的东西,
现在全是空的。
- **并发只在 SQLite 单文件这个前提下测过。** 没被放到多机、多写者场景里验过,也没打算验。
- **有一类东西系统内根本测不到**:用户「没输入就离开」的人数。连门都没进,
系统内不产生任何记录。处理方式是登记在案 + **不伪造近似值**。
---
### 七、它不是什么
- 不是 AI 辩论平台。AI 不生成论点、不评价论点。
- 不是投票定胜负的工具。
- 不是完整产品,是一条**跑通的链**。
- 不是通用框架,它依赖一个叫 Scaffold 的存储层(也在同一个账号下)。
代码是 Python 3 + 标准库,**零第三方依赖**,不需要 `pip install`。
13 条检查 + 200 个测试用例,CI 跑两个平台 × 两个 Python 版本。
仓库里还放了四份**约束文档**(身份 / 任务卡 / 约束附录 / 最早的设计稿)——
因为代码里到处是 `§C9 #5` 这样的引用,不放进去外面的人根本查不到。
仓库地址:**https://github.com/aic-123/arena**
---
**我最想听的不是「写得不错」,是这两类问题:**
1. **哪条检查其实是空转的?** 上面三个例子都是我自己撞出来的,
但我只有一双眼。这类洞的特点就是**看起来是绿的**。
2. **「AI 不做裁判」这条立场,还有哪些地方会被代码偷偷绕过?**
比如某个看起来中立的默认值、某个排序、某个「顺手」的聚合。
---