用 Claude Code 做 A/B 测试:面向 SaaS 与博客变现的实战指南
用 Claude Code 设计假设、事件、服务端分流、SQL 分析、隐私同意与回滚,安全优化 SaaS 和博客变现。
先写假设,再写代码
A/B 测试不是把页面随机切成两个版本那么简单。对 SaaS 来说,它要回答免费注册、付费意向、试用转化是否真的改善;对博客来说,它要回答广告、联盟链接、邮件订阅和咨询入口是否带来长期收益,而不是只多了几个点击。Claude Code 很擅长快速生成切换逻辑,但如果没有假设、事件口径、样本量、护栏指标和回滚规则,最后看到的数字很可能不能用于决策。
先把术语说清楚。Variant 是“被比较的版本”。Exposure 是“用户第一次看到某个版本,并被记录下来”。Guardrail metric 是“不能变差的安全指标”,例如 LCP、错误率、付费链接点击率。False positive 是“随机波动看起来像胜利”,常见原因是样本太小、每天偷看结果、同时比较太多版本。
给 Claude Code 的第一条提示词应该从业务问题开始:
为 Next.js App Router 的 SaaS/博客做 A/B 测试。
目标是变现,不是虚荣点击。
实验 ID: pricing_page_offer_2026_06
假设: 把价格页 CTA 从 "Start free trial" 改为 "Start with the free plan",会提高注册开始率,同时不降低付费意向点击。
主要指标: signup_start_rate
护栏指标: purchase_link_click_rate, p75 LCP, JavaScript error rate
请输出: 事件 schema、服务端分流、Cookie/localStorage 注意事项、BigQuery 风格 SQL、Playwright 验证、发布和回滚清单。
实际使用时至少准备三个场景:SaaS 价格页 CTA、博客联盟链接位置、newsletter 注册表单、咨询预约入口、onboarding 步骤。每个场景都要同时写主要指标和护栏指标。功能开关的基础可以参考Claude Code 功能标志实现,埋点体系可以参考Claude Code 分析实现。
| 场景 | 主要指标 | 护栏指标 | 常见失败 |
|---|---|---|---|
| SaaS 价格页 CTA | 注册开始率 | 付费意向点击、错误率、LCP | 注册多了,但真正愿意付费的人少了 |
| 博客广告/联盟区块 | 商品链接点击率 | 阅读完成率、跳出率、速度 | 收益区块太靠前,破坏信任 |
| 邮件订阅表单 | 订阅完成率 | 垃圾注册率、退订率 | 数量上升但名单质量下降 |
| Onboarding 页面 | 首次成功率 | 支持请求、激活质量 | 短期完成率掩盖后续流失 |
先固定事件 Schema
A/B 测试最贵的错误,是上线后才发现数据无法汇总。一个点击如果有时叫 button_click,有时叫 ctaClicked,有时叫 signup_click,分析阶段就会变成人工清洗。让 Claude Code 先输出类型化事件契约,再写 UI。
如果使用 Google Analytics,请先看官方的 GA4 事件参考 和 Google tag 参数参考。自定义参数想在报表中使用,通常还要在 GA4 中配置自定义维度或指标。
// lib/experiment-events.ts
export type ExperimentId = "pricing_page_offer_2026_06";
export type VariantId = "control" | "free_plan_copy";
export type ExperimentEvent =
| {
event_name: "experiment_exposure";
experiment_id: ExperimentId;
variant: VariantId;
anonymous_id: string;
page_path: string;
}
| {
event_name: "cta_click";
experiment_id: ExperimentId;
variant: VariantId;
anonymous_id: string;
cta_id: "pricing_primary" | "article_bottom" | "sidebar_offer";
page_path: string;
}
| {
event_name: "purchase_link_click";
experiment_id: ExperimentId;
variant: VariantId;
anonymous_id: string;
product_id: string;
value_usd: number;
page_path: string;
}
| {
event_name: "guardrail_metric";
experiment_id: ExperimentId;
variant: VariantId;
anonymous_id: string;
metric_name: "lcp_ms" | "js_error" | "bounce";
value: number;
page_path: string;
};
declare global {
interface Window {
gtag?: (command: "event", name: string, params: Record<string, unknown>) => void;
}
}
export function trackExperimentEvent(event: ExperimentEvent) {
if (typeof window === "undefined") return;
window.gtag?.("event", event.event_name, {
experiment_id: event.experiment_id,
variant: event.variant,
anonymous_id: event.anonymous_id,
page_path: event.page_path,
...event,
});
}
不要把邮箱、姓名、公司名或用户输入原文放进事件。涉及广告或分析存储时,应在发送 tag 之前处理同意状态。Google 的同意模式指南说明了默认同意状态和更新方式。对变现实验来说,同意不是上线后的补丁,而是实验设计的一部分。
用 Next.js Route Handler 做服务端分流
只用 localStorage 分流很方便,但会带来首屏闪烁、登录前后版本变化、隐身模式清空、浏览器阻止存储、机器人流量不稳定等问题。MDN 把 localStorage 定义为按 origin 保存且可跨浏览器会话保留的数据,参考 MDN localStorage。这不等于它适合做首屏版本来源。
在 Next.js App Router 中,可以先用 Route Handler 实现服务端分配。官方 route.ts 文档说明 Route Handler 使用 Web Request/Response API 处理请求。Cookie 写入可以使用 NextResponse。如果需要在边缘层改写请求,还要注意 Next.js 16 已将 Middleware 改名为 Proxy,参考 proxy.js 文档。
// app/api/experiments/assign/route.ts
import { NextRequest, NextResponse } from "next/server";
export const runtime = "edge";
type Variant = "control" | "free_plan_copy";
const EXPERIMENTS = {
pricing_page_offer_2026_06: {
cookieName: "ab_pricing_page_offer_2026_06",
variants: [
{ id: "control", weight: 50 },
{ id: "free_plan_copy", weight: 50 },
] satisfies Array<{ id: Variant; weight: number }>,
},
};
function hashToBucket(input: string) {
let hash = 2166136261;
for (let index = 0; index < input.length; index += 1) {
hash ^= input.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return Math.abs(hash) % 100;
}
function chooseVariant(experimentId: keyof typeof EXPERIMENTS, anonymousId: string): Variant {
const experiment = EXPERIMENTS[experimentId];
const bucket = hashToBucket(`${experimentId}:${anonymousId}`);
let cumulative = 0;
for (const variant of experiment.variants) {
cumulative += variant.weight;
if (bucket < cumulative) return variant.id;
}
return experiment.variants[0].id;
}
export async function GET(request: NextRequest) {
const experimentId = request.nextUrl.searchParams.get("experiment");
if (experimentId !== "pricing_page_offer_2026_06") {
return NextResponse.json({ error: "Unknown experiment" }, { status: 404 });
}
const experiment = EXPERIMENTS[experimentId];
const testAnonymousId = request.headers.get("x-test-anonymous-id");
const existingCookie = request.cookies.get(experiment.cookieName)?.value;
const anonymousId = testAnonymousId ?? existingCookie ?? crypto.randomUUID();
const variant = chooseVariant(experimentId, anonymousId);
const response = NextResponse.json({
experimentId,
variant,
anonymousId,
});
response.cookies.set(experiment.cookieName, anonymousId, {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
maxAge: 60 * 60 * 24 * 30,
});
return response;
}
Cookie 也不是万能答案。MDN 的安全 Cookie 配置指南解释了 Secure、HttpOnly、SameSite。会员 SaaS 可以在登录后使用哈希化用户 ID;公开博客更适合短期匿名 Cookie;涉及广告时要以 CMP 和地区规则为准。
用功能开关控制发布和回滚
实验和发布要分开。代码可以先上线,但流量比例必须可以从 0%、10%、50% 到 100% 调整。Vercel 用户可以评估官方 Vercel Flags,自建项目则可以先用配置文件。
# config/experiments.yaml
experiments:
pricing_page_offer_2026_06:
status: running
owner: masa
hypothesis: "Free-plan copy increases signup starts without hurting paid intent."
allocation_percent: 50
variants:
control: 50
free_plan_copy: 50
primary_metric: signup_start_rate
guardrails:
- purchase_link_click_rate
- p75_lcp_ms
- js_error_rate
rollback:
if_js_error_rate_increases_by: 0.02
if_p75_lcp_ms_worse_by_ms: 300
action: "set allocation_percent to 0 and keep logging exposure for audit"
回滚条件要在实验开始前写好。新版本如果让错误率升高、LCP 变慢、付费意向下降,就先把流量设为 0,并保留曝光记录用于复盘。不要看到短期好结果就直接推到 100%。
用 SQL 分析,但不要被假阳性骗到
分析要从 exposure 开始。没有看过实验版本的用户不应进入分母;同一用户看过多个 variant 时,应排除或调查。下面的查询用于汇总转化和护栏,不是自动判断赢家的统计引擎。BigQuery 官方的 SAFE_DIVIDE 文档可以避免除以零导致报表失败。
-- BigQuery Standard SQL
WITH exposure_raw AS (
SELECT
anonymous_id,
experiment_id,
ARRAY_AGG(variant ORDER BY event_timestamp LIMIT 1)[OFFSET(0)] AS variant,
MIN(event_timestamp) AS first_exposed_at,
COUNT(DISTINCT variant) AS variant_count
FROM `project.dataset.events`
WHERE event_name = 'experiment_exposure'
AND experiment_id = 'pricing_page_offer_2026_06'
GROUP BY anonymous_id, experiment_id
),
exposure AS (
SELECT anonymous_id, experiment_id, variant, first_exposed_at
FROM exposure_raw
WHERE variant_count = 1
),
events_after_exposure AS (
SELECT
e.variant,
e.anonymous_id,
ev.event_name,
ev.value_usd,
ev.value_ms
FROM exposure e
LEFT JOIN `project.dataset.events` ev
ON ev.anonymous_id = e.anonymous_id
AND ev.experiment_id = e.experiment_id
AND ev.event_timestamp >= e.first_exposed_at
)
SELECT
variant,
COUNT(DISTINCT anonymous_id) AS exposed_users,
COUNT(DISTINCT IF(event_name = 'cta_click', anonymous_id, NULL)) AS cta_users,
SAFE_DIVIDE(
COUNT(DISTINCT IF(event_name = 'cta_click', anonymous_id, NULL)),
COUNT(DISTINCT anonymous_id)
) AS cta_click_rate,
COUNT(DISTINCT IF(event_name = 'purchase_link_click', anonymous_id, NULL)) AS purchase_intent_users,
SAFE_DIVIDE(
COUNT(DISTINCT IF(event_name = 'purchase_link_click', anonymous_id, NULL)),
COUNT(DISTINCT anonymous_id)
) AS purchase_intent_rate,
AVG(IF(event_name = 'guardrail_metric' AND value_ms IS NOT NULL, value_ms, NULL)) AS avg_guardrail_ms,
SUM(IF(event_name = 'guardrail_metric' AND value_usd IS NOT NULL, value_usd, 0)) AS revenue_proxy_usd
FROM events_after_exposure
GROUP BY variant
ORDER BY variant;
样本量要在实验前确定。每天偷看结果,看到新版本领先就停止,会显著增加假阳性。多版本、多分段、事后更换主要指标、和广告投放同时开始,也会让结论失真。让 Claude Code 输出“最小样本、观察周期、排除规则、停止条件、复盘日期”,比只让它算一个 p 值更可靠。
用 Playwright 验证机制
上线前至少确认三件事:同一匿名 ID 分到同一版本;未知实验 ID 返回错误;变现 CTA 只渲染一次。Playwright 官方文档介绍了 test 和 expect,以及会自动重试的断言。
// tests/experiments.spec.ts
import { test, expect } from "@playwright/test";
test.describe("pricing_page_offer_2026_06", () => {
test("keeps assignment stable for the same anonymous id", async ({ request, baseURL }) => {
const url = `${baseURL}/api/experiments/assign?experiment=pricing_page_offer_2026_06`;
const headers = { "x-test-anonymous-id": "demo-user-42" };
const first = await request.get(url, { headers });
const second = await request.get(url, { headers });
expect(first.ok()).toBeTruthy();
expect(second.ok()).toBeTruthy();
expect(await first.json()).toMatchObject(await second.json());
});
test("rejects unknown experiments", async ({ request, baseURL }) => {
const response = await request.get(`${baseURL}/api/experiments/assign?experiment=missing`);
expect(response.status()).toBe(404);
});
test("renders one monetization CTA on the pricing page", async ({ page }) => {
await page.goto("/pricing?e2e_anonymous_id=demo-user-42");
await expect(page.getByTestId("pricing-cta")).toBeVisible();
await expect(page.getByTestId("pricing-cta")).toHaveCount(1);
});
});
这个测试不证明收入增长,只证明实验基础没有坏。基础坏了,后面的统计分析就没有意义。
隐私、运营和实际结果
变现实验会处理用户行为数据,所以规则要写清楚:不发送个人信息;尊重分析和广告同意;Cookie 被拒绝时有默认版本;记录负责人、开始日期、结束日期、假设、指标、护栏和回滚步骤;失败实验也要留下结论,避免下次重复踩坑。
Masa 把这套流程用于文章 CTA 检查时,最有价值的不是漂亮的报表,而是上线前的事件表。先决定哪些点击算变现 CTA、什么时候记录曝光、同一读者进入多个版本怎么办,Claude Code 的返工明显减少。一次小型测试中,Playwright 的同一 ID 检查发现了 localStorage 首屏闪烁,于是改回服务端 Cookie 分流。
如果要继续把 Claude Code 用到真实收入漏斗,可以看Claude Code 培训和产品模板。重点不是制造更多实验,而是让每个实验都能安全回答一个业务问题。
免费 PDF: Claude Code 速查表
输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。
我们会妥善保护你的信息,不发送垃圾邮件。
把 Claude Code 变成真正能带来结果的工作流
先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。
关于作者
Masa
专注 Claude Code 实务流程、团队导入和内容转化的工程师。
相关文章
Claude Code权限安全阶梯:逐步放开访问而不失控
从只读到有限编辑、验证命令和部署检查的 Claude Code 权限升级流程。
Claude Code 小PR证据包:让小改动真正可审查
用差异、验证命令、公开URL、CTA路径和回滚说明,把Claude Code的小PR变得可审查。
Claude Code 提交前 Review Gate:同时检查差异、测试、公开 URL 和 CTA
提交前用 Claude Code 审查差异范围、build、公开 URL、Gumroad 链接、咨询 CTA、缺少测试和无关文件。