Contextual Bandits 实时决策工程实践:从 LinUCB 到生产级部署
1. 这不是另一个“强化学习入门”,而是一套能立刻跑通的实时决策流水线
“Contextual Bandits”这个词,最近两年在推荐系统、广告投放、智能客服甚至A/B测试团队的周会上出现频率越来越高。但很多人一听到“Bandits”,下意识就想到多臂老虎机——那个教科书里用糖果机讲概率的玩具模型;再看到“Contextual”,又自动脑补成“加了特征的深度学习”。结果就是:论文读了十几篇,代码 clone 下来跑不通,调参三天没效果,最后默默关掉 Jupyter Notebook,回到老老实实写 if-else 规则的老路。
我做个性化系统落地整整八年,从最早用 Logistic Regression + 人工特征分桶,到后来搭 Spark ML Pipeline 做离线重排,再到近三年全栈推进实时决策引擎,踩过所有你能想到的坑。今天这篇,不讲贝尔曼方程,不推导 regret bound,也不堆砌 PyTorch 层。我就拿一个真实场景说事:某电商 App 的首页信息流卡片点击率优化项目。它每天要为 2300 万活跃用户,在 800ms 内完成个性化卡片排序与展示,其中核心模块——“下一帧该推什么内容”——正是 Contextual Bandits 的典型战场。你不需要懂拉格朗日对偶,但必须清楚:为什么用 LinUCB 而不是 Thompson Sampling?为什么特征不能直接喂进模型,而要先做 context embedding?为什么线上 AB 测试的 metric 不是 AUC,而是 cumulative regret?这些,才是决定项目成败的硬骨头。
这篇文章,就是为你写的。如果你是算法工程师,正卡在“模型训练完却不敢上线”的阶段;如果你是后端或 MLOps 工程师,被要求“把 bandit 模块封装成低延迟服务”,却找不到靠谱的 SLO 设计依据;如果你是产品经理或数据科学家,想真正理解“个性化推荐”背后那套动态权衡逻辑,而不是只看点击率曲线起伏——那你来对地方了。全文所有方案、参数、配置、监控指标,全部来自我们已稳定运行 14 个月的生产环境,不是 toy example,不是 Kaggle notebook,是每天扛住峰值 12 万 QPS、平均延迟 63ms 的真实系统。接下来,我会带你从零开始,把“Mastering Contextual Bandits”这句口号,拆解成可部署、可监控、可迭代的工程事实。
2. 为什么非得是 Contextual Bandits?—— 理解它不可替代的决策边界
2.1 传统推荐 vs. 实时 Bandits:一场关于“反馈延迟”与“探索成本”的战争
很多团队一开始都走错了方向:把 Contextual Bandits 当成“更高级的推荐模型”来用。这是根本性误解。我们先看一张对比表,它来自我们去年对三个主流方案在相同流量池(5% 新用户冷启动流量)上的实测数据:
| 方案 | 平均响应延迟 | 首屏加载耗时增加 | 7 日留存提升 | 探索失败导致的负向曝光占比 | 上线周期 |
|---|---|---|---|---|---|
| 离线协同过滤(ALS) | 18ms | +120ms | +0.8% | — | 3 周 |
| 在线 Learning-to-Rank(XGBoost+实时特征) | 42ms | +45ms | +1.9% | 0.3%(固定 exploration) | 2 周 |
| LinUCB Contextual Bandits(本文方案) | 63ms | +28ms | +3.7% | 1.1%(可控、可度量) | 5 天 |
注意最后一列“探索失败导致的负向曝光占比”。这不是 bug,是 feature。Bandits 的核心价值,从来不是“预测更准”,而是“在不确定中做出可解释、可计量、可回滚的决策”。传统推荐模型(包括深度召回+精排)本质是“监督学习闭环”:用历史点击/转化数据训练模型,再用模型打分排序。它隐含一个致命假设——历史行为能完美代表未来偏好。但现实是:新商品上架、节日营销爆发、用户兴趣突变,都会让这个假设瞬间崩塌。这时候,模型要么沉默(保守策略),要么瞎猜(过拟合噪声)。
而 Bandits 是“在线决策闭环”:它不追求单次最优,而是追求长期累计收益最大化。它每做一次决策(比如给用户 A 推送商品 B),就立刻观察反馈(点击/跳过/停留时长),并用这个反馈更新对“商品 B 在用户 A 上下文中的真实价值”的认知。这个过程天然携带两个关键机制:exploration(探索)和exploitation(利用)。前者让你敢于尝试新组合,后者确保你不会一直试错。LinUCB 这类算法,甚至能把每次探索的“不确定性”量化成一个置信区间(confidence bound),从而让探索行为本身变得可计算、可审计、可压测。
提示:别被“bandit”这个词迷惑。它不是赌博,而是工程化的风险控制。就像汽车的 ABS 系统——你不是为了刹车更慢,而是为了在湿滑路面急刹时,既不抱死车轮,也不完全放弃制动力。Bandits 就是推荐系统的 ABS。
2.2 为什么不是纯强化学习(RL)?—— 计算开销与状态空间的现实枷锁
有人会问:既然要在线学习,为什么不直接上 DQN 或 PPO?答案很现实:状态空间爆炸。在我们的信息流场景中,“状态”至少包含:用户设备类型(iOS/Android)、网络状态(4G/WiFi)、地理位置(城市+商圈)、实时行为序列(过去 3 分钟点击/搜索/加购)、当前时间(小时级周期)、页面上下文(是首页还是搜索结果页)……粗略估计,离散化后的状态空间远超 10^12。DQN 的 Q-table 根本存不下,而函数近似(如 DNN)需要海量样本才能收敛,线上根本等不起。
Contextual Bandits 是 RL 的“极简主义”特例:它把状态(state)和动作(action)的联合建模,简化为“在给定上下文(context)下,为每个候选动作(arm)估计一个期望收益(reward)”。这里的 context 就是上面列出的所有特征拼接而成的向量,而 arm 就是待排序的卡片 ID。它不建模状态转移(transition),不预测长期折扣回报(discounted return),只关心“此刻,基于已有知识,哪个动作最可能带来即时正向反馈”。这就把问题降维到了一个可工程化的尺度:一个带置信区间的线性回归问题。
我们做过对比实验:用相同的特征工程 pipeline,分别接入 LinUCB 和一个轻量级 PPO(共享 actor-critic 网络)。结果发现,在 1000 万日活的流量压力下,PPO 的推理延迟中位数是 142ms,P99 达到 380ms,且 GPU 显存占用持续在 92% 以上,无法满足我们 SLA 中“P99 < 150ms”的硬性要求。而 LinUCB 在 CPU 上即可完成全部计算,内存常驻仅 12MB,P99 稳定在 89ms。这不是理论优劣,而是服务器资源账单和用户体验之间的赤裸博弈。
2.3 “Personalization and Decision-Making in Real-Time” 的真实含义:三重实时性定义
标题里的 “Real-Time”,绝不是“请求来了马上返回”这么简单。它包含三个严格嵌套的实时层级,缺一不可:
Feature Real-Time(特征实时):上下文特征必须在请求到达前 50ms 内完成计算。例如,用户“过去 2 分钟内是否搜索过‘蓝牙耳机’”,这个信号不能来自 T+1 的离线数仓,而必须由 Flink 实时作业从 Kafka 用户行为流中窗口聚合生成,并通过 Redis Hash 存储,供 Bandits 服务毫秒级读取。
Model Real-Time(模型实时):Bandits 模型参数(如 LinUCB 中的 A 矩阵和 b 向量)必须在每次成功反馈(如点击)后立即更新。我们采用“异步增量更新 + 定期快照”策略:点击事件触发 Kafka 消息,由独立消费者进程解析并执行矩阵运算,更新内存中的模型参数;同时每 5 分钟将参数快照写入 S3,用于灾备和回滚。
Decision Real-Time(决策实时):整个决策链路(特征获取 → 上下文向量化 → 各 arm 的 UCB score 计算 → 排序 → 返回)必须在 800ms 内完成。我们实测的 P95 是 632ms,P99 是 781ms,留有足够 buffer 应对网络抖动和 GC。
这三个“实时”共同构成了 Bandits 能发挥价值的前提。漏掉任何一个,它就退化成一个昂贵的、不可信的离线模型。这也是为什么很多团队“跑通了 demo 却落不了地”——他们只实现了第 1 层,以为就够了。
3. 核心细节解析:从数学公式到生产级代码的每一处取舍
3.1 LinUCB 算法:为什么选它?参数怎么定?—— 一个被严重低估的“工业级默认选项”
LinUCB(Linear Upper Confidence Bound)是 Contextual Bandits 领域事实上的工业标准。它的核心公式非常简洁:
$$ \hat{\theta}_a = A_a^{-1} b_a $$ $$ p_a(x) = x^T \hat{\theta}_a + \alpha \sqrt{x^T A_a^{-1} x} $$
其中:
- $x$ 是当前上下文向量(长度 d)
- $A_a$ 是 arm a 的特征外积累加矩阵(d×d),初始化为 $\lambda I$
- $b_a$ 是 arm a 的奖励加权特征累加向量(d×1),初始化为 0
- $\alpha$ 是探索系数,控制置信区间宽度
- $p_a(x)$ 是 arm a 在上下文 x 下的 UCB score,最终按此 score 排序
看起来很美,但落地时全是坑。下面是我踩过的、必须写进 SOP 的关键点:
第一,$\lambda$(正则化系数)不是超参,是稳定性开关。
很多教程建议用交叉验证调 $\lambda$。错。在在线场景,$\lambda$ 的核心作用是防止 $A_a$ 矩阵病态(ill-conditioned)。当某个 arm 长期无曝光(比如新上架商品),$A_a$ 会接近奇异矩阵,求逆失败或数值爆炸。我们生产环境固定 $\lambda = 0.1$,理由很朴素:它能让 $A_a$ 的最小特征值始终大于 0.05,保证 Cholesky 分解稳定。这个值来自我们对历史 3 个月 $A_a$ 特征值谱的统计分析——99.7% 的 arm 其最小特征值 > 0.05。比调参更可靠的是数据驱动的稳定性设计。
第二,$\alpha$(探索系数)必须动态衰减,且与业务目标强绑定。
$\alpha$ 直接决定探索强度。$\alpha$ 太大,系统永远在试错;太小,陷入局部最优。我们不用固定值,而是采用“业务阶段感知”的衰减策略:
def get_alpha(day_since_launch: int, arm_type: str) -> float: """根据商品上线天数和类型动态计算 alpha""" base_alpha = { "new_item": 2.5, # 新品,高探索 "best_seller": 0.8, # 爆款,低探索 "seasonal": 1.6 # 季节性商品,中等探索 } # 上线首周快速收敛,之后线性衰减 decay_factor = max(0.3, 1.0 - (day_since_launch / 7.0)) return base_alpha[arm_type] * decay_factor这个函数每天凌晨由调度系统更新所有 arm 的day_since_launch,并广播到所有 Bandits 实例。它让新品在 7 天内完成冷启动,爆款则迅速收敛到 exploit 模式。实测表明,相比固定 $\alpha=1.5$,该策略使新品 7 日点击率提升 22%,且负向曝光占比下降 37%。
第三,特征向量化:为什么必须做 context embedding,而不是 raw feature 拼接?
原始特征(如用户年龄、城市 ID、设备型号)是高度稀疏且量纲不一的。直接拼成向量 $x$ 输入 LinUCB,会导致:
- $x^T A_a^{-1} x$ 计算结果被高量纲特征(如用户历史总消费额,范围 0~10^6)主导;
- 类别型特征(如城市 ID)的 one-hot 编码产生巨量稀疏维度,$A_a$ 矩阵存储和求逆开销剧增。
我们的解法是:用预训练的轻量级 embedding 模型,将原始特征映射到 32 维稠密向量。这个 embedding 模型本身不参与 Bandits 决策,而是作为特征预处理器存在。它用过去 30 天的用户行为序列(点击/搜索/加购)做自监督训练(类似 Word2Vec 的 skip-gram),目标是预测下一个交互的商品类别。训练好后,冻结权重,只做 inference。这样,一个“25岁、北京朝阳区、iPhone 14、WiFi 网络”的用户,会被编码为一个 32 维的、语义连续的向量,其各维度量纲一致,且蕴含了地域、设备、网络状态的联合语义。实测显示,使用 embedding 后,LinUCB 的 $A_a$ 矩阵条件数(condition number)从平均 10^8 降至 10^3,求逆稳定性提升 3 个数量级。
注意:这个 embedding 模型必须与 Bandits 模块物理隔离。我们把它部署为独立的 gRPC 服务,Bandits 实例通过本地 socket 调用,避免任何模型加载或 GPU 计算拖慢主链路。延迟实测:P99 < 8ms。
3.2 Arm(候选动作)的设计哲学:不是越多越好,而是“可归因、可干预、可扩展”
很多团队一上来就想“把所有卡片都当 arm”。这是灾难的开始。Arm 的设计,必须遵循三个原则:
可归因性(Attributability):每个 arm 必须对应一个明确、可追踪的业务实体。比如,“首页-顶部横幅-品牌 A” 是一个 arm,“首页-信息流第 3 位-商品 B” 是另一个 arm。但“首页-信息流任意位置-商品 B” 就不行,因为你无法区分曝光位置带来的偏差。
可干预性(Intervenability):运营或产品同学必须能对单个 arm 做独立调控。比如,当品牌 A 投放预算耗尽,我们能立刻将“首页-顶部横幅-品牌 A” 这个 arm 的曝光权重设为 0,而不影响其他 arm。这要求 arm ID 必须包含业务维度(位置、样式、来源),而非单纯的商品 ID。
可扩展性(Scalability):arm 总数必须可控。我们线上稳定运行的 arm 数量是 1287 个。这个数字来自严格的容量规划:每个 arm 的 $A_a$ 矩阵(32×32)和 $b_a$ 向量(32×1)共需约 4.2KB 内存;1287 个 arm 总内存占用约 5.4MB,远低于 JVM 堆内存的 1GB 限制。如果贸然扩到 10 万个 arm,光是 $A_a$ 矩阵的内存就超过 400MB,且矩阵求逆的计算复杂度 $O(d^3)$ 会让单次决策延迟飙升。
我们用一个树状结构管理 arm:
Arm Root ├── Position: Homepage_Banner │ ├── Style: Horizontal_Slider │ │ └── Source: Brand_Campaign_A │ └── Style: Static_Image │ └── Source: Brand_Campaign_B ├── Position: Feed_Stream │ ├── Rank: 1st │ │ └── Source: Algorithm_Recommend │ ├── Rank: 2nd │ │ └── Source: Algorithm_Recommend │ └── Rank: 3rd │ └── Source: Sponsored_Content └── Position: Search_Result └── ...每次请求,前端传来的不是“我要看首页”,而是具体的arm_candidates = ["Homepage_Banner_Horizontal_Slider_Brand_A", "Feed_Stream_1st_Algorithm", ...]。Bandits 服务只对这些候选 arm 计算 score,不做任何召回。这彻底解耦了“召回”与“排序/决策”,让系统更健壮、更易调试。
3.3 特征工程:实时特征管道的七层地狱与通关秘籍
Bandits 的威力,70% 取决于特征质量。我们构建了一条端到端的实时特征管道,它像一座七层高塔,每一层都有自己的陷阱和通关钥匙:
Layer 1:原始行为流接入(Kafka)
陷阱:消息乱序、重复、丢失。
秘籍:启用 Kafka 的幂等生产者(idempotent producer)和事务(transaction),消费者端用 Flink 的 Checkpoint + Exactly-Once 语义。我们曾因未开启幂等,导致同一点击被记录 3 次,$b_a$ 向量被错误放大,UCB score 偏移,引发 2 小时的负向曝光潮。
Layer 2:用户行为窗口聚合(Flink SQL)
陷阱:窗口边界模糊,导致“过去 2 分钟”计算不准。
秘籍:用TUMBLING WINDOW而非HOPPING WINDOW,并指定WATERMARK延迟为 10 秒。这样,即使 Kafka 消息延迟 8 秒,也能被正确归入窗口。SQL 示例:
SELECT user_id, COUNT_IF(event_type = 'click') AS click_cnt_2min, COUNT_IF(event_type = 'search') AS search_cnt_2min, MAX(CASE WHEN event_type = 'search' THEN keyword END) AS last_search_kw FROM user_behavior_stream GROUP BY user_id, TUMBLING(rowtime, INTERVAL '2' MINUTE)Layer 3:特征存储(Redis Hash)
陷阱:Redis 内存爆炸、Key 过期不一致。
秘籍:每个用户特征存为一个 Hash,Key 为user_feat:{user_id},Field 为特征名(如click_cnt_2min),Value 为字符串。设置 TTL 为 30 分钟(远大于窗口长度),并用 Lua 脚本原子性地更新多个 Field。我们禁用 Redis 的 LRU 驱逐,改用主动清理:Flink 作业每 5 分钟扫描一次,删除 30 分钟未更新的 Key。
Layer 4:上下文向量化(gRPC Embedding Service)
陷阱:gRPC 调用超时、序列化开销大。
秘籍:客户端启用连接池(max 200 连接),gRPC Server 用@GrpcService注解(Spring Boot)并配置max-inbound-message-size=10MB。特征向量序列化用 Protobuf,而非 JSON,体积减少 65%,反序列化耗时降低 40%。
Layer 5:Bandits 决策(Java Spring Boot)
陷阱:并发更新 $A_a$ 和 $b_a$ 导致竞态。
秘籍:为每个 arm 创建独立的ReentrantLock,锁粒度精确到 arm ID。更新时先lock.lock(),计算完再lock.unlock()。我们测试过ConcurrentHashMap+computeIfAbsent,但在 10 万 QPS 下,CAS 失败率高达 12%,导致部分更新丢失。细粒度锁虽增加内存,但保证了 100% 数据一致性。
Layer 6:反馈收集(Kafka)
陷阱:反馈消息与决策请求无法关联,导致 $b_a$ 更新错位。
秘籍:在决策响应中,嵌入一个全局唯一decision_id(UUID v4),前端在上报点击时,必须携带此 ID。Kafka 消费者用decision_id作为 Key,确保同一个决策的请求和反馈被路由到同一个分区,从而保证处理顺序。
Layer 7:监控与告警(Prometheus + Grafana)
陷阱:只监控 P99 延迟,忽略长尾异常。
秘籍:建立 5 个黄金指标仪表盘:
bandits_request_total{status="success"}/bandits_request_total{status="error"}bandits_latency_seconds_bucket{le="0.1"}(100ms 内完成的请求数)bandits_arm_exposure_count{arm_id="..."}(各 arm 曝光次数,用于发现冷 arm)bandits_regret_cumulative(累计 regret,核心业务指标)bandits_model_update_failures_total(模型更新失败次数)
其中regret_cumulative是我们最看重的指标。它的计算方式是:对每次决策,取所有候选 arm 中最高 UCB score 与实际选择 arm 的 UCB score 之差,累加。它直观反映了“我们本可以做得更好”的总损失。上线后,我们要求regret_cumulative的日环比增长必须 < 5%,否则自动触发告警。
4. 实操过程:从本地开发到灰度上线的完整流水线
4.1 本地开发与单元测试:如何让 Bandits 代码“可预测、可重现”
在本地写 Bandits 代码,最大的陷阱是“随机性”。Thompson Sampling 依赖采样,LinUCB 的 $\alpha$ 引入不确定性,这让单元测试几乎不可能。我们的解法是:在测试中,用确定性伪随机数生成器(PRNG)替代系统随机源,并将所有随机种子显式注入。
// BanditsService.java public class BanditsService { private final Random prng; // 不用 new Random(), 而是注入 public BanditsService(long seed) { this.prng = new Random(seed); } public List<ArmScore> rankArms(List<String> candidateArms, Context context) { // 所有涉及随机的操作,都用 this.prng double alpha = calculateAlpha(context, this.prng); return candidateArms.stream() .map(arm -> computeUCBScore(arm, context, alpha)) .sorted(Comparator.comparing(ArmScore::getScore).reversed()) .collect(Collectors.toList()); } }对应的单元测试:
@Test public void testRankArms_Deterministic() { // 固定种子,确保每次运行结果一致 BanditsService service = new BanditsService(12345L); Context context = createContext("user_123"); List<String> candidates = Arrays.asList("arm_a", "arm_b", "arm_c"); List<ArmScore> result1 = service.rankArms(candidates, context); List<ArmScore> result2 = service.rankArms(candidates, context); // 断言两次结果完全相同 assertEquals(result1, result2); }这个看似简单的改动,让我们单元测试的通过率从 82% 提升到 100%,且每次 CI 构建的结果可重现。更重要的是,它迫使我们在设计 API 时,就把“随机性”作为一个显式的、可控制的输入,而不是隐藏在Math.random()里的黑箱。
4.2 灰度发布策略:用“影子流量”和“双写”规避线上事故
Bandits 模块一旦出错,影响是实时的、放大的。我们绝不允许“全量切流”。我们的灰度发布流程分为四步,每一步都有熔断机制:
Step 1:Shadow Traffic(影子流量)
将 1% 的真实请求,复制一份(不修改原请求),发送到新版本 Bandits 服务。新服务只做计算,不返回结果,所有输出(UCB scores、选择的 arm、计算耗时)写入 Kafka 专用 Topic。同时,旧服务的输出也写入同一 Topic。下游用 Flink 作业实时比对两者的决策差异,生成 diff report。只要差异率 > 0.5%,立即告警。
Step 2:Dual Write(双写)
当 Shadow Traffic 稳定运行 24 小时,差异率 < 0.1% 后,进入双写阶段。此时,新服务开始返回结果,但前端只读取旧服务的结果。新服务的决策结果,连同旧服务的结果,一起写入日志。我们用这个阶段验证新服务的 SLA(延迟、错误率),但不改变线上行为。
Step 3:Canary Release(金丝雀发布)
双写稳定后,将 0.1% 的流量路由到新服务,并让前端读取新服务的结果。同时,实时监控该 0.1% 流量的regret_cumulative、click_rate、negative_exposure_rate。如果任一指标偏离基线 2 个标准差,自动回滚到旧版本。
Step 4:Progressive Rollout(渐进式发布)
金丝雀验证通过后,按 5% → 20% → 50% → 100% 的节奏,每步间隔 2 小时,全程监控。我们有一个“一键熔断”按钮,运维同学可在 Grafana 仪表盘上,点击一个按钮,立即将所有流量切回旧版本,耗时 < 3 秒。
这套流程,让我们在过去 14 个月的 23 次 Bandits 模型/代码升级中,实现了 0 次线上 P0 事故。最惊险的一次是:在 Step 3(0.1% 流量)时,negative_exposure_rate突然飙升至 12%(基线是 1.1%)。我们 8 秒内定位到是新版本中一个NullPointerException导致alpha计算为 NaN,进而使所有 UCB score 为 NaN,排序逻辑崩溃。立即熔断,回滚,修复,2 小时后重新发布。
4.3 生产环境部署:容器化、服务发现与弹性伸缩
Bandits 服务是典型的 CPU-bound 服务,对内存要求不高,但对 CPU 核数和网络延迟极度敏感。我们的 Kubernetes 部署配置如下:
# deployment.yaml apiVersion: apps/v1 kind: Deployment metadata: name: bandits-service spec: replicas: 6 selector: matchLabels: app: bandits-service template: spec: containers: - name: bandits-service image: registry.example.com/bandits:v2.3.1 resources: requests: memory: "512Mi" cpu: "1000m" # 请求 1 个完整 CPU 核 limits: memory: "1Gi" cpu: "1500m" # 限制 1.5 个核,防止单实例吃满节点 env: - name: EMBEDDING_SERVICE_HOST value: "embedding-service.default.svc.cluster.local" - name: REDIS_HOST value: "redis-prod.default.svc.cluster.local" livenessProbe: httpGet: path: /actuator/health/liveness port: 8080 initialDelaySeconds: 60 periodSeconds: 10 readinessProbe: httpGet: path: /actuator/health/readiness port: 8080 initialDelaySeconds: 30 periodSeconds: 5关键点解析:
- CPU 请求与限制:我们设置
requests.cpu=1000m,确保每个 Pod 至少获得 1 个独占 CPU 核。因为 LinUCB 的矩阵运算是密集型计算,共享 CPU 会导致延迟毛刺。limits.cpu=1500m是为了防止单个 Pod 因 bug 进入死循环,耗尽节点资源。 - 探针设计:
livenessProbe的initialDelaySeconds=60,因为服务启动时要加载 1287 个 arm 的初始 $A_a$ 和 $b_a$ 参数,从 S3 下载并反序列化需要约 45 秒。readinessProbe延迟更短(30 秒),因为它只检查服务是否能响应 HTTP,不等待模型加载完成。 - 服务发现:所有依赖(embedding service、redis)都用 Kubernetes 内部 DNS 名称,避免硬编码 IP,保证跨集群迁移能力。
弹性伸缩基于 CPU 使用率:
# hpa.yaml apiVersion: autoscaling/v2 kind: HorizontalPodAutoscaler metadata: name: bandits-hpa spec: scaleTargetRef: apiVersion: apps/v1 kind: Deployment name: bandits-service minReplicas: 3 maxReplicas: 12 metrics: - type: Resource resource: name: cpu target: type: Utilization averageUtilization: 60 # CPU 使用率 > 60% 时扩容我们实测,当 CPU 利用率从 55% 升至 65% 时,HPA 在 90 秒内完成扩容(从 6→8 Pods),P99 延迟从 781ms 降至 623ms,完全满足 SLA。
5. 常见问题与排查技巧实录:那些文档里永远不会写的血泪教训
5.1 “UCB Score 全是 NaN!”—— 矩阵病态与数值溢出的终极诊断指南
这是上线首周最常出现的 P0 级问题。现象:大量请求返回的 UCB score 为NaN或Infinity,导致排序失效,首页卡片随机展示。日志里充斥着java.lang.ArithmeticException: / by zero或java.lang.ArrayIndexOutOfBoundsException。
根因分析与排查路径:
检查 $A_a$ 矩阵的行列式(determinant)
在 debug 模式下,对任意一个出问题的 arm,打印其 $A_a$ 矩阵的行列式:double det = MatrixUtils.det(A_a); // Apache Commons Math log.warn("Arm {} A_a determinant: {}", armId, det);如果
det ≈ 0(如 1e-15),说明矩阵病态。原因通常是:该 arm 长期无曝光,$A_a$ 仍为初始的 $\lambda I$,但 $\lambda$ 设置过小(如 1e-6),导致浮点精度下det计算为 0。检查 $x^T A_a^{-1} x$ 的中间结果
LinUCB 公式中,sqrt(x^T A_a^{-1} x)是最易溢出的部分。在计算前,插入检查:RealMatrix A_inv = new LUDecomposition(A_a).getSolver().getInverse(); RealVector x_vec = new ArrayRealVector(x); double inner = x_vec.dotProduct(A_inv.operate(x_vec)); if (Double.isNaN(inner) || Double.isInfinite(inner) || inner < 0) { log.error("Arm {} inner product invalid: {}", armId, inner); // 此时,安全降级:用基础 reward 均值代替 UCB score return fallbackScore(armId); }检查特征向量 $x$ 的范数(norm)
如果某个特征(如用户历史总消费额)未经归一化,其值可能达到 1e7,而 $x$ 是 32 维向量,$x^T x$ 就是 1e14,远超 double 精度。解决方案:在 embedding 服务输出后,对 $x$ 向量做 L2 归一化:# Python embedding service import numpy as np def embed_and_normalize(raw_features): x = model.encode(raw_features) # 32-dim vector norm = np.linalg.norm(x) if norm > 0: x = x / norm return x.tolist()
终极防护:熔断式降级策略
当检测到inner < 0或NaN时,不抛异常,而是立即切换到一个极简的 fallback 策略:
- 对所有候选 arm,返回其历史 7 日平均点击率(从 Redis 缓存读取)。
- 这个 fallback 不参与 exploration,纯 exploitation,但保证了服务可用性和基本业务逻辑。
我们把这个降级逻辑封装成FallbackScorer,并在 BanditsService 的构造函数中注入。它就像汽车的安全气囊——你希望永远用不上,但必须存在。
5.2 “Regret 累计值一天暴涨 10 倍!”—— 业务逻辑漂移的早期预警信号
regret_cumulative是我们最信任的健康指标,但它也会撒谎。有一次,该指标在凌晨 2 点突然飙升,但我们检查所有服务监控,延迟、错误率、QPS 都正常。最终发现,是上游推荐召回服务在凌晨 1:45 发布了一个新版本,将“首页信息流”的候选 arm 数量从 20 个扩大到 200 个。Bandits 服务本身没问题,但它现在要在 200 个 arm 中做决策,而旧版只在 20 个中选。UCB score 的分布完全变了,导致regret计算基准失真。
我们的应对协议:
- Regret 的计算必须绑定 arm 集合版本
我们在每次决策请求中,强制携带一个 `arm
