站内有一篇广告 SDK 接入与瀑布流聚合 ,那是单一引擎(Defold)口径的实现笔记:怎么把某个网络的 SDK 挂上去、回调怎么写、瀑布怎么配。本节的定位不同——它讲的是聚合这件事本身的架构:一次广告请求进来,中介层、适配器层、网络 SDK 层各管什么,瀑布流在算术上等价于什么。还有一篇广告变现设计清单 是清单式短文,列了该做什么;这一节给你一台能算账的机器:改一个填充率,立刻看到当日收入怎么变。三者分工清楚,不重复。
前四章我们处理的是「钱怎么花出去、怎么归因」:投放后台、出价放量、素材工业化、移动归因。但收入的另一半——钱怎么收回来——落在变现侧。而变现侧的第一块地基,就是聚合层:你几乎不可能只接一家广告网络,因为单一网络的填充率撑不满你的广告位。聚合层就是那个「一次请求、多家网络、择优填充」的中间件。
聚合层到底解决什么问题
先看一个朴素的问题:假设你只接了穿山甲一家,它在某次请求上的填充率是 72%。那么每 100 次请求里,有 28 次是空着的——广告位亮着却没内容,这就是直接漏掉的收入。聚合层做的事很简单:在第一次请求落空之后,立刻按顺序去问第二家、第三家,直到有人接单。这不是「多接几家就更赚钱」这么粗的结论——它的价值在于把多个网络的填充概率串成一条链,把整体填充率从单家的 72% 推到接近 100%。代价是每一次请求都可能要多问几家、每次问询都有延迟和 SDK 开销,这就是为什么聚合层还要配一个「兜底层」——通常是自有广告或低价网络——保证长尾请求也有内容。
三层架构:SDK、适配器与中介
| 层 | 职责 | 对谁负责 | 换网络时要改什么 |
|---|---|---|---|
| 网络 SDK 层 | 各网络自己的广告拉取、渲染、回调 | 各家广告平台 | 由各家自行升级,你只跟版本 |
| 适配器层 | 把各家异构 API 归一成统一接口 | 聚合层 | 换网络 = 加一个适配器 |
| 中介层 | 决定请求顺序(瀑布)或竞价规则 | 你的业务配置 | 改配置,不改代码 |
关键在适配器层:它是聚合层能「多接几家」而不把代码写成一团乱麻的根本原因。每家网络的初始化步骤、回调签名、错误码、额外字段都不一样,适配器的职责就是把这些差异收敛到一套统一接口背后。有了适配器,中介层只认「填充 / 未填充 / 出价」这三个语义,不关心背后是谁。这正是卷二「平台 API 抽象层」 那个主张在变现侧的又一次出现。
瀑布流的请求链与整数公式
瀑布流(waterfall)是最基础的中介策略:把网络按期望 eCPM 从高到低排成一条链,请求自上而下走,命中即停。写成整数公式(成本与收益统一用「分」,避免浮点):
每次请求:自上而下遍历网络 i
r = randPermille() # [0,1000) 的可播种整数随机数
r < fillPermille[i] → 第 i 层填充,本次贡献 ecpmFen[i],停止
否则继续第 i+1 层;全部落空 → 本次未填充(unfilled)
填充率(‰) = filled × 1000 / requests
平均 eCPM(分) = Σ ecpmFen(命中层) / filled
当日收入(分) = Σ ecpmFen(命中层) / 1000
这里 ecpmFen 是「千次展示收益」,单位分,所以一次填充贡献的收入是 ecpmFen / 1000 分。随机数必须可播种(配置里的 seed),否则每次跑出来的填充分布都不一样,跨语言对拍就无从谈起——这也是本套从头到尾坚持「整数 LCG、禁用 Math.random()」的原因。
S 层仿真:一次请求沿瀑布走一遍
下面这段是 S 层仿真脚本 sim/o05_mediation.py。它读 config/o05_mediation.json 的四层网络,用可播种整数 LCG 把 requestsPerDay 次请求逐条走完瀑布,输出填充率、平均 eCPM、当日收入与各层填充次数,并产出 JSON + CSV + SVG。
#!/usr/bin/env python3
# file: sim/o05_mediation.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/o05_mediation.py --config config/ --out out/
"""广告聚合瀑布流仿真:把「一次广告请求沿瀑布自上而下找填充」的过程按整数走一遍。
对 config/o05_mediation.json 的请求流:
每次请求从瀑布第 0 层开始,逐层抽一个千分比随机数 r(可播种整数 LCG):
r < fillPermille → 该层填充,本次请求贡献 ecpmFen(千次展示收益,分)
否则继续下一层;全部落空则该请求未填充(unfilled)。
fillPermille = filled * 1000 // requests # 整体填充率
avgEcpmFen = ecpmSum // filled # 平均 eCPM(分)
revenueFen = ecpmSum // 1000 # 当日广告收入(分)
随机数用 config 里的 seed + 自实现整数 LCG(1664525 / 1013904223 / 2^31),
与 core/src/o05/mediation.ts 逐位一致,两套实现给出同一个 digest。
全程整数、地板除,无浮点。产出 out/o05_mediation.json + .csv + .svg。
平台参数是「公开可观察机制」口径的建模假设;真实填充率与真实 eCPM 需真实业务
环境,命令行不可复现(X)。
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET
MOD = 2147483648 # 2^31
MUL = 1664525
INC = 1013904223
def lcg_next(state: int) -> int:
"""整数线性同余:x_{n+1} = (1664525 * x_n + 1013904223) mod 2^31。"""
return (MUL * state + INC) % MOD
def rand_permille(state: int) -> int:
"""由状态取出 [0, 1000) 的整数千分比,保留高位、避免低位相关。"""
return (lcg_next(state) * 1000) // MOD
def simulate(cfg: dict) -> dict:
nets = cfg["networks"]
requests = cfg["requestsPerDay"]
state = cfg["seed"]
ecpm_sum = 0
filled = 0
per_net = {n["id"]: 0 for n in nets}
for _ in range(requests):
for n in nets:
r = rand_permille(state)
state = lcg_next(state)
if r < n["fillPermille"]:
filled += 1
ecpm_sum += n["ecpmFen"]
per_net[n["id"]] += 1
break
fill_permille = filled * 1000 // requests
avg_ecpm = ecpm_sum // filled if filled else 0
revenue_fen = ecpm_sum // 1000
return {
"requests": requests,
"filledRequests": filled,
"unfilledRequests": requests - filled,
"fillPermille": fill_permille,
"avgEcpmFen": avg_ecpm,
"revenueFen": revenue_fen,
"ecpmSumFen": ecpm_sum,
"perNetworkFill": [{"id": n["id"], "name": n["name"], "filled": per_net[n["id"]]}
for n in nets],
}
def draw_svg(path: str, cfg: dict, res: dict) -> int:
"""纯 Python SVG:各层网络的填充次数横条 + 顶部关键指标。"""
nets = res["perNetworkFill"]
W, H, PAD, ROW = 760, 96 + 34 * len(nets), 190, 34
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 = (
f'《合成小岛》瀑布流各层填充次数({res["requests"]} 次请求 · 填充率 '
f'{res["fillPermille"]}‰ · 平均 eCPM {res["avgEcpmFen"]}分)')
ET.SubElement(root, "text", {"x": "16", "y": "50", "font-size": "12", "fill": "#6b7280"}).text = (
f'当日广告收入 {res["revenueFen"]}分 · 未填充 {res["unfilledRequests"]} 次')
span = W - PAD - 24
hi = max([n["filled"] for n in nets] + [1])
for i, n in enumerate(nets):
y = 70 + i * ROW
ET.SubElement(root, "text", {"x": "16", "y": str(y + 16), "font-size": "12",
"fill": "#374151"}).text = n["name"]
w = span * n["filled"] // hi
ET.SubElement(root, "rect", {"x": str(PAD), "y": str(y + 2), "width": str(w),
"height": "18", "rx": "3", "fill": "#2563eb"})
ET.SubElement(root, "text", {"x": str(PAD + w + 6), "y": str(y + 16), "font-size": "11",
"fill": "#6b7280"}).text = str(n["filled"])
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, "o05_mediation.json"), encoding="utf-8") as f:
cfg = json.load(f)
res = simulate(cfg)
digest = (f'{res["filledRequests"]}:{res["fillPermille"]}:{res["avgEcpmFen"]}:'
f'{res["revenueFen"]}:{res["ecpmSumFen"]}')
os.makedirs(args.out, exist_ok=True)
svg_bytes = draw_svg(os.path.join(args.out, "o05_mediation.svg"), cfg, res)
report = {"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
"seed": cfg["seed"], "networkOrder": [n["id"] for n in cfg["networks"]],
"result": res, "digest": digest,
"svg": os.path.join(args.out, "o05_mediation.svg"), "svgBytes": svg_bytes}
with open(os.path.join(args.out, "o05_mediation.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
with open(os.path.join(args.out, "o05_mediation.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["id", "name", "ecpmFen", "fillPermille", "filledCount"])
for n in cfg["networks"]:
hit = next(x["filled"] for x in res["perNetworkFill"] if x["id"] == n["id"])
w.writerow([n["id"], n["name"], n["ecpmFen"], n["fillPermille"], hit])
print(json.dumps({"digest": digest, "result": res}, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
瀑布参数放在 config/o05_mediation.json。注意这里的数字是「公开可观察机制」口径的建模假设,不是任何聚合平台或网络后台的实测:真实填充率、真实 eCPM 属于 X 层——需真实业务环境,命令行不可复现。
{
"version": 1,
"game": "merge-island",
"observedAt": "2026-10",
"seed": 20261013,
"requestsPerDay": 20000,
"note": "广告聚合瀑布流参数,口径是「公开可观察机制」的建模假设,不是任何聚合平台或广告网络后台的实测。ecpmFen=该层网络千次展示收益(分);fillPermille=该层在该请求上被填充的千分比;瀑布按数组顺序自上而下请求,命中即停。真实填充率、真实 eCPM、真实竞价结果需真实业务环境,命令行不可复现(X)。全整数,无浮点。",
"networks": [
{ "id": "pangle", "name": "穿山甲", "ecpmFen": 6800, "fillPermille": 720 },
{ "id": "ylh", "name": "优量汇", "ecpmFen": 5200, "fillPermille": 810 },
{ "id": "admob", "name": "AdMob", "ecpmFen": 4300, "fillPermille": 900 },
{ "id": "house", "name": "自有兜底", "ecpmFen": 800, "fillPermille": 1000 }
]
}
在本机跑 for f in sim/o05_mediation.py; do /opt/homebrew/bin/python3 "$f" --config config/ --out out/; done,这条链跑出来的结果(同机相对量级,绝对数值不可移植):
| 层级 | 网络 | eCPM(分) | 本层填充率‰ | 填充次数 |
|---|---|---|---|---|
| 1 | 穿山甲 | 6800 | 720 | 14304 |
| 2 | 优量汇 | 5200 | 810 | 4645 |
| 3 | AdMob | 4300 | 900 | 943 |
| 4 | 自有兜底 | 800 | 1000 | 108 |
整体填充率 1000‰(兜底层保证长尾不漏),平均 eCPM 6278 分,当日广告收入 125562 分(约 1256 元)。这张表把聚合层的价值讲透了:穿山甲一家吃掉 71.5% 的请求,但如果没有下面三层,剩下 28.5% 的请求就全空了;而它们虽然单价低,却把填充率从 720‰ 拉到了 1000‰——聚合层的钱,很大一部分来自「把漏掉的补回来」。
P 层内核:跨语言对拍,证明公式没写错
光看一张表,你没法确认「是模型对」还是「算错了碰巧看着对」。本套用一份 P 层 TypeScript 内核 core/src/o05/mediation.ts 复算同一条链:同样的整数 LCG、同样的瀑布命中即停、同样的收入口径。它读同一份 config/o05_mediation.json,产出和 Python 逐字符一致的摘要。
// file: core/src/o05/mediation.ts
// tier: P
// verify: node --test 'core/test/o05_mediation.test.ts'
//
// 广告聚合瀑布流内核:与 sim/o05_mediation.py 逐位等价的 TypeScript 对偶实现。
// 读同一份 config/o05_mediation.json;整数 LCG、地板除、无浮点;只用可擦除语法。
export interface MediationNetwork {
readonly id: string; readonly name: string;
readonly ecpmFen: number; readonly fillPermille: number;
}
export interface MediationConfig {
readonly version: number; readonly game: string;
readonly seed: number; readonly requestsPerDay: number;
readonly networks: readonly MediationNetwork[];
}
export interface MediationResult {
readonly requests: number; readonly filledRequests: number; readonly unfilledRequests: number;
readonly fillPermille: number; readonly avgEcpmFen: number; readonly revenueFen: number;
readonly ecpmSumFen: number;
readonly perNetworkFill: readonly { id: string; name: string; filled: number }[];
}
const MOD = 2147483648; // 2^31
const MUL = 1664525;
const INC = 1013904223;
/** 整数线性同余:x_{n+1} = (1664525 * x_n + 1013904223) mod 2^31。 */
export function lcgNext(state: number): number {
return (MUL * state + INC) % MOD;
}
/** 由状态取出 [0, 1000) 的整数千分比,保留高位、避免低位相关。 */
export function randPermille(state: number): number {
return Math.floor((lcgNext(state) * 1000) / MOD);
}
export function simulate(cfg: MediationConfig): MediationResult {
const nets = cfg.networks;
const requests = cfg.requestsPerDay;
let state = cfg.seed;
let ecpmSum = 0;
let filled = 0;
const perNet = new Map<string, number>(nets.map((n) => [n.id, 0]));
for (let i = 0; i < requests; i += 1) {
for (const n of nets) {
const r = randPermille(state);
state = lcgNext(state);
if (r < n.fillPermille) {
filled += 1;
ecpmSum += n.ecpmFen;
perNet.set(n.id, (perNet.get(n.id) ?? 0) + 1);
break;
}
}
}
const fillPermille = Math.floor((filled * 1000) / requests);
const avgEcpmFen = filled ? Math.floor(ecpmSum / filled) : 0;
const revenueFen = Math.floor(ecpmSum / 1000);
return {
requests, filledRequests: filled, unfilledRequests: requests - filled,
fillPermille, avgEcpmFen, revenueFen, ecpmSumFen: ecpmSum,
perNetworkFill: nets.map((n) => ({ id: n.id, name: n.name, filled: perNet.get(n.id) ?? 0 })),
};
}
/** 跨语言对拍摘要:与 sim/o05_mediation.py 的 digest 逐字符一致。 */
export function digest(res: MediationResult): string {
return `${res.filledRequests}:${res.fillPermille}:${res.avgEcpmFen}:${res.revenueFen}:${res.ecpmSumFen}`;
}
配套测试 core/test/o05_mediation.test.ts 把 Python 落盘的摘要写成金标准,断言 TS 内核复算结果逐字符相等,并顺手验瀑布顺序与收入口径。在本机跑 node --test 'core/test/o05_mediation.test.ts',5 个用例全过;两套独立实现给出同一个摘要:
digest = 20000:1000:6278:125562:125562500
只要有一边的整数语义、LCG 常数、命中即停的 break 写错,摘要立刻分叉。这和卷二「一份逻辑、两种宿主」
是同一个信念:同一个数,在 Python 仿真、TS 内核、浏览器原型里必须长得一模一样。
X 层:真实聚合后台不可复现
必须把边界划清。上面算的是模型侧:给定各层填充率与 eCPM,整体填充率与收入是多少。而下面这些属于 X 层,需真实业务环境,命令行不可复现:
- 真实的各层填充率(受网络侧库存、地域、时段、用户标签共同影响,且聚合平台侧不透明);
- 真实的 eCPM 与出价分布(同一广告位、同一人群,不同网络能差很多),以及聚合平台后台的聚合策略(不同平台对「瀑布顺序」、延迟容忍度、兜底层的处理各不相同)。
这些都不是「跑个脚本就能知道」的。工程上正确的做法是:把可复现的模型层与不可复现的真实数据层分开——模型层用来做决策前的「如果…会怎样」,真实数据层用来在投放后反标定模型参数(把实测填充率 / eCPM 填回 config,模型立刻反映现实)。这也是第 6 章广告位配置要跟模型字段一一对应的原因。
自检表
| 检查项 | 合格标准 |
|---|---|
| 三层清晰 | SDK / 适配器 / 中介各司其职,换网络只加适配器 |
| 瀑布完整 | 每层都有填充率与 eCPM,末层有兜底,没有断点 |
| 口径统一 | 成本与收益统一到「分」的整数,收入可复算 |
| 随机可复现 | 用 config 里的 seed + 整数 LCG,无 Math.random() |
| 模型可反标定 | config 字段能直接吃线上实测填充率 / eCPM |
| 边界清晰 | 真实后台数据标 X,不写成「实测」 |
聚合层最容易被忽略的一点是:它不是一个「接了就完事」的开关,而是一层持续调优的中间件。瀑布顺序、各层底价、兜底策略,每一项都在影响填充率与 eCPM 的乘积。下一节我们把三家网络的接入差异摊开——穿山甲、优量汇、AdMob 在初始化步骤、额外字段、能力位上到底差在哪,以及这些差异如何折成一个可比较的接入成本。
阅读导航:上一节:4.3 三方归因与渠道数据对账 · 下一节:5.2 穿山甲、优量汇与 AdMob 的接入差异 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。