简介:本资源是一套基于GIKT(Graph-based Interaction Knowledge Tracing)深度知识追踪模型的个性化习题推荐系统Python实现,面向计算机、教育技术及相关专业本科生,适用于毕业设计、课程综合实践与期末大作业等高阶项目训练。系统通过建模学生与习题的交互图结构,动态追踪知识掌握状态,并驱动精准化习题推送,切实解决传统推荐中忽视认知演化路径的问题。压缩包共143个文件,含25个核心Python模块(含Flask后端与训练逻辑)、13个Vue前端组件、11个预处理/训练数据(.npy/.csv)、3个Jupyter实验脚本(含test_recommend.ipynb)、以及配置文件(yaml/json)、静态资源(html/css/js/png)和模型权重(.pth/.npz),整体大小为10.85MB,结构清晰、模块解耦,便于理解知识追踪全流程。目前已有52人学习下载,资源已通过学术导师审核并获评优秀毕业设计,附带完整可执行代码、多轮测试验证记录及部署配置指南,开箱即用,为教育智能推荐方向提供可复现、可扩展的工程实践范本。 做在线教育或自适应学习的朋友,估计都有过这种感受:题库越建越厚,推荐策略却还是“错题重发”的老路子。学生练了半天,会的题反复刷,不会的题一直碰不到。这背后缺的不是题目数量,而是一个能真正判断“学生到底掌握了什么”的模型。
所以深度知识追踪(Deep Knowledge Tracing)这两年成了教育数据方向的一个热词。DKT、DKVMN 这些模型把答题序列塞进 RNN 里,按时间顺序学出一个潜在能力向量,对“下一题答对概率”的预测确实比传统 IRT 强不少。但它们有一个共同毛病:几乎不关心知识点之间的结构关系。后来有人把图神经网络引进来,就有了 GIKT,全称 Graph-based Interaction Knowledge Tracing。GIKT 把习题和知识点建模成图结构,学生在习题上的交互不再是孤立事件,而是沿着知识点关系扩散,推荐结果也就更贴近个人水平。
这篇文章把整个项目完整拆一遍:从 GIKT 的核心原理,到 Python 源码怎么组织、关键模块怎么实现,再到用 FastAPI 和 Docker 把推荐服务部署出去的具体步骤。代码保留真实项目里比较常见的写法,但为了讲清楚核心逻辑,会做一定简化。适合正在做教育数据处理、推荐系统,或者想研究知识追踪算法落地的同学参考。
1. 项目背景、算法原理与应用场景
1.1 深度知识追踪到底在解决什么问题
知识追踪的定义很简单:根据学生过去一段时间内的做题记录,推断出他对各知识点的掌握程度,并预测他在下一次做题时的表现。传统做法有两类:一类是认知诊断,比如 DINA、IRT,通过题目参数和答题结果反推学生能力,但需要人工定义题目参数;另一类是贝叶斯知识追踪 BKT,把每个知识点看成一个隐变量,用马尔可夫链维护“学会/没学会”的转移概率,但忽略了知识点之间的依赖。
深度知识追踪 DKT 把这些序列交给 RNN/LSTM,学一个隐状态来代表“学生当前的知识状态”。实践证明 DKT 在 AUC 等指标上普遍优于 BKT。可问题在于:DKT 把习题当作离散 ID 处理,没有利用“这道题考哪个知识点”“这个知识点和另一个知识点有没有关系”这类结构化信息。DKT 的隐状态虽然能隐式地学到一点关系,但在数据稀疏时很不稳定,经常出现学过的知识点又突然预测错误的情况。
GIKT 的出发点就是补上这一环。它把“习题—知识点”关系建成一张图,用图神经网络让习题的嵌入表示包含相邻知识点的信息,这样当模型预测某个知识点的正确率时,不是只盯着这一道题,而是会连带考虑相关知识点上的历史表现。用一句话总结:GIKT = 深度序列模型 + 习题知识图传播。它的预测结果比纯序列模型更有解释性,也更容易做知识点级别的诊断。
1.2 GIKT 相比 DKT/BKT 的优势在哪里
我用一个比较直观的表格来说明,方便你判断什么时候值得从 DKT 换成 GIKT。
| 对比维度 | BKT | DKT | GIKT |
|---|---|---|---|
| 知识点关系 | 基本不考虑,单知识点独立建模 | 隐式学习,缺乏显式结构 | 显式通过图结构传播 |
| 输入表示 | 手动构造每个知识点状态 | 习题 ID 的 Embedding | 习题 ID + 知识点图嵌入 |
| 序列建模 | 马尔可夫链 | RNN/LSTM/GRU | RNN/Transformer + GNN |
| 数据需求 | 低 | 中 | 中高,图结构有额外收益 |
| 可解释性 | 中 | 低 | 中高,可追溯到知识点传播路径 |
| 适合场景 | 小型题库、单知识点练习 | 大题库、纯序列数据 | 有题库标注、知识点关系已知 |
从实际项目角度看,GIKT 最大的优势体现在两个场景:一是数据稀疏,学生只做过少量题,但知识图能把相关题目的信息“借”过来,起到类似平滑的作用;二是诊断报告,系统能明确告诉你“这个学生三角函数的恒等变换薄弱,是因为他在单位圆知识点上的表现也一般”,这是 DKT 难以直接给出的。
1.3 个性化习题推荐的应用场景与核心难点
这套系统最典型的落地场景是自适应练习平台:学生每次答题后,系统立刻更新他的知识状态,下一组推荐题目不是随机或按难度排序,而是根据当前薄弱点、遗忘风险和最近练习记录动态生成。类似场景还有智能组卷、考前复习诊断、教材章节配套练习等等。
但个性化习题推荐有几个很实际的难点。第一是冷启动问题,新学生只有几条答题记录,知识状态很不确定,这时推荐要偏向探索,多推几个覆盖面广的知识点,而不是直接推荐“最优”题。第二是数据稀疏和噪声,学生乱猜、题目描述有歧义都会污染数据。第三是推荐多样性,如果只盯着“预测错误率最高的知识点”,学生会被反复喂同类型题目,练习体验非常差。
因此,我在这套系统里没有把“预测下一题正确概率”直接当作推荐分数,而是设计了一个带难度、覆盖度、最近练习次数惩罚的推荐得分函数。后面源码部分会细说。
2. 系统整体架构与源码模块设计
2.1 端到端系统分层设计
整个系统按数据流可以分成五层,我把每一层负责的事情列出来:
- 数据采集层:接收学生答题事件,包含学生 ID、习题 ID、对应知识点、答题结果、时间戳等信息,保存到数据库或者日志文件。
- 数据处理层:清洗数据、过滤无效记录、按学生做时间排序、切分成固定长度的交互序列,同时生成习题与知识点的关系矩阵。
- 算法模型层:实现 GIKT 模型。模型读入一个学生的序列,输出两个东西:下一个交互的答对概率,以及当前对各知识点的掌握度向量。
- 推荐引擎层:根据掌握度向量和候选习题,计算推荐得分,排序后输出 TopN 习题列表。
- 服务部署层:用 FastAPI 暴露 REST 接口,Docker 容器化部署,Redis 做热门推荐缓存,Nginx 负责反向代理。
这个分层的好处是每一层都可以单独替换。比如你今天不想用 GIKT,想换成 DKT 做对比,只需要替换模型层和数据处理层里的特征构造逻辑,接口层完全不用动。
数据流转我用文字描述一下:原始答题日志经过数据预处理变成模型能吃的序列数据,模型在前向传播时会把一张预构建好的习题-知识点图一起载入,序列编码器和图嵌入层并行工作,最后融合输出预测概率。推荐引擎拿到概率后,结合候选习题的难度和知识点覆盖度,生成一个排序列表。整个过程从收到请求到返回推荐,在 CPU 机器上大约几十毫秒,GPU 机器上更快。
2.2 源码目录结构与关键依赖
下面是一份可以直接照着建目录的项目结构。我在真实项目里基本就是这样的风格:核心代码放src,配置和启动文件放deploy,实验脚本放scripts。
gikt-recommend/ ├── data/ │ ├── raw/ # 原始答题日志 │ ├── processed/ # 清洗后的序列数据 │ └── graph/ # 知识点关系图和 Q 矩阵 ├── src/ │ ├── data/ │ │ ├── dataset.py # 数据加载、序列切分 │ │ ├── graph.py # 构建习题-知识点图 │ │ └── preprocess.py # 特征工程 │ ├── models/ │ │ ├── gikt.py # GIKT 模型主体 │ │ ├── layers.py # 图卷积层、注意力层 │ │ └── predictor.py # 掌握度输出和预测头 │ ├── recommender/ │ │ └── ranker.py # 推荐得分计算 │ └── serve/ │ ├── api.py # FastAPI 接口 │ └── schemas.py # 请求/响应模型 ├── scripts/ │ ├── train.py # 训练脚本 │ ├── evaluate.py # 离线评估 │ └── export_onnx.py # 导出 ONNX 可选 ├── deploy/ │ ├── Dockerfile │ └── docker-compose.yml ├── requirements.txt └── README.md依赖库我建议锁在以下版本附近,不是越新越好。PyTorch 不必追求最新版,2.0 以上基本够用;如果你机器只有 CPU,也完全能跑,只是训练慢一点。
torch>=2.0.0 numpy>=1.24 pandas>=2.0 scikit-learn>=1.3 fastapi>=0.104 uvicorn[standard]>=0.24 pydantic>=2.0 redis>=5.0 docker-compose>=2.03. 核心源码实现解析
3.1 数据结构与 Q 矩阵设计
GIKT 需要的数据比 DKT 多了一张“习题-知识点”关系矩阵,通常叫 Q 矩阵。行是习题,列是知识点。如果习题 i 考查了知识点 j,那么 Q[i][j] = 1,否则为 0。实际场景中一道题往往会同时涉及多个知识点,Q 矩阵一行可以有多个 1。
一个典型的输入样本是这样的:
| user_id | exercise_id | correct | timestamp | knowledge_point |
|---|---|---|---|---|
| 101 | 3001 | 1 | 1700000001 | [5, 6] |
| 101 | 3002 | 0 | 1700000100 | [5] |
| 101 | 3004 | 1 | 1700000200 | [7, 8] |
在数据处理时,我不会把knowledge_point直接丢给模型,而是把它和 Q 矩阵关联起来。每个 exercise_id 对应一个或多个知识点,这样模型才能把题目嵌入和知识点嵌入放在同一个向量空间里。
Q 矩阵有两种常见用途:一种是用 GCN 聚合时,构建习题节点和知识点节点的邻接矩阵;另一种是在模型输出后,把习题预测概率映射回知识点掌握度。我在实现里两种都会用到。下面这段代码构造一个简单的二分图邻接矩阵:
import numpy as np def build_interaction_matrix(num_exercises, num_concepts, q_matrix): """ 构建习题-知识点交互矩阵,用于图传播。 q_matrix: shape [num_exercises, num_concepts] 返回值: 稠密邻接矩阵 [num_exercises + num_concepts, num_exercises + num_concepts] """ num_nodes = num_exercises + num_concepts adj = np.zeros((num_nodes, num_nodes), dtype=np.float32) for ex_id in range(num_exercises): for cpt_id in range(num_concepts): if q_matrix[ex_id, cpt_id] > 0: adj[ex_id, num_exercises + cpt_id] = 1.0 adj[num_exercises + cpt_id, ex_id] = 1.0 # 加自环,避免节点自身信息丢失 adj += np.eye(num_nodes, dtype=np.float32) return adj这里把习题节点编号放在前num_exercises,知识点节点编号放在后num_concepts,构造出的邻接矩阵同时包含“习题到知识点”和“知识点到习题”的双向信息。GIKT 在传播时,习题嵌入会融合关联知识点的信息,知识点嵌入也会反向聚合到所有涉及它的习题特征,这是模型能捕捉“知识点联动”的关键。
3.2 图嵌入层实现:用 GCN 给习题加关系上下文
图嵌入层我实现的方案是用两层 GCN 对习题嵌入和知识点嵌入做消息传递。代码不追求花哨,但结构完整:
import torch import torch.nn as nn import torch.nn.functional as F class GCNLayer(nn.Module): def __init__(self, in_dim, out_dim, dropout=0.2): super().__init__() self.linear = nn.Linear(in_dim, out_dim) self.dropout = nn.Dropout(dropout) def forward(self, x, adj): # x: [num_nodes, in_dim] # adj: [num_nodes, num_nodes] 归一化后的邻接矩阵 support = self.linear(x) # [num_nodes, out_dim] out = torch.mm(adj, support) # 图卷积聚合 return F.relu(out) class KnowledgeGraphEncoder(nn.Module): def __init__(self, num_exercises, num_concepts, embed_dim, hidden_dim): super().__init__() self.exercise_embed = nn.Embedding(num_exercises, embed_dim) self.concept_embed = nn.Embedding(num_concepts, embed_dim) self.gcn1 = GCNLayer(embed_dim, hidden_dim) self.gcn2 = GCNLayer(hidden_dim, embed_dim) def forward(self, adj): num_ex = self.exercise_embed.num_embeddings ex_emb = self.exercise_embed(torch.arange(num_ex, device=adj.device)) cpt_emb = self.concept_embed(torch.arange( self.concept_embed.num_embeddings, device=adj.device )) x = torch.cat([ex_emb, cpt_emb], dim=0) x = self.gcn1(x, adj) x = self.gcn2(x, adj) ex_out = x[:num_ex] cpt_out = x[num_ex:] return ex_out, cpt_out这一步输出是融合了知识图谱上下文信息的习题嵌入ex_out和知识点嵌入cpt_out。在初始化阶段,每个习题嵌入不再是随机初始化的孤立向量,而是它的相邻知识点嵌入的平均扩展。我刚开始实现时只聚合了一层 GCN,效果不稳定,换成两层后明显稳定,原因是习题到知识点的信息需要一跳,知识点再到相邻习题又需要一跳,一层根本传不到位。
3.3 序列编码与掌握度预测模块
GIKT 的序列编码部分可以继续用 LSTM 或 GRU,也可以换成 Transformer。为了部署轻量,我优先用 GRU,参数量小,训练快。在把习题 ID 送入 GRU 之前,先把上一步得到的图增强习题嵌入取出来,这样每个时间步的输入都带上了知识图谱信息。
掌握度预测模块有两层含义:一是预测“下一个交互的答对概率”,用于离线评估;二是生成“当前知识点掌握度向量”,用于推荐。我在实现里通过一个注意力层把 GRU 的隐状态序列压缩成最终的知识状态向量,然后分别接两个输出头。
class GIKTModel(nn.Module): def __init__(self, graph_encoder, hidden_dim=128, dropout=0.2): super().__init__() self.graph_encoder = graph_encoder self.gru = nn.GRU(input_size=hidden_dim, hidden_size=hidden_dim, batch_first=True, num_layers=1) self.attn = nn.MultiheadAttention(hidden_dim, num_heads=4, batch_first=True, dropout=dropout) self.dropout = nn.Dropout(dropout) self.pred_head = nn.Sequential( nn.Linear(hidden_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, 1) ) self.mastery_head = nn.Linear(hidden_dim, graph_encoder.concept_embed.num_embeddings) def forward(self, ex_ids, adj): ex_emb, _ = self.graph_encoder(adj) # 根据交互序列取对应习题嵌入 seq_emb = ex_emb[ex_ids] # [B, T, D] seq_emb = self.dropout(seq_emb) gru_out, _ = self.gru(seq_emb) # [B, T, D] attn_out, _ = self.attn(gru_out, gru_out, gru_out) # 取最后一个时间步作为当前状态 state = attn_out[:, -1, :] logit = self.pred_head(state).squeeze(-1) # [B] mastery = torch.sigmoid(self.mastery_head(state)) # [B, num_concepts] return logit, mastery这段代码里有两个细节容易踩坑。第一,attn_out我取了最后一步而不是平均池化,因为推荐场景更看重最近表现,最后一步能更好反映当前状态。第二,mastery_head输出后接sigmoid,把每个知识点的掌握阈值压缩到 0 到 1 之间,方便推荐时直接当作掌握度分数。如果你希望掌握度可以超过 1 或小于 0,也可以不接sigmoid,但推荐阈值就不好设了。
3.4 个性化习题推荐策略实现
模型预测出的掌握度向量mastery是推荐的核心输入。但直接给未掌握知识点对应的所有习题排序,效果往往很差。原因前面讲过:学生会觉得题目重复、难度忽高忽低。我在实际项目里把推荐分数设计成三部分组合:
import numpy as np def recommend_score(mastery_vec, candidate_exer_ids, q_matrix, exercise_difficulty, recent_exercise_ids, history_count, alpha=0.6, beta=0.3, gamma=0.1): """ mastery_vec: [num_concepts] 当前知识点掌握度 candidate_exer_ids: 候选习题 ID q_matrix: [num_exercises, num_concepts] exercise_difficulty: [num_exercises] 每题难度,0-1,越大越难 recent_exercise_ids: 最近练习过的习题 ID history_count: 每道题的历史练习次数 """ scores = [] for ex_id in candidate_exer_ids: cpt_mask = q_matrix[ex_id] > 0 # 1. 掌握度分:预测正确概率低的知识点,越值得推荐 mastery_score = 1.0 - float(np.mean(mastery_vec[cpt_mask])) # 2. 难度分:与学生当前水平差距越小的题,越适合 current_level = float(np.mean(mastery_vec)) difficulty_score = 1.0 - abs(exercise_difficulty[ex_id] - current_level) # 3. 重复惩罚:最近刷过或历史做过很多次的题要降权 rec_penalty = gamma * history_count[ex_id] if ex_id in recent_exercise_ids: rec_penalty += 0.5 final = alpha * mastery_score + beta * difficulty_score - rec_penalty scores.append(final) top_idx = np.argsort(scores)[::-1][:10] return top_idxalpha、beta、gamma是三个超参数。我一开始把alpha设到 0.9,结果推荐内容高度集中,学生全在做同一个薄弱点。把beta加到 0.3 之后,题目难度贴近学生当前水平,做题体验明显改善。gamma不能设太大,否则真正薄弱的知识点会被惩罚太低,推荐不出来。
4. 环境准备与数据预处理
4.1 Python 环境与依赖安装
这个项目跑通需要 Python 3.9 或 3.10,建议不要用 3.12 以下太老的版本。我用 conda 创建虚拟环境:
conda create -n gikt python=3.10 conda activate gikt pip install -r requirements.txt如果机器有 NVIDIA GPU,建议先单独装匹配版本的 PyTorch,再装其他依赖。CUDA 版本不匹配是很常见的坑,跑模型时弹出AssertionError: Torch not compiled with CUDA enabled,基本都是因为安装的是 CPU 版。你可以用python -c "import torch; print(torch.cuda.is_available())"确认。
训练数据不大的话,CPU 也能跑。我在一个约 5 万条交互的数据集上,用 CPU 训练 30 轮大概要 20 分钟,GPU 只要 2 分钟。如果只是调试,强烈建议先用小数据集跑通流程,再切全量数据。
4.2 数据清洗与序列切分
预处理有几个容易忽略但很关键的步骤。原始答题日志里经常有同一道题短时间内重复提交的记录,这不能全当有效交互,否则会给推荐系统带来虚假信号。我通常的做法是:
- 删除
correct字段缺失的记录; - 同一学生同一道题 30 秒内的重复提交只保留一次;
- 过滤掉练习次数少于 3 次的学生,因为序列太短,模型学不出可靠状态;
- 时间戳按秒升序排列,保证序列顺序正确。
处理完之后,把每个学生的交互序列切成固定长度max_seq_len。我默认取 50,超过 50 的用滑窗切出多条样本;不足 50 的用 0 填充成一个批次。填充的时候要记录mask,在计算损失时把 padding 位置忽略掉,否则模型会学到一堆无意义信息。
构建数据集的核心代码如下:
def build_sequences(interactions, max_seq_len=50): """ interactions: DataFrame, 按 user_id, timestamp 排好序 返回样本列表: [{'user': [], 'ex_ids': [], 'labels': [], 'mask': []}] """ samples = [] for user, group in interactions.groupby('user_id'): ex_ids = group['exercise_id'].tolist() labels = group['correct'].tolist() for start in range(0, len(ex_ids) - 1, max_seq_len): end = min(start + max_seq_len, len(ex_ids) - 1) seq = ex_ids[start:end] targets = labels[start + 1: end + 1] # padding pad_len = max_seq_len - len(seq) seq_padded = seq + [0] * pad_len target_padded = targets + [0] * pad_len mask = [1] * len(seq) + [0] * pad_len samples.append({ 'ex_ids': seq_padded, 'labels': target_padded, 'mask': mask }) return samples这里有个常见错误:预测目标是labels[start+1: end+1],也就是用前 50 道题预测第 51 道,不是用当前题预测当前题。很多新手写训练集时把labels和ex_ids错位对齐,模型训完 AUC 还在 0.5 附近徘徊,就是因为学到了“上一题答案决定下一题答案”的错误规律。
5. 模型训练、评估与调参
5.1 数据加载与训练循环
训练时用 PyTorch 的 Dataset 和 DataLoader 封装,这里我直接给简化版训练循环,关键地方带注释:
from torch.utils.data import Dataset, DataLoader class GIKTDataset(Dataset): def __init__(self, samples): self.samples = samples def __len__(self): return len(self.samples) def __getitem__(self, idx): sample = self.samples[idx] return (torch.tensor(sample['ex_ids'], dtype=torch.long), torch.tensor(sample['labels'], dtype=torch.float32), torch.tensor(sample['mask'], dtype=torch.float32)) def train_one_epoch(model, dataloader, optimizer, loss_fn, adj, device): model.train() total_loss = 0 for ex_ids, labels, mask in dataloader: ex_ids = ex_ids.to(device) labels = labels.to(device) mask = mask.to(device) optimizer.zero_grad() logit, _ = model(ex_ids, adj) loss_all = loss_fn(logit, labels) # 只计算有效位置损失 loss = (loss_all * mask).sum() / mask.sum() loss.backward() optimizer.step() total_loss += loss.item() return total_loss / len(dataloader)这里有一点要强调:loss_fn用BCEWithLogitsLoss,但reduce=False,这样我们才能配合mask手动求平均。如果直接让BCEWithLogitsLoss自己平均,padding 位置的 0 标签会被当成负样本参与计算,导致模型被大量无意义样本干扰。
我还会在每轮结束算一次验证集 AUC,等 AUC 连续 5 轮不再上升就早停,避免过拟合。训练轮数一般 30 到 50 轮足够,设太高反而浪费时间。
5.2 评估指标与离线评测
推荐系统离线评估常用 AUC 和 Accuracy,知识追踪领域还有 RMSE 等指标。但我在项目里更看重 AUC,因为它不受阈值影响,能直接衡量“预测答对概率的排序能力”。模型预测出的概率很重要,推荐系统最终是根据这个概率排序题目,而不是直接硬分类成“会/不会”。
评测时有个细节:要按学生分组评估,不能把所有预测结果混在一起算 AUC。因为不同学生的答题基础差别很大,混合计算会把“跨学生能力差异”也算进去,虚高。正确做法是对每个学生计算 AUC,再取所有学生 AUC 的平均值,代码大致是:
from sklearn.metrics import roc_auc_score def evaluate_auc(model, dataloader, adj, device): model.eval() student_preds = {} student_gts = {} with torch.no_grad(): for ex_ids, labels, mask in dataloader: # 这里需要额外返回 user_id,实际代码可以扩展 Dataset pass # 按 user_id 分组后分别计算 AUC严格实现需要 Dataset 返回 user_id,这里就不展开了。总之,如果你在论文或项目里报告 AUC,一定要说明是不是按学生分组计算,否则评估结果会有误导性。
5.3 超参数调优经验
我踩过的坑不少,总结几个超参数的调整方向,供你参考:
| 超参数 | 建议范围 | 调参心得 |
|---|---|---|
| hidden_dim | 64 ~ 256 | 太小,图传播信息装不下;太大,小数据集上过拟合严重 |
| num_layers(GRU) | 1 ~ 2 | 两层通常够,三层更容易过拟合且训练更慢 |
| dropout | 0.1 ~ 0.3 | 序列数据普遍要加 dropout,低于 0.1 效果和没加一样 |
| learning_rate | 1e-3 ~ 5e-3 | 推荐 Adam + 1e-3 起步,loss 震荡就降一半 |
| batch_size | 32 ~ 128 | 我常用 64,序列长度长时适当减小 |
| max_seq_len | 30 ~ 100 | 序列太长会导致早期信号被淹没,不一定越长越好 |
学习率是第一个要调的。如果 loss 一开始就震荡或者直接 NaN,先降到原来的十分之一。其次是 dropout,小数据集上 dropout 提高 0.1 往往比增加模型层数更有效。最后才是 hidden_dim 和 num_layers,这两个参数在小数据集上提升有限。
训练完成后,我会把模型保存成两个文件:一个是 PyTorch 的state_dict,用于后续继续训练;另一个是导出成 ONNX 格式,用于部署时推理。ONNX 导出后推理速度比原生 PyTorch 快不少,尤其在没有 GPU 的服务器上,CPU 推理性能差距很明显。
6. 推荐接口实现与部署指南
6.1 用 FastAPI 快速封装推荐服务
部署层我选择 FastAPI,原因是它自带请求校验和文档页面,写几行代码就能把推荐服务暴露出去。核心是三个接口:
POST /predict:输入用户最近做题序列,返回下一题答对概率;POST /recommend:输入用户 ID,返回 TopN 推荐习题列表;GET /health:健康检查,给 Docker 和监控探针用。
一个最简单的接口代码如下:
from fastapi import FastAPI from pydantic import BaseModel import torch app = FastAPI(title="GIKT Recommend Service") class RecommendRequest(BaseModel): user_id: int top_n: int = 10 class RecommendResponse(BaseModel): exercise_ids: list[int] reason: str @app.post("/recommend", response_model=RecommendResponse) def recommend(req: RecommendRequest): # 从 Redis 缓存查这个用户最近推荐,如有直接返回 # 否则从数据库加载用户交互序列,调用模型推理 # 最终得到推荐习题列表 return RecommendResponse(exercise_ids=[1001, 1002], reason="当前薄弱点:函数与导数")真实项目里,recommend内部逻辑会更长:要查用户最近答题序列、组装候选习题、预处理成模型输入、推理、调用推荐排序函数、再写回 Redis。FastAPI 的异步能力在这里能发挥一定作用,但模型推理本身是 CPU/GPU 同步操作,所以别指望接口能通过纯异步把推理加速。真正提速要靠缓存和模型 warm-up。
所谓的模型 warm-up,就是在服务启动时加载模型后,先拿一条假数据跑一次推理,把 CUDA 或者 ONNX Runtime 的初始化开销在服务启动阶段消化掉。我第一次部署时没做 warm-up,前几个请求要么超时要么特别慢,做了之后稳定在几十毫秒。
6.2 Docker 打包与一键部署
Docker 部署的核心是一个镜像能同时包含 Python 环境和模型文件。我习惯训练好模型后把model.onnx和requirements.txt一起放进deploy目录。
FROM python:3.10-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY src/ ./src/ COPY deploy/model/ ./model/ EXPOSE 8000 CMD ["uvicorn", "src.serve.api:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"]如果你只部署一个服务,这样写就够了。但推荐系统通常还依赖 Redis 做缓存,用 docker-compose 管理更省心:
version: "3.8" services: gikt-api: build: . ports: - "8000:8000" environment: - REDIS_HOST=redis depends_on: - redis redis: image: redis:7-alpine ports: - "6379:6379"布到服务器上就两条命令:
docker-compose build docker-compose up -d第一次部署时我有两个教训。第一,模型文件不要打进镜像太大,尽量用 ONNX 格式,能比 PyTorch 原生模型小 30% 到 50%。第二,docker-compose 里要设置restart: always,否则服务器重启后服务不会自动拉起,学生端就刷不出推荐了。
6.3 性能优化与监控
接口上线后,最先要盯的是 P99 延迟和推荐结果覆盖率。P99 延迟超过 500 毫秒就要注意了。优化手段按性价比排序:
- Redis 缓存高频推荐结果,TTL 设置 5 到 10 分钟;
- 用 ONNX Runtime 替换 PyTorch 推理;
- 批量推理:把多个并发请求合并成一个 batch,同时推理;
- 候选集预筛:不必对题库里所有题算推荐分数,先按知识点覆盖和难度过滤到前 200 道,再做精排。
另外推荐系统容易被忽略的是推荐日志。每一条推荐请求都应该记录:用户 ID、推荐习题列表、模型预测概率、实际是否答题。哪怕当下用不上,之后的模型迭代和 A/B 测试全靠这份日志。我在项目里用结构化日志写入 JSON 行,方便后续离线分析。
7. 常见问题与排查记录
7.1 训练阶段:Loss 不降、AUC 异常
项目里最常遇到的三类问题,我整理成一份速查表:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| Loss 降不下去,徘徊在 0.7 左右 | 标签与输入错位;学习率过大;padding 位置参与损失计算 | 检查训练集构造,确认目标是“下一题”的标签 |
| AUC 接近 1.0 | 数据泄漏,推荐结果或未来信息混入训练集 | 严格按时间切分,不能随机切分 |
| 训练时显存 OOM | 序列太长;batch_size 太大 | 降低 max_seq_len 或 batch_size;用梯度累积 |
| 输出概率接近 0.5,区分度低 | 模型容量不够;数据量太小 | 尝试增大 hidden_dim;降低 dropout;加大数据量 |
其中“数据泄漏”是最隐蔽的。很多同学做知识追踪时用随机切分训练集和测试集,但同一个学生的交互序列可能同时落在训练集和测试集,模型相当于看过答案,AUC 虚高到 0.95 以上,上线后立刻打回原形。正确做法是按学生和时间维度切分,保证测试集里所有交互都在训练集交互之后。我实际项目里采用“每个学生最后 20% 的交互作为测试集”,这样才符合真实线上场景。
7.2 部署阶段:端口、内存、超时
部署到服务器后最容易遇到的不是模型问题,而是环境问题。
第一是端口冲突。如果服务器上 8000 端口已经被其他服务占了,docker-compose 启动会报address already in use。我习惯把服务端口改成 8001 或 8002,避开常见端口。第二是内存不足。python:3.10-slim 镜像本身不大,但 PyTorch 和 ONNX Runtime 加起来可能吃掉 2 GB 内存,小内存服务器很容易 OOM。解决办法是部署时用 ONNX Runtime,并限制容器内存:
deploy: resources: limits: memory: 1.5G第三是接口超时。有时候模型加载需要时间,第一个请求会非常慢,甚至触发网关超时。解决方案就是我前面说的模型 warm-up,启动容器时执行一次假推理。
7.3 数据与模型层面的避坑清单
做一个完整可落地的推荐系统,我的经验是算法只占一半,数据质量和工程细节占另一半。给几个可能让你省一整个加班夜的提醒:
- 先跑通 DKT 基线,再上 GIKT。GIKT 的增益在数据稀疏时明显,但如果没有 DKT 的基线对照,你很难判断模型优化方向对不对。
- 知识点粒度要统一。有的习题标注到“函数”,有的标注到“函数的单调性”,粒度不一致会导致 Q 矩阵特别稀疏,图传播效果大打折扣。
- 推荐多样性不能靠纯算法解决。我最后在推荐列表里加了一条硬规则:同一知识点在同一次推荐里最多出现 3 道题。这样既保证针对性,又避免刷题体验枯燥。
- 保存模型时把训练数据统计信息一起保存。比如习题总数、知识点总数、每个知识点的平均难度,这些在做推理时需要用到,很容易漏掉。
- 线上服务一定要有手动开关。当模型效果回退时,能快速切回最近一个版本的模型,或者切到纯规则推荐。我经历过一次模型上线后预测概率整体偏移的问题,如果没有一键回滚,问题影响会扩大很多。
关于部署和调参,最后再分享一个小技巧:在接口里增加一个debug参数,当它设为true时,接口会把推荐分数拆解成“掌握度分、难度分、重复惩罚分”返回。这个功能在排查推荐结果异常时非常有用,能直接看出是模型学偏了,还是推荐排序的权重设置有问题。我在第一版系统上线后,靠这个 debug 接口快速定位了好几个问题,建议你也保留这个设计。
本文还有配套的精品资源,点击获取