5.1 聚合架构:SDK、适配器与瀑布流

广告聚合层是小游戏 IAA 收入的中枢:它把一次广告请求沿瀑布流自上而下逐层找填充,把多家网络的 SDK、适配器与配置折叠成一条可算账的链。本节讲聚合架构的三层分工,用可跑的 Python 瀑布流仿真与 TypeScript 内核跨语言对拍,算出填充率、平均 eCPM 与当日广告收入。

站内有一篇广告 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穿山甲680072014304
2优量汇52008104645
3AdMob4300900943
4自有兜底8001000108

整体填充率 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 的接入差异 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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