标题这件事,我一开始是靠感觉的。写了三十多篇之后翻后台,阅读量加起来不到八百,横向对比才发现:同一个话题换个标题写法,阅读量能差三倍。于是我把标题实验从「灵感」挪到了「流水线」里------不增加发布量,只是给当天的稿子打两个标记,隔日补对照,数据回流后自动判胜。
这篇讲工程实现。踩过的最大一个坑是幂等闸和实验机制的冲突,我会放在第三节细说,那是我两个月里最贵的一次调试。
一、数据模型:实验是标记,不是分支
先把语义定清楚,否则后面全是坑。A/B 实验不产生额外的发布行为,它只在内容台账的一条记录上加两个字段:
python
@dataclass
class ArticleRecord:
title: str
platform: str
topic: str
url: str | None = None
ab_group: str | None = None # ab-<主题>-<YYYYMMDD>
ab_variant: str | None = None # A / B
views: int = 0
likes: int = 0
当天三个平台照常各发一篇。我选流量最好的平台(这里是 CSDN)给它当天的稿子打 ab_variant="A",另外两个平台发同主题、不同标题的稿子,实验字段留空。
为什么坚持「标记而非分支」?因为发布是每天必须完成的硬任务,实验只是附加观测。我第一版把两者耦合------「今天做实验,所以只发实验平台」------直接导致另外两个平台断更,账号权重掉了一周才缓过来。把实验做成可选的元数据,最坏情况是实验失败,而不是当天没内容。
实验组 ID 必须带日期。同一主题做第二次实验时,如果不带日期,两条记录会混进同一个组,判胜直接判错,而且这种错误在报表上完全看不出来------你只会觉得「这组数据怎么这么怪」。
二、调度:pending-b 的自动提醒
B 变体要第二天发,靠人记必然漏。我把提醒做进调度器:每天开工先扫台账,找出状态为 pending-b 的组(A 已发、B 未发),为它生成一条带 ab_variant="B" 的任务;然后遍历三个平台,把没有任务的平台补上普通稿。
python
PLATFORMS = ("csdn", "juejin", "toutiao")
def plan_today(records):
tasks: dict[str, dict] = {}
for r in records:
if r.ab_group and r.ab_variant == "A" and not has_b(records, r.ab_group):
tasks[r.platform] = {
"platform": r.platform,
"ab_group": r.ab_group,
"ab_variant": "B",
"style": next_style(r.ab_group), # 与 A 不同的风格
}
for p in PLATFORMS:
tasks.setdefault(p, {"platform": p, "ab_group": None, "ab_variant": None})
return list(tasks.values())
最后那个 setdefault 循环是三平台不缺篇的保证。我一开始漏了它,结果补 B 那天只产出两篇------实验平台有稿,另外两个平台空着,当天日报看起来还很正常,因为任务清单确实「都执行完了」。
另一个细节:B 变体的标题风格必须和 A 明确不同。我在生成任务时就把 next_style 定下来(A 用数字列表,B 就用疑问式),而不是让模型自由发挥------自由发挥的结果往往是它挑自己最熟的那种,最后两个变体风格趋同,实验白做。
三、幂等闸冲突:deduped 不是成功
这是核心坑。我的发布器里有一道幂等闸,发布前对标题做归一化再算哈希,命中历史记录就跳过:
python
def publish(platform: str, title: str, body: str) -> dict:
h = sha256(normalize(title))
if h in history(platform):
return {"ok": True, "deduped": True, "url": lookup_url(h)}
...
return {"ok": True, "deduped": False, "url": new_url}
这道闸救过我:有一次网络抖动触发重试,同一个标题连发五次,账号差点被封。但它和 A/B 实验天然冲突------我让模型「参考历史高表现标题」来写 B 变体,模型非常听话地复刻了一篇旧文的标题,哈希撞库,闸门静默跳过,返回 ok: True。而调度器只看 ok,把它记成了成功。当天该平台零新文章,日报却显示「已发布」。
修复分两层。
第一层,调用方必须把 deduped 当失败:
python
res = publish(platform, title, body)
if res.get("deduped"):
raise NotPublishedError(f"标题被幂等闸拦截,本平台今日零产出: {title}")
第二层,生成标题时加相似度约束,从源头避免撞库:
python
def too_similar(title: str, history_titles: list[str], th: float = 0.85) -> bool:
s1 = set(title)
return any(
len(s1 & set(t)) / len(s1 | set(t)) > th
for t in history_titles
)
字符级 Jaccard 足够用,不必上向量检索------标题普遍在 20 到 40 字,集合重叠度对复刻非常敏感。命中就重生成,最多三次,三次还撞就换风格模板。
这个教训我认为是通用的:任何带幂等设计的系统,调用方都必须区分「被执行」和「被跳过」 。它们在日志里长得几乎一样,而语义完全相反。我后来在所有写操作里都统一了这个约定:返回体必须显式带 deduped 或 skipped 字段,调用方不检查就抛异常。
四、判胜:样本门槛比公式重要
数据回流后判胜。我最初想搞加权公式,试了两轮就放弃了------样本量太小,加权只是给噪声乘系数。现在的规则极简:
python
def judge(a: ArticleRecord, b: ArticleRecord, min_views: int = 50) -> str | None:
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 输了,但这数据毫无信息量。没到门槛就老实挂着,别急着沉淀洞察------错误的洞察比没有洞察更糟,它会持续误导后续所有选题。
判胜之后,结果写进一张洞察表,维度是「平台 × 标题风格 → 胜出次数」。下次起标题先查表,查不到再凭感觉。这张表是这套机制真正的产出物,单次实验的输赢反倒次要。
五、两组真实数据
第一组,九月底,CSDN,接口框架实战主题:
| 变体 | 风格 | 阅读 | 点赞 |
|---|---|---|---|
| A | 数字列表 + 实战干货 | 109 | 4 |
| B | 疑问式 + 悬念反差 | 39 | 1 |
A 胜,接近三倍差距。
第二组,十月下旬开的,A 变体已发,B 变体数据尚未回流,状态为 awaiting-data。我没有把它当结论用。
把已判胜的几组摊开看,CSDN 上「实战干货 + 数字列表」胜出六次,疑问式只在极少数场景翻盘。所以现在 CSDN 默认走这个风格。
六、几条工程约定
A 和 B 的间隔压在一两天内。隔太久,推荐池状态、账号权重、热点都变了,测的就不只是标题。我有一组隔了五天,数据完全没法归因。
正文可以微调,但主题和核心内容别动,否则变量不唯一。
同一天不要给多个平台同时开实验。第二天要补多个 B 变体,出错概率翻三倍,一个平台挂了整组实验都废。
实验状态机只有三个值:pending-b(A 已发待补 B)、awaiting-data(两变体已发等回流)、concluded(已判胜)。状态少的系统才不容易错。
收尾
这套东西跑下来,最大的变化不是阅读量涨了多少,而是早上打开文档不用再纠结标题了------表里有答案。
代码层面真正重要的就三件事:实验标记与发布解耦、deduped 必须当失败处理、判胜要设样本门槛。前两件保证系统不出错,第三件保证结论不跑偏。剩下的都是细节。