站内《上线变现与长线运营》 第 13 章讲过内容产能排期——版本列车与内容团队的人力怎么排。站内游戏产业专题 的 81 篇则覆盖了团队组建、外包管理、远程协作的组织面。本节的定位与两者都不同:它只做外包产能如何接进投放节拍这一条线——投放一放量,素材消耗就加速,外包跟不跟得上直接决定你能不能持续放量。内容排期看卷三,组织管理看 game/industry,本节只算一件事:需求涨到第几周,外包产能会不够用?
外包不是「找人干活」,是把产能接进投放节拍
很多团队把外包当成「活多了就找人」。这个理解在投放驱动的产品上是错的。投放素材有一个和内容团队完全不同的特点:它是消耗品。一条素材跑几天就疲劳、就衰减,要不停换新的。这意味着素材需求不是一条平线,而是跟着投放放量一起爬坡的曲线——今天消耗 12 条/周,放量后可能是 30 条/周。
于是外包管理的核心问题从「找人干活」变成了产能与节拍的匹配:外包每周能稳定产出多少(扣除返工)、投放每周要消耗多少(跟着放量涨)、两条曲线的交点在哪一周出现。这个交点就是你的产能墙——撞上它,放量就得停,因为没素材可投了。
有效产能:名义交付要打折
外包给的报价通常是名义交付数(「每周给你 10 条」)。但真实可用的是有效产能——扣掉返工、沟通、修改的部分:
有效产能(a) = weeklyDeliverables(a) × (1000 − reworkPermille(a)) // 1000
需求(w) = baseWeeklyNeed + rampPerWeek × w # w = 0..horizonWeeks-1
缺口周数 = #{ w : 需求(w) > 总产能 }
累计缺口 = Σ_w max(0, 需求(w) − 总产能)
利用率‰ = 总产能 × 1000 // 峰值需求
reworkPermille(返工率)是这里最容易被忽略的参数。它把「名义 10 条」打回「有效 7 条」——返工吃掉的不只是时间,是产能本身。远程协作还会叠加一层:时区重叠小时越少,返工率通常越高(来回问一轮就隔了一天)。所以远程不是免费的,它会在 reworkPermille 上收税。
三个外包,一张产能表
《合成小岛》的素材外包分三条线:美术、动效、视频。它们的名义产能、返工率、时区各不相同:
| 外包 | 名义交付/周 | 返工率 | 有效产能/周 | 时区偏移 | 重叠小时 |
|---|---|---|---|---|---|
| A·美术 | 10 | 250‰ | 7 | 0 | 8 |
| B·动效 | 12 | 300‰ | 8 | +6 | 3 |
| C·视频 | 8 | 200‰ | 6 | −3 | 5 |
| 合计 | 30 | — | 21 | — | 最大 8 |
三条线的有效产能合计只有 21 条/周(名义 30 被打掉 9 条)。而素材需求从 12 条/周起步、每周 +3——第 4 周就会撞上这堵墙。下面这段是 S 层仿真脚本 sim/o13_capacity_plan.py,把这条匹配逐周算出来,产出 JSON + CSV + SVG。
#!/usr/bin/env python3
# file: sim/o13_capacity_plan.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/o13_capacity_plan.py --config config/ --out out/
"""外包素材产能与投放需求节拍匹配仿真:读 config/o13_capacity.json,把「外包有效产能」
与「跟随放量的素材需求曲线」逐周对齐,算出缺口周数、累计缺口、产能利用率与时区重叠窗口。
有效产能(a) = weeklyDeliverables * (1000 - reworkPermille) // 1000
需求(w) = baseWeeklyNeed + rampPerWeek * w # w = 0..horizonWeeks-1
缺口周数 = #{ w : 需求(w) > 总产能 }
累计缺口 = Σ_w max(0, 需求(w) - 总产能)
利用率‰ = 总产能 * 1000 // 峰值需求
口径:真实外包产能、真实返工率、真实时区协作窗口需真实业务环境,命令行不可复现(X)。
产出 out/o13_capacity_plan.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_capacity.json"), encoding="utf-8") as f:
return json.load(f)
def effective_capacity(a: dict) -> int:
return a["weeklyDeliverables"] * (1000 - a["reworkPermille"]) // 1000
def evaluate(cfg: dict) -> dict:
cap = sum(effective_capacity(a) for a in cfg["outsourcers"])
weeks = []
for w in range(cfg["horizonWeeks"]):
need = cfg["demand"]["baseWeeklyNeed"] + cfg["demand"]["rampPerWeek"] * w
weeks.append({"week": w, "need": need, "capacity": cap,
"shortfall": max(0, need - cap)})
gap_weeks = sum(1 for r in weeks if r["shortfall"] > 0)
backlog = sum(r["shortfall"] for r in weeks)
peak_need = max(r["need"] for r in weeks)
utilization = cap * 1000 // peak_need if peak_need else 0
first_short = next((r["week"] for r in weeks if r["shortfall"] > 0), -1)
max_overlap = max(a["overlapHours"] for a in cfg["outsourcers"])
return {"capacity": cap, "weeks": weeks, "gapWeeks": gap_weeks, "backlog": backlog,
"peakNeed": peak_need, "utilizationPermille": utilization,
"firstShortfallWeek": first_short, "maxOverlapHours": max_overlap,
"nominalWeekly": sum(a["weeklyDeliverables"] for a in cfg["outsourcers"])}
def draw_svg(path: str, weeks: list, cap: int) -> int:
"""纯 Python SVG:每周需求柱 + 产能红线。"""
W, H, PAD, BASE, BW = 760, 300, 60, 250, 56
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 投放素材需求(周)"
peak = max([r["need"] for r in weeks] + [cap])
scale = (BASE - 40) / peak if peak else 1
y_cap = BASE - int(cap * scale)
ET.SubElement(svg, "line", {"x1": str(PAD), "y1": str(y_cap), "x2": str(W - 20),
"y2": str(y_cap), "stroke": "#ef4444", "stroke-dasharray": "5 4"})
ET.SubElement(svg, "text", {"x": str(W - 150), "y": str(y_cap - 6), "font-size": "11",
"fill": "#ef4444"}).text = f"产能 {cap}/周"
for r in weeks:
x = PAD + 16 + r["week"] * BW
h = int(r["need"] * scale)
color = "#f97316" if r["shortfall"] > 0 else "#0ea5e9"
ET.SubElement(svg, "rect", {"x": str(x), "y": str(BASE - h), "width": "30", "height": str(h),
"rx": "3", "fill": color})
ET.SubElement(svg, "text", {"x": str(x + 6), "y": str(BASE - h - 5), "font-size": "10",
"fill": "#6b7280"}).text = str(r["need"])
ET.SubElement(svg, "text", {"x": str(x + 6), "y": str(BASE + 16), "font-size": "10",
"fill": "#374151"}).text = f"W{r['week']}"
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)
digest = (f"outsourcers={len(cfg['outsourcers'])},capacity={r['capacity']},"
f"baseNeed={cfg['demand']['baseWeeklyNeed']},peakNeed={r['peakNeed']},"
f"gapWeeks={r['gapWeeks']},backlog={r['backlog']},"
f"utilizationPermille={r['utilizationPermille']},"
f"maxOverlapHours={r['maxOverlapHours']}")
os.makedirs(a.out, exist_ok=True)
svg = draw_svg(os.path.join(a.out, "o13_capacity_plan.svg"), r["weeks"], r["capacity"])
with open(os.path.join(a.out, "o13_capacity_plan.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(("week", "need", "capacity", "shortfall"))
for row in r["weeks"]:
w.writerow((row["week"], row["need"], row["capacity"], row["shortfall"]))
rep = {"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
"horizonWeeks": cfg["horizonWeeks"], "outsourcerCount": len(cfg["outsourcers"]),
"nominalWeekly": r["nominalWeekly"], "capacity": r["capacity"],
"baseWeeklyNeed": cfg["demand"]["baseWeeklyNeed"], "peakNeed": r["peakNeed"],
"gapWeeks": r["gapWeeks"], "backlog": r["backlog"],
"utilizationPermille": r["utilizationPermille"],
"firstShortfallWeek": r["firstShortfallWeek"], "maxOverlapHours": r["maxOverlapHours"],
"weeks": r["weeks"], "digest": digest,
"csv": os.path.join(a.out, "o13_capacity_plan.csv"),
"svg": os.path.join(a.out, "o13_capacity_plan.svg"), "svgBytes": svg}
with open(os.path.join(a.out, "o13_capacity_plan.json"), "w", encoding="utf-8") as f:
json.dump(rep, f, ensure_ascii=False, indent=2)
print(json.dumps({"digest": digest, "capacity": r["capacity"], "peakNeed": r["peakNeed"],
"gapWeeks": r["gapWeeks"], "backlog": r["backlog"],
"utilizationPermille": r["utilizationPermille"],
"firstShortfallWeek": r["firstShortfallWeek"],
"maxOverlapHours": r["maxOverlapHours"]}, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
产能与需求放在 config/o13_capacity.json。真实外包产能、真实返工率、真实时区协作窗口属于 X 层——需真实业务环境,命令行不可复现。
{
"version": 1,
"game": "merge-island",
"observedAt": "2026-10",
"horizonWeeks": 8,
"note": "外包素材产能与投放需求节拍匹配表。口径是「行业惯例」的建模假设,不是任何团队的真实排产。weeklyDeliverables=外包每周名义交付数;reworkPermille=返工率千分比(有效产能 = 名义 × (1000 - 返工率) // 1000);tzOffsetHours=相对本地时区偏移;overlapHours=每日与本地团队的可协作重叠小时。demand.baseWeeklyNeed=首周素材需求;rampPerWeek=每周需求线性增量(跟随投放放量节奏)。真实外包产能、真实返工率、真实时区协作窗口需真实业务环境,命令行不可复现(X)。全整数,无浮点。",
"demand": { "baseWeeklyNeed": 12, "rampPerWeek": 3 },
"outsourcers": [
{ "id": "a1", "name": "外包A·美术", "weeklyDeliverables": 10, "reworkPermille": 250, "tzOffsetHours": 0, "overlapHours": 8 },
{ "id": "a2", "name": "外包B·动效", "weeklyDeliverables": 12, "reworkPermille": 300, "tzOffsetHours": 6, "overlapHours": 3 },
{ "id": "a3", "name": "外包C·视频", "weeklyDeliverables": 8, "reworkPermille": 200, "tzOffsetHours": -3, "overlapHours": 5 }
]
}
在本机跑 /opt/homebrew/bin/python3 sim/o13_capacity_plan.py --config config/ --out out/,逐周匹配的结果是(同机相对量级,绝对数值不可移植):
| 周 | 需求(条) | 产能(条) | 缺口(条) |
|---|---|---|---|
| W0 | 12 | 21 | 0 |
| W1 | 15 | 21 | 0 |
| W2 | 18 | 21 | 0 |
| W3 | 21 | 21 | 0 |
| W4 | 24 | 21 | 3 |
| W5 | 27 | 21 | 6 |
| W6 | 30 | 21 | 9 |
| W7 | 33 | 21 | 12 |
firstShortfallWeek=4:产能墙在第 4 周出现,之后缺口一路扩大,gapWeeks=4、backlog=30(8 周累计差 30 条)。utilizationPermille=636 说明按峰值需求算,外包产能只覆盖了 63.6%——放量越猛,缺口越大。这解释了为什么「外包签了就能放量」是幻觉:产能是周产能,它不随投放需求同比例增长,撞墙是必然的。
P 层内核:跨语言对拍,证明产能匹配没算错
一张表看不出「是模型对」还是「碰巧看着对」。本套用一份 P 层 TypeScript 内核 core/src/o13/capacity.ts 复算同一套产能逻辑:同样的有效产能折扣、同样的逐周匹配、同样的缺口累计。它读同一份 config/o13_capacity.json,产出与 Python 逐字符一致的摘要。
// file: core/src/o13/capacity.ts
// tier: P
// verify: node --test 'core/test/o13_*.test.ts'
//
// 外包素材产能内核:把「外包有效产能」与「跟随放量的素材需求曲线」逐周对齐。
// 与 sim/o13_capacity_plan.py 逐位等价的 TypeScript 对偶实现;读同一份 config/o13_capacity.json。
// 全整数、地板除、无浮点、无 Math.random。
export interface Outsourcer {
readonly id: string; readonly name: string; readonly weeklyDeliverables: number;
readonly reworkPermille: number; readonly tzOffsetHours: number; readonly overlapHours: number;
}
export interface CapacityConfig {
readonly version: number; readonly game: string; readonly horizonWeeks: number;
readonly demand: { readonly baseWeeklyNeed: number; readonly rampPerWeek: number };
readonly outsourcers: readonly Outsourcer[];
}
export interface WeekRow {
readonly week: number; readonly need: number; readonly capacity: number; readonly shortfall: number;
}
export interface CapacityResult {
readonly capacity: number; readonly weeks: WeekRow[]; readonly gapWeeks: number;
readonly backlog: number; readonly peakNeed: number; readonly utilizationPermille: number;
readonly firstShortfallWeek: number; readonly maxOverlapHours: number; readonly nominalWeekly: number;
}
/** 有效产能 = 名义交付 × (1000 - 返工率) // 1000。 */
export function effectiveCapacity(a: Outsourcer): number {
return Math.floor((a.weeklyDeliverables * (1000 - a.reworkPermille)) / 1000);
}
export function evaluate(cfg: CapacityConfig): CapacityResult {
const capacity = cfg.outsourcers.reduce((s, a) => s + effectiveCapacity(a), 0);
const weeks: WeekRow[] = [];
for (let w = 0; w < cfg.horizonWeeks; w += 1) {
const need = cfg.demand.baseWeeklyNeed + cfg.demand.rampPerWeek * w;
weeks.push({ week: w, need, capacity, shortfall: Math.max(0, need - capacity) });
}
const gapWeeks = weeks.filter((r) => r.shortfall > 0).length;
const backlog = weeks.reduce((s, r) => s + r.shortfall, 0);
const peakNeed = weeks.reduce((mx, r) => Math.max(mx, r.need), 0);
const utilizationPermille = peakNeed ? Math.floor((capacity * 1000) / peakNeed) : 0;
const firstShortfallWeek = weeks.find((r) => r.shortfall > 0)?.week ?? -1;
const maxOverlapHours = cfg.outsourcers.reduce((mx, a) => Math.max(mx, a.overlapHours), 0);
const nominalWeekly = cfg.outsourcers.reduce((s, a) => s + a.weeklyDeliverables, 0);
return { capacity, weeks, gapWeeks, backlog, peakNeed, utilizationPermille,
firstShortfallWeek, maxOverlapHours, nominalWeekly };
}
/** 跨语言对拍摘要:与 sim/o13_capacity_plan.py 的 digest 逐字符一致。 */
export function digest(cfg: CapacityConfig, r: CapacityResult): string {
return `outsourcers=${cfg.outsourcers.length},capacity=${r.capacity},`
+ `baseNeed=${cfg.demand.baseWeeklyNeed},peakNeed=${r.peakNeed},`
+ `gapWeeks=${r.gapWeeks},backlog=${r.backlog},`
+ `utilizationPermille=${r.utilizationPermille},maxOverlapHours=${r.maxOverlapHours}`;
}
配套测试 core/test/o13_capacity.test.ts 把 Python 落盘的摘要写成金标准,断言 TS 内核复算结果逐字符相等,并顺手验有效产能折扣、缺口周数与利用率。在本机跑 node --test 'core/test/o13_*.test.ts',6 个用例全过;两套独立实现给出同一个摘要:
ℹ tests 6 ℹ pass 6 ℹ fail 0
digest = outsourcers=3,capacity=21,baseNeed=12,peakNeed=33,gapWeeks=4,backlog=30,utilizationPermille=636,maxOverlapHours=8
只要有一边的有效产能折扣、缺口判据或利用率口径写错,摘要立刻分叉。这与卷二「一份逻辑、两种宿主」 是同一个信念:同一个数,在 Python 仿真、TS 内核、浏览器原型里必须长得一模一样。
X 层:真实产能与协作窗口不可复现
边界必须划清。上面算的是排产侧:给定名义产能、返工率与需求爬坡,缺口在哪一周出现。而下面这些属于 X 层,需真实业务环境,命令行不可复现:
- 真实的外包产能与返工率(报价是名义值,真实交付质量随批次波动,返工率只有合作几轮后才知道);
- 真实的远程协作效率(时区重叠小时只是上界,实际沟通成本还受语言、工具、响应习惯影响);
- 真实的投放放量节奏(需求爬坡曲线本身取决于平台学习期与素材衰减,不是线性的)。
工程上正确的做法是:把可复现的产能模型与不可复现的执行现实分开——模型层用来回答「按这个爬坡,第几周撞墙、要不要提前扩产能」,执行层用来在合作中反标定(把实际返工率与交付数填回 config,模型立刻反映真实产能)。外包与远程最危险的错觉,是把「名义产能」当成「可用产能」——中间隔着一个返工率。
自检表
| 检查项 | 合格标准 |
|---|---|
| 产能打折 | 用有效产能(扣返工),不用名义交付数 |
| 需求建模 | 素材需求跟着放量爬坡,不是一条平线 |
| 撞墙点明确 | 算得出第几周缺口出现、累计缺多少 |
| 远程计价 | 时区重叠与返工率挂钩,远程不当作免费 |
| 边界清晰 | 真实产能与协作效率标 X,不写成「实测」 |
到这一章,运营机制这条线就走完了:SOP 管内部节奏、路由表管事件分派、产能模型管外包节拍。但所有这些动作,最终都要落到钱上——投放花的钱、变现收的钱、外包付的钱,合起来是不是一笔划算的账。下一章我们把这本账从头算一遍:从单个用户的获取成本一路算到回本。
阅读导航:上一节:13.2 客服、社区与舆情的分工边界 · 下一节:14.1 单用户全链路账:CAC → ARPDAU → LTV → 回本 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。