年初我们内部立项了一个代号叫 20-ETH 的专项全称在文档里写的是“难度调整方向第 20 个实验版本”不过团队叫习惯了平时就只说代号。简单讲这个项目做的就是让在线刷题系统根据用户最近的答题表现动态调整后续推送题目的难度让不同水平的人都能处在一个“有点难、但又够得着”的状态。做过学习类产品的朋友应该都清楚这种感觉用户进来第一屏如果遇到太难或者太简单的题流失率立马上来反过来如果题目的难易程度一直不匹配后端的评测数据也会变得很不可靠。20-ETH 要解决的核心问题有三个一是新用户容易被高难度题劝退二是老用户觉得题目太简单导致评测结果没有区分度三是题库本身的难度分布不均匀静态规则很难照顾到所有人。本文会把整个项目的设计思路、参数标定、算法实现、上线验证以及我踩过的坑完整记录下来适合正在做学习系统、能力评测、游戏关卡自适应或者想在推荐链路里引入动态反馈机制的同学参考。1. 项目背景与整体思路拆解1.1 静态难度体系到底卡在哪里早期版本的系统用的是静态难度标签每道题上线前先人工定一个难度档位比如“简单、中等、困难”然后按用户的历史等级去推题。上线以后很快发现几个麻烦事。第一用户的真实水平和我们预设的能力等级不在一个评价体系里。有人做题很快但正确率不高有人正确率很高但耗时很长用单一的用户标签去匹配题目难度本质上是在用粗粒度变量解释细粒度行为。第二静态题库很难应对用户状态的动态变化。一个用户上午可能头脑清醒下午就状态下滑静态标签完全感知不到这种变化。第三也是最直接的A/B 测试里的指标很难看新手用户的首单完成率低活跃用户的次日留存也不理想。当时我们讨论过要不要直接上 IRT项目反应理论那套东西把用户能力和题目难度都建模成潜变量用逻辑回归去估算。数据同事很支持但工程侧评估下来发现一个问题整个题库的作答样本量还没有大到可以稳定拟合三参数模型的程度而且 IRT 模型上线后的解释成本太高业务同学看不懂参数出了问题也很难定位。妥协之后我们定了一个原则先用可解释的启发式规则把链路跑起来积累足够数据后再考虑上更复杂的统计模型。这个决策后来看是对的后面详细说。1.2 为什么最终选择了滑动窗口正确率启发式方案里也有好几个候选包括指数移动平均EMA、最近 N 题正确率、累计正确率还有基于响应时长的难度修正。我们最后选了“最近 N 题滑动窗口正确率”作为核心观测信号。原因是滑动窗口有三个优势。第一它天然只关心近期表现能捕捉用户状态的短期变化不会因为用户很久以前的作答把当前难度拖死。第二计算成本极低维护一个队列就能实现每来一次作答更新一次不需要在线的极大似然估计或者贝叶斯更新。第三容易解释任何时候你都能指着曲线说“这个用户最近 30 题对了 18 题正确率 0.60低于目标区间下限所以难度需要下调”业务方听起来不费劲。对比之下EMA 虽然实现更简洁但它的调参抽象业务同学很难理解“平滑系数 0.2”是什么意思累计正确率则完全不适合做在线调整因为历史一长单次作答对整体比例的影响微乎其微。启动阶段我们甚至没有做任何复杂的去偏处理就是最简单的“对错二分”先把链路打通再考虑惩罚因子。1.3 难度调整引擎的整体架构整个引擎跑在判题服务之后、选题服务之前总共拆成三个模块观测器、调整器、限幅器。观测器负责维护每个用户最近一段时间的作答记录。这里不光是正确率还包括每道题的难度值、作答时间、题目知识点标签。调整器的输入是观测器算出来的近期正确率输出是一个难度增量可能为正也可能为负。限幅器是所有规则的最后一道闸门它保证单次调整幅度不会超过设定阈值同时也保证用户难度状态永远落在合法区间内。架构上我们刻意让三个模块保持无状态每次请求都走一个独立的纯函数计算所有状态只放在 Redis 和用户行为日志里。这样做的直接好处是方便做灰度发布和回滚新算法版本在老版本旁边并行跑一段时间对比产出结果发现问题随时切换。这个设计在后面排查线上问题时帮了大忙。2. 难度参数设计与核心算法细节2.1 难度值的定义与归一化所有算法讨论的前提是先统一难度值的表示方式。我们在 20-ETH 里把难度定义成一个 0 到 1 之间的浮点数0 表示最简单1 表示最难。为什么不用 0 到 100 的整数因为连续浮点数在调整时更平滑不会因为一次调整越过了某个边界而产生跳变。举个例子如果难度值只允许取 20、40、60、80 这些档位那么微调步长 2 根本没法生效只有跨档位时用户体感才变化这会让曲线看起来像是阶梯而非平滑过渡。题目侧和用户侧各有一个难度值但含义不同。题目的难度值是一个相对固定的标签来自历史作答数据的校准用户的难度状态是一个动态变量代表当前引擎认为“这个用户比较适合做哪个难度的题”。选题时推荐模块会在题库里找难度值最接近用户难度状态的题目。这个映射关系我们花了很長時間对齐刚开始两边定义不一致的时候系统会出现“用户难度状态 0.6推的题却是 0.2”的诡异现象排查了整整半天才发现是归一化口径错了。2.2 目标正确率区间与步长的标定调整器需要一个“健康区间”作为判断标准正确率落在这个区间内就保持难度不变超出区间则向反向修正。20-ETH 里我们把默认目标区间定为 0.60 到 0.70。这个区间是有依据的。0.60 到 0.70 在多数评测场景里被认为是“心流区间”用户不会因为题目太难而频繁失败产生挫败感也不会因为太简单而觉得无聊失去挑战。我们当时用小规模真人在线实验对比了 0.50~0.60、0.60~0.70、0.70~0.80 三档结果 0.60~0.70 这一档在完题率和重复访问次数上都明显更好。步长的设计比想象中讲究。最简单的是固定步长比如不管偏差多大每次难度只调 0.02。固定步长的好处是稳定但问题在于收敛太慢一个正确率只有 0.40 的深度劣势用户可能要经过非常多次调整才回到目标区间。我们最终采用的是动态步长核心思路是偏差越大单次调整幅度越大但设定上限保证不会飞出去。具体计算公式我放在后面第三部分的代码里先用文字描述设偏差为 recent_accuracy 与目标区间中值的差步长等于基础步长乘以偏差的绝对值再做一次截断。这样既能在偏差大的时候快速回调又能在接近目标区间时逐渐减小调整力度避免在临界点反复横跳。2.3 滑动窗口与统计口径的选择滑动窗口的长度 W 是另一个关键参数。窗口太小比如 W10单次作答对正确率的影响太大波动会很剧烈窗口太大比如 W100用户状态变化又反映得太慢失去“动态调整”的意义。我们先用历史数据做了回放测试把过去一个月的用户作答日志拿出来分别用 W20、30、50 跑模拟看谁的难度收敛曲线更平滑。结果 W30 综合最好既保持了足够的灵敏度又不会因为两三次偶然的答错就大幅下调难度。这里还隐藏了一个细节滑动窗口的统计口径要根据内容类型区别对待。如果用户做的是 10 分钟的长题30 题窗口可能意味着一周的作答量响应速度根本跟不上如果是几秒钟一道的快题30 题窗口可能一天就跑完了。我们的场景以中短题为主所以 30 题是合理的。你如果用在自己的场景里建议先算一下用户每日平均做题数再反推窗口天数别生搬硬套。2.4 新题目与新用户的冷启动处理冷启动是这种系统最容易翻车的地方我们分两条线处理。新用户侧我们设计了一个“定位期”。前 8 道题不进入正式调整循环而是刻意混合简单、中等、偏难三种难度的题目用这几道题的作答结果快速估算用户的初始难度状态。定位期结束后把计算结果作为调整器的初始值。这样做的好处是避免了从默认难度开始逐步爬升的漫长过程体验更好的同时也减少了对有经验的用户的“试探感”。新题目侧我们遇到的情况是题库运营同学每天都会录入新题这些题没有任何历史作答数据。如果贸然给一个精准难度值很容易误导调整器。我们的做法是新题先给一个基于内容特征的预置难度积累 20 次作答后开始用后验正确率校准难度标签。校准公式同样用滑动窗口只不过窗口设在题目侧而不是用户侧。这里必须提醒一句新题尽量不要直接推给处在冷启动期的用户否则两道题的难度误差会叠加最后你根本分不清是用户状态算错了还是题目难度标错了。我们后来在推荐逻辑里加了限制新题优先给有大量历史行为的成熟用户作为难度标签校准样本。3. 实操过程与核心环节实现3.1 调整算法的 Python 示例核心逻辑用一个类实现保留了线上版本的骨架去掉了一些和业务耦合的部分方便你直接看思路。from collections import deque import numpy as np class DifficultyAdjuster: def __init__(self, window_size30, target_low0.60, target_high0.70, base_step0.02, max_step0.06, min_diff0.05, max_diff0.95): self.window_size window_size self.target_low target_low self.target_high target_high self.target_mid (target_low target_high) / 2.0 self.base_step base_step self.max_step max_step self.min_diff min_diff self.max_diff max_diff def _clip(self, value): return max(self.min_diff, min(self.max_diff, value)) def _compute_step(self, deviation): # 偏差绝对值越大步长越大但限制在 max_step 以内 step self.base_step * abs(deviation) * 10 return min(self.max_step, step) def update(self, recent_results, current_difficulty): if len(recent_results) 0: return current_difficulty # 观测器计算滑动窗口内的正确率 recent_accuracy np.mean(recent_results) # 偏差正确率相对目标区间中值的位置 deviation recent_accuracy - self.target_mid # 在目标区间内不调整 if self.target_low recent_accuracy self.target_high: return current_difficulty if deviation 0: # 正确率高于目标说明题太简单提高难度 new_difficulty current_difficulty self._compute_step(deviation) else: # 正确率低于目标说明题太难降低难度 new_difficulty current_difficulty - self._compute_step(deviation) return self._clip(round(new_difficulty, 4))这个类的使用方式很简单维护一个 deque每来一次新作答就 append 一个 0 或 1超过窗口长度时从左侧 pop然后把整个 deque 传给update拿到新的难度状态。这里有几个刻意为之的设计点偏差和步长的关系里乘了一个 10是为了让基础步长 0.02 在偏差 0.1 时变成 0.02在偏差 0.3 时变成 0.06量级上更好理解。你可以根据实际需要调整这个系数但建议保留上限保护。用target_mid减去实际正确率得到偏差而不是用区间边界。如果正确率落在区间内则直接返回这是一个小的容错优化避免用户在临界区间内被来回调整。_clip把难度限制在 0.05 到 0.95 之间而不是 0 到 1是因为极端难度在现实中几乎不存在留出边界可以防止由于单道错题导致状态归零或拉满。3.2 参数计算示例走一遍真实数字我拿一个模拟场景把数字过一遍这样你能直观感受到调整幅度是怎么算出来的。假设某用户当前难度状态是 0.50最近 30 题答对了 24 题正确率 0.80。目标区间是 0.60~0.70中值 0.65。偏差 0.80 - 0.65 0.15。步长 0.02 * 0.15 * 10 0.03。因为正确率高于目标代表题目偏简单所以新的难度状态 0.50 0.03 0.53。这个幅度听起来不大但连续几次推进后效果就出来了。如果偏差到了 0.30步长会被截断在 0.06防止单次跳变过大。再看反向场景。用户当前难度 0.50最近 30 题只对了 12 题正确率 0.40。偏差 0.40 - 0.65 -0.25。步长 0.02 * 0.25 * 10 0.05。因为正确率低于目标新的难度状态 0.50 - 0.05 0.45。两次调整之后用户难度就会来到 0.40 附近。还有一个容易被忽略的细节正确率本身在边界附近时要不要做显著性判断比如窗口 30 题正确率 0.63 和 0.67 其实没有本质区别但它们一个低于中值一个高于中值在边界逻辑里会触发不同方向的调整。我们的解决方案就是把目标区间设置得比实际觉得“健康”的范围宽一点比如内部认为 0.65 最理想但区间设置成 0.60~0.70这样就能把统计噪声吃进去一大部分。3.3 离线回放与上线前验证算法写完不等于能上线我们做了两轮验证。第一轮是历史日志回放。取过去一个月的用户作答记录按照时间顺序重新跑一遍难度调整看每个用户的难度曲线是不是收敛到合理区间。这轮的价值在于没有线上风险可以反复调整参数。回放时我们重点看两个指标一是难度曲线的振荡幅度二是收敛到目标正确率区间所需的平均作答数。以 W30、基础步长 0.02 为基准回放结果平均大概需要 50 到 80 次作答才能让正确率稳定进区间。第二轮是模拟器压力测试。用程序生成三组虚拟用户弱水平、中等水平、强水平分别设定他们的真实答对概率为 0.40、0.65、0.90然后模拟作答并跑调整逻辑。理想情况下弱水平用户的难度状态应该逐步下降并停在低位中等水平用户停在中位附近强水平用户逐步上升。这一步主要验证算法没有系统性偏向不会把所有人推到一个方向。两轮验证都通过后才进入线上灰度。灰度期我们只放 5% 的流量观察对象是分桶用户的平均正确率、难度分布和完题率。这里要特别强调一下上线前一定要把“回滚开关”准备好一旦分桶指标出现异常直接切回旧逻辑不需要发新代码。我们线上灰度时就遇到过一个问题后面在常见问题部分细说。4. 常见问题与排查技巧实录4.1 困难一难度曲线震荡不收敛灰度大概第三天监控群里就看到一张让人冒汗的曲线某分桶用户的平均难度值在 0.30 到 0.70 之间来回大幅摆动像心跳图一样。排查路径是这样的先看是不是步长设置过大。我们当时把基础步长从 0.02 提到过 0.04结果震荡幅度肉眼可见地放大回滚之后明显改善。再看是不是窗口太短。W10 时用户偶然连错三题就会让正确率降到 0.70 以下触发下调连对几题又升上去触发上调。最后发现还有一个隐蔽问题同一个知识点下高质量题目数量不足导致用户不是在同难度区间内选题而是被动地在不同知识点的题目之间跳难度的可比性变差了。解法是组合拳窗口从 10 调整到 30基础步长从 0.04 降回 0.02同时在选题逻辑里增加“知识点内选题”的约束保证难度状态的变化是在同一知识主题下比较。这个坑告诉我们难度调整不是纯算法问题它依赖题库的充足且均匀的覆盖度。4.2 困难二冷启动期用户被过度试探定位期上线前我们只用一道中等难度题作为所有新用户的起始点。结果新用户里的高能力用户大量反馈“题目太简单”低能力用户又觉得“太难”两拨人都不满意。定位期设计成 8 道混合难度题以后情况好了一些但出现了新的副作用定位期那几道题本身难度不均有些用户连续撞上高难度题还没进入正式调整人就跑了。后来我们在定位期的选题策略里加了顺序约束前三题按简单、中等、简单排列哪怕后续的定位题比较难起码第一印象不会太挫败。更细的一个技巧是定位期内不做“正式的难度状态更新”但会把每题作答数据喂给一个临时记录器定位期结束一次性算出初始难度。这样避免用户在定位期的表现被重复计入后续滑动窗口造成同一个行为被加权两次。4.3 困难三题目难度标签落后于实际水平系统上线运行两个月后我对比了题目的预置难度和后验正确率发现相当一部分题目的实际难度和标签差了超过 0.15。原因在于人工标注的难度本身带有主观性同时随着用户群体的构成变化同一道题对不同人群的真实难度也在变化。我们的解决方案是给题目侧也建了一个难度校准任务每 24 小时跑一次离线批处理。具体做法是把每道题最近一次滑动窗口内所有用户的作答结果聚合成后验正确率再映射回题目难度。注意这里不能用全局平均正确率必须按“作答该题的用户当时所处的难度状态”做分层校准否则高难度题因为只有高能力用户会做到后验正确率可能会显得虚高误导校准公式。这轮校准上线后推荐模块的匹配准确率提升非常明显最直接的表现是用户平均正确率从 0.68 上升到了 0.72因为题目的难度标签和用户状态在同一个坐标系里了。4.4 监控、告警与熔断机制的落地最后这部分是给系统兜底的前面所有算法逻辑再精巧没有可靠的监控就是裸奔。我们设置了三个核心告警指标。第一个是“全站平均正确率”如果这个值在短时间内陡升或陡降超过 5 个百分点说明调整器可能出现了系统性偏差立刻触发人工介入。第二个是“难度分布比例”正常情况下用户难度状态应该呈近似正态分布如果某个难度区间突然堆积了大量用户可能是选题逻辑出了问题。第三个是“调整器空转率”即用户连续多次作答但难度状态始终不变这可能意味着题库里没有合适难度的题目可推或者调整器被限幅器卡死了。熔断机制同样重要。有一次线上题库的缓存出现故障导致选题服务只能从一小撮旧题里选那批题难度全部集中在中位区间调整器无论怎么调整推题的难度都不变。如果我们没有在推荐模块里做“难度匹配失败则回落随机选题”的熔断整个系统的用户都会被困在一组不合适的题里。我们的熔断规则非常保守连续 5 次未能选出与用户难度状态偏差小于 0.15 的题目就自动切换到随机选题并记录一条日志。这种状态下评测数据的可信度会下降但至少用户的做题体验不会被拖垮等缓存恢复后系统自动回到正常链路。5. 过程中的几点复盘与心得体会项目做到后期回头再看最初的决策最庆幸的是没有一上来就扑向复杂的统计模型。先用规则把整个链路打通、把数据埋点做好是让后续一切优化都变得可视化的基础。可视化这一点怎么强调都不为过疑难杂症几乎都是靠难度曲线图找出原因的光看报表里的平均数是看不出问题的。关于参数调优我也得到一个挺扎心的经验没有一组参数是放之四海而皆准的。窗口长度、目标正确率区间、步长系数这三者之间存在明显的耦合关系调其中一个就必须考虑另外两个。我们最终采用的 30/0.60-0.70/0.02 组合完整跑了一个季度效果稳定但如果你换一个题型、换一种用户构成这个组合很可能直接失效。比如同样算法用到长答题场景时W30 可能代表一个月的作答量响应速度就完全跟不上了。另外值得一说的是“可解释性”在协作中的价值。业务同事问“为什么这个人难度一直在涨”时我们不需要解释概率密度函数只需要把最近 30 题的正确率和目标区间摆出来一句话就能说清楚。这也是这套方案能在团队里扎下根的原因。最后分享一个小细节上线初期我们把所有调整事件都以日志形式完整记录下来包括时间戳、旧难度、正确率、新难度、触发的规则分支。这个设计后来帮了大忙几乎每次排查问题都靠它还原现场。如果你也要做类似系统强烈建议从一开始就把这条审计日志留好它会成为你后续优化最重要的线索。