站内《上线变现与长线运营》 把 eCPM 与填充率建过模,站内Defold 的广告接入那篇 讲清了 SDK 怎么接、瀑布流怎么聚。本节与两者分工不同:它不重做收益模型,也不重写 SDK 接入,只做一件运维最常撞上的事——**广告位接好了、配置也下发了,为什么收益还是上不去?**答案往往就藏在漏斗的某一级里。
上一节把「底价该填多少」算清楚了。但真实线上你会遇到更棘手的情况:按模型填了底价,收入反而掉了。这时候要的不是再算一遍模型,而是诊断——先定位钱丢在哪一级,再决定动哪里。
广告漏斗:五级计数,四级通过率
一个广告位从被调用到完成,经过五个计数、四级通过率:
| 级 | 计数 | 通过率 | 健康线(千分比) |
|---|---|---|---|
| 请求→填充 | requests → fills | fillRate = fills × 1000 // requests | fillMin = 800 |
| 填充→展示 | fills → shows | showOfFill = shows × 1000 // fills | showOfFillMin = 700 |
| 展示→点击 | shows → clicks | clickOfShow = clicks × 1000 // shows | clickOfShowMin = 15 |
| 点击→完成 | clicks → completes | completeRate = completes × 1000 // clicks | completeOfClickMin = 800 |
四个通过率回答四个不同的问题:填充低是广告网络没货(底价太高、网络覆盖不够);展示低是有货没播出去(预加载没到位、生命周期太短、频次被限);点击低是播了没人理(形态不匹配、位置不显眼);完成低是点了没看完(激励视频被中途关掉、奖励吸引力不够)。
诊断树:上游优先短路
四级通过率不能平铺着看——它们有因果顺序。填充率崩了,展示率再漂亮也没有意义(根本没有广告可展示)。所以诊断必须上游优先短路:从填充率开始逐级往下看,第一个跌破健康线的级别就是当前唯一该动手的瓶颈,它之后的级别一律标 downstream,读数不可解释。
bottleneck = 第一个 rate < threshold 的级别;全达标则 ok
gap = threshold - rate(千分比,负值取 0)
这条规则看着简单,却是运维最常犯错的点:看到「点击率低」就去改素材,其实填充率早就崩了,改素材纯属浪费。
S 层:诊断每个广告位的瓶颈
下面这段是 S 层仿真脚本 sim/o06_funnel_diag.py。它读 config/o06_funnel.json 的五个广告位计数,算出四级通过率、按诊断树定位瓶颈,并统计「哪个级别被卡得最多」,产出 JSON + CSV + SVG。
#!/usr/bin/env python3
# file: sim/o06_funnel_diag.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/o06_funnel_diag.py --config config/ --out out/
"""广告漏斗诊断树:读 config/o06_funnel.json,把每个广告位的「请求 → 填充 → 展示 →
点击 → 完成」五级计数换算成四级通过率,再按诊断树定位瓶颈。
fillRate = fills * 1000 // requests (请求 → 填充)
showOfFill = shows * 1000 // fills (填充 → 展示)
clickOfShow = clicks * 1000 // shows (展示 → 点击)
completeRate = completes * 1000 // clicks (点击 → 完成)
诊断树按**上游优先**短路:先看填充率——填充崩了,下游展示率再高也没有意义;
填充达标才看展示率,依此类推。第一个跌破健康线的级别就是当前唯一该动手的瓶颈,
它之后的级别一律标 downstream(上游没修好,下游读数不可解释)。
bottleneck = 第一个 rate < threshold 的级别;全达标则 ok;gap = threshold - rate
口径:真实填充率 / 展示率由聚合后台给出,需真实业务环境,命令行不可复现(X)。
产出 out/o06_funnel_diag.json + .csv + .svg。全整数,无浮点。
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET
STAGES = [
("fill", "请求→填充", "fills", "requests", "fillMin"),
("show", "填充→展示", "shows", "fills", "showOfFillMin"),
("click", "展示→点击", "clicks", "shows", "clickOfShowMin"),
("complete", "点击→完成", "completes", "clicks", "completeOfClickMin"),
]
def load(d: str) -> dict:
with open(os.path.join(d, "o06_funnel.json"), encoding="utf-8") as f:
return json.load(f)
def diagnose(slot: dict, th: dict) -> dict:
"""返回该广告位的四级通过率 + 瓶颈级别(上游优先短路)。"""
rates = {}
for key, _cn, num, den, _tk in STAGES:
rates[key] = slot[num] * 1000 // slot[den] if slot[den] else 0
bottleneck, gap = "ok", 0
for key, _cn, _num, _den, tk in STAGES:
if rates[key] < th[tk]:
bottleneck = key
gap = th[tk] - rates[key]
break
downstream = False
stage_rows = []
for key, cn, _num, _den, tk in STAGES:
status = "ok"
if key == bottleneck:
status = "bottleneck"
downstream = True
elif downstream:
status = "downstream"
stage_rows.append({"stage": key, "label": cn, "ratePermille": rates[key],
"thresholdPermille": th[tk], "status": status})
return {"rates": rates, "bottleneck": bottleneck, "gapPermille": gap, "stages": stage_rows,
"passThroughPermille": slot["completes"] * 1000 // slot["requests"]}
def draw_svg(path: str, rows: list) -> int:
"""每个广告位一组 4 条:各级通过率 / 健康线(1000 = 刚好达标),跌破线标红。"""
W, H, PAD, ROW = 760, 80 + 74 * len(rows), 210, 74
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": "28", "font-size": "15", "fill": "#111827"}).text = "广告漏斗诊断:各级通过率 / 健康线(1000=达标,红=跌破)"
span = W - PAD - 150
for i, r in enumerate(rows):
y = 54 + i * ROW
ET.SubElement(svg, "text", {"x": "16", "y": str(y + 16), "font-size": "12", "fill": "#374151"}).text = r["name"]
ET.SubElement(svg, "text", {"x": "16", "y": str(y + 32), "font-size": "11", "fill": "#ef4444"}).text = f'瓶颈 {r["bottleneck"]} · 缺口 {r["gapPermille"]}‰'
for j, st in enumerate(r["stages"]):
ratio = st["ratePermille"] * 1000 // st["thresholdPermille"] if st["thresholdPermille"] else 0
w = span * min(ratio, 1400) // 1400
color = "#ef4444" if st["status"] == "bottleneck" else ("#cbd5e1" if st["status"] == "downstream" else "#16a34a")
ET.SubElement(svg, "rect", {"x": str(PAD), "y": str(y + j * 15 + 2), "width": str(w), "height": "12", "rx": "3", "fill": color})
ET.SubElement(svg, "text", {"x": str(PAD + w + 6), "y": str(y + j * 15 + 12), "font-size": "10", "fill": "#6b7280"}).text = f'{st["label"]} {st["ratePermille"]}‰ (线 {st["thresholdPermille"]}‰)'
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)
th = cfg["thresholdsPermille"]
rows = [{**s, **diagnose(s, th)} for s in cfg["slots"]]
hist = {k: 0 for k, _cn, _n, _d, _t in STAGES}
hist["ok"] = 0
for r in rows:
hist[r["bottleneck"]] += 1
digest = ",".join(f'{r["id"]}:{r["bottleneck"]}' for r in rows) + ";" + \
",".join(f"{k}={hist[k]}" for k in ("fill", "show", "click", "complete", "ok"))
os.makedirs(a.out, exist_ok=True)
svg = draw_svg(os.path.join(a.out, "o06_funnel_diag.svg"), rows)
with open(os.path.join(a.out, "o06_funnel_diag.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["id", "name", "platform", "requests", "fills", "shows", "clicks", "completes",
"fillPermille", "showOfFillPermille", "clickOfShowPermille", "completeOfClickPermille",
"passThroughPermille", "bottleneck", "gapPermille"])
for r in rows:
rt = r["rates"]
w.writerow([r["id"], r["name"], r["platform"], r["requests"], r["fills"], r["shows"],
r["clicks"], r["completes"], rt["fill"], rt["show"], rt["click"], rt["complete"],
r["passThroughPermille"], r["bottleneck"], r["gapPermille"]])
rep = {"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
"thresholdsPermille": th, "slotCount": len(rows), "slots": rows, "histogram": hist,
"digest": digest, "csv": os.path.join(a.out, "o06_funnel_diag.csv"),
"svg": os.path.join(a.out, "o06_funnel_diag.svg"), "svgBytes": svg}
with open(os.path.join(a.out, "o06_funnel_diag.json"), "w", encoding="utf-8") as f:
json.dump(rep, f, ensure_ascii=False, indent=2)
print(json.dumps({"digest": digest, "histogram": hist,
"slots": [(r["id"], r["bottleneck"], r["gapPermille"], r["passThroughPermille"]) for r in rows]},
ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
参数放在 config/o06_funnel.json。健康线是判断值,计数是同一广告位同期的整数:
{
"version": 1,
"game": "merge-island",
"observedAt": "2026-10",
"note": "广告漏斗诊断参数。口径是「公开可观察机制」的建模假设,不是任何聚合后台实测。requests/fills/shows/clicks/completes 是同一广告位同期的计数;thresholdsPermille 是每一级的健康线(千分比)。真实填充率/展示率由聚合后台给出,需真实业务环境,命令行不可复现(X)。全整数,无浮点。",
"thresholdsPermille": { "fillMin": 800, "showOfFillMin": 700, "clickOfShowMin": 15, "completeOfClickMin": 800 },
"slots": [
{ "id": "rv_double_wx", "name": "激励视频·翻倍(微信)", "platform": "wechat", "requests": 120000, "fills": 114000, "shows": 108000, "clicks": 19000, "completes": 17000 },
{ "id": "rv_offline_wx", "name": "激励视频·离线收益(微信)", "platform": "wechat", "requests": 90000, "fills": 58000, "shows": 55000, "clicks": 9000, "completes": 8000 },
{ "id": "itl_level_end_dy", "name": "插屏·过关(抖音)", "platform": "douyin", "requests": 100000, "fills": 92000, "shows": 46000, "clicks": 7000, "completes": 6000 },
{ "id": "banner_home_web", "name": "Banner·主页(Web)", "platform": "web", "requests": 200000, "fills": 180000, "shows": 170000, "clicks": 1200, "completes": 1100 },
{ "id": "rv_double_dy", "name": "激励视频·翻倍(抖音)", "platform": "douyin", "requests": 80000, "fills": 76000, "shows": 72000, "clicks": 14000, "completes": 8000 }
]
}
在本机跑 for f in sim/o06_*.py; do /opt/homebrew/bin/python3 "$f" --config config/ --out out/; done,诊断结果是(同机相对量级,绝对数值不可移植):
| 广告位 | 填充‰ | 展示/填充‰ | 点击/展示‰ | 完成/点击‰ | 端到端‰ | 瓶颈 | 缺口‰ |
|---|---|---|---|---|---|---|---|
| 激励视频·翻倍(微信) | 950 | 947 | 175 | 894 | 141 | ok | 0 |
| 激励视频·离线收益(微信) | 644 | 948 | 163 | 888 | 88 | fill | 156 |
| 插屏·过关(抖音) | 920 | 500 | 152 | 857 | 60 | show | 200 |
| Banner·主页(Web) | 900 | 944 | 7 | 916 | 5 | click | 8 |
| 激励视频·翻倍(抖音) | 950 | 947 | 194 | 571 | 100 | complete | 229 |
五个广告位恰好覆盖了五种情况——一个健康、四个各卡在不同级别。这正是诊断树的价值:每个广告位给出一个明确、唯一的动作方向。离线收益位填充只有 644‰,先别管它的展示率;插屏过关位填充 920‰ 达标,才轮到看它的展示率只有 500‰(有货没播出去)。级别分布统计是 fill=1, show=1, click=1, complete=1, ok=1。
B 基准:诊断对健康线有多敏感
诊断结论依赖健康线,而健康线是人的判断。所以必须问:「如果我把某条线上调 / 下调 20%,瓶颈判定会翻盘吗?」下面这段是 B 层基准脚本 sim/o06_funnel_bench.py,逐条阈值 ±20% 重跑诊断,比较每个广告位的瓶颈是否变化,软告警不硬失败。
#!/usr/bin/env python3
# file: sim/o06_funnel_bench.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/o06_funnel_bench.py --config config/ --out out/
"""B 基准:漏斗诊断对「健康线设在哪儿」的稳健性。
诊断结论依赖 thresholdsPermille——而健康线是**人的判断**,不是客观常数。所以要问:
「如果我把某条健康线上调 / 下调 20%,瓶颈判定会翻盘吗?」
做法:逐条阈值 ±20% 重跑诊断,逐广告位比较瓶颈级别是否变化。
软告警:任一广告位的瓶颈级别发生翻转即打 WARN(说明该位的诊断对健康线敏感)。
退出码恒为 0——这是基准,不是门禁。全整数,无浮点。
"""
from __future__ import annotations
import argparse
import json
import os
from o06_funnel_diag import diagnose, load
PERTURB_PCT = 20
KEYS = ("fillMin", "showOfFillMin", "clickOfShowMin", "completeOfClickMin")
def perturbed(th: dict, key: str, pct: int) -> dict:
out = dict(th)
out[key] = th[key] * (100 + pct) // 100
return out
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)
th = cfg["thresholdsPermille"]
base = {s["id"]: diagnose(s, th)["bottleneck"] for s in cfg["slots"]}
cases, flips = [], []
for key in KEYS:
for pct in (-PERTURB_PCT, PERTURB_PCT):
th2 = perturbed(th, key, pct)
verdict = {s["id"]: diagnose(s, th2)["bottleneck"] for s in cfg["slots"]}
changed = {sid: [base[sid], verdict[sid]] for sid in base if verdict[sid] != base[sid]}
cases.append({"threshold": key, "pct": pct, "newValue": th2[key], "verdict": verdict,
"changed": changed})
for sid, pair in changed.items():
flips.append({"threshold": key, "pct": pct, "slot": sid, "from": pair[0], "to": pair[1]})
os.makedirs(a.out, exist_ok=True)
rep = {"version": cfg["version"], "source": "sim/o06_funnel_bench.py", "perturbPct": PERTURB_PCT,
"baseThresholds": th, "baseVerdict": base, "cases": cases,
"flipCount": len(flips), "flips": flips,
"verdict": "PASS(soft)" if not flips else f"WARN(soft) {len(flips)} 条翻转"}
with open(os.path.join(a.out, "o06_funnel_bench.json"), "w", encoding="utf-8") as f:
json.dump(rep, f, ensure_ascii=False, indent=2)
print(json.dumps({"baseVerdict": base, "flipCount": len(flips), "flips": flips,
"verdict": rep["verdict"]}, ensure_ascii=False, indent=2))
return 0 # B 层软告警:恒返回 0
if __name__ == "__main__":
raise SystemExit(main())
在本机跑它,结果是 6 条翻转(WARN(soft),退出码仍为 0):
| 扰动 | 广告位 | 瓶颈 从 → 到 |
|---|---|---|
fillMin −20% | 离线收益(微信) | fill → ok |
fillMin +20% | 翻倍(微信) | ok → fill |
fillMin +20% | 插屏(抖音) | show → fill |
fillMin +20% | Banner(Web) | click → fill |
fillMin +20% | 翻倍(抖音) | complete → fill |
completeOfClickMin +20% | 翻倍(微信) | ok → complete |
读这张表要读出两件事。第一,填充健康线是最敏感的旋钮——它一上调,四个广告位的瓶颈全部「前移」到填充。这不是 bug,而是诊断树短路逻辑的必然:填充是上游,上游线一动,下游结论全部重排。第二,这也说明诊断结论必须和健康线一起交代——单说「这个位卡在展示」而不说「在填充线 800‰ 的前提下」,是把判断当成了事实。B 基准的用途正是把这层前提暴露出来。
X 层:真实后台计数不可复现
边界必须划清。上面算的是诊断方法:给定五级计数与健康线,瓶颈在哪一级。下面这些属于 X 层,需真实业务环境,命令行不可复现:
- 真实的五级计数(由聚合后台 / 平台后台给出,口径与去重规则各家不同);
- 真实的健康线(不同品类、不同平台、不同季节的合理填充率差异很大,没有普适常数);
- 真实的原因归属(填充低可能是底价、可能是网络封禁、可能是地域无货——诊断树只指出「哪一级」,不指出「为什么」)。
工程上正确的做法是:把可复现的诊断树与不可复现的计数来源分开——诊断树保证「给定计数,结论确定」,计数靠埋点与后台导出。诊断树只回答「哪一级」,为什么要靠人去查后台、查底价、查网络覆盖。
自检表
| 检查项 | 合格标准 |
|---|---|
| 五级齐全 | 请求 / 填充 / 展示 / 点击 / 完成每一级都有计数 |
| 上游优先 | 诊断从填充开始短路,不跳级下结论 |
| 唯一瓶颈 | 每个广告位给出一个瓶颈级别,不含糊 |
| 健康线留痕 | 结论与所用健康线一起给出,不把判断当事实 |
| 稳健性有据 | 对健康线做 ±20% 扰动,量化结论翻转数 |
| 边界清晰 | 真实计数与原因归属标 X,不写成「实测」 |
漏斗诊断最危险的错觉,是把「瓶颈级别」当成「根因」。诊断树只告诉你钱丢在哪一级,为什么丢永远要回到后台、底价与网络覆盖去查。第 6 章到这里把广告位的配置、选价与诊断串成了一条闭环:6.1 把配置送到端上,6.2 决定配置填什么,6.3 检查填完之后哪里漏。下一章我们换一条完全不同的变现通道——内购与支付。
阅读导航:上一节:6.2 eCPM 分层与流量分组 · 下一节:7.1 平台内购通道与虚拟支付 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。