一个人也能做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有三处不现实:

  1. 流量不够——一篇文章几千阅读,分成两组每组更少,等不到"统计显著",内容热度已经过了
  2. 时间不够——固定周期意味着你必须干等,而OPC的节奏是快速试错
  3. 人不够——没有数据科学家帮你解读结果

所以OPC版A/B测试换三件事:

  • 决策目标:不是"证明B比A好99%",而是"当前流量下,B比A好的概率够不够高,够高就切"
  • 停止规则:不设固定周期,用贝叶斯后验概率实时判断,够了就收敛
  • 执行方式:收敛后自动切换,不用人盯着

一句话:大公司A/B测试是学术实验,OPC的A/B测试是自动决策器。

这套对比的本质是换决策工具:从"拍脑袋"换成"看后验概率"。要做到这一点,事件埋点必须前置——上一篇搭好的events表,要额外记录两件事:用户被分到了哪个变体(exposure事件),以及该用户是否完成目标行为(conversion事件)。埋点字段就两个:experiment和variant,必须和实验配置里的名字严格一致,少一个字段,收敛判断就是错的。


二、整体架构:3层自动化A/B系统

![article20-ab-testing-pipeline: 3层自动化A/B测试架构图,从实验设计、自动分流到贝叶斯收敛自动切换的完整闭环](../diagrams/article20-ab-testing-pipeline.png)

┌──────────────────────────────────────────────┐
│ 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系统:

  1. Layer 1:experiment.yaml定义实验 + LLM生成变体,一个人也能有5个候选
  2. Layer 2:一致性哈希分流,同一用户全程只看到一个版本,零状态零存储
  3. Layer 3:复用上一篇的events表,Beta-Bernoulli贝叶斯收敛,胜率超95%自动切换

现在你的OPC系统能"自己试错、自己收敛、自己切换"了。但还有一个问题:这套系统跑在服务器上,挂了怎么办?深夜数据库连接池满了,谁起来处理?

下一篇预告:从996到007——OPC的无人值守运维体系
文章、客服、交付、分析、优化全都自动化之后,系统本身就是你的员工。但员工会生病,服务器会宕机。下一篇,我们搭建OPC的无人值守运维体系:健康检查、自动重启、异常告警、日志轮转——让系统自己照顾自己,你才能真正睡个好觉。

关于作者:魏无记,AI 和数智化实践者。专注 Agent 工程化与 Loop Engineering 研究以及数智化转型。公众号持续更新 Agent 工程化实战系列和数智化转型相关知识实践——每篇都是保姆级教程照做就行。
posted @ 2026-08-05 21:16  魏无记  阅读(1)  评论(0)    收藏  举报