卷三的目录 已经把「怎么把用户买进来」这件事拆过一遍:渠道机制、素材设计框架、增长预算分配都在那 51 篇里。本节不重做那套框架,只做一件卷三没有落到后台细节的事——投放后台本身的四层层级结构。卷三告诉你「该买什么样的量」,本节告诉你「这些钱在后台是怎么被一层层挂上去、又怎么沿树往下摊的」。两者是「策略」与「结构」的分工,不重复。
一个新手打开投放后台,看到的第一样东西往往不是数据,而是一棵树:最上面是账户,往下是计划,再往下是广告组,最底下是创意。这四个词听起来像行政层级,但它们真正的含义是预算与定向的作用域:钱挂在哪一层,定向设在哪一层,素材换在哪一层,各自能改什么、不能改什么,全由这棵树决定。把树理解错,后面所有优化都会踩空。
站内还有一篇小游戏行业与团队的观察 ,那是从组织与协作角度看这门生意。本节是纯工程视角:不聊团队怎么分工,只聊投放后台这棵树的每个节点各自承担什么、预算怎么往下分。
四层层级各管什么
把四个层级摆在一起,每一层的作用域和颗粒度就清楚了。关键认知是:预算的挂载点与定向的挂载点常常不在同一层,这正是新手最容易混淆的地方。
| 层级 | 管什么 | 颗粒度 | 预算通常挂在哪 | 改动代价 |
|---|---|---|---|---|
| 账户 | 资质、结算、总预算上限、投放主体 | 全局 | 日预算上限 | 最高,动它等于动整盘 |
| 计划(Campaign) | 投放目标、优化目标、总预算、排期 | 目标维度 | 计划日预算 | 高,改目标会重置学习 |
| 广告组(Ad Group) | 定向、出价、版位、单组预算 | 人群/出价维度 | 广告组日预算 | 中,改定向/出价触发重新学习 |
| 创意(Creative) | 素材本身:视频/图片/文案/落地页 | 素材维度 | 不单独挂预算 | 低,换素材最安全 |
一个常见的误解是「创意也能单独设预算」。绝大多数后台里创意不挂预算——它只是广告组下可替换的素材槽位,预算在广告组层面统一花。所以想让某个素材多花钱,靠的不是给素材加预算,而是把它放进一个预算更充足的广告组。这就是为什么「素材测试」在结构上其实是「广告组测试」。
另一条规律:越往上层动,代价越大。改账户资质要重新审核;改计划目标会让系统重新进入学习期;改广告组定向同样重置学习;唯独换创意代价最低,随时可换。层级结构本质上是一张「改动代价表」,它告诉你优化应该优先在哪一层动手。
预算沿树怎么摊
既然预算挂在计划与广告组两层,那么「账户的总预算如何落到每个广告组」就是一道可算的题。用整数公式表达,一切都清楚了(预算单位统一用「分」,避免浮点):
每广告组日预算 = 计划日预算 // 广告组数
每创意日预算 = 每广告组日预算 // 创意数
计划残差 = 计划日预算 - 每广告组日预算 × 广告组数
账户残差 = 账户日预算 - Σ 各计划日预算
是否进学习期 = 每广告组日预算 ≥ 学习期门槛
注意这里用的是地板除:整除不尽的部分不会凭空消失,它变成「残差」留在上一层,而不会自动补给下一层。这看起来是个小细节,但它解释了一个真实现象——为什么「平均分预算」常常让某些广告组差一点点就够不到学习期门槛:残差被截断了。
S 层仿真:把账户预算沿树摊下去
下面这段是 S 层仿真脚本 sim/o01_hierarchy.py,是完整可运行文件。它读 config/o01_hierarchy.json 的三层计划结构,按整数公式把账户日预算一路摊到广告组与创意,标出哪些广告组够得到学习期门槛,并产出 JSON + CSV + SVG。
#!/usr/bin/env python3
# file: sim/o01_hierarchy.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/o01_hierarchy.py --config config/ --out out/
"""投放账户四层层级(账户 → 计划 → 广告组 → 创意)的预算分摊仿真。
对 config/o01_hierarchy.json 的每个计划:
perAdGroupFen = campaign.dailyBudgetFen // nAdGroups # 每广告组日预算(分)
perCreativeFen = perAdGroupFen // nCreatives # 每创意日预算(分)
aboveFloor = perAdGroupFen >= minAdGroupBudgetFen # 是否够进学习期
层级本身是结构(D),但「预算怎么沿树往下摊、摊到哪一层就不够进学习期」是
可算的量。全程整数、地板除,无浮点。产出 out/o01_hierarchy.json + .csv + .svg。
层级字段名、平台限额与学习期门槛属「公开可观察机制」口径的建模假设;真实后台
字段与限额需真实业务环境,命令行不可复现(X)。
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET
def evaluate(cfg: dict):
floor = cfg["minAdGroupBudgetFen"]
campaigns, adgroups = [], []
for c in cfg["campaigns"]:
ags = c["adGroups"]
n_ag = len(ags)
per_ag = c["dailyBudgetFen"] // n_ag
n_cr_total = 0
for g in ags:
n_cr = len(g["creatives"])
n_cr_total += n_cr
per_cr = per_ag // n_cr
adgroups.append({
"campaignId": c["id"], "id": g["id"], "name": g["name"],
"budgetFen": per_ag, "nCreatives": n_cr, "perCreativeFen": per_cr,
"aboveFloor": per_ag >= floor,
})
campaigns.append({
"id": c["id"], "name": c["name"], "budgetFen": c["dailyBudgetFen"],
"nAdGroups": n_ag, "nCreatives": n_cr_total, "perAdGroupFen": per_ag,
"allocatedFen": per_ag * n_ag,
"residualFen": c["dailyBudgetFen"] - per_ag * n_ag,
})
return campaigns, adgroups
def digest(adgroups: list[dict]) -> str:
return ";".join(
f'{g["id"]}:{g["budgetFen"]}:{g["nCreatives"]}:{g["perCreativeFen"]}'
f':{1 if g["aboveFloor"] else 0}' for g in adgroups)
def draw_svg(path: str, adgroups: list[dict], floor: int) -> int:
"""纯 Python SVG:每个广告组一根横条(日预算分),画一条学习期门槛虚线。"""
W, PAD, ROW = 760, 210, 34
H = 64 + ROW * len(adgroups)
root = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W),
"height": str(H), "viewBox": f"0 0 {W} {H}"})
ET.SubElement(root, "rect", {"width": str(W), "height": str(H), "fill": "#ffffff"})
ET.SubElement(root, "text", {"x": "16", "y": "28", "font-size": "15",
"fill": "#111827"}).text = (
"《合成小岛》各广告组日预算分摊(分)· 虚线=学习期门槛")
span = W - PAD - 24
hi = max([g["budgetFen"] for g in adgroups] + [floor]) or 1
xf = PAD + span * floor // hi
ET.SubElement(root, "line", {"x1": str(xf), "y1": "44", "x2": str(xf), "y2": str(H - 16),
"stroke": "#ef4444", "stroke-dasharray": "4 4"})
ET.SubElement(root, "text", {"x": str(xf + 4), "y": "58", "font-size": "11",
"fill": "#ef4444"}).text = f"门槛 {floor}"
for i, g in enumerate(adgroups):
y = 56 + i * ROW
w = span * g["budgetFen"] // hi
color = "#2563eb" if g["aboveFloor"] else "#f59e0b"
ET.SubElement(root, "text", {"x": "16", "y": str(y + 16), "font-size": "11",
"fill": "#374151"}).text = g["id"]
ET.SubElement(root, "rect", {"x": str(PAD), "y": str(y + 4), "width": str(w),
"height": "18", "rx": "3", "fill": color})
ET.SubElement(root, "text", {"x": str(PAD + w + 6), "y": str(y + 17), "font-size": "11",
"fill": "#6b7280"}).text = (
f'{g["budgetFen"]} 分 · {g["nCreatives"]} 创意 · 每创意 {g["perCreativeFen"]} 分')
os.makedirs(os.path.dirname(path), exist_ok=True)
ET.ElementTree(root).write(path, encoding="utf-8", xml_declaration=True)
return len(ET.tostring(root))
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config/")
ap.add_argument("--out", default="out/")
args = ap.parse_args()
with open(os.path.join(args.config, "o01_hierarchy.json"), encoding="utf-8") as f:
cfg = json.load(f)
campaigns, adgroups = evaluate(cfg)
total_campaign_fen = sum(c["budgetFen"] for c in campaigns)
total_allocated_fen = sum(c["allocatedFen"] for c in campaigns)
account_residual_fen = cfg["accountDailyBudgetFen"] - total_campaign_fen
above = sum(1 for g in adgroups if g["aboveFloor"])
dg = digest(adgroups)
os.makedirs(args.out, exist_ok=True)
svg_bytes = draw_svg(os.path.join(args.out, "o01_hierarchy.svg"), adgroups,
cfg["minAdGroupBudgetFen"])
report = {
"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
"accountName": cfg["accountName"], "accountDailyBudgetFen": cfg["accountDailyBudgetFen"],
"minAdGroupBudgetFen": cfg["minAdGroupBudgetFen"],
"campaignCount": len(campaigns), "adGroupCount": len(adgroups),
"creativeCount": sum(g["nCreatives"] for g in adgroups),
"adGroupsAboveFloor": above,
"totalCampaignFen": total_campaign_fen, "totalAllocatedFen": total_allocated_fen,
"accountResidualFen": account_residual_fen,
"campaigns": campaigns, "adGroups": adgroups, "digest": dg,
"svg": os.path.join(args.out, "o01_hierarchy.svg"), "svgBytes": svg_bytes,
}
with open(os.path.join(args.out, "o01_hierarchy.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
with open(os.path.join(args.out, "o01_hierarchy.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(("campaignId", "id", "name", "budgetFen", "nCreatives",
"perCreativeFen", "aboveFloor"))
for g in adgroups:
w.writerow([g["campaignId"], g["id"], g["name"], g["budgetFen"],
g["nCreatives"], g["perCreativeFen"], g["aboveFloor"]])
print(json.dumps({
"campaignCount": report["campaignCount"], "adGroupCount": report["adGroupCount"],
"creativeCount": report["creativeCount"], "adGroupsAboveFloor": above,
"accountResidualFen": account_residual_fen,
"perAdGroupFen": [g["budgetFen"] for g in adgroups], "digest": dg,
}, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
层级结构与门槛参数放在 config/o01_hierarchy.json——注意这里的数字是「公开可观察机制」口径的建模假设,不是任何投放后台的实测:真实字段名、真实限额与真实学习期规则属于 X 层,需真实业务环境,命令行不可复现。
{
"version": 1,
"game": "merge-island",
"observedAt": "2026-10",
"accountName": "merge-island-main",
"accountDailyBudgetFen": 300000,
"minAdGroupBudgetFen": 20000,
"note": "投放账户四层层级(账户/计划/广告组/创意)的预算分摊参数,口径是「公开可观察机制」的建模假设,不是任何投放后台实测。accountDailyBudgetFen=账户日预算(分);minAdGroupBudgetFen=单广告组进学习期的日预算下限(分)。真实后台字段名、限额与学习期规则由平台定义,需真实业务环境,命令行不可复现(X)。全整数,无浮点。",
"campaigns": [
{ "id": "c1", "name": "合成-信息流-冷启动", "dailyBudgetFen": 120000, "adGroups": [
{ "id": "g11", "name": "合并爽感-视频", "creatives": [ { "id": "cr11", "name": "连锁合并15s" }, { "id": "cr12", "name": "失败重试8s" } ] },
{ "id": "g12", "name": "装扮解锁-视频", "creatives": [ { "id": "cr13", "name": "装扮解锁10s" } ] } ] },
{ "id": "c2", "name": "合成-激励视频-放量", "dailyBudgetFen": 100000, "adGroups": [
{ "id": "g21", "name": "离线收益-视频", "creatives": [ { "id": "cr21", "name": "离线翻倍12s" }, { "id": "cr22", "name": "挂机爆产6s" } ] },
{ "id": "g22", "name": "关卡挑战-视频", "creatives": [ { "id": "cr23", "name": "限时关卡9s" }, { "id": "cr24", "name": "三星通关7s" } ] },
{ "id": "g23", "name": "节日皮肤-图片", "creatives": [ { "id": "cr25", "name": "节日皮肤静态" } ] } ] },
{ "id": "c3", "name": "合成-联盟-补充", "dailyBudgetFen": 50000, "adGroups": [
{ "id": "g31", "name": "通用-图片", "creatives": [ { "id": "cr31", "name": "主视觉A" }, { "id": "cr32", "name": "主视觉B" }, { "id": "cr33", "name": "主视觉C" } ] } ] }
]
}
在本机跑 /opt/homebrew/bin/python3 sim/o01_hierarchy.py --config config/ --out out/,摊下来的结果(同机相对量级,绝对数值不可移植):
| 广告组 | 所属计划 | 日预算(分) | 创意数 | 每创意(分) | 过门槛 |
|---|---|---|---|---|---|
| g11 合并爽感 | c1 | 60000 | 2 | 30000 | 是 |
| g12 装扮解锁 | c1 | 60000 | 1 | 60000 | 是 |
| g21 离线收益 | c2 | 33333 | 2 | 16666 | 是 |
| g22 关卡挑战 | c2 | 33333 | 2 | 16666 | 是 |
| g23 节日皮肤 | c2 | 33333 | 1 | 33333 | 是 |
| g31 通用 | c3 | 50000 | 3 | 16666 | 是 |
这张表暴露了两个结构问题。第一,计划的广告组数直接稀释了单组预算:c1 只有 2 个组,每组能拿 60000;c2 有 3 个组,每组只剩 33333——同样的计划预算,组越多每组越薄。第二,账户残差 30000 分被闲置:账户日预算 300000,三个计划预算之和只有 270000,差额 30000 既没挂到计划也没进学习,是一笔沉默的空转预算。模型不替你做这个决定,它只把残差如实报出来。
P 层内核:跨语言对拍,证明整除口径没写错
光看一张表,你没法确认「是结构对」还是「碰巧算对」。本节用一份 P 层 TypeScript 内核 core/src/o01/hierarchy.ts 复算同一棵树:同样的地板除、同样的门槛判定、同样的残差口径。它读同一份 config/o01_hierarchy.json,产出和 Python 逐字符一致的摘要。
// file: core/src/o01/hierarchy.ts
// tier: P
// verify: node --test 'core/test/o01_hierarchy.test.ts'
//
// 账户四层层级预算分摊内核:与 sim/o01_hierarchy.py 逐位等价的 TypeScript 对偶实现。
// 读同一份 config/o01_hierarchy.json;全整数、地板除、无浮点;只用可擦除语法(禁 enum / namespace)。
export interface Creative { readonly id: string; readonly name: string; }
export interface AdGroup { readonly id: string; readonly name: string; readonly creatives: readonly Creative[]; }
export interface Campaign { readonly id: string; readonly name: string; readonly dailyBudgetFen: number; readonly adGroups: readonly AdGroup[]; }
export interface HierarchyConfig {
readonly version: number; readonly game: string; readonly accountDailyBudgetFen: number;
readonly minAdGroupBudgetFen: number; readonly campaigns: readonly Campaign[];
}
export interface CampaignResult {
readonly id: string; readonly name: string; readonly budgetFen: number;
readonly nAdGroups: number; readonly nCreatives: number; readonly perAdGroupFen: number;
readonly allocatedFen: number; readonly residualFen: number;
}
export interface AdGroupResult {
readonly campaignId: string; readonly id: string; readonly name: string;
readonly budgetFen: number; readonly nCreatives: number; readonly perCreativeFen: number;
readonly aboveFloor: boolean;
}
export function evaluate(cfg: HierarchyConfig): { campaigns: CampaignResult[]; adGroups: AdGroupResult[] } {
const floor = cfg.minAdGroupBudgetFen;
const campaigns: CampaignResult[] = [];
const adGroups: AdGroupResult[] = [];
for (const c of cfg.campaigns) {
const nAg = c.adGroups.length;
const perAg = Math.floor(c.dailyBudgetFen / nAg);
let nCrTotal = 0;
for (const g of c.adGroups) {
const nCr = g.creatives.length;
nCrTotal += nCr;
adGroups.push({
campaignId: c.id, id: g.id, name: g.name, budgetFen: perAg, nCreatives: nCr,
perCreativeFen: Math.floor(perAg / nCr), aboveFloor: perAg >= floor,
});
}
campaigns.push({
id: c.id, name: c.name, budgetFen: c.dailyBudgetFen, nAdGroups: nAg, nCreatives: nCrTotal,
perAdGroupFen: perAg, allocatedFen: perAg * nAg, residualFen: c.dailyBudgetFen - perAg * nAg,
});
}
return { campaigns, adGroups };
}
/** 跨语言对拍摘要:与 sim/o01_hierarchy.py 的 digest 逐字符一致。 */
export function digest(adGroups: readonly AdGroupResult[]): string {
return adGroups.map((g) => `${g.id}:${g.budgetFen}:${g.nCreatives}:${g.perCreativeFen}:`
+ `${g.aboveFloor ? 1 : 0}`).join(";");
}
配套测试 core/test/o01_hierarchy.test.ts 把 Python 落盘的摘要写成金标准,断言 TS 内核复算结果逐字符相等,并顺手验整除、门槛与残差。在本机跑 node --test 'core/test/o01_hierarchy.test.ts',4 个用例全过;两套独立实现给出同一个摘要:
digest = g11:60000:2:30000:1;g12:60000:1:60000:1;g21:33333:2:16666:1;g22:33333:2:16666:1;g23:33333:1:33333:1;g31:50000:3:16666:1
只要有一边的整数语义、门槛方向或残差口径写错,摘要立刻分叉。这和卷二「一份逻辑、两种宿主」 是同一个信念:同一个数,在 Python 仿真与 TS 内核里必须长得一模一样。
X 层:真实后台结构不可复现
必须把边界划清。上面算的是模型侧:给定一棵树和一组预算,钱怎么摊、谁够门槛。而下面这些属于 X 层,需真实业务环境,命令行不可复现:真实后台的字段命名与层级深度(不同平台层数与命名各异);真实的账户/计划/广告组限额(单账户日预算上限、单计划最小预算、单组最大预算);真实的学习期门槛(需要多少次转化才「跑稳」,由平台算法定义且常变);真实的审核与资质约束(改账户级配置要重审)。
工程上正确的做法是:把可复现的「结构模型」与不可复现的「平台规则」分开——结构模型用来做「如果这样分预算会怎样」的推演,平台规则靠真实后台去核对。这也是为什么下一节要把「出价方式」单独拎出来:出价挂在广告组层,是这棵树上改动代价中等、但对成本影响最直接的一层。
自检表
| 检查项 | 合格标准 |
|---|---|
| 层级清晰 | 账户/计划/广告组/创意四层各自的作用域写在案上 |
| 预算挂载点明确 | 知道预算挂在计划与广告组,创意不单独挂预算 |
| 整除口径统一 | 每层预算用地板除,残差如实报出,不偷偷补下传 |
| 门槛可判定 | 每个广告组都标了是否够到学习期门槛 |
| 改动代价可见 | 越上层改动代价越大,优化优先从低代价层动手 |
| 边界清晰 | 真实后台字段与限额标 X,不写成「实测」 |
层级是「静态结构」,但结构决定了钱怎么花。真正让成本动起来的是下一层的选择:同一批广告组,用 oCPM 还是 CPA,钱会流向完全不同的组。下一节我们把出价方式折叠成同一把尺子,看预算该分给谁。
阅读导航:上一节:目录 · 下一节:1.2 出价方式与预算分配 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。