先说踩坑现场。十月中旬某天,我的调度器按计划给 CSDN 补发 B 变体,生成、校验、提交,发布器返回 ok: true,日志绿油油一片。我顺手去后台看了一眼------当天零新文章。
排查了两个小时,问题出在我自己写的幂等闸上:B 变体标题被模型「参考历史高表现标题」参考得太到位,直接复刻了一篇旧文的标题,标题哈希撞库,幂等闸判定重复发布,静默跳过并返回 deduped: true。而调度器只判断了返回码,没看这个字段,把「跳过」当成了「成功」。
这篇就把标题 A/B 实验这套机制从数据模型到判胜规则完整拆一遍,6 个细节,都是我在真实运行中改出来的。
细节 1:实验语义是「打标记」,不是「多发一篇」
最容易理解错的地方在这儿。A/B 实验不增加任何发布量,它只是在当天某篇稿子上加两个字段:
python
article = {
"title": "...",
"platform": "csdn",
"abGroup": "ab-无人值守AI内容矩阵-20261027", # 实验组 ID
"abVariant": "A", # A=先发标题
}
```
当天三个平台照常各发一篇。我挑流量最好的那个平台(CSDN)给它的稿子打上 A 标记,另外两个平台发同主题、不同标题的稿子,`abGroup` 留空。
这个设计的好处是解耦:实验失败不会导致断更,断更也不会连累实验。我第一版把两者耦合在一起,结果是「今天做实验所以只发实验平台」,另外两个平台断更,账号权重掉了整整一周才缓过来。
## 细节 2:隔日补 B 变体,交给调度器而不是日历
B 变体要第二天发,靠人记必然漏。我的调度器每天开工第一件事,是扫实验台账里状态为 `pending-b` 的组:
```python
def plan_today(experiments):
tasks = []
for exp in experiments:
if exp["status"] == "pending-b":
tasks.append({
"platform": exp["platform"],
"abGroup": exp["group"],
"abVariant": "B",
"style": exp["next_style"], # 必须与 A 变体风格不同
})
for p in ("csdn", "juejin", "toutiao"):
if p not in {t["platform"] for t in tasks}:
tasks.append({"platform": p, "abGroup": None, "abVariant": None})
return tasks
```
注意最后那个循环------它保证了「补 B 变体」只是替换某个平台的标题,另外两个平台照样有任务。这三行是我后来补上去的,之前漏了,导致补 B 那天只有一篇稿子。
## 细节 3:B 变体标题必须是全新的,幂等闸不认感情
这是本文开头那个坑的根因。我的发布器里有一道幂等闸,发布前算标题哈希,命中历史记录就跳过:
```python
def publish(platform, title, body):
h = sha256(normalize(title))
if h in load_history(platform):
return {"ok": True, "deduped": True, "url": find_old_url(h)}
...
```
它本来是为防重试风暴的------有一次网络抖动,同一个标题连发五次,差点被封号。但它和 A/B 实验天然冲突:模型倾向复用「高表现标题」,而复用的结果就是撞库。
修复分两处。第一处,调用方必须把 `deduped` 当成失败处理:
```python
res = publish(platform, title, body)
if res.get("deduped"):
raise NotPublishedError(f"标题被幂等闸拦截,今日该平台零产出: {title}")
```
第二处,生成 B 变体标题时加硬约束:与台账内所有历史标题的相似度必须低于阈值,不满足就重生成。我用的是简单的字符级 Jaccard 相似度,阈值 0.85,够用了:
```python
def too_similar(title, history, th=0.85):
s1 = set(title)
return any(len(s1 & set(t)) / len(s1 | set(t)) > th for t in history)
```
这两处改完,再没出现过假成功。这个教训是通用的:**任何带幂等设计的系统,调用方都必须区分「被执行」和「被跳过」**,它们在日志里长得太像了。
## 细节 4:判胜规则要设样本门槛,别拿 1 阅读当结论
数据回流后判胜,我一开始想搞加权公式,后来发现样本太小,公式只是自欺欺人。现在的规则非常朴素:
```python
def judge(a, b, min_views=50):
if a["views"] < min_views or b["views"] < min_views:
return None # 样本不足,保持 awaiting-data
if a["views"] != b["views"]:
return "A" if a["views"] > b["views"] else "B"
return "A" if a["likes"] >= b["likes"] else "B"
```
`min_views` 这个门槛必不可少。我有一组实验 A 变体 1 阅读、B 变体 0 阅读,按规则 B 输了,但这数据毫无信息量。没到门槛就老实标 `awaiting-data`,别急着沉淀结论------错误的洞察比没有洞察更糟,它会持续误导后续选题。
## 细节 5:两组真实数据,一组已判胜一组仍在等
第一组是九月底在 CSDN 做的,主题是接口框架实战。A 变体走数字列表式,B 变体走疑问式:
| 变体 | 标题风格 | 阅读 | 点赞 |
|---|---|---|---|
| A | 数字列表 + 实战干货 | 109 | 4 |
| B | 疑问式 + 悬念反差 | 39 | 1 |
A 胜,差距接近三倍。第二组是十月下旬开的,A 变体已发,B 变体数据尚未回流,状态挂在 `awaiting-data`,我没有把它当结论用。
把已判胜的几组横向摊开,规律很清楚:CSDN 上「实战干货 + 数字列表」这种写法胜出了六次,疑问式只在极少数场景翻盘。所以我现在给 CSDN 起标题默认走这个风格,除非有特别强的理由才换。
## 细节 6:实验组 ID 带日期,A/B 间隔压到两天内
两个我建议直接抄的约定。
实验组 ID 一定要带日期,形如 `ab-<主题>-<YYYYMMDD>`。不带日期的话,同一主题做第二次实验时,两条记录会混进同一个组,判胜直接判错,而且很难发现。
A 和 B 的发布间隔尽量压在一两天内。隔太久,平台推荐池状态、账号权重、热点都变了,你测到的就不只是标题这一个变量了。我早期有一组隔了五天,数据完全没法归因。
另外两条经验:正文可以微调,但主题和核心内容别动;同一天不要给多个平台同时开实验,第二天要补多个 B 变体,出错概率翻三倍,一个平台挂了整组实验都废。
## 小结
这套机制跑下来,最大的变化不是阅读量涨了多少,而是「标题怎么写」从凭感觉变成了有据可查。每次判胜,系统往洞察表里加一行:哪个平台、哪种风格、赢了几次。下次起标题先查表,查不到再凭感觉。
代码层面的核心就三件事:实验标记与发布解耦、`deduped` 必须当失败处理、判胜要设样本门槛。前两件保证系统不出错,第三件保证结论不跑偏。