13.1 运营 SOP 与例会机制

运营 SOP 与例会机制是把「指标异动 → 谁在多久内发现 → 怎么升级」固定成可编排的节奏表:把投放与变现驱动的关键指标挂到日/周/月三档例会,量化每个指标的发现延迟、SOP 缺口与 owner 负载,配可跑的 Python 仿真与 TypeScript 内核跨语言对拍。

站内《上线变现与长线运营》 那一卷把「活动、赛季、版本列车」的内容节奏写透了——节点运营日历、内容产能排期、生命周期退场都在那里。站内游戏产业专题 的 81 篇则覆盖了客服、社区、团队、外包、排期、复盘等组织面。本节的定位与两者都不同:它只做由投放与变现驱动的那部分 SOP——把「每天的消耗、CPI、ROAS、eCPM、填充率、对账差异」这些花钱与收钱的指标,编排成一张有节奏、有 owner、有升级动作的表,并把它量化成「发现延迟」。内容节奏看卷三,组织分工看 game/industry,本节只回答一件事:这些指标,最迟会在第几天被人发现?

例会不是开会,是一套延迟控制器

外行把例会理解成「定期同步进度」。操盘手看它是另一回事:例会是把「指标异动」压缩进可接受延迟的一种控制器。一个指标从「变坏」到「被人发现」之间的时间,叫发现延迟;延迟越长,亏损累积越多。而延迟的下界,直接由检查节奏决定——日报能把延迟压到 1 天,周报只能到 7 天,月报到 30 天。

这解释了一个反直觉的现象:同样的异常,放在不同节奏的例会上,代价差一个数量级。D7 ROAS 崩了,如果它挂在日会上,第二天就有人止损;如果它挂在月会上,等你发现时已经烧了 30 天的预算。所以 SOP 设计的第一性问题不是「开不开会」,而是——哪些指标必须进日会、哪些可以进周会、哪些只能进月会,以及有没有指标被漏掉了。

SOP 是一张「指标 × 节奏 × owner」的表

把 SOP 写成数据,它就是一个三列的东西:指标(投放/变现驱动的关键量)、节奏(挂在哪档例会)、owner(谁负责看、谁负责升级)。合并成表就是:

节奏周期典型指标单次时长升级动作
每日投放站会1 天日消耗、CPI15 分钟超阈值即停投 / 降预算
周度变现复盘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/,节奏表算出来的结果是(同机相对量级,绝对数值不可移植):

指标域发现延迟(天)
日消耗 spendua1
单安装成本 cpiua1
D7 ROAS roasua7
eCPMmonetize7
填充率 fillmonetize7
ARPDAUmonetize30
结算对账差异 settlefinance30
创意疲劳度 fatigueua无节点(缺口)

这张表暴露了两个真相。第一,最慢被发现的是月度指标——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 客服、社区与舆情的分工边界 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「minigame」更多文章

  1. 附录 F:全书公式、参数与数据集索引
  2. 9.3 同意管理与数据出境
  3. 9.2 COPPA 与儿童向游戏的限制