一个人也能做A/B测试——AI驱动的自动化优化系统
开篇:改标题靠拍脑袋,就是在赌运气
你写完一篇文章,标题起了三个备选,最后选了"感觉最好的"那个。落地页文案改了几个版本,你挑了"看起来顺眼"的。某个渠道效果差,你犹豫要不要换掉。
停一下。只要你在用"感觉"做选择,你就是在赌运气。 大公司怎么做?A/B测试:一半用户看A,一半用户看B,数据说话。但传统A/B测试是团队专属——要统计学知识、要几万样本量、要Optimizely这种按年收费的工具。一个人公司,凭什么玩得起?
答案是:OPC不需要传统A/B测试,需要的是"够用且自动"的A/B测试。 不追求学术级的统计显著性,追求在有限流量里快速找到更好的方案,并且自动切换过去。这套系统有3层:AI生成实验变体、哈希分流、贝叶斯自动收敛。搭完之后,你只需要写一份新实验的配置,剩下的——分流、回收、判断、切换——全部自动。
上一篇文章([数据驱动的增长引擎——从阅读量到付费转化的自动分析](../articles/article-19.md))我们搭好了数据分析引擎,events表里躺着用户行为数据。今天这套A/B系统,直接复用它。
一、先想明白:OPC要的A/B测试,和教科书不一样
教科书A/B测试的流程:先算样本量,跑满固定周期,算p值,小于0.05才算显著。这套流程对OPC有三处不现实:
- 流量不够——一篇文章几千阅读,分成两组每组更少,等不到"统计显著",内容热度已经过了
- 时间不够——固定周期意味着你必须干等,而OPC的节奏是快速试错
- 人不够——没有数据科学家帮你解读结果
所以OPC版A/B测试换三件事:
- 决策目标:不是"证明B比A好99%",而是"当前流量下,B比A好的概率够不够高,够高就切"
- 停止规则:不设固定周期,用贝叶斯后验概率实时判断,够了就收敛
- 执行方式:收敛后自动切换,不用人盯着
一句话:大公司A/B测试是学术实验,OPC的A/B测试是自动决策器。
这套对比的本质是换决策工具:从"拍脑袋"换成"看后验概率"。要做到这一点,事件埋点必须前置——上一篇搭好的events表,要额外记录两件事:用户被分到了哪个变体(exposure事件),以及该用户是否完成目标行为(conversion事件)。埋点字段就两个:experiment和variant,必须和实验配置里的名字严格一致,少一个字段,收敛判断就是错的。
二、整体架构:3层自动化A/B系统

┌──────────────────────────────────────────────┐
│ Layer 1: 实验定义与AI变体生成 │
│ → experiment.yaml 声明实验 │
│ → LLM 根据原始文案生成 N 个变体 │
├──────────────────────────────────────────────┤
│ Layer 2: 自动分流 │
│ → 一致性哈希: hash(实验:user_id) % N 分流 │
│ → A组(原版) | B组(变体) │
├──────────────────────────────────────────────┤
│ Layer 3: 数据回收与贝叶斯收敛 │
│ → 复用上一篇的 events 表 │
│ → Beta分布更新 → 胜率计算 │
│ → 胜率超阈值 → 自动切换最优变体 │
└──────────────────────────────────────────────┘
每层职责单一,数据单向流动。下面逐层拆开讲。
三、Layer 1:实验定义与AI变体生成
第一步,定义实验配置。一个实验文件描述:实验对象(标题/落地页/邮件主题)、原始版本、评估指标、停止阈值:
# abtest/experiment.py
import dataclasses
@dataclasses.dataclass
class Experiment:
name: str # 实验名,如 "landing_v1"
target: str # 实验对象: title | landing | email_subject
control: str # 对照组原始文案
variants: list[str] # 待测试变体
metric: str = "conversion" # 评估指标,对应 events 表中的事件名
traffic_ratio: float = 0.5 # 实验组流量占比
stop_threshold: float = 0.95 # 胜率超过95%即收敛
EXPERIMENTS: dict[str, Experiment] = {}
def register(exp: Experiment):
EXPERIMENTS[exp.name] = exp
变体从哪来?一个人憋不出10个标题,但LLM可以。写一个生成器,把原始文案和变体要求发给模型,返回候选列表:
# abtest/variant_generator.py
import openai
client = openai.OpenAI()
def generate_variants(exp: Experiment, n: int = 3) -> list[str]:
"""基于对照文案生成n个变体,返回候选列表"""
prompt = (
"你是增长文案专家。下面是一个落地页标题,请生成"
f"{n}个不同角度的备选标题,每个不超过15个字,直接输出编号列表。\n"
f"原始标题:{exp.control}"
)
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
temperature=0.9,
)
lines = [l.strip() for l in resp.choices[0].message.content.splitlines() if l.strip()]
variants = [l.split(".", 1)[-1].strip() for l in lines if "." in l or "、" in l]
return variants[:n]
生成之后人工确认一次,把变体写进实验配置。注意:把 temperature 设置为 0.9,让LLM多输出几个"不那么稳"的候选——变体测试要的是差异,不是另一个版本的自己。
四、Layer 2:自动分流——同一用户永远只看到同一版本
分流只有一个硬要求:同一个用户,整个实验期间必须只看到同一个变体。 否则用户今天看A明天看B,数据全脏。
用一致性哈希,不需要存任何分流记录:
# abtest/traffic_split.py
import hashlib
def assign_variant(user_id: str, exp_name: str, total_groups: int) -> int:
"""把用户稳定分到 0..total_groups-1 组,同一个用户永远同组"""
key = f"{exp_name}:{user_id}".encode()
digest = int(hashlib.md5(key).hexdigest(), 16)
return digest % total_groups
def should_serve_experiment(user_id: str, exp: Experiment) -> bool:
"""按流量比例决定该用户是否进入实验"""
bucket = assign_variant(user_id, exp.name + ":gate", 100)
return bucket < exp.traffic_ratio * 100
接入点就一处——渲染标题、落地页或邮件主题时:
# abtest/middleware.py
def resolve_content(user_id: str, exp: Experiment) -> str:
"""返回该用户应看到的文案版本"""
if not should_serve_experiment(user_id, exp):
return exp.control
group = assign_variant(user_id, exp.name, len(exp.variants) + 1)
if group == 0:
return exp.control # A组:原版
return exp.variants[group - 1] # B/C组:变体
分流层到这里就完成了:不查库、不记状态、纯函数,任何请求进来都能立刻算出该看哪个版本。
流量比例也可以按风险调——改标题这种低风险实验放50%流量,动价格这种高风险实验先放10%流量试水,traffic_ratio写成0.1即可。
五、Layer 3:贝叶斯收敛——数据够了自动切
用户看到变体后,行为事件(exposure/conversion/payment_done)落进上一篇搭好的events表。接下来是核心:判断哪个变体更好。
用Beta-Bernoulli模型:每个变体的转化率是一个Beta分布,看到一次转化就更新一次参数。贝叶斯的好处是——样本少的时候判断保守,样本够了自然收敛,而且随时可以给出"B比A好"的概率:
# abtest/bayesian.py
import random
import psycopg2
DB_DSN = "postgresql://user:pass@localhost:5432/analytics"
BETA_PRIOR_A = 1.0 # Beta(1,1) 无信息先验
BETA_PRIOR_B = 1.0
def fetch_counts(exp_name: str, days: int = 14) -> dict:
"""从events表统计每个变体的曝光与转化数"""
conn = psycopg2.connect(DB_DSN)
try:
with conn.cursor() as cur:
cur.execute("""
SELECT payload->>'variant',
COUNT(*) FILTER (WHERE event_type = 'exposure'),
COUNT(*) FILTER (WHERE event_type = %s)
FROM events
WHERE payload->>'experiment' = %s
AND created_at >= NOW() - INTERVAL '%s days'
GROUP BY payload->>'variant'
""", ("conversion", exp_name, str(days)))
return {r[0]: {"exposures": r[1], "conversions": r[2]} for r in cur.fetchall()}
finally:
conn.close()
def win_probability(a: dict, b: dict) -> float:
"""B比A好的后验概率,用蒙特卡洛采样近似"""
random.seed(42)
wins = 0
samples = 20000
for _ in range(samples):
pa = random.betavariate(BETA_PRIOR_A + a["conversions"], BETA_PRIOR_B + a["exposures"] - a["conversions"])
pb = random.betavariate(BETA_PRIOR_A + b["conversions"], BETA_PRIOR_B + b["exposures"] - b["conversions"])
if pb > pa:
wins += 1
return wins / samples
然后每6小时执行一次收敛检查,胜率超过阈值就自动切换并通知:
# abtest/auto_converge.py
from abtest.bayesian import fetch_counts, win_probability
from abtest.experiment import EXPERIMENTS
def check_and_switch(exp_name: str):
exp = EXPERIMENTS[exp_name]
counts = fetch_counts(exp_name)
if exp.control not in counts or len(counts) < 2:
return # 数据还不够,继续等
best_variant = max(
(v for v in counts if v != exp.control),
key=lambda v: counts[v]["conversions"] / max(counts[v]["exposures"], 1),
default=None,
)
if not best_variant:
return
p = win_probability(counts[exp.control], counts[best_variant])
if p >= exp.stop_threshold:
# 胜率足够高 → 把最优变体写入线上配置,切换完成
apply_winner(exp, best_variant)
notify(f"实验「{exp.name}」收敛:变体「{best_variant}」胜率 {p:.1%},已自动切换")
apply_winner的实现取决于你的系统——标题就写入文章元数据,落地页就更新页面配置,邮件主题就写进发送队列。核心动作只有一个:把胜出变体变成默认版本。 注意两个埋点必须成对出现:resolve_content返回哪个版本,就要立即写入一条exposure事件;conversion事件则沿用上一篇的转化埋点。缺了exposure,贝叶斯公式里的分母就是错的,判断全废。
cron配置(每6小时):
# crontab -e 添加以下行
0 */6 * * * cd /home/opc/abtest && python3 -m abtest.auto_converge --all >> /var/log/abtest.log 2>&1
六、Before vs After:你省下了什么
| 环节 | 传统做法 | 这套系统 |
|---|---|---|
| 想变体 | 一个人憋标题 | LLM一次生成3-5个候选 |
| 分流 | 手动复制链接、人工统计 | 一致性哈希,零状态零存储 |
| 判断 | 靠感觉/Excel拍板 | 贝叶斯后验概率 |
| 切换 | 手动修改线上配置 | 收敛后自动切换 |
| 盯盘 | 每天刷后台 | 6小时一次自动检查,结果推企业微信 |
你唯一要做的:写experiment.yaml(几十行),生成变体后人肉确认一次,然后等着收通知。
七、进阶思考:为什么是贝叶斯,为什么自动
1. 小样本下贝叶斯比p值诚实。 频率学派要攒够样本才能开口,贝叶斯随时能给出"当前证据下B更好的概率"。流量小的时候这个概率会犹豫——比如52%——这恰恰是对的,它不会骗你说"显著了"。等数据够了,概率自然爬到95%以上。对OPC来说,"没到95%就不切换"就是最稳的决策纪律。
2. "自动"比"准确"重要。 大公司A/B测试的产出是一份报告,因为有人读报告做决策。OPC没人读报告,所以系统必须自己决策。收敛阈值写在配置里,到点自动执行切换,这就是无人值守。上一篇文章说过:OPC的时间只花在系统搭不到的地方。
3. 实验纪律:一次只改一个变量。 系统再自动,也架不住你同时测标题、落地页、价格三个变量——赢了也不知道是哪个赢的。自动化的前提是实验本身干净。每个实验只动一个变量,这是你在设计阶段要守住的底线。
八、总结与下一步
今天搭完了3层自动化A/B系统:
- Layer 1:experiment.yaml定义实验 + LLM生成变体,一个人也能有5个候选
- Layer 2:一致性哈希分流,同一用户全程只看到一个版本,零状态零存储
- Layer 3:复用上一篇的events表,Beta-Bernoulli贝叶斯收敛,胜率超95%自动切换
现在你的OPC系统能"自己试错、自己收敛、自己切换"了。但还有一个问题:这套系统跑在服务器上,挂了怎么办?深夜数据库连接池满了,谁起来处理?
下一篇预告:从996到007——OPC的无人值守运维体系
文章、客服、交付、分析、优化全都自动化之后,系统本身就是你的员工。但员工会生病,服务器会宕机。下一篇,我们搭建OPC的无人值守运维体系:健康检查、自动重启、异常告警、日志轮转——让系统自己照顾自己,你才能真正睡个好觉。
关于作者:魏无记,AI 和数智化实践者。专注 Agent 工程化与 Loop Engineering 研究以及数智化转型。公众号持续更新 Agent 工程化实战系列和数智化转型相关知识实践——每篇都是保姆级教程照做就行。

浙公网安备 33010602011771号