推荐系统(十五)—— 实时推荐与在线学习
实时推荐的工程师视角:流式管道(Kafka + Flink)、在线学习(SGD、FTRL、AdaGrad)、Bandit(UCB、Thompson Sampling、LinUCB)、延迟预算、特征新鲜度、概念漂移检测,以及生产环境真正在调的缓存与计算权衡。
用户 14:02 打开 App 搜「越野跑鞋」,到 15:30 兴趣已经飘到厨房好物。如果你的模型还在跑昨晚的离线快照,16:00 推给他的依然是萨洛蒙广告——这条时间差就是实时系统真正要补上的洞。但有意思的不是「让一切更快」,而是「到底什么需要快」:大量特征即便实时化也基本不影响 AUC,方向选错就是花钱买空气。
你将学到
- 两条路径:实时推荐其实是两条管道的拼接——异步的写路径(事件 → 状态 → 模型)和同步的读路径(请求 → 召回 → 排序 → 返回)。
- 毫秒去哪了:延迟不是均值游戏,p99 才是用户感受到的指标。我们会按阶段、按分位拆解 100 ms 的预算。
- 在线学习的工程实现:SGD、AdaGrad,以及生产里真正在跑的 FTRL-Proximal——也就是 Google 在广告 CTR 系统中公开的算法。
- Bandit:UCB1、Thompson Sampling、上下文版 LinUCB,以及它们的 regret 界在物品每天都换的现实里到底意味着什么。
- 流式架构:一套具体的 Kafka + Flink + KV 存储方案,包含 checkpoint 与 exactly-once 语义。
- 概念漂移:怎么检测(ADWIN、DDM、Page-Hinkley),检测到了又该如何反应。
- 缓存 vs 计算:你在生产里真正在调的那条权衡曲线——以及为什么答案几乎总是「混合」。
前置知识
- Python 与 NumPy(第 1-2 篇)
- 梯度下降与损失函数(第 7 篇)
- 推荐系统管道总览(第 11 篇)
1. 为什么要实时,以及「实时」到底指什么
三个现实把人推向实时:
- 会话很短。 Feed 类应用的中位会话时长 3-7 分钟。一个每天才更新一次的模型,在大多数会话结束之前根本没机会看到它们。
- 趋势更短。 一条爆款视频可能在前 6 小时就吃掉一生 80% 的互动。昨晚的离线模型对它一无所知。
- 反馈循环就是模型本身。 推送出去的物品、回流的点击,本身就是下一条训练样本。把这条循环从「天级」缩到「秒级」,是「学习系统」与「停滞系统」的分水岭。
但「实时」不是单点,而是一道光谱:
| 层级 | 更新频率 | 典型用途 |
|---|---|---|
| 实时 | < 1 秒 | 会话意图、Feed 内去重、风控信号 |
| 准实时 | 1 秒 – 1 小时 | 近期点击序列、单作者 CTR |
| 小时级 | 1 – 24 小时 | 热门话题、物品热度衰减 |
| 离线 | 1 天以上 | 用户人口属性、物品 Embedding、召回索引 |
最常见的错误就是把所有东西都做成实时。后面图 4 会显示:人口属性、物品元数据基本不会从实时管道里得到任何 AUC 收益,但近期点击序列能拿到 2-3 个百分点。实时是一份预算,要花在真正能撬动指标的特征上。
2. 双路径架构

实时推荐天然分成两条路径:
写路径(异步、看吞吐)。 客户端事件进入按 user_id 分区的 Kafka topic,被 Flink 任务聚合成滚动窗口(最近 N 次点击、10 分钟 CTR 等),最后落到两个地方:一个特征存储(Redis 或 RocksDB)供读路径取用,一个在线学习器更新模型权重。每隔几分钟,新的模型快照被推到注册中心。
读路径(同步、看延迟)。 请求到达后做召回(Embedding 上的 ANN,加上倒排索引覆盖新物品),然后做单次往返的特征拉取,进入排序,再做多样性与业务规则的重排,最后返回结果。整体预算 < 100 ms。
工程纪律是把这两条路径解耦。读路径永远不写模型、不训练、不阻塞流式计算。即便流式那边掉队了,读路径仍能用稍微旧一点的特征继续工作——退化但不宕机。
3. 延迟预算 —— 每一毫秒去了哪

100 ms 端到端是 Feed 类产品的行业惯例(人因实验把视觉更新「感觉瞬时」的阈值定在 100-200 ms 上下)。在这份预算里,典型分配大致如下:
| 阶段 | p50 | p95 | p99 | 备注 |
|---|---|---|---|---|
| 入向网络 | 4 | 7 | 12 | 长连接摊薄了 TLS 握手 |
| 召回(ANN) | 10 | 18 | 28 | 上亿物品的 HNSW 或 ScaNN |
| 特征拉取 | 6 | 14 | 30 | Redis pipeline;尾部来自 GC / 网络抖动 |
| 排序(DNN) | 18 | 32 | 55 | ~500 候选批量打分 |
| 重排 + 日志 | 4 | 9 | 18 | 多样性、业务规则、异步落日志 |
| 出向网络 | 3 | 6 | 11 | |
| 端到端 | 45 | 86 | 154 | p99 越线,这是常态 |
两条工程经验:
- 均值会撒谎。 p50 = 45 ms 看起来宽裕。p99 = 154 ms 意味着每一百次请求就有一次超 SLO——日活十亿的平台,一天就是数百万次。
- 排序最费时间。 候选批处理、模型蒸馏、TensorRT/ONNX-Runtime 量化,单点收益超过其他所有优化的总和。Pinterest 公开过把 6 层 DNN 蒸馏成 2 层学生模型加特征交叉,p99 直接降了 30%。
4. 流式:生产中的 Kafka + Flink

4.1 Kafka —— 持久化的传输层
Kafka 的角色窄而关键:一条持久化、分区、可重放的日志。三个属性最重要:
- 按
user_id分区:保证同一个用户的事件落在同一分区,因果顺序得以保留——「点击发生在曝光之前还是之后」这类有状态 join 必须依赖此性质。 - 副本(一般
replication-factor=3):单 broker 挂掉不会丢数据。 - 保留策略:让你能回放过去 7 天来回填一个新模型——线上路径与恢复路径走同一份代码。
Kafka 不做的事情:聚合、join、机器学习。它就是一家邮局。
| |
4.2 Flink —— 有状态的流式计算
如果说 Kafka 是传输,Flink 就是有状态的流处理器。它的杀手锏是故障下的 exactly-once——基于 Chandy-Lamport 风格的分布式快照实现:每个 checkpoint 周期(默认 60 秒),Flink 原子地捕获每个算子的状态并持久化到 S3。失败发生时,回退 Kafka offset 到上一个 checkpoint 重放,对外表现就像没出过事一样。
一个典型的 Flink 点击归因任务:
| |
watermark 是大多数人最容易写错的地方。它声明的是「我不会再看到 event_time < watermark 的事件」,从而授权 Flink 关闭并发射一个窗口。设置太紧会丢晚到事件,设置太松特征就被系统性地拖延。
5. 在线学习:从 SGD 到 FTRL

5.1 核心更新
对于带参数 $\theta$ 的逻辑回归排序器,单条样本 $(x_t, y_t)$ 的更新是:
$$\theta_{t+1} = \theta_t - \eta_t \, \nabla_\theta \mathcal{L}(\sigma(\theta_t^\top x_t), y_t)$$这就是普通 SGD。能用,但放到 Web 规模的 CTR 数据上——上百万稀疏特征、每条只在很小一部分样本里出现——它有两个问题:
- 没有按特征的学习率:万分之一才出现一次的特征,其步长本应远大于每次都出现的特征。
- 没有稀疏性:权重会在噪声里游走,离不开零点。一个 10⁹ 参数永不归零的模型根本没法服务化。
5.2 AdaGrad —— 按特征自适应步长
AdaGrad 通过累积每个特征的梯度平方解决第一个问题:
$$\theta_{t+1, i} = \theta_{t, i} - \frac{\eta}{\sqrt{G_{t, i} + \varepsilon}} g_{t, i}, \quad G_{t, i} = \sum_{s=1}^{t} g_{s, i}^2$$罕见特征 $G$ 小,少数几次出现时步子可以迈大;高频特征 $G$ 大,更新趋于谨慎。
5.3 FTRL-Proximal —— 生产里的主力
FTRL-Proximal(McMahan 等,Ad Click Prediction: a View from the Trenches,KDD 2013——这是 Google 公开介绍其广告系统所用算法的那篇论文)把 AdaGrad 的按特征自适应学习率,与能产生真正零值而非小权重的 $L_1$ 正则结合起来。逐坐标更新规则:
$$ z_{t,i} \leftarrow z_{t-1,i} + g_{t,i} - \frac{\sigma_{t,i}}{\eta} \theta_{t-1,i}, \qquad \theta_{t,i} = \begin{cases} 0 & \text{若 } |z_{t,i}| \le \lambda_1 \\ -\frac{1}{\eta_{t,i}} \big(z_{t,i} - \mathrm{sign}(z_{t,i}) \lambda_1\big) & \text{否则} \end{cases} $$其中 $\sigma_{t,i} = \frac{1}{\eta_{t,i}} - \frac{1}{\eta_{t-1,i}}$,$\eta_{t,i} = \alpha / (\beta + \sqrt{\sum_s g_{s,i}^2})$。
关键性质:模型是真正稀疏的。Google 报告 FTRL-Proximal 相比朴素 SGD + $L_2$ 在同等 AUC 下把模型体积压了一个数量级。
| |
5.4 在线 vs 离线 —— 全景图
图 3 左侧是稳态情形:在线学习平滑收敛,离线重训呈阶梯——每 200 条事件出一个新快照,中间是平台。在静态任务上两者期望差距会逐渐收敛,但随时间积分,在线明显占优。
右侧才是真正的胜负手:突发的分布漂移(爆款事件、新品上线、节日效应)。离线模型对训练窗口外的世界一无所知,要等满一个完整窗口才能恢复;在线学习从漂移后的第一条样本就开始调整,大约 100 条事件后就回到接近峰值的 AUC。生产环境里漂移是常态,不是例外——这才是在线学习不只是学术偏好,而是真正的工程杠杆的原因。
6. 特征新鲜度 —— 到底有多重要?

新鲜度问题是认真的团队真正会争论的,因为把一个特征做成实时是昂贵的。来自公开报告(Meta 的深度学习推荐模型、Pinterest 的 PinnerSAGE、字节的 Monolith)的经验模式高度一致:
- AUC 在 log 陈旧度上大致线性下降。 1 秒到 1 分钟:损失微乎其微。1 分钟到 1 小时:开始有感(约 0.005 AUC)。1 小时到 1 天:损失显著(约 0.015-0.020 AUC)。
- 损失集中在行为类特征。 近期点击序列与会话意图贡献了约 80% 的新鲜度溢价。人口属性与物品元数据基本是平的——每周更新一次都看不出影响。
这决定了架构形态:不要为不能回本的特征支付流式成本。一个典型的 Feed 系统会并行跑三条管道——行为特征走实时、热度类聚合走小时级、Embedding 与人口属性走天级。
7. Bandit —— 理论可证的探索故事
7.1 一句话描述问题
你有 $K$ 个物品要选。每个物品都有未知的点击概率 $\mu_i$。每一轮你挑一个、看到它的点击、然后更新。$T$ 轮之后,遗憾就是你相对于一直挑最好那个所损失的:
$$R_T = T \mu^* - \mathbb{E}\!\left[\sum_{t=1}^T \mu_{a_t}\right]$$「好」算法的标准是次线性遗憾:$R_T = o(T)$,即每轮平均损失 → 0。
7.2 UCB1 —— 不确定时持乐观态度
UCB1(Auer 等,2002)选择上置信界最高的臂:
$$a_t = \arg\max_i \left( \hat\mu_i + \sqrt{\frac{2 \ln t}{n_i}} \right)$$其中 $n_i$ 是臂 $i$ 已被拉的次数。奖金随 $n_i$ 上升而收缩——「不确定就探索,确定了就利用」。UCB1 达到 $O(\log T)$ 遗憾,对稳态 Bandit 而言这在常数因子内可证最优(Lai-Robbins 下界)。
7.3 Thompson Sampling —— 贝叶斯路线
对每个臂的成功率维护一个后验分布。伯努利奖励的共轭先验是 Beta:
$$\theta_i \sim \text{Beta}(\alpha_i, \beta_i), \quad \text{更新:} (\alpha_i, \beta_i) \leftarrow (\alpha_i + r, \beta_i + 1 - r)$$每一轮,从每个臂的后验里采样一个 $\theta_i$,挑采样值最高的。后验宽(不确定)的臂会被探索——它的样本噪声大,时不时会高出别人;后验窄的臂会被利用。Thompson Sampling 在渐近意义上匹配 UCB1 的 $O(\log T)$(Agrawal & Goyal,2012),且在大多数 benchmark 上实测更优,更易扩展到延迟反馈、批量更新和复杂奖励模型。
| |
7.4 LinUCB —— 真正能用的「上下文」
普通 Bandit 学的是全局排序。但推荐的全部价值就在个性化。LinUCB(Li 等,A Contextual-Bandit Approach to Personalized News Article Recommendation,WWW 2010 —— 雅虎当年用于首页新闻个性化的算法)假设期望奖励对上下文向量 $x_t$ 线性:
$$\mathbb{E}[r_a \mid x_t] = x_t^\top \theta_a$$每个臂维护一个岭回归 $(A_a, b_a)$,其中 $A_a = I + \sum X X^\top$、$b_a = \sum r X$。选择规则:
$$a_t = \arg\max_a \left( x_t^\top \hat\theta_a + \alpha \sqrt{x_t^\top A_a^{-1} x_t} \right), \quad \hat\theta_a = A_a^{-1} b_a$$奖金项 $\sqrt{x_t^\top A_a^{-1} x_t}$ 就是岭回归在该上下文下的预测标准差——当前上下文与该臂历史样本越不像,奖金越大。遗憾界是 $\tilde O(\sqrt{d T})$,$d$ 是上下文维度。
| |
实战中你几乎不会用「纯」LinUCB。线上跑的是深度排序模型,Bandit 在它上面一层,决定给某次请求分配哪种策略(创作者助推、新物品助推、纯利用)。臂是策略,不是物品。
8. 概念漂移 —— 检测它,不要假装它不存在

只有学习器能适应,在线学习才会真的适应漂移——如果学习率早就衰减到几乎为零,它什么都做不了。生产系统会显式检测漂移并主动反应。
8.1 三个经典检测器
- Page-Hinkley 检验:累积偏离均值的差值,CUSUM 超阈值即报警。适合单调漂移。
- DDM(Drift Detection Method,Gama 等 2004):跟踪二项分布错误率 $p_t$ 与其标准差 $s_t$。$p_t + s_t \ge p_{\min} + 2 s_{\min}$ 触发预警,$\ge p_{\min} + 3 s_{\min}$ 触发报警。
- ADWIN(Bifet & Gavaldà 2007):维护自适应窗口,只要两个子窗口的均值差超过 Hoeffding 界,就丢弃旧的一半。可证的有界误报率。
图 6 用的是简化版 z-score 检测器,也是你会真的部署的 v1:保留一个「已知良态」的参考窗口,计算近期滚动均值 CTR 相对参考的 z-score,$|z| > 3$ 时报警。
8.2 检测之后的反应
检测没有响应只是个告警。从便宜到昂贵的实战反应:
- 拉高学习率(便宜、可逆)。在固定窗口内将 $\eta$ 乘以 2-5x。
- 重置 Bandit 后验(中等代价)。把受影响的臂的 $\alpha, \beta$ 减半——保留先验形状,但翻倍了不确定性。
- 强制回滚 checkpoint(昂贵)。退回上一个良态快照,从 Kafka 重放,在漂移后窗口上重训。
| |
9. 缓存 vs 计算 —— 你真正在调的那条权衡

读路径上的每个决定都落在这条权衡曲线上:
- 总是重算:正确但昂贵——每次请求都打模型、每个特征都现算。
- 激进缓存:便宜又快,但陈旧——刚换了意图的会话拿到的还是几分钟前的特征。
- 混合模式:生产系统几乎都收敛到这个形态:
- 热路径(头部用户 / 活跃会话 / 新物品):实时计算,不缓存。
- 冷路径(其余流量):从缓存返回,TTL 30-60 秒。
- 负缓存:把「啥都没变」的响应也缓存起来,避免对静默用户的重复计算。
图 7 右侧解释了为什么:60 秒 TTL 拿走了大部分延迟红利,新鲜度损失却很小;纯重算花了 5 倍计算只换来约 0.005 AUC。「总是重算」点在 Pareto 前沿上,但很少值得。
10. 拼到一起 —— 一个最小但接近真实的系统
| |
几个看似细节、但生产里很要紧的点:
- Bandit 跑在策略层,不是物品层。「从一亿候选里选一个」的动作空间,没有任何上下文 Bandit 能在合理时间内收敛;但「从 4 个排序策略里选一个」恰好是它的舒适区。
- 快照存的是在线学习器的状态,不是预测结果。万一一批坏样本污染了权重,回滚状态、从 Kafka 重放干净事件就能恢复。
DriftDetector读的是校准误差|p - y|,不是原始 CTR。校准漂移能捕捉到比单看 CTR 更多的失效模式。
Q & A
Q:在线学习系统的 A/B 测试怎么做? 标准 A/B 假设观测彼此独立,但在线学习器会从自己的实验组里学习。修正方案:要么用 Chapelle 等(2012)的 interleaved comparison,要么把一边设置成「冻结」模型、另一边跑实时模型,比较的是累积奖励而非单请求 CTR。
Q:Flink 与 Spark Streaming 的真正区别? Flink 是真流式(逐事件、毫秒级),Spark Streaming 是微批(秒级)。对推荐而言,Flink 更低的延迟以及更成熟的 exactly-once 状态管理两件事都重要——近年大规模新建的 Feed 系统几乎清一色 Flink(或类似的内部产品,如 Twitter 的 Heron、字节的 Aiops)。
Q:在线学习是不是不稳定? 有可能不稳定,那正是你必须工程化对抗的失效模式。三道护栏:(1) 学习率有界;(2) 梯度裁剪;(3) 维护快照与回滚通道。Google 的 FTRL 论文专门有一节「trade tricks」——饱和保护、按坐标的学习率下限、校准损失监控——存在的全部目的就是让生产环境的在线学习行为可控。
Q:Bandit 怎么处理延迟反馈? 两种模式。批量更新:攒 $k$ 条观测,一次性更新,如此往复。UCB 与 Thompson Sampling 都有证明:批量化最多让遗憾乘上 $\log k$ 因子。乐观计数:拉一次臂,立刻把 $n_i$ 加一并假设奖励 = 历史均值(或保守起见 = 0),等真实奖励回来再修正。生产用的是乐观版本,因为它能避免在延迟窗口内对单一臂过度采样。
Q:什么时候不应该上实时管道? 特征本身缓变(人口属性、长期口味 Embedding);或者陈旧推荐的代价很小(仓库搜索、目录浏览);或者你测不出收益(低流量场景从 1 小时到 1 秒的新鲜度提升淹没在噪声里)。一条实时管道的成本约是离线日批的 10-100 倍——挣到这份钱再上。
总结
- 实时推荐 = 两条管道,靠特征存储与模型注册中心粘合:异步写路径不停刷新状态,同步读路径在硬性 SLO 下提供服务。
- 延迟是尾部问题。 优化 p99,不是 p50——排序最重,蒸馏与量化的回报最大。
- 生产中的在线学习是 FTRL-Proximal。 这是 Google 公开的广告 CTR 算法,SGD/AdaGrad 是台阶。
- 流式 = Kafka + Flink。 Kafka 负责持久化传输,Flink 负责有状态计算,分布式快照支撑 exactly-once。
- 新鲜度有价格曲线。 行为特征值得付实时成本,人口属性与物品元数据不值得。
- Bandit 在排序器之上工作,不在物品层面工作。 它的动作空间是「哪种策略」,不是「哪个物品」。
- 漂移一定会发生。 检测它(z-score、ADWIN、DDM),廉价响应(拉高 $\eta$),保留回滚通道。
- 缓存 vs 计算不是二选一。 生产答案永远是混合——热路径实时、冷路径缓存,30-60 秒 TTL 用一小部分成本覆盖 95% 的流量。
我在每一个见过的大规模系统上都验证过的一条经验:只把真正能撬动 AUC 的东西做成实时,其余按它实际变化的频率配预算。
系列导航
本文是推荐系统 16 篇系列的第 15 篇。
| 上一篇 | 下一篇 | |
|---|---|---|
| 第十四篇:跨域推荐与冷启动解决方案 | 所有文章 | 第十六篇:工业级架构与最佳实践 |