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 日留存提升探索失败导致的负向曝光占比上线周期离线协同过滤ALS18ms120ms0.8%—3 周在线 Learning-to-RankXGBoost实时特征42ms45ms1.9%0.3%固定 exploration2 周LinUCB Contextual Bandits本文方案63ms28ms3.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 的推理延迟中位数是 142msP99 达到 380ms且 GPU 显存占用持续在 92% 以上无法满足我们 SLA 中“P99 150ms”的硬性要求。而 LinUCB 在 CPU 上即可完成全部计算内存常驻仅 12MBP99 稳定在 89ms。这不是理论优劣而是服务器资源账单和用户体验之间的赤裸博弈。2.3 “Personalization and Decision-Making in Real-Time” 的真实含义三重实时性定义标题里的 “Real-Time”绝不是“请求来了马上返回”这么简单。它包含三个严格嵌套的实时层级缺一不可Feature Real-Time特征实时上下文特征必须在请求到达前 50ms 内完成计算。例如用户“过去 2 分钟内是否搜索过‘蓝牙耳机’”这个信号不能来自 T1 的离线数仓而必须由 Flink 实时作业从 Kafka 用户行为流中窗口聚合生成并通过 Redis Hash 存储供 Bandits 服务毫秒级读取。Model Real-Time模型实时Bandits 模型参数如 LinUCB 中的 A 矩阵和 b 向量必须在每次成功反馈如点击后立即更新。我们采用“异步增量更新 定期快照”策略点击事件触发 Kafka 消息由独立消费者进程解析并执行矩阵运算更新内存中的模型参数同时每 5 分钟将参数快照写入 S3用于灾备和回滚。Decision Real-Time决策实时整个决策链路特征获取 → 上下文向量化 → 各 arm 的 UCB score 计算 → 排序 → 返回必须在 800ms 内完成。我们实测的 P95 是 632msP99 是 781ms留有足够 buffer 应对网络抖动和 GC。这三个“实时”共同构成了 Bandits 能发挥价值的前提。漏掉任何一个它就退化成一个昂贵的、不可信的离线模型。这也是为什么很多团队“跑通了 demo 却落不了地”——他们只实现了第 1 层以为就够了。3. 核心细节解析从数学公式到生产级代码的每一处取舍3.1 LinUCB 算法为什么选它参数怎么定—— 一个被严重低估的“工业级默认选项”LinUCBLinear 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 模式。实测表明相比固定 $\alpha1.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。可扩展性Scalabilityarm 总数必须可控。我们线上稳定运行的 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)$ 会让单次决策延迟飙升。我们用一个树状结构管理 armArm 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 过期不一致。秘籍每个用户特征存为一个 HashKey 为user_feat:{user_id}Field 为特征名如click_cnt_2minValue 为字符串。设置 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-size10MB。特征向量序列化用 Protobuf而非 JSON体积减少 65%反序列化耗时降低 40%。Layer 5Bandits 决策Java Spring Boot陷阱并发更新 $A_a$ 和 $b_a$ 导致竞态。秘籍为每个 arm 创建独立的ReentrantLock锁粒度精确到 arm ID。更新时先lock.lock()计算完再lock.unlock()。我们测试过ConcurrentHashMapcomputeIfAbsent但在 10 万 QPS 下CAS 失败率高达 12%导致部分更新丢失。细粒度锁虽增加内存但保证了 100% 数据一致性。Layer 6反馈收集Kafka陷阱反馈消息与决策请求无法关联导致 $b_a$ 更新错位。秘籍在决策响应中嵌入一个全局唯一decision_idUUID v4前端在上报点击时必须携带此 ID。Kafka 消费者用decision_id作为 Key确保同一个决策的请求和反馈被路由到同一个分区从而保证处理顺序。Layer 7监控与告警Prometheus Grafana陷阱只监控 P99 延迟忽略长尾异常。秘籍建立 5 个黄金指标仪表盘bandits_request_total{statussuccess}/bandits_request_total{statuserror}bandits_latency_seconds_bucket{le0.1}100ms 内完成的请求数bandits_arm_exposure_count{arm_id...}各 arm 曝光次数用于发现冷 armbandits_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 ListArmScore rankArms(ListString 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); ListString candidates Arrays.asList(arm_a, arm_b, arm_c); ListArmScore result1 service.rankArms(candidates, context); ListArmScore result2 service.rankArms(candidates, context); // 断言两次结果完全相同 assertEquals(result1, result2); }这个看似简单的改动让我们单元测试的通过率从 82% 提升到 100%且每次 CI 构建的结果可重现。更重要的是它迫使我们在设计 API 时就把“随机性”作为一个显式的、可控制的输入而不是隐藏在Math.random()里的黑箱。4.2 灰度发布策略用“影子流量”和“双写”规避线上事故Bandits 模块一旦出错影响是实时的、放大的。我们绝不允许“全量切流”。我们的灰度发布流程分为四步每一步都有熔断机制Step 1Shadow Traffic影子流量将 1% 的真实请求复制一份不修改原请求发送到新版本 Bandits 服务。新服务只做计算不返回结果所有输出UCB scores、选择的 arm、计算耗时写入 Kafka 专用 Topic。同时旧服务的输出也写入同一 Topic。下游用 Flink 作业实时比对两者的决策差异生成 diff report。只要差异率 0.5%立即告警。Step 2Dual Write双写当 Shadow Traffic 稳定运行 24 小时差异率 0.1% 后进入双写阶段。此时新服务开始返回结果但前端只读取旧服务的结果。新服务的决策结果连同旧服务的结果一起写入日志。我们用这个阶段验证新服务的 SLA延迟、错误率但不改变线上行为。Step 3Canary Release金丝雀发布双写稳定后将 0.1% 的流量路由到新服务并让前端读取新服务的结果。同时实时监控该 0.1% 流量的regret_cumulative、click_rate、negative_exposure_rate。如果任一指标偏离基线 2 个标准差自动回滚到旧版本。Step 4Progressive Rollout渐进式发布金丝雀验证通过后按 5% → 20% → 50% → 100% 的节奏每步间隔 2 小时全程监控。我们有一个“一键熔断”按钮运维同学可在 Grafana 仪表盘上点击一个按钮立即将所有流量切回旧版本耗时 3 秒。这套流程让我们在过去 14 个月的 23 次 Bandits 模型/代码升级中实现了 0 次线上 P0 事故。最惊险的一次是在 Step 30.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.cpu1000m确保每个 Pod 至少获得 1 个独占 CPU 核。因为 LinUCB 的矩阵运算是密集型计算共享 CPU 会导致延迟毛刺。limits.cpu1500m是为了防止单个 Pod 因 bug 进入死循环耗尽节点资源。探针设计livenessProbe的initialDelaySeconds60因为服务启动时要加载 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 PodsP99 延迟从 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