13.3 团队、外包与远程协作

团队、外包与远程协作的工程问题只有一个:外包素材产能跟不跟得上投放放量带来的需求。本节把外包有效产能(扣除返工)与线性爬坡的素材需求逐周对齐,算出缺口周数、累计缺口、产能利用率与跨时区重叠窗口,配可跑的 Python 仿真与 TypeScript 内核跨语言对拍。

站内《上线变现与长线运营》 第 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·美术10250‰708
B·动效12300‰8+63
C·视频8200‰6−35
合计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/,逐周匹配的结果是(同机相对量级,绝对数值不可移植):

周需求(条)产能(条)缺口(条)
W012210
W115210
W218210
W321210
W424213
W527216
W630219
W7332112

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 → 回本 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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