离线、影子与在线 A/B
正在读取学习记录…
离线评测是在保存好的请求上运行候选版本,先判断它会给什么结果。影子评测把线上正在发生的输入再送给候选,观察差异,用户仍接收正式版本的结果。在线 A/B 则把不同用户分到不同版本,直接观察他们使用后产生的反馈或业务指标。
以退货规则为例:A允许7天,B允许30天。“收货10天”在A走人工核对,在B生成退款意图。离线能发现这个差异;影子能看到真实流量中它有多常见;用户是否满意、工单是否解决,则需要正确设计的在线对照。
影子版本像坐在副驾驶记录路线的导航员。它能说“我建议右转”,但转方向盘的入口应该由正式执行路径掌握。对带退款、发信、建单等工具的 Agent,复制输入时也要隔离执行器,避免同一请求产生两次业务动作。
先控制执行入口,再谈分组
程序把“生成意图”与“执行意图”拆开。离线和影子都只调用 propose();在线演示才调用 LocalSink.execute()。这个执行器把意图保存在内存字典,方便检查选中了哪一路。
在线分组使用“实验编号+用户ID”的SHA-256摘要映射到0–99,前50个桶给B,其余给A。用户u1出现两次,始终落在B。实验以用户为随机化单位;稳定分组避免同一个人来回体验两套政策。微软实验平台的预实验建议讨论了随机化单位、哈希和实验种子。
保存完整程序并运行
REQUESTS 已准备4条请求、3位用户和收货天数。A/B策略只比较天数上限;所有模式都在本地处理相同数据。在线路径的 local_writes 是保存意图的本地记录数,退款与人工核对意图都会写入。
在 PowerShell 创建并进入文件夹:
mkdir l7-online
cd l7-online把下面完整代码保存为该文件夹的 online_lab.py。数据已经写在程序中,直接保存即可运行。
"""回放退款请求,比较离线、影子与按用户固定分组的本地执行。"""
import hashlib
EXPERIMENT = "refund-policy-v1"
REQUESTS = [
{"request": "r1", "user": "u1", "days": 10},
{"request": "r2", "user": "u2", "days": 40},
{"request": "r3", "user": "u3", "days": 5},
{"request": "r4", "user": "u1", "days": 10},
]
def assignment(user, experiment=EXPERIMENT):
# Python 的 hash() 跨进程可能变化;稳定摘要加实验编号可重现分组。
raw = f"{experiment}:{user}".encode("utf-8")
bucket = int(hashlib.sha256(raw).hexdigest(), 16) % 100
return "B" if bucket < 50 else "A"
def propose(request, variant):
limit = {"A": 7, "B": 30}[variant]
return {"request": request["request"],
"action": "refund" if request["days"] <= limit else "review"}
class LocalSink:
def __init__(self):
self.records = {}
def execute(self, intent):
# 副作用集中在这里:只写本地内存,不包含网络或真实退款接口。
key = intent["request"]
if key in self.records and self.records[key] != intent:
raise ValueError("conflicting intent")
self.records[key] = dict(intent)
def evaluate(mode, requests, sink):
rows = []
for request in requests:
a = propose(request, "A")
b = propose(request, "B")
if mode == "offline":
rows.append({"request": request["request"], "candidate": b["action"]})
elif mode == "shadow":
# 影子候选仅生成意图,执行器根本不会被调用。
rows.append({"request": request["request"], "disagree": a != b})
elif mode == "online":
group = assignment(request["user"])
chosen = a if group == "A" else b
sink.execute(chosen)
rows.append({"request": request["request"], "user": request["user"],
"group": group, "action": chosen["action"]})
else:
raise ValueError("unknown mode")
return rows
def main():
for mode in ("offline", "shadow", "online"):
sink = LocalSink()
rows = evaluate(mode, REQUESTS, sink)
if mode == "offline":
print(f"offline candidates={len(rows)} local_writes={len(sink.records)}")
elif mode == "shadow":
print(f"shadow disagreements={sum(r['disagree'] for r in rows)} "
f"local_writes={len(sink.records)}")
else:
for row in rows:
print(f"online {row['request']} user={row['user']} group={row['group']} "
f"action={row['action']}")
print(f"online local_writes={len(sink.records)} "
f"A_requests={sum(r['group'] == 'A' for r in rows)} "
f"B_requests={sum(r['group'] == 'B' for r in rows)}")
unique = {r["user"]: r["group"] for r in rows}
print(f"online A_users={sum(g == 'A' for g in unique.values())} "
f"B_users={sum(g == 'B' for g in unique.values())}")
if __name__ == "__main__":
main()保存后列出文件,确认能看到 online_lab.py,再运行:
ls
python -X utf8 online_lab.py程序输出:
offline candidates=4 local_writes=0
shadow disagreements=2 local_writes=0
online r1 user=u1 group=B action=refund
online r2 user=u2 group=B action=review
online r3 user=u3 group=A action=refund
online r4 user=u1 group=B action=refund
online local_writes=4 A_requests=1 B_requests=3
online A_users=1 B_users=2逐项读懂结果
离线生成4条B候选,local_writes=0 说明没有调用执行器。它能校验候选行为,但保存好的数据未必代表即将到来的用户。
影子发现2次分歧,对应u1的r1和r4:10天超过A的7天,落在B的30天以内;40天在两组都要核对,5天在两组都符合。执行器仍未调用。
在线的r1、r4同属u1,两次均为B;u2为B、u3为A。r2生成 review,另外三条生成 refund。每次只把选中策略的一份意图写入本地账本,因此共4条记录。
最后分别列出请求数和用户数:A有1次请求、1位用户,B有3次请求、2位用户。50/50桶配置不保证3位用户恰好一半一半。不能把四次请求当成四位互相独立的实验参与者,也不能从这个小数据推断哪个政策更好。
改一处,再看对应结果
把 propose() 中 "B": 30 改成 "B": 7,运行 python -X utf8 online_lab.py。影子分歧从2变0;在线u1仍然属于B,但动作从refund变review。分组机制没有变,策略内容变了。
恢复30后再运行测试。第一项测试给离线/影子传入一个“一调用就报错”的执行器;测试通过意味着这两种模式在代码路径上没有调用它。
三种结果各自回答什么问题
离线能反复回放并定位具体样本,适合先查正确性和回归。影子保留真实输入分布,适合发现格式、负载和差异;它不能直接测量候选对用户的影响,因为用户没有接收候选结果。SageMaker 影子测试文档说明了将请求副本发送给影子版本的方式。
在线A/B需要预先确定用户单位、主要指标、样本量、持续时间及停止规则。实验日志要保存分组、实际曝光的版本和结果,并查样本比例异常:预期50/50而长期出现90/10,可能是路由或日志缺失。仅修改桶比例不会解决分析错误。
重复用户应按用户聚合或使用考虑聚类的统计方法。如果用户共同属于团队、互相影响,可能需要以团队为单位分组。不同组用户彼此分享政策也会产生干扰,分析设计要考虑这种关系。微软的跨产品 A/B 说明强调随机化单位与处理分配。
真实退款接口接入时,执行入口还应检查业务授权、幂等键和审批。影子路径能调用只读工具来比较信息;带写能力的工具必须换成隔离实现。只把影子答案藏起来,没有切断工具调用,仍可能产生重复动作。
保存并运行测试
6 项测试检查离线/影子没有调用执行器、同请求比较、摘要分组稳定、重复用户固定组、唯一策略执行和同请求键去重。
把下面代码保存为同一文件夹的 test_online_lab.py,确认两个文件都在当前目录,运行:
ls
python -X utf8 -m unittest -v test_online_lab.py完整测试:test_online_lab.py
"""检查影子隔离、固定分组与单次执行。"""
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import online_lab as lab
class OnlineTests(unittest.TestCase):
def test_offline_and_shadow_do_not_call_executor(self):
class ForbiddenSink:
def execute(self, intent):
raise AssertionError("executor called")
for mode in ("offline", "shadow"):
self.assertEqual(len(lab.evaluate(mode, lab.REQUESTS, ForbiddenSink())), 4)
def test_shadow_compares_same_requests(self):
rows = lab.evaluate("shadow", lab.REQUESTS, lab.LocalSink())
self.assertEqual([r["disagree"] for r in rows], [True, False, False, True])
def test_assignment_is_stable_and_experiment_specific(self):
for user in ("u1", "u2", "u3"):
self.assertEqual(lab.assignment(user), lab.assignment(user))
expected = int(lab.hashlib.sha256(b"refund-policy-v1:u1").hexdigest(), 16) % 100
self.assertEqual(lab.assignment("u1"), "B" if expected < 50 else "A")
def test_repeated_user_stays_in_same_group(self):
rows = lab.evaluate("online", lab.REQUESTS, lab.LocalSink())
self.assertEqual(rows[0]["group"], rows[3]["group"])
def test_only_assigned_variant_executes(self):
sink = lab.LocalSink()
rows = lab.evaluate("online", lab.REQUESTS, sink)
self.assertEqual(len(sink.records), 4)
for request, row in zip(lab.REQUESTS, rows):
self.assertEqual(sink.records[request["request"]],
lab.propose(request, row["group"]))
def test_duplicate_execution_uses_same_request_key(self):
sink = lab.LocalSink()
lab.evaluate("online", [lab.REQUESTS[0]] * 2, sink)
self.assertEqual(len(sink.records), 1)
with self.assertRaises(ValueError):
sink.execute({"request": "r1", "action": "different"})
if __name__ == "__main__":
unittest.main()继续查文档
读 SageMaker 的影子版本请求流,理解候选如何接收请求副本。再读 微软预实验设计中的随机化单位与分组,结合业务选择用户或团队。
课后练习展开习题收起习题
如果B的用户多次重试,而A用户只请求一次,按请求计算解决率会怎样偏?影子候选的答案不展示给用户时,工具写入是否也自然消失?
正在读取本地记录…
不调用模型,不执行代码。简答、代码和场景题仅保存答案并展示自查标准,不生成评分、徽章或认证。输入内容不会自动保存,请点击提交;不要填写个人信息或密钥。
选择题 · 影子版本如何处理真实请求中的退款动作
影子版本如何处理真实请求中的退款动作?
对应知识点:L7-online
简答题 · 解释机制
离线、影子和在线A/B各用了什么输入、谁执行动作?
对应知识点:L7-online
代码题 · 核对给定数据
用代码比较同一用户的两次分组结果应满足什么?
对应知识点:L7-online
场景题 · 分析具体变化
新版本错误率在一类订单上上升,如何处理实验?
对应知识点:L7-online
描述会发送给本站维护者,用于修订文章,保存90天。请只填写文章问题,省略个人信息、密钥和内部资料。