ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java智能CAI系统核心业务逻辑落地:掌握度评估、出题策略与复习推送

Java智能CAI系统核心业务逻辑落地:掌握度评估、出题策略与复习推送

做后端这几年,我见过不少挂着“智能”头衔的练习系统,本质就是个带筛选条件的题库。这种情况,项目验收的时候大概率会被问同一个问题:你到底智能在哪?回答不上来,就只能反复强调“能自动判分”“能生成报告”,但这些都只是标配而已。

这套Java智能CAI系统的核心价值,恰恰不在花哨的界面,也不在某个独立的算法模型,而在于业务逻辑的落地实现。学生在系统里每做一道题,背后至少要发生五件事:记录答题明细、更新知识点掌握度、评估是否进入下一难度、计算下次复习时间、决定下一次推送什么题。这些事情串在一起,环环相扣,任何一处考虑不周,都会导致学生走偏、数据失真、推荐混乱。今天就从真实项目中的几个关键代码片段出发,聊一聊这些业务逻辑是怎么落地,以及每一步设计取舍背后的理由。

这篇文章适合正在做、或者准备做教学系统、练习平台、自适应学习类后端项目的工程师阅读。如果你只想看“怎么写一个接口”,那这里没有太多东西;如果你想搞明白“为什么这个接口要这么设计”,那下面的内容应该能帮到你。

1. 内容整体设计与思路拆解

1.1 先想清楚“智能”到底指什么

很多团队一上来就讨论用哪种推荐算法、要不要上模型,结果数据质量跟不上,模型根本没法训练。实际上,CAI(Computer-Assisted Instruction,计算机辅助教学)系统真正该先解决的问题,是一个学习状态机的问题。

一个好用的教学活动闭环,大致是这样:根据学生当前的知识掌握情况,推送给合适的题目;学生作答之后,系统记录结果并刷新掌握度;根据新的掌握度决定下一道题的难度、题型、讲解策略;同时把薄弱知识点挂入复习队列,安排后续的复习时间。

这个闭环里,最复杂的不是判分,而是状态流转。

同一道题,一个学生昨天连续做对了三次,今天错了一次,那么他应该被判定为什么档位?是重新退回“不熟练”,还是只轻微降级?另一个学生,连续三次做对但每次间隔了五天,跟前一个学生比,掌握度谁更高?这些问题如果不先在业务逻辑层想清楚,后面做什么算法都是空中楼阁。

所以系统里最核心的实体,并不是题目表,而是学生知识点状态表。它记录的不只是一个“正确率”,而是一串互相配合的字段:连续答对次数、最近一次作答时间、当前掌握度、上次题目难度、下次复习时间。这个表设计得好不好,直接决定整个系统的上限。

1.2 为什么没有拆成微服务

项目初期,我们内部有过一次争论:要不要把出题、判分、复习计划拆成三个独立服务?理由是后续可能会接入APP、小程序、H5多个端,服务拆分便于独立扩容。

讨论到最后,还是放弃了。

核心原因是一个作答动作,必须同步影响多个业务模块。学生提交答案的那一刻,系统要同时写入答题明细、更新掌握度、计算复习时间、刷新待推送列表。如果拆成独立服务,哪怕只是一个服务短暂不可用,整套状态流转就会产生中间态,最终引发数据不一致。

所以最终采用的是模块化单体的方式:代码层严格划分模块边界,但部署上还是一个应用。这样既保留了后续拆分的可能性,又避免了一开始就陷入分布式事务的泥潭。教学业务本质上是强一致性强于高并发的,先把稳定可靠做出来,比什么都重要。

2. 关键代码片段一:知识点掌握度评估

2.1 很容易写错的第一版方案

第一版掌握度评估,我们踩过很典型的坑。当时有个同事图省事,直接写了一个基于最近十次做题正确率的统计方法,大致逻辑是这样的:

public double evaluateByAccuracy(List<Boolean> recentResults) { if (recentResults.isEmpty()) { return 0.0; } long correctCount = recentResults.stream().filter(r -> r).count(); return (double) correctCount / recentResults.size(); }

看着没毛病,运行起来问题很大。

第一,它完全忽略时间因素。一个月前做对的题和昨天做对的题权重一样,学生早遗忘光了,系统还认为他很熟练。

第二,它忽略连续性和间隔性。A学生连错三次后再连对三次,B学生前后各做三次全对,从抽样来看俩人的正确率完全一样,但B的稳定性明显更高,这个模型区分不出来。

第三,也是最尴尬的,这种基于滑动窗口的正确率,在窗口边界附近会有突变。比如学生考了90分,系统评估可能瞬间从“熟练”跌回“一般”,对用户体验特别不友好。

2.2 换成一个能落地的衰减增强模型

后来参考了间隔重复和简化遗忘曲线的思路,把评估逻辑重构成了一个增量更新的模型。核心思路不复杂:每次答题,都对知识点掌握度做一次增量调整。

关键参数有四个:

  • 初始掌握度:0.4。学生没做过题,默认处于不高不低的临界区,需要靠后续作答来证明实力。
  • 连续答对加成:每次答对,根据连续答对次数动态上调,上限有保护。
  • 答错惩罚:答错一次扣的分数,要高于答对一次的加分。因为一次错误中蕴含的信息量,远大于一次蒙对的正确。
  • 时间衰减:超过一定天数没参与练习,掌握度按天衰减。

具体实现大致是这样的:

public class KnowledgePointEvaluator { private static final double INIT_MASTERY = 0.4; private static final double CORRECT_BASE_BONUS = 0.12; private static final double WRONG_PENALTY = 0.20; private static final double MAX_MASTERY = 0.98; private static final double MIN_MASTERY = 0.05; private static final double DECAY_PER_DAY = 0.015; public Evaluation update(Evaluation current, boolean correct, LocalDateTime now) { double mastery = current.getMasteryLevel(); long daysSinceLast = current.getLastPracticeTime() == null ? 0 : ChronoUnit.DAYS.between(current.getLastPracticeTime(), now); // 先做时间衰减,超过7天未练习,每天衰减固定比例 if (daysSinceLast > 7) { double decayFactor = (daysSinceLast - 7) * DECAY_PER_DAY; mastery = mastery - decayFactor; } if (correct) { int streak = current.getHitStreak() + 1; double bonus = CORRECT_BASE_BONUS + Math.min(0.06 * streak, 0.1); mastery = mastery + bonus; current.setHitStreak(streak); current.setWrongStreak(0); } else { mastery = mastery - WRONG_PENALTY; current.setHitStreak(0); current.setWrongStreak(current.getWrongStreak() + 1); } current.setMasteryLevel(clamp(mastery)); current.setLastPracticeTime(now); return current; } private double clamp(double value) { return Math.max(MIN_MASTERY, Math.min(MAX_MASTERY, value)); } }

这个模型的好处有三个。

一是可解释。如果学生问:“为什么系统觉得我这个知识点掌握不好?”我们能直接拿出证据:最近答题有错、间隔天数太久、连续答对记录被清零了。而不是给一个“AI计算的置信度为0.6”的黑盒数字。

二是增量更新,成本低。不需要频繁重算历史做题记录,每次答题只更新当次相关的知识点状态即可。哪怕单个用户做题百万道,评估过程也就是几次内存更新。

三是参数可调。衰减比例、惩罚力度、连续答对加成上限都抽成了常量,后续可以根据真实教学效果做A/B调整。

2.3 评估结果为什么必须“能解释”

还有一个特别重要的取舍值得说一说:我们没有在掌握度评估这块引入深度模型,也没有用复杂贝叶斯网络,原因不是技术做不到,而是业务根本不需要这种不确定性。

教学场景里,学生、老师、家长都会对结论提出质疑。老师看到“掌握度0.8”,会接着问:这是怎么算出来的?我能不能看详细依据?0.8意味着他可以做哪种难度的题?这些后续状态,都需要一条条可追溯的业务证据来支撑。

一旦评估逻辑变成黑盒,产品、运营、老师都没法判断这个分数是不是靠谱,连带着后续所有出题策略都会被质疑。所以在设计阶段,我们强行给自己加了一个约束:每一个分数变化,都必须能在数据库记录里找到原始触发事件。没有这个约束,后面做管理后台时,根本没法和业务方解释。

3. 关键代码片段二:出题策略的设计取舍

3.1 用策略模式替代if-else

出题模块最容易写成一堆if-else。判断学生是新手、中等、熟练,然后分别从不同的表里查题。这种写法第一版确实快,但后患无穷。等产品提出“还需要考虑今天的做题量”“还要避免题型单一”“还要根据最近薄弱点调整”,你就只能在if-else里面继续嵌套,代码看着看着就疯了。

我们用的是策略模式。核心抽出一个接口,各种不同出题策略各自实现,运行期根据学生画像选择策略:

public interface QuestionSelectStrategy { List<Question> select(StudentProfile profile, CandidatePool pool, int limit); }

具体实现中,不同策略的核心差异,集中在候选题目池怎么排序上。比如“基础巩固策略”会把难度为1-2、与该知识点匹配的新题放到最前;“能力提升策略”则优先选择难度3-4、带综合性的题目;“冲刺策略”直接过滤掉低难度题,只保留有区分度的题目。

每个策略类都是独立的一段逻辑,互不干扰,改动某个策略不会影响其他策略。调参时可以只动策略内部,出题主流程完全不用改。

3.2 一个用优先队列组合的打分排序器

题目选择最容易被忽略的一个需求,是避免总刷同一种类型的题。如果不做控制,一个薄弱知识点下面十道题可能全是选择题,学生越做越机械,最后把答案顺序都背下来了。

我们在策略内部,用一个打分排序器来综合排序候选题目。每道题的得分由三个因素构成:做过次数越少越优先、知识点掌握度越低的越优先、相邻两道题题型不重复的越优先。

核心实现:

public class QuestionScoringSelector { private static final double WEIGHT_ANSWER_COUNT = 0.4; private static final double WEIGHT_KNOWLEDGE_GAP = 0.4; private static final double WEIGHT_TYPE_DIVERSITY = 0.2; public List<Question> selectTop(StudentProfile profile, List<Question> candidates, Set<Long> answeredQuestionIds, int limit) { PriorityQueue<Question> queue = new PriorityQueue<>( (q1, q2) -> Double.compare(score(q2, profile, answeredQuestionIds), score(q1, profile, answeredQuestionIds)) ); for (Question q : candidates) { if (answeredQuestionIds.contains(q.getId())) { continue; } queue.offer(q); } List<Question> result = new ArrayList<>(); int previousType = -1; while (!queue.isEmpty() && result.size() < limit) { Question q = queue.poll(); // 如果与上一题类型相同,先临时放回,再找下一题 if (q.getType() == previousType && !queue.isEmpty()) { Question next = queue.poll(); queue.offer(q); q = next; } result.add(q); previousType = q.getType(); } return result; } }

这段代码初看很简单,但实际写的时候有一个设计取舍很重要:不要等到出题主流程里再过滤已做题。已做题集合应该在进策略之前从用户日志表一次性查出来,转成一个Set。如果把它放在策略里面,每选一道题查一次数据库,这个接口在高并发下根本撑不住。

3.3 候选池查询,索引比算法更重要

排序做得再好,底层的候选题目查不出来也是白搭。我们的题目表里,有一个字段存知识点标签,但是早期的写法很随意,直接用了字符串模糊匹配:

SELECT * FROM question WHERE knowledge_tags LIKE '%一元二次方程%'

这个SQL,在数据量几千条的时候没什么感觉。但题库一旦超过几十万条,每道题都触发全表扫描,出题接口就变成性能灾难了。

后来重建了知识点和题目的关联表,核心查询变成:

SELECT q.id, q.type, q.difficulty, q.content, k.knowledge_point_id FROM question q JOIN question_knowledge_ref k ON q.id = k.question_id WHERE k.knowledge_point_id IN (:pointIds) ORDER BY q.difficulty ASC LIMIT 200

记住一个原则:业务逻辑松耦合,数据查询必须紧耦合。出题策略再花哨,SQL没法在三毫秒内返回候选池,整个链路都不可能快。实践下来,关联表 + 聚簇索引的查询方案,直接把出题接口的整体响应时间降低了两个数量级。

4. 关键代码片段三:复习推送与幂等控制

4.1 什么时候推复习题,不是拍脑袋

很多系统做完题就没有然后了。学生练完,系统也“练完”了,不安排复习,不提示薄弱点,过两周再上来,知识全还回去了。

我们把复习计划作为一个独立的业务模块来做。核心逻辑是:每次答完题,系统会根据当前的掌握度,计算这个知识点下一次该出现的时间。简化版本的实现思路,是维护一个NextReviewTime字段,每次答完题后重新计算:

public class ReviewPlanService { public LocalDateTime calculateNextReview(KnowledgeState state, LocalDateTime now) { int intervalDays; if (state.getMasteryLevel() >= 0.8) { intervalDays = 7; } else if (state.getMasteryLevel() >= 0.6) { intervalDays = 3; } else { intervalDays = 1; } // 如果最近一次答错,复习必须安排在24小时内 if (state.getWrongStreak() > 0) { intervalDays = Math.min(intervalDays, 1); } return now.plusDays(intervalDays).withHour(9).withMinute(0); } }

注意这里面的一个业务取舍:复习时间是按状态算出来的,不是固定时间轮询。如果所有知识点都用同一个时点去扫描,数据库压力大,学生收到的复习提醒也容易扎堆。按每个知识点独立计算下次复习时间,配合一个定时扫描任务,效果比统一跑批好得多。

还有一个细节:复习时间统一安排在每天上午9点。为什么?因为大部分学生的活跃时间是晚上,如果复习队列推送到晚上,学生做的时候已经太疲惫,正确率容易被干扰,反而影响掌握度评估的准确性。安排到9点,是等待学生精力最好、用户在线率最高的时段。

4.2 重复提交数据怎么防

这个问题单拎出来讲,是因为实在太常见了。学生网络不好,点了一次没反应,又点了一次,结果服务端收到了两次提交。第一次插入答题记录成功,第二次又插入一条重复记录,然后掌握度被连续更新两次,学生的评估结果直接失真。

第一版我们天真地在Controller层加了一个同步锁。结果部署到多实例环境后,锁只在单台服务器内生效,两台服务器同时收到请求照样重复。

最终方案是用数据库约束兜底。答题明细表上加了一个唯一索引,防止同一学生、同一题目、同一份作业周期内重复记录:

ALTER TABLE answer_record ADD UNIQUE INDEX uk_user_question_homework (user_id, question_id, homework_id);

业务层再配合一个前置检查,流程是:先查一下有没有已存在的有效记录;如果存在,直接返回旧记录,不再走后续评估逻辑。数据库唯一索引是最后的保险丝,即便代码里漏了查重,也不会产生脏数据。

实测下来,线上唯一索引冲突捕获到的重复提交请求,每天都有上百次,但是全部被安全拦截,业务数据稳如老狗。

5. 实际踩坑与常见问题排查实录

5.1 大事务导致锁竞争,状态更新被拖慢

项目上线初期,有一次做压测,发现学生批量交卷的接口响应时间忽高忽低。排查下来,问题出现在事务边界上。当时为了省事,把所有业务逻辑全塞进了一个大事务:插入答题明细、更新多个知识点状态、刷新复习计划、更新作业进度,全部放在一个事务里。

因为一次作答可能涉及多个知识点,事务里要更新的行数很多,而多个并发请求可能会更新同一个知识点的同一行数据,导致数据库行锁竞争严重。后面改成两段式提交:先把答题明细单独提交;再异步处理状态更新。这样核心提交链路只涉及单次插入,状态更新通过消息队列异步消费,吞吐量直接翻了一倍。

这里要记住:CAI系统的状态更新,不需要和答题提交保持强一致。学生要的是提交后立刻看到结果,掌握度更新慢几秒钟,用户是无感知的。用好这个业务特性,能绕开很多分布式难题。

5.2 配置参数放数据库还是写死,要有边界

出题策略里,难度阈值、复习间隔这些参数,我推荐放到配置中心或数据库表里,因为产品随时可能调运营策略。但是,掌握度评估里的衰减系数、加分比例、惩罚力度这些核心算法参数,建议写死在常量类里,不要随便做成后台可配置项。

原因很现实:如果运营看到后台有个“答错扣0.2”的输入框,今天就可能改成0.35,明天改成0.1。表面看是在调策略,实际早已把评估模型的内部逻辑破坏了。算法参数一旦可配置,就必须配套一套专门的分析工具来观察效果,否则就是给自己埋坑。

所以我们在项目里做了一条硬性约定:业务规则配置化,算法权重代码化。这样既保证灵活性,又守住算法稳定性。

5.3 慢查询集中在知识点表和题目表

线上跑了一阵子,慢SQL日志里出现最多的两类:一类是查知识点掌握度时没有索引,每次都要全表扫;另一类是查待复习队列时,按时间范围找NextReviewTime,但表里没有索引,查询特别慢。

这两类问题都不需要动业务逻辑,加索引即可:

ALTER TABLE student_knowledge_state ADD INDEX idx_user_knowledge (user_id, knowledge_point_id); ALTER TABLE review_queue ADD INDEX idx_review_time (user_id, review_time);

加完后,慢SQL数量基本可以清零。这个经验看起来太基础,但真的提醒过很多人:先把索引设计好,再回来投诉算法不够好。业务逻辑本身没错,数据检索慢,并不代表算法得换。

5.4 常见问题速查表

问题现象可能原因解决方法
同一个作业多次提交后,知识点掌握度异常高幂等控制不到位在答题明细表加唯一索引,业务层先查重再更新
出题接口非常慢知识点标签使用LIKE匹配换成关联表 + IN查询
复习队列扫描任务CPU飙高每次扫描全量用户知识点按NextReviewTime分段扫描,每次只取一小批
学生反馈练完没效果复习计划没有及时生成答完题后,立即计算下次复习时间并入库
多台服务器下重复复习推送单纯用JVM锁防重引入数据库唯一索引或分布式锁

6. 最后分享一点个人体会

做完这套Java智能CAI系统,我个人最大的感受是:一个项目里最难的部分,往往不是某个算法有多炫,而是几十个业务规则怎么组织得清晰、稳定、不互相打架。

掌握度怎么更新、题目怎么排、什么时候复习,每一环单独拎出来都不难,但把它们严丝合缝地接起来,并且能在线上稳定跑下去,需要的是对业务本身的深刻理解,和对每一个边缘情况的认真处理。如果你也想做类似的自适应教学系统,我的建议是:先别急着上高大上的模型,先把最朴素的数据结构和状态流转跑通,把每次答题后所有需要更新的数据一次算清楚,再去优化策略。这条路走通之后,后续想加多少“智能”,都有坚实的地基。

过程中我踩过最大的坑,就是把“评估逻辑”和“推送逻辑”强行解耦,结果两边各自为战,数据互相打架。后来把整个闭环在代码结构上放到了一起,问题自然就消失了。这个经验,在你设计类似系统的时候,大概率也能用上。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进