上一节把《合成小岛》的时间线跑成了一条可复现的曲线,这一节处理另一半——做过的那些决策,现在回看对不对。先说边界:本节不重新论证「引擎该怎么选」「变现该怎么配」这些方法本身,那些分别属于引擎选型评分 、混合变现比例 、包体预算 、难度曲线 、自然增长 与灰度放量 各章。本节只做一件事:把这些岔路口当时的选择、当时的依据、后来的结果摆在一起,量化「当时想对了没有、有没有更好的选项」。
复盘不是检讨,是把岔路口量化
大多数复盘会开成两种:一种是「复盘会 = 甩锅会」,争谁的责任;另一种是「复盘会 = 表扬会」,结论永远是「下次继续努力」。两种都缺同一个东西——可比较的量。要判断一条决策做得好不好,需要三个数:当时的预期(决定时你觉得它能带来多少)、后来的实际(它最终带来了多少)、当时的最优选项(如果选了另一条路,最多能带来多少)。有了这三个数,「决策做得好不好」就变成一次减法,而不是一次表态。
对每条决策,我们算两样东西。预测偏差 predGap = 预期 − 实际:正值说明高估了(以为能拿 120,实际只有 115),负值说明低估了(以为 120,实际 135)。机会成本 regretVsBest = 最优选项 − 实际选择:它衡量的是「当初如果选了别的,能多拿多少」——这才是复盘真正该看的东西,因为一条决策就算预测得准,也可能选错了选项。全部用千分比表示,全整数。
D 层:把决策台账写进配置
下面这份是 D 层(静态层)配置,把七条关键决策做成一份台账:每条给出决策点、备选方案及各方案对北极星的贡献、当时的选择、当时的预期与信心、以及当时的依据。所有贡献值与预期值都是示例假设,真实数值需真实业务环境,命令行不可复现(X)。
{
"version": 1,
"game": "merge-island",
"observedAt": "2026-10",
"note": "关键决策复盘台账(D 层)。每条决策记录:决策点、备选方案及其对北极星的贡献 effectPermille、当时选择 chosen、当时预期 expectedPermille、决策时信心 confidencePermille、当时依据 rationale。chosen 的 effect 即复盘实际贡献。effectPermille 与 expectedPermille 均为示例假设值,真实数值需真实业务环境,命令行不可复现(X)。全整数(千分比),禁浮点。",
"units": {
"effectPermille": "该选项对北极星复合分的贡献(千分比,示例假设)",
"expectedPermille": "决策时的预期贡献(千分比)",
"confidencePermille": "决策时的信心(千分比)"
},
"decisions": [
{
"id": "engine", "point": "引擎选型", "chosen": "引擎+自研适配层",
"expectedPermille": 120, "confidencePermille": 800,
"rationale": "包体预算与平台 API 差异需要一层自己的适配,纯引擎不够、纯自研太重",
"options": [
{ "name": "纯自研", "effectPermille": 90 },
{ "name": "现成引擎", "effectPermille": 110 },
{ "name": "引擎+自研适配层", "effectPermille": 135 }
]
},
{
"id": "monetize", "point": "变现结构", "chosen": "混合(IAA 为主 + 小额 IAP)",
"expectedPermille": 125, "confidencePermille": 700,
"rationale": "轻度用户不付费但看广告,重度用户愿意去广告,两条腿走路比单腿稳",
"options": [
{ "name": "纯 IAA", "effectPermille": 100 },
{ "name": "纯 IAP", "effectPermille": 95 },
{ "name": "混合(IAA 为主 + 小额 IAP)", "effectPermille": 130 }
]
},
{
"id": "package", "point": "包体裁剪", "chosen": "首包精简+分包",
"expectedPermille": 130, "confidencePermille": 850,
"rationale": "首包只留启动必需,其余分包按需加载,换取冷启动时长",
"options": [
{ "name": "全量首包", "effectPermille": 95 },
{ "name": "首包精简+分包", "effectPermille": 125 }
]
},
{
"id": "difficulty", "point": "难度曲线", "chosen": "平滑心流",
"expectedPermille": 120, "confidencePermille": 750,
"rationale": "轻度用户对挫败极敏感,前期必须顺滑,难度只在后期抬",
"options": [
{ "name": "线性递增", "effectPermille": 100 },
{ "name": "平滑心流", "effectPermille": 125 },
{ "name": "陡峭挑战", "effectPermille": 90 }
]
},
{
"id": "growth", "point": "增长方式", "chosen": "自然为主",
"expectedPermille": 118, "confidencePermille": 650,
"rationale": "预算有限,先靠分享与平台自然流量,买量只做小规模验证",
"options": [
{ "name": "重买量", "effectPermille": 105 },
{ "name": "自然为主", "effectPermille": 120 },
{ "name": "自然+小预算买量", "effectPermille": 130 }
]
},
{
"id": "canary", "point": "放量方式", "chosen": "灰度放量",
"expectedPermille": 118, "confidencePermille": 900,
"rationale": "新版本先给小流量观察指标,没问题再逐档放大,出事可回滚",
"options": [
{ "name": "全量上线", "effectPermille": 100 },
{ "name": "灰度放量", "effectPermille": 120 }
]
},
{
"id": "ops", "point": "运营节奏", "chosen": "节点活动",
"expectedPermille": 120, "confidencePermille": 600,
"rationale": "先把节假日节点活动跑通,赛季结构留到有产能时再上",
"options": [
{ "name": "无活动", "effectPermille": 90 },
{ "name": "节点活动", "effectPermille": 115 },
{ "name": "赛季+节点", "effectPermille": 130 }
]
}
]
}
每条决策里,chosen 那个选项的 effectPermille 就是复盘实际值——比如引擎选型选了「引擎+自研适配层」,它的实际贡献是 135。expectedPermille 是当时预期(120),confidencePermille 是当时的信心(800‰,说明当时也没底)。其余选项给出「如果当初选它」的贡献,用来算机会成本。
S 层:预测偏差与机会成本
下面这段是 S 层脚本,读上面这份台账,对每条决策算预测偏差、机会成本与校准率,再汇总成「决策质量分」;最后做一次对照——把所有实际效果按 ±15% 缩放,看「最大机会成本的决策」排名是否稳定。
#!/usr/bin/env python3
# file: sim/l14_decisions.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/l14_decisions.py --config config/ --out out/
"""关键决策复盘评分:读 config/l14_decisions.json。
每条决策有若干备选方案,各带对北极星的贡献 effectPermille;chosen 的贡献即复盘实际值。
对每条决策算三样东西:
- 预测偏差 predGap = expected - chosenEffect(正=高估,负=低估);
- 机会成本 regretVsBest = bestEffect - chosenEffect(当初若选最优能多拿多少);
- 校准率 alignment = 1000 - |predGap|*1000/chosenEffect。
汇总得到「决策质量分」= 1000 - ΣregretVsBest*1000/ΣbestEffect。
再做一次对照:把每条决策的 chosen 效果按 ±15% 缩放,看「最大机会成本决策」排名是否稳定。
产出 JSON + CSV + SVG。effect/expected 均为示例假设值,真实数值需真实业务环境,命令行不可复现(X)。
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET
def load(d: str) -> dict:
with open(os.path.join(d, "l14_decisions.json"), encoding="utf-8") as f:
return json.load(f)
def evaluate(dec: dict) -> dict:
opts = dec["options"]
chosen = next(o for o in opts if o["name"] == dec["chosen"])
actual = chosen["effectPermille"]
best = max(o["effectPermille"] for o in opts)
best_name = max(opts, key=lambda o: o["effectPermille"])["name"]
pred_gap = dec["expectedPermille"] - actual
regret = best - actual
alignment = 1000 - abs(pred_gap) * 1000 // actual if actual > 0 else 0
return {"id": dec["id"], "point": dec["point"], "chosen": dec["chosen"],
"chosenEffect": actual, "bestOption": best_name, "bestEffect": best,
"expected": dec["expectedPermille"], "predGap": pred_gap, "regretVsBest": regret,
"alignmentPermille": alignment, "confidencePermille": dec["confidencePermille"]}
def rank_regret(rows: list, scale_permille: int) -> list:
"""按 chosen 效果缩放 scale_permille‰ 后,机会成本从大到小排序的决策 id。"""
scored = []
for r in rows:
actual = r["chosenEffect"] * scale_permille // 1000
best = r["bestEffect"] * scale_permille // 1000
scored.append((r["id"], best - actual))
scored.sort(key=lambda kv: (-kv[1], kv[0]))
return [i for i, _ in scored]
def draw_svg(path: str, rows: list) -> int:
W, PAD, ROW = 720, 240, 40
H = 60 + ROW * len(rows)
svg = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W),
"height": str(H), "viewBox": f"0 0 {W} {H}"})
ET.SubElement(svg, "rect", {"width": str(W), "height": str(H), "fill": "#ffffff"})
ET.SubElement(svg, "text", {"x": "16", "y": "26", "font-size": "15", "fill": "#111827"}).text = \
"关键决策:预期 vs 复盘实际(千分比,示例假设)"
maxv = max(max(r["expected"], r["chosenEffect"], r["bestEffect"]) for r in rows) or 1
scale = (W - PAD - 120) // maxv
for i, r in enumerate(rows):
y = 46 + i * ROW
ET.SubElement(svg, "text", {"x": "16", "y": str(y + 22), "font-size": "12",
"fill": "#374151"}).text = r["point"]
# 预期条(浅)与 实际条(深)叠放
we = r["expected"] * scale
wa = r["chosenEffect"] * scale
ET.SubElement(svg, "rect", {"x": str(PAD), "y": str(y + 4), "width": str(max(we, 2)),
"height": "10", "rx": "2", "fill": "#93c5fd"})
ET.SubElement(svg, "rect", {"x": str(PAD), "y": str(y + 18), "width": str(max(wa, 2)),
"height": "10", "rx": "2", "fill": "#2563eb"})
ET.SubElement(svg, "text", {"x": str(PAD + max(wa, we) + 8), "y": str(y + 22),
"font-size": "11", "fill": "#6b7280"}).text = \
f"预期 {r['expected']} / 实际 {r['chosenEffect']}"
ET.SubElement(svg, "text", {"x": str(PAD), "y": str(H - 8), "font-size": "12",
"fill": "#374151"}).text = "上条=当时预期,下条=复盘实际"
os.makedirs(os.path.dirname(path), exist_ok=True)
ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)
return os.path.getsize(path)
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config/")
ap.add_argument("--out", default="out/")
a = ap.parse_args()
cfg = load(a.config)
rows = [evaluate(d) for d in cfg["decisions"]]
total_actual = sum(r["chosenEffect"] for r in rows)
total_best = sum(r["bestEffect"] for r in rows)
total_regret = total_best - total_actual
total_abs_gap = sum(abs(r["predGap"]) for r in rows)
quality = 1000 - total_regret * 1000 // total_best
pred_bias = total_abs_gap * 1000 // total_actual
by_regret = sorted(rows, key=lambda r: (-r["regretVsBest"], r["id"]))
worst = by_regret[0]
# 对照:缩放 ±15%,看最大机会成本决策是否仍是同一个
scale_ranks = {s: rank_regret(rows, s) for s in (850, 1000, 1150)}
stable = all(v[0] == worst["id"] for v in scale_ranks.values())
os.makedirs(a.out, exist_ok=True)
sb = draw_svg(os.path.join(a.out, "l14_decisions.svg"), rows)
with open(os.path.join(a.out, "l14_decisions.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["id", "point", "chosen", "expected", "chosenEffect", "bestOption",
"bestEffect", "predGap", "regretVsBest", "alignmentPermille", "confidencePermille"])
for r in rows:
w.writerow([r["id"], r["point"], r["chosen"], r["expected"], r["chosenEffect"],
r["bestOption"], r["bestEffect"], r["predGap"], r["regretVsBest"],
r["alignmentPermille"], r["confidencePermille"]])
rep = {"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
"decisionCount": len(rows), "totalActualPermille": total_actual,
"totalBestPermille": total_best, "totalRegretPermille": total_regret,
"qualityScore": quality, "predictionBiasPermille": pred_bias,
"worstRegretDecision": worst["id"], "worstRegretPoint": worst["point"],
"worstRegretPermille": worst["regretVsBest"],
"regretRankingStableAcrossScale": stable,
"regretRankingByScale": scale_ranks,
"perDecision": rows, "svgBytes": sb}
with open(os.path.join(a.out, "l14_decisions.json"), "w", encoding="utf-8") as f:
json.dump(rep, f, ensure_ascii=False, indent=2)
print(json.dumps({"qualityScore": quality, "totalActual": total_actual, "totalBest": total_best,
"totalRegret": total_regret, "predictionBiasPermille": pred_bias,
"worst": worst["id"], "worstRegret": worst["regretVsBest"],
"stableAcrossScale": stable,
"ranking": [r["id"] for r in by_regret]}, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
在本机跑它,输出如下(同机相对量级,绝对数值不可移植):
{"qualityScore": 973, "totalActual": 870, "totalBest": 895, "totalRegret": 25, "predictionBiasPermille": 44, "worst": "ops", "worstRegret": 15, "stableAcrossScale": true, "ranking": ["ops", "growth", "canary", "difficulty", "engine", "monetize", "package"]}
七条决策的实际贡献合计 870‰,如果每条都选最优能到 895‰,机会成本合计 25‰,于是决策质量分 = 973‰——也就是说,这七条岔路口我们拿下了九成七,剩下 2.7% 丢在「当初可以选得更好」上。预测偏差率是 44‰(平均每条决策的预期和实际差了约 5.6‰),说明当时的判断相当准,没有系统性高估也没有系统性低估。
三个真正该记住的结论
第一,决策质量 ≠ 预测准确。七条决策里,预测偏差最大的是引擎选型(预期 120、实际 135,低估了 15),但它没有机会成本——因为当时就选了最优的那个选项(135)。反过来,运营节奏的预测偏差只有 5(预期 120、实际 115),看着挺准,但它机会成本最大(15‰):因为当时只上了节点活动(115),而「赛季+节点」(130)才是最优解。预测准不准,和选得对不对,是两码事——复盘要看后者。
第二,机会成本集中在一两条决策上。按机会成本排序是 ops(15) > growth(10) > 其余全为 0。也就是说,25‰ 的机会成本里,运营节奏占 15、增长方式占 10,其余五条一分没丢。这个结论很实用:下次资源紧张时,最该补的短板是运营节奏(把赛季结构补上)和增长方式(在自然增长上叠小预算买量),而不是去动那些已经做对的决策。
第三,结论稳不稳要看对照。把七条决策的实际效果统一按 850‰ / 1000‰ / 1150‰ 三档缩放,最大机会成本决策始终是 ops(regretRankingStableAcrossScale 为 true)。这说明「运营节奏是最大短板」不是被某一组具体数字凑出来的,换个量级结论不变——这正是复盘结论该有的稳健性。反过来,如果换个缩放结论就翻盘,那这条结论就不该写进复盘纪要。
小结与下一步
这一节把七条关键决策做成了一份可评分的台账:每条算预测偏差与机会成本,汇总出决策质量分 973‰,再用 ±15% 缩放确认结论稳定。三件事记住就够——预测准不代表选得对,复盘要看机会成本;机会成本往往集中在少数几条决策上;任何复盘结论都要做一次量级对照,稳了才写进纪要。
到这里,「怎么做的」和「做得对不对」都盘完了,还剩最后一环——做完之后,数据到底动了没有。下一节把上线后的留存、ARPDAU 逐周铺开,看每次迭代到底拉动了哪条曲线、每投入一天的产出是多少。
阅读导航:上一节:14.1 立项到上线的时间线 · 下一节:14.3 数据表现与迭代 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。