灰度完成率从100%变成50%:五条失败记录怎样消失

0 阅读18分钟

灰度完成率从100%变成50%:五条失败记录怎样消失

B 组完成率 100%,可以扩大 Agent 灰度吗?先把报表查询条件打开:如果一开始只保留“有成功输出载荷”的记录,那么超时、放弃和重试耗尽的任务根本没机会进入分母。这个百分比可以算得完全正确,却回答了另一个问题。

下文用一份作者构造的固定记录,把 B 组的 5/5 复算成 5/10。没有真实用户,没有调用 Agent,也没有上线实验;输入中的任务状态、成本和禁止事件全部预置。程序验证的是哪些记录进入计数,不能证明某个 Agent 变更让业务变好或变差。

先把这次变更要回答的问题写窄

五次成功没变,分母却少了五人

虚构变更是:预约查询 Agent 将工具失败后的重试策略从 A 换成 B。上线前假设是“同一个用户的首次合格任务,在入组后十分钟内确认完成的比例提高至少五个百分点”。这里的五个百分点是演示用的业务最小收益要求,不是统计显著性阈值,也不是行业标准。

随机单位选用户,主指标也按用户计。一个用户在这个实验中只贡献其首次合格任务的一次结果;同一任务重试十次不会变成十个实验单位。首次任务之外的后续任务可以保存作诊断,但不在本例主指标里重复加权。若实际产品关心的是全部任务而非首次任务,就需要另一个事先定义的指标及与用户分桶匹配的分析,不能在读到结果后临时切换。

本例将“合格”预先定义为:已经具备预约查询权限、请求类型受两组共同支持、在使用策略前进入统一入口的首次任务。不能用“B 确实调用了新工具”“最终拿到了回复”这些会受策略影响的条件筛人。固定记录已经压成每个用户一行的合格首次任务;脚本会检查唯一用户,但没有接真实流量做资格判定。

入组使用逻辑分钟 [0,60),每位用户有十分钟结果窗口,统一在第 70 分钟分析。第 60 分钟的入组不算本轮,第 -1 分钟也不算;不能一边看结果一边把边界往有利方向挪。十分钟内有确认成功才进入分子。超时、明确失败、放弃与重试耗尽都仍是入组用户,只是不进成功分子。

十个用户,为什么只剩五个分母

十名入组用户,失败五行仍保留

固定 salt 为 agent-canary-v1,对 salt|user_id 做 SHA-256,取前八字节的大端整数奇偶来分桶。代码对连续的 u01 到 u16 得到 A 六人、B 十人;没有挑选另一个 salt 来凑成八比八。这只是可重跑的确定性分桶演示,不是现实随机化已成功的证明。

B 组的十条记录如下。成本为自造单位;禁止事件为自造计数,并不表示运行中真的发生了越权。

用户最终状态成本单位禁止事件
u01成功200
u04成功200
u05成功200
u06成功200
u11成功200
u12超时200
u13放弃200
u14失败201
u15超时200
u16重试耗尽200

错误查询从成功输出表出发,或将它与分桶表做仅保留成功载荷的连接,最后看到的是前五行。它算出五次成功、五个用户,完成率 100%;u12 到 u16 的五条失败记录在连接时消失了。这里并非把失败偷偷标成成功,而是在计算之前已经失去了失败对象。

以首次合格入组记录为分母,B 组则是五个确认成功、十个入组用户,完成率 50%。A 组四个成功、六个入组用户,为 66.67%。这两个百分比只描述这份人为设定的表,不能当成 B 的真实负效果,更不能推断未来用户收益。

失败记录必须有自己的证据入口。本例每行都有已知终态;成功在入组后两分钟观察到,超时在十分钟,失败在三分钟,放弃在四分钟,重试耗尽在八分钟。这些时间也是作者设定,代码核对分析时已经可见。如果真实系统只有“没收到成功事件”,则首先只能说“尚未确认成功”,还需区分未成熟、采集缺失和已确认失败。固定分母不会自动修复丢失遥测。

一个标准库程序复算两种报表

把同一批记录放回原分母

把代码保存为 canary_denominator_demo.py,用 Python 3.10 或以上版本运行。输入直接列在文件中,方便逐行核对;没有在线随机用户、模型调用或业务服务。

"""Author-constructed records. No users, agents, rollout, or inference."""
from hashlib import sha256

SALT = "agent-canary-v1"
# Constructed relative observation times; failures have terminal evidence too.
OBSERVED_AFTER = {"success": 2, "timeout": 10, "failed": 3, "aborted": 4, "retry_exhausted": 8}
JOIN_START, JOIN_END, OUTCOME_MINUTES, ANALYZE_AT = 0, 60, 10, 70
# user, first eligible minute, terminal status, cost units, prohibited event count
ROWS = [
    ("u01", 1, "success", 20, 0),
    ("u02", 2, "success", 8, 0),
    ("u03", 3, "success", 8, 0),
    ("u04", 4, "success", 20, 0),
    ("u05", 5, "success", 20, 0),
    ("u06", 6, "success", 20, 0),
    ("u07", 7, "success", 8, 0),
    ("u08", 8, "success", 8, 0),
    ("u09", 9, "timeout", 8, 0),
    ("u10", 10, "failed", 8, 0),
    ("u11", 11, "success", 20, 0),
    ("u12", 12, "timeout", 20, 0),
    ("u13", 13, "aborted", 20, 0),
    ("u14", 14, "failed", 20, 1),
    ("u15", 15, "timeout", 20, 0),
    ("u16", 16, "retry_exhausted", 20, 0),
    ("u17", 60, "success", 8, 0),  # outside [0, 60)
    ("u18", -1, "success", 8, 0),  # before window
]


def arm(user):
    return "B" if int.from_bytes(sha256((SALT + "|" + user).encode()).digest()[:8], "big") % 2 else "A"


def summarize(rows, analyze_at):
    if analyze_at < JOIN_END + OUTCOME_MINUTES:
        raise ValueError("enrollment/outcome windows not closed")
    assert len({r[0] for r in rows}) == len(rows)
    cohort = [r for r in rows if JOIN_START <= r[1] < JOIN_END]
    assert all(r[2] in {"success", "timeout", "failed", "aborted", "retry_exhausted"} for r in cohort)
    summary = {}
    for group in ("A", "B"):
        assigned = [r for r in cohort if arm(r[0]) == group]
        assert assigned
        assert all(r[1] + OBSERVED_AFTER[r[2]] <= analyze_at for r in assigned)
        # Broken dashboard: only successful output payloads survive the join.
        payload_rows = [r for r in assigned if r[2] == "success" and OBSERVED_AFTER[r[2]] <= OUTCOME_MINUTES]
        completed = len(payload_rows)
        summary[group] = (completed, len(assigned), sum(r[3] for r in assigned), sum(r[4] for r in assigned))
        print(f"{group}: payload_join={completed}/{len(payload_rows)}; frozen={completed}/{len(assigned)}; "
              f"cost_per_assigned={summary[group][2]/len(assigned):.1f}; prohibited={summary[group][3]}")
    lost = [r[0] for r in cohort if arm(r[0]) == "B" and r[2] != "success"]
    print("B_missing_from_payload_join=" + ",".join(lost))
    assert lost == ["u12", "u13", "u14", "u15", "u16"]
    assert summary == {"A": (4, 6, 48, 0), "B": (5, 10, 200, 1)}
    return summary


def main():
    print("constructed_fixture=true; real_users=0; agent_calls=0")
    print(f"salt={SALT}; unit=user; join=[{JOIN_START},{JOIN_END}); outcome={OUTCOME_MINUTES}; analysis={ANALYZE_AT}")
    summary = summarize(ROWS, ANALYZE_AT)
    assert arm("u01") == arm("u01") == "B"  # retry shares the assigned user bucket
    print("retry_u01_bucket=B; retry_adds_primary_denominator=0")
    gain = summary["B"][0]/summary["B"][1] - summary["A"][0]/summary["A"][1]
    assert gain < 0.05
    print(f"B_minus_A_pp={100*gain:.2f}; business_target_pp=5.00; target_met=false")
    cost_block = summary["B"][2]/summary["B"][1] > 12
    security_block = summary["B"][3] > 0
    assert cost_block and security_block
    print("cost_guardrail_block=true; security_stop=true; expand=false")
    try:
        summarize(ROWS, 69)
    except ValueError:
        print("early_analysis_69=rejected")
    else:
        raise AssertionError("immature window accepted")
    print("statistical_significance=not_computed; causal_effect=not_estimated")


if __name__ == "__main__":
    main()
python3 canary_denominator_demo.py

本次实际运行退出码为 0,stdout 如下:

constructed_fixture=true; real_users=0; agent_calls=0
salt=agent-canary-v1; unit=user; join=[0,60); outcome=10; analysis=70
A: payload_join=4/4; frozen=4/6; cost_per_assigned=8.0; prohibited=0
B: payload_join=5/5; frozen=5/10; cost_per_assigned=20.0; prohibited=1
B_missing_from_payload_join=u12,u13,u14,u15,u16
retry_u01_bucket=B; retry_adds_primary_denominator=0
B_minus_A_pp=-16.67; business_target_pp=5.00; target_met=false
cost_guardrail_block=true; security_stop=true; expand=false
early_analysis_69=rejected
statistical_significance=not_computed; causal_effect=not_estimated

五条被丢弃的 B 记录 ID 被完整打印,便于追溯。retry_u01_bucket=B 表示同一用户在重试时仍按相同用户 ID 查询原桶;程序没有把重试载荷加进主指标记录。它没有测试跨设备身份合并或真实调度器的重试行为。唯一用户和允许终态检查,是为了让这份压缩后的输入保持明确;真实系统仍需从原始事件生成它,而不是复制这些状态。

early_analysis_69=rejected 不是说现有十六行在第 69 分钟还没终态,而是这份事先设定的分析规则要求等整个入组窗口加结果窗口结束。真实实验往往还需更长观测周期、最低样本量与延迟数据水位;这个时间检查不提供统计功效。

主指标之外,失败成本也留在原分母里

护栏也要覆盖失败的入组对象

若只对成功任务算成本,重试失败的消耗同样会消失。因此,本例的成本护栏是“所有入组用户对应首次任务的总成本,除以入组用户数”,包括成功与失败的消耗。A 为 48/6=8,B 为 200/10=20;演示护栏上限设为 12,B 超线。

另一个事先设定的停止条件是禁止事件数量大于零。B 的 u14 有一个构造的禁止事件,因此 security_stop=true。即使主指标达到目标,也不能用收益抵消这项停止条件。真实系统一旦确认严重安全事件,应按实际预案立即停止或隔离;不需要等统计窗口成熟后再保护用户。这里等到分析时打印它,只是有限表格的复算方式。

最后,B 减 A 为 -16.67 个百分点,未达到预设的正五个百分点业务目标,成本护栏和禁止事件也各自阻止扩量。程序因此给出 expand=false。这是一份自设规则在自设记录上的确定结果,不是统计检验,也不具备真实发布授权。

入组表和成功载荷表,职责从起点就不同

首次合格任务的入组记录回答“这位用户有没有进入本次比较”。成功载荷回答“执行过程有没有交付确认成功的结果”。前者应该在策略有机会影响结果之前确定,后者恰恰是待测结果的一部分。若用后者决定前者,就等于让被测策略决定哪些失败还能留在自己的报告里。本文的错误筛选用一个列表表达这件事,并没有真的创建线上数据表。

把这个关系迁移到报表查询时,分析可以从已冻结的入组对象出发,再附加可用的执行事实。没有成功载荷仍应保留用户行,直到按预定规则归入已知失败、未确认完成或待补证据。不能因为某个字段为空就删除整个对象,也不能仅为填满布尔字段把不明原因一律改成失败。分母完整和结果证据完整,是两项不同的要求。

还有一个容易忽略的变化:成功用户也可能从原始事件里出现很多次。同一任务重试、重复上报、一次流式回复的多个片段,都可能使“成功事件数”大于“成功用户数”。本例使用每个用户恰好一条的压缩记录,唯一用户断言只能发现输入已经重复,不能替真实事件流完成去重。真实系统要先声明哪个稳定标识对应主指标对象,再决定同一对象的多个事件如何合并。

如果组织最后选择按任务评估,也应把这项改变写清。一个用户发起十个任务,是否应该比另一个只发起一次任务的用户拥有十倍权重,是指标问题,不由日志行数自动决定。按用户分桶却把所有请求当独立样本做统计,还会混入同一用户内的相关性。本例没有提供这类估计;它特意选首次任务,降低计数对象的歧义。

不要让版本差异变成资格差异

假设新策略 B 增加了一个工具调用,团队为了分析“真正体验过新能力的人”,只保留日志里出现这个工具的用户。A 按设计不会调用它,B 里调用前就超时的人也不会出现。即便过滤条件在 SQL 上合法,两组选择出来的人群也不再对应同一项先验资格。这个虚构例子说明筛选发生在哪一侧的重要性,不是本次实际运行过的新工具流程。

因此,本例将资格限定在策略执行之前,并明确要求两组都支持的请求类型和权限条件。它并不要求任何产品永远分析全部用户;若只对某种请求感兴趣,可以定义更窄的总体,但判定规则必须在两组中有一致含义,且不能因为一个版本的失败路径缺日志就排除它。若需要策略后的诊断切片,也应保留它与主指标总体的差别。

同样,按用户固定 salt 只能让相同标识重复得到同一个桶。用户退出登录后换标识,或同一人跨设备使用不同标识,这个函数并不知道。把它接进真实入口之前,应先明确身份稳定性是由谁保证的。脚本重复查询 u01 并没有模拟登录、设备或账号合并;它验证的是确定性函数,不是生产身份系统。

盐值、分桶函数和策略版本也各自有责任。若实验中途换 salt,某些用户可能换桶;若只更换 B 的策略内容,桶虽然没变,用户经历的处理也已经改变。两者都会影响如何解释前后记录。本文把 salt 和输入版本写在可运行文件里,保持这次复算起点固定,没有模拟配置热更新,更没有从 hash 稳定推出整个实验期间处理恒定。

时间窗口要同时约束入组和观察

第 60 分钟不再入组,不表示所有已入组对象都在这一刻完成观察。刚好靠近窗口尾部的用户仍需要完整的十分钟结果机会。统一第 70 分钟分析,是本例为所有可入组时点留足相同结果窗口的一种保守安排。若把晚入组用户与早入组用户一并在第 60 分钟截断,晚入组对象的成功机会就变短了。

反过来,也不能让某些用户无限等待直到成功,再把它与十分钟超时的用户放到同一个指标里。结果超过窗口是否记为迟到成功,可以另存诊断信息,但本例主指标定义的是十分钟内确认完成。改变这项期限会改变问题本身,需要保留新的口径和解释,而不是静默让失败记录回填到过去报表里。

真实数据还存在事件发生时间和采集到达时间的差别。一个任务可能在规定窗口内完成,但成功事件迟到分析系统。此时应检查预先确定的数据水位与补录方式,再决定报告是否成熟。本文的 OBSERVED_AFTER 是作者直接给定的可见时点,没有建立跨服务时钟或延迟队列,所以它不能检验迟到事件处理是否正确。

暂停扩量也应与停止收集证据分开理解。严重事件触发停止时,可以冻结后续暴露,同时保留已经入组对象的结果记录,避免报告只留下停止之前刚好成功的人。真实系统需要怎样回滚正在执行的动作,取决于动作本身与授权;本例没有执行外部动作,不用一个 expand=false 布尔值冒充已经完成回滚。

分母恢复以后,仍要检查哪些数字碰巧没变

本例 B 的每个用户都被设成成本 20,因此即使删掉五位失败用户,剩下成功用户的成本均值也仍是 20。平均数没有变化,不能证明失败消耗没有丢失。被过滤掉的用户连同它们的成本对象一起消失了,只看成功者均值会看不见这个缺口。这个特意简单的成本设置让读者能手算,不代表真实失败与成功具有相同成本。

对实际重试变更,常见的关注点可能是一次任务所有尝试的合计成本。若日志只记录最后一次成功调用,前面的失败重试仍可能缺失;即使分母按用户保持正确,分子总成本也不完整。所以,固定入组分母是起点,成本归属还需覆盖这个指标承诺的任务范围。本例成本已经被构造成每位用户首次任务的总值,没有验证真实计费事件聚合。

禁止事件也不能只在成功任务里查。u14 在本例属于失败且没有成功载荷,却带有禁止事件;错误连接把它删掉时,报告会同时失去一条失败和这项停止依据。因此,用成功输出表承载全部安全观测会形成明确盲区。真实事件是否已经确认、怎样分级及谁执行隔离,应由实际系统定义;本例只有一个用于复算的预设计数。

修复报表以后,要保留改口径前的解释

把五位用户放回去后,B 的完成率从百分之百变成百分之五十,发生变化的是报告覆盖范围,输入行为没有重新运行。不能把这次下降画成“刚上线又回归”的版本趋势,也不能将修复前后的两次计数当成两批独立实验。应让报告读者看到:同一批入组对象、同一份终态,只是旧查询遗漏了哪些记录。

若后续开展真实实验,冻结文件可以继续保存随机单位、资格、入组窗口、成功期限、主指标及各项护栏的版本。样本量和分析方法则要依据真实决策补齐,不从本例十六个虚构用户推出来。一次确定性复算已经能够证明查询有漏斗,却还不足以估计一个版本在用户总体上的价值。这正是修复计数后仍需保留的下一步边界。

分桶稳定,还不足以证明实验可信

Microsoft ExP 的实验前设计文章讨论了假设、随机单位、护栏及分阶段放量。这里借用的是“先确定观察单位与决策条件”的方法,没有复用其任何产品收益。

按用户分桶不适合所有 Agent。若同一组织共享额度、知识库、队列或操作结果,A 用户的行为可能影响 B 用户;若设备或账号会频繁变化,用户身份也可能不稳定。这些情况下需要按实际共享边界重新选择单位并处理相关性。当前脚本没有模拟这种干扰,固定 hash 更不能证明它不存在。

六比十也不能凭肉眼诊断 Sample Ratio Mismatch。真实实验需要检查与配置比例、样本量相匹配的样本比例异常,并排查分配、执行、连接和过滤环节。Microsoft 的SRM 排障文章特别讨论了筛选与日志处理带来的偏差。本例直接知道五条记录为何消失,展示的是可定位的过滤反例,没有运行 SRM 检验,也没有宣称通过任何实验可信性认证。

若要从这份计数产物继续到真实 A/B 结论,还需在实验前确定样本量、周期、最小可检测效应与分析方案;统计方法必须匹配随机单位以及是否允许中期查看。固定期方案不能看到显著就提前停,有预先设计的序贯方法则应遵守其边界。统计显著仍须与预先选择的业务最小收益一起解释,不能看到一个小 p 值就自动扩量。本文没有计算 p 值、置信区间或因果效应。

读者真正可带走的是这个复算入口:先冻结“谁已经进入实验”,再把成功、失败、成本和停止事件挂回同一批对象。下一份灰度完成率出现 100% 时,先列出没成功的入组用户去了哪里,再讨论是否值得扩大流量。