站内《上线变现与长线运营》 那一卷把「活动、赛季、版本列车」的内容节奏写透了——节点运营日历、内容产能排期、生命周期退场都在那里。站内游戏产业专题 的 81 篇则覆盖了客服、社区、团队、外包、排期、复盘等组织面。本节的定位与两者都不同:它只做由投放与变现驱动的那部分 SOP——把「每天的消耗、CPI、ROAS、eCPM、填充率、对账差异」这些花钱与收钱的指标,编排成一张有节奏、有 owner、有升级动作的表,并把它量化成「发现延迟」。内容节奏看卷三,组织分工看 game/industry,本节只回答一件事:这些指标,最迟会在第几天被人发现?
例会不是开会,是一套延迟控制器
外行把例会理解成「定期同步进度」。操盘手看它是另一回事:例会是把「指标异动」压缩进可接受延迟的一种控制器。一个指标从「变坏」到「被人发现」之间的时间,叫发现延迟;延迟越长,亏损累积越多。而延迟的下界,直接由检查节奏决定——日报能把延迟压到 1 天,周报只能到 7 天,月报到 30 天。
这解释了一个反直觉的现象:同样的异常,放在不同节奏的例会上,代价差一个数量级。D7 ROAS 崩了,如果它挂在日会上,第二天就有人止损;如果它挂在月会上,等你发现时已经烧了 30 天的预算。所以 SOP 设计的第一性问题不是「开不开会」,而是——哪些指标必须进日会、哪些可以进周会、哪些只能进月会,以及有没有指标被漏掉了。
SOP 是一张「指标 × 节奏 × owner」的表
把 SOP 写成数据,它就是一个三列的东西:指标(投放/变现驱动的关键量)、节奏(挂在哪档例会)、owner(谁负责看、谁负责升级)。合并成表就是:
| 节奏 | 周期 | 典型指标 | 单次时长 | 升级动作 |
|---|---|---|---|---|
| 每日投放站会 | 1 天 | 日消耗、CPI | 15 分钟 | 超阈值即停投 / 降预算 |
| 周度变现复盘 | 7 天 | ROAS、eCPM、填充率 | 60 分钟 | 调底价 / 换网络 / 调频次 |
| 月度经营会 | 30 天 | ARPDAU、结算对账差异 | 120 分钟 | 改分成模型 / 追对账差异 |
这张表的价值在于它可被证伪:只要把指标和节点填进去,「有没有指标没人看」这件事就变成了一个能算出来的数——SOP 缺口。下面这段整数公式把节奏表量化:
metricLatencyDays(m) = min{ periodDays(n) : 节点 n 检查指标 m } # 无节点 → -1(缺口)
covered = #{ m : latency(m) >= 0 }
gap = #{ m : latency(m) < 0 }
worstLatencyDays = max{ latency(m) : latency(m) >= 0 } # 最慢被发现
escalationNodes = #{ n : n.escalate }
ownerLoadMin(o) = Σ_{节点 n 属 o} (monthDays // periodDays(n)) * durationMin(n)
metricLatencyDays 取最小值而不是平均值:只要有一个日会节点在盯这个指标,它就最多 1 天被发现,其余更慢的节点不改变这个事实。ownerLoadMin 则是例会成本——它把「这个人一个月要开多少分钟的会」算出来,防止 SOP 设计得漂亮却没人扛得住。
S 层仿真:把节奏表算到底
下面这段是 S 层仿真脚本 sim/o13_sop_rhythm.py。它读 config/o13_sop.json 的三档节奏与七个 SOP 节点,算出每个指标的发现延迟、SOP 缺口、最慢发现时间与每个 owner 的月度会议负载,产出 JSON + CSV + SVG。
#!/usr/bin/env python3
# file: sim/o13_sop_rhythm.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/o13_sop_rhythm.py --config config/ --out out/
"""运营 SOP 与例会机制仿真:把「指标 → 检查节点 → 例会节奏」编排成一张可量化的节奏表。
对 config/o13_sop.json:
metricLatencyDays(m) = 检查 m 的所有节点里 periodDays 的最小值;无节点 → -1(SOP 缺口)
covered = latency >= 0 的指标数
gap = latency < 0 的指标数
worstLatencyDays = covered 指标里的最大 latency(最慢被发现的时间)
escalationNodes = escalate=true 的节点数
ownerLoadMin(o) = Σ_{节点属 o} (monthDays // periodDays) * durationMin # 月度会议分钟
口径:真实团队编制、真实例会安排、真实升级动作需真实业务环境,命令行不可复现(X)。
产出 out/o13_sop_rhythm.json + .csv + .svg。全整数、地板除,无浮点。
"""
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, "o13_sop.json"), encoding="utf-8") as f:
return json.load(f)
def evaluate(cfg: dict) -> dict:
cad = {c["id"]: c for c in cfg["cadences"]}
latencies = []
for m in cfg["metrics"]:
days = [cad[n["cadence"]]["periodDays"] for n in cfg["nodes"] if n["metric"] == m["id"]]
latencies.append({"id": m["id"], "name": m["name"], "domain": m["domain"],
"latencyDays": min(days) if days else -1})
covered = sum(1 for r in latencies if r["latencyDays"] >= 0)
gap = sum(1 for r in latencies if r["latencyDays"] < 0)
worst = max((r["latencyDays"] for r in latencies if r["latencyDays"] >= 0), default=0)
escalations = sum(1 for n in cfg["nodes"] if n["escalate"])
load: dict[str, int] = {}
for n in cfg["nodes"]:
per = cfg["monthDays"] // cad[n["cadence"]]["periodDays"]
load[n["owner"]] = load.get(n["owner"], 0) + per * cad[n["cadence"]]["durationMin"]
return {"latencies": latencies, "covered": covered, "gap": gap,
"worstLatencyDays": worst, "escalations": escalations, "ownerLoadMin": load}
def draw_svg(path: str, load: dict, worst: int, gap: int) -> int:
"""纯 Python SVG:每个 owner 一根横条 = 月度会议分钟。"""
items = sorted(load.items(), key=lambda kv: (-kv[1], kv[0]))
W, H, PAD, ROW = 720, 74 + 40 * len(items), 150, 40
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": "28", "font-size": "15",
"fill": "#111827"}).text = (
f"例会机制负载与 SOP 缺口(最慢发现 {worst} 天 / 缺口指标 {gap} 个)")
span = W - PAD - 90
hi = max([v for _, v in items] + [1])
for i, (owner, mins) in enumerate(items):
y = 52 + i * ROW
w = span * mins // hi
ET.SubElement(svg, "text", {"x": "16", "y": str(y + 20), "font-size": "12",
"fill": "#374151"}).text = owner
ET.SubElement(svg, "rect", {"x": str(PAD), "y": str(y + 6), "width": str(w),
"height": "18", "rx": "3", "fill": "#0ea5e9"})
ET.SubElement(svg, "text", {"x": str(PAD + w + 8), "y": str(y + 20), "font-size": "11",
"fill": "#6b7280"}).text = f"{mins} 分/月"
os.makedirs(os.path.dirname(path), exist_ok=True)
ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)
return len(ET.tostring(svg))
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)
r = evaluate(cfg)
owner_load = r["ownerLoadMin"]
max_load = max(owner_load.values()) if owner_load else 0
# 跨语言对拍摘要:core/test/o13_sop.test.ts 复算同一串数字,逐位比对。
digest = (f"nodes={len(cfg['nodes'])},metrics={len(cfg['metrics'])},"
f"covered={r['covered']},gap={r['gap']},worstLatencyDays={r['worstLatencyDays']},"
f"escalations={r['escalations']},maxOwnerLoadMin={max_load}")
os.makedirs(a.out, exist_ok=True)
svg = draw_svg(os.path.join(a.out, "o13_sop_rhythm.svg"), owner_load,
r["worstLatencyDays"], r["gap"])
with open(os.path.join(a.out, "o13_sop_rhythm.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(("metricId", "metricName", "domain", "latencyDays"))
for m in r["latencies"]:
w.writerow((m["id"], m["name"], m["domain"], m["latencyDays"]))
rep = {"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
"monthDays": cfg["monthDays"], "nodeCount": len(cfg["nodes"]),
"metricCount": len(cfg["metrics"]), "latencies": r["latencies"],
"covered": r["covered"], "gap": r["gap"], "worstLatencyDays": r["worstLatencyDays"],
"escalations": r["escalations"], "ownerLoadMin": owner_load, "maxOwnerLoadMin": max_load,
"digest": digest, "csv": os.path.join(a.out, "o13_sop_rhythm.csv"),
"svg": os.path.join(a.out, "o13_sop_rhythm.svg"), "svgBytes": svg}
with open(os.path.join(a.out, "o13_sop_rhythm.json"), "w", encoding="utf-8") as f:
json.dump(rep, f, ensure_ascii=False, indent=2)
print(json.dumps({"digest": digest, "covered": r["covered"], "gap": r["gap"],
"worstLatencyDays": r["worstLatencyDays"], "escalations": r["escalations"],
"ownerLoadMin": owner_load, "maxOwnerLoadMin": max_load},
ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
节奏表放在 config/o13_sop.json。它是纯数据、无浮点:周期用天、时长用分钟、升级动作是布尔。真实团队编制与真实例会安排属于 X 层——需真实业务环境,命令行不可复现。
{
"version": 1,
"game": "merge-island",
"observedAt": "2026-10",
"monthDays": 30,
"note": "运营 SOP 与例会机制编排表。口径是「行业惯例」的建模假设,不是任何团队的真实排班。cadences=三档例会节奏(periodDays=检查周期天数,durationMin=单次时长);metrics=投放/变现驱动的关键指标;nodes=SOP 节点(每个节点把某个指标挂到某档例会、指定 owner,escalate=是否带升级动作)。metricLatencyDays=检查该指标最灵敏节点的周期(越短越早发现),无节点即 SOP 缺口。真实团队编制、真实例会安排、真实升级动作需真实业务环境,命令行不可复现(X)。全整数,无浮点。",
"cadences": [
{ "id": "daily", "name": "每日投放站会", "periodDays": 1, "durationMin": 15 },
{ "id": "weekly", "name": "周度变现复盘", "periodDays": 7, "durationMin": 60 },
{ "id": "monthly", "name": "月度经营会", "periodDays": 30, "durationMin": 120 }
],
"metrics": [
{ "id": "spend", "name": "日消耗", "domain": "ua" },
{ "id": "cpi", "name": "单安装成本", "domain": "ua" },
{ "id": "roas", "name": "D7 ROAS", "domain": "ua" },
{ "id": "ecpm", "name": "eCPM", "domain": "monetize" },
{ "id": "fill", "name": "填充率", "domain": "monetize" },
{ "id": "arpdau", "name": "ARPDAU", "domain": "monetize" },
{ "id": "settle", "name": "结算对账差异", "domain": "finance" },
{ "id": "fatigue", "name": "创意疲劳度", "domain": "ua" }
],
"nodes": [
{ "id": "n1", "name": "消耗与 CPI 日报", "cadence": "daily", "owner": "投手", "metric": "spend", "escalate": true },
{ "id": "n2", "name": "CPI 阈值巡检", "cadence": "daily", "owner": "投手", "metric": "cpi", "escalate": true },
{ "id": "n3", "name": "ROAS 周复盘", "cadence": "weekly", "owner": "投放负责人", "metric": "roas", "escalate": true },
{ "id": "n4", "name": "eCPM 周巡检", "cadence": "weekly", "owner": "变现负责人", "metric": "ecpm", "escalate": false },
{ "id": "n5", "name": "填充率周巡检", "cadence": "weekly", "owner": "变现负责人", "metric": "fill", "escalate": true },
{ "id": "n6", "name": "ARPDAU 月度复盘", "cadence": "monthly", "owner": "数据分析", "metric": "arpdau", "escalate": false },
{ "id": "n7", "name": "结算对账", "cadence": "monthly", "owner": "财务", "metric": "settle", "escalate": true }
]
}
在本机跑 /opt/homebrew/bin/python3 sim/o13_sop_rhythm.py --config config/ --out out/,节奏表算出来的结果是(同机相对量级,绝对数值不可移植):
| 指标 | 域 | 发现延迟(天) |
|---|---|---|
| 日消耗 spend | ua | 1 |
| 单安装成本 cpi | ua | 1 |
| D7 ROAS roas | ua | 7 |
| eCPM | monetize | 7 |
| 填充率 fill | monetize | 7 |
| ARPDAU | monetize | 30 |
| 结算对账差异 settle | finance | 30 |
| 创意疲劳度 fatigue | ua | 无节点(缺口) |
这张表暴露了两个真相。第一,最慢被发现的是月度指标——ARPDAU 与结算对账差异都要 30 天,这本身可以接受(它们本来就是月度口径)。第二,也是真正要命的:「创意疲劳度」这个天天影响素材消耗率的指标,没有任何例会节点在盯——它在三档例会上都是盲区。这正是 SOP 缺口(gap=1)的含义:不是「没人做这件事」,而是「没有一条固定节奏把它接住」,于是它只在出事时才被想起。
| owner | 月度会议负载 |
|---|---|
| 投手 | 900 分/月 |
| 变现负责人 | 480 分/月 |
| 投放负责人 | 240 分/月 |
| 数据分析 | 120 分/月 |
| 财务 | 120 分/月 |
maxOwnerLoadMin=900 说明投手光例会就占掉 15 小时/月(两个日会 × 30 天 × 15 分)——这是 SOP 的成本面。加节点不是免费的,每加一个日会节点,owner 的负载就线性上升。好的 SOP 是在「发现延迟」和「例会成本」之间求平衡,不是节点越多越好。
P 层内核:跨语言对拍,证明节奏表没算错
一张表看不出「是模型对」还是「碰巧看着对」。本套用一份 P 层 TypeScript 内核 core/src/o13/sop.ts 复算同一套节奏逻辑:同样的延迟取最小、同样的缺口识别、同样的 owner 负载口径。它读同一份 config/o13_sop.json,产出与 Python 逐字符一致的摘要。
// file: core/src/o13/sop.ts
// tier: P
// verify: node --test 'core/test/o13_*.test.ts'
//
// 运营 SOP 与例会机制内核:把「指标 → 检查节点 → 例会节奏」编排成节奏表。
// 与 sim/o13_sop_rhythm.py 逐位等价的 TypeScript 对偶实现;读同一份 config/o13_sop.json。
// 全整数、地板除、无浮点、无 Math.random。
export interface Cadence {
readonly id: string; readonly name: string;
readonly periodDays: number; readonly durationMin: number;
}
export interface SopMetric { readonly id: string; readonly name: string; readonly domain: string; }
export interface SopNode {
readonly id: string; readonly name: string; readonly cadence: string;
readonly owner: string; readonly metric: string; readonly escalate: boolean;
}
export interface SopConfig {
readonly version: number; readonly game: string; readonly monthDays: number;
readonly cadences: readonly Cadence[]; readonly metrics: readonly SopMetric[];
readonly nodes: readonly SopNode[];
}
export interface MetricLatency {
readonly id: string; readonly name: string; readonly domain: string; readonly latencyDays: number;
}
export interface SopResult {
readonly latencies: MetricLatency[]; readonly covered: number; readonly gap: number;
readonly worstLatencyDays: number; readonly escalations: number;
readonly ownerLoadMin: Record<string, number>;
}
/** 检查某指标最灵敏节点的周期(天);无节点返回 -1(SOP 缺口)。 */
export function metricLatency(cfg: SopConfig, metricId: string): number {
const days: number[] = [];
for (const n of cfg.nodes) {
if (n.metric !== metricId) continue;
const c = cfg.cadences.find((x) => x.id === n.cadence);
if (c) days.push(c.periodDays);
}
return days.length ? Math.min(...days) : -1;
}
export function evaluate(cfg: SopConfig): SopResult {
const latencies: MetricLatency[] = cfg.metrics.map((m) => ({
id: m.id, name: m.name, domain: m.domain, latencyDays: metricLatency(cfg, m.id),
}));
const covered = latencies.filter((r) => r.latencyDays >= 0).length;
const gap = latencies.filter((r) => r.latencyDays < 0).length;
const worstLatencyDays = latencies.filter((r) => r.latencyDays >= 0)
.reduce((mx, r) => Math.max(mx, r.latencyDays), 0);
const escalations = cfg.nodes.filter((n) => n.escalate).length;
const ownerLoadMin: Record<string, number> = {};
for (const n of cfg.nodes) {
const c = cfg.cadences.find((x) => x.id === n.cadence);
if (!c) continue;
const per = Math.floor(cfg.monthDays / c.periodDays);
ownerLoadMin[n.owner] = (ownerLoadMin[n.owner] ?? 0) + per * c.durationMin;
}
return { latencies, covered, gap, worstLatencyDays, escalations, ownerLoadMin };
}
/** 跨语言对拍摘要:与 sim/o13_sop_rhythm.py 的 digest 逐字符一致。 */
export function digest(cfg: SopConfig, r: SopResult): string {
const maxLoad = Math.max(0, ...Object.values(r.ownerLoadMin));
return `nodes=${cfg.nodes.length},metrics=${cfg.metrics.length},`
+ `covered=${r.covered},gap=${r.gap},worstLatencyDays=${r.worstLatencyDays},`
+ `escalations=${r.escalations},maxOwnerLoadMin=${maxLoad}`;
}
配套测试 core/test/o13_sop.test.ts 把 Python 落盘的摘要写成金标准,断言 TS 内核复算结果逐字符相等,并顺手验缺口识别、延迟取最小与 owner 负载。在本机跑 node --test 'core/test/o13_*.test.ts',6 个用例全过;两套独立实现给出同一个摘要:
ℹ tests 6 ℹ pass 6 ℹ fail 0
digest = nodes=7,metrics=8,covered=7,gap=1,worstLatencyDays=30,escalations=5,maxOwnerLoadMin=900
只要有一边的「取最小还是取平均」「缺口判据」「负载口径」写错,摘要立刻分叉。这和卷二「一份逻辑、两种宿主」 是同一个信念:同一个数,在 Python 仿真、TS 内核、浏览器原型里必须长得一模一样。
X 层:真实排班与升级动作不可复现
边界必须划清。上面算的是编排侧:给定节奏与节点,发现延迟与负载是多少。而下面这些属于 X 层,需真实业务环境,命令行不可复现:
- 真实的团队编制与值班安排(谁真的在看、有没有备份人、休假期间谁顶);
- 真实的升级动作与执行力度(「超阈值即停投」写在纸上容易,真到日会上敢不敢停是另一回事)。
工程上正确的做法是:把可复现的节奏模型与不可复现的组织现实分开——模型层用来回答「如果节点这样排,延迟是多少、缺口在哪」,组织层用来在每周复盘时反标定(把实际发现时间填回表里,模型立刻反映哪条节奏是摆设)。这也是第 16 章操盘手册要把 SOP 一并交付的原因。
自检表
| 检查项 | 合格标准 |
|---|---|
| 指标齐全 | 投放与变现驱动的关键指标都在表里,无遗漏 |
| 缺口显式 | 没有节点接住的指标被算成 gap,不藏着 |
| 升级明确 | 超阈值后的动作写死在节点上,不靠临场发挥 |
| 边界清晰 | 真实排班与执行力度标 X,不写成「实测」 |
SOP 管的是「你自己团队内部」的节奏。但只要游戏上线,问题就会从外面涌进来——玩家投诉、社区争论、舆情发酵。这些事该谁接、接到什么程度、什么时候必须同步给投放和变现,是下一节要划清的分工边界。
阅读导航:上一节:12.3 税务、发票与跨境收款 · 下一节:13.2 客服、社区与舆情的分工边界 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。