简介这是一份基于深度知识追踪GIKT模型的习题推荐系统毕业设计源码主要面向计算机、人工智能、大数据等专业的本科生作为课程设计、期末大作业或毕设参考也适合已具备Python基础的技术学习者深入研读。项目采用前后端分离结构后端使用Flask完成推荐逻辑与模型调用前端以Vue搭建交互界面兼顾知识追踪、习题推荐与可视化展示等关键环节。压缩包共59个文件其中20个Python源码承载模型训练与推荐算法13个Vue组件和7个JS文件构成前端页面另有JSON配置、图片与数据文件辅助运行与演示整体约9.46MB。资源经过调试下载后可直接运行包含完整源码与基础数据方便读者对照学习GIKT的建模思路、推荐流程和工程实现。目前已吸引126人学习下载适合用于毕业设计讲解、实验复现及功能二次开发。1. 刷了一千道题系统还在推你会做的那道GIKT 到底改了什么做习题推荐系统的同学多半有过这种体验题库里攒了几千道题规则引擎只会按「正确率低就推」或者「错过的题反复推」来出题结果学生刷了一千道始终在同一批知识点上打转。原因在于这种推荐没有建立「学生当前到底掌握了什么」的模型。GIKTGraph-based Interaction-aware Knowledge Tracing基于图交互的深度知识追踪正是冲着这个问题去的它把知识点之间的关联结构画成图再结合做题序列预测学生对下一个知识点的掌握概率最后把概率接上推荐策略形成一套从刷题日志到出题的闭环。对于 python 毕业设计来说这个课题把深度学习、图神经网络和推荐引擎串在了一条线上数据集公开、效果可量化、源码可扩展属于性价比很高的方向。下面我就按「原理 → 数据 → 实现 → 推荐 → 避坑 → 验证」的顺序把它讲透。2. GIKT 模型拆开看把知识点图谱变成模型的前置信息2.1 深度知识追踪的起点DKT 做了什么、哪里不够2015 年提出的 DKTDeep Knowledge Tracing深度知识追踪第一次把循环神经网络用到了知识追踪上。它的做法很直观把学生每一次答题表示成一个向量比如把「习题 ID」和「是否正确」分别做 embedding 再拼接或相加然后送进 LSTM/GRU最后用隐藏状态去预测下一道题的正确概率。它的贡献在于摆脱了传统贝叶斯知识追踪BKT需要人工设定知识点参数的问题让模型直接从数据里学出知识状态的变化规律。但 DKT 有两个明显的硬伤。第一知识点之间是孤立的模型认为每个概念独立地「掌握」或「未掌握」不会因为学生做对了「一元一次方程」就直接提升「不等式」的掌握度而真实的学习过程里这两个概念高度相关。第二DKT 把一道题绑定到一个知识点上遇到综合题一题涉及多个知识点就变得很别扭只能硬选一个标签丢失了大量信息。这两个问题直接影响了推荐质量系统不知道该把学生往哪个相邻概念推出题路径就会很跳跃。2.2 图从哪里来概念共现、先修关系与交互图GIKT 的改进核心是引入图结构。这个图不是凭空造的常见来源有三个。第一个来源是先修关系教材目录里写得很清楚「因式分解」是「一元二次方程」的前置知识这种边可以直接人工录入适合小规模题库。第二个来源是共现关系如果一道题同时考察了「因式分解」和「十字相乘」那这两个概念之间就有一条强边更宽泛一点同一个学生在相近时间内连续做对两道分别涉及不同概念且都做错的题也能说明这两个概念在本阶段互相影响。第三个来源是交互图GIKT 的原型工作里把「学生-习题-概念」做成交互图边代表了一次真实的作答行为这样图里既包含了结构信息也带了行为信号。我在实际项目里最常用的组合是以概念为节点建图边来自「同题共现 先修关系」边的权重用共现次数归一化。下面这个表格是三种建图方式的对比做毕设时选一种为主、一种为辅就够建图方式数据来源优点缺点先修关系教材/大纲人工整理结构准确符合教学逻辑覆盖不全人工成本高共现关系题目-知识点映射表自动生成贴合真实题库存在噪声边需要阈值过滤交互图学生作答日志带行为强度个性化强冷启动学生/习题没有边2.3 输入输出映射一个做题样本在图模型里怎么流动在模型层面GIKT 的数据流是这样的先把每个概念映射成 embedding作为图节点的初始特征接着在图卷积层里做两轮消息传递让每个概念吸收邻居概念的信息得到「增强后的概念表示」然后把「当前习题表示」和「该习题涉及的概念表示」拼起来作为 LSTM 在时间步 t 的输入LSTM 维护学生状态向量 h_t最后接一个预测头输出对所有习题的正确概率。一个训练样本的格式是五件套下面这个结构会在代码里反复出现字段形状说明user_id标量学生唯一标识question_seq[window]按时间排序的习题 ID 序列concept_seq[window]与习题一一对应的概念 ID 序列label_seq[window]正确为 1错误为 0edge_index[2, num_edges]概念图的稀疏邻接表这里容易搞混的一点是图卷积只在概念上做LSTM 才处理时间序列。有不少初学者把图卷积直接作用在习题序列上导致每个时间步都要重算一遍图传播训练慢一个量级。正确做法是概念图提前算好整个训练过程只传播一次得到增强后的概念 embedding 之后后面的流程就跟 DKT 很像了。3. 数据工程从原始日志到 GIKT 的训练样本3.1 数据集选择Assistments 与 EdNet 的字段差异做这个题目第一个实际决策就是数据集。最常被用的是美国 Assistments 公开数据集它来自在线教学平台的真实做题日志。我用的是 2009-2010 那一版核心字段不多user_id、problem_id、skill_id也就是知识点、correct外加一些作答耗时字段。字段少的好处是清洗简单坏处是题目难度等元信息缺失模型只能从行为里反推难度。如果你想要更接近国内教育场景的数据可以看 EdNet-KT4它是韩国一个英语学习 App 的数据包含了用户作答、题目内容、知识标签和详细的交互事件比如切出页面、打草稿。字段更丰富但体积也大很多预处理要跑更久。做毕设的话我建议先用 Assistments 跑通全流程再决定要不要换数据。下面是对比对比项Assistments 2009-2010EdNet-KT4规模约 4000 学生10 万 条记录约 1 万学生百万级交互知识点字段skill_id单标签knowledge tag可多标签是否含耗时有部分有且更细预处理难度低中高3.2 概念图的构建共现统计、阈值过滤与归一化拿到题目-概念映射表之后第一步是把概念图建出来。基本原则是两道概念同时出现在同一道题里的次数越多它们之间的边权重越大。下面这段代码就是核心逻辑注意我用稀疏矩阵而不是普通二维数组概念数量上万后普通数组直接几十 MB稀疏矩阵只要几 KB。import numpy as np from collections import defaultdict # problem_concept_list: dict, problem_id - [concept_id1, concept_id2, ...] num_concept 100 # 以你的实际概念数为准从数据里统计得到 co_occur np.zeros((num_concept, num_concept), dtypenp.float32) for p, c_list in problem_concept_list.items(): # 只对一题多知识点的样本建边单知识点题目不会带来共现信息 if len(c_list) 2: continue for i in range(len(c_list)): for j in range(i 1, len(c_list)): a, b c_list[i], c_list[j] co_occur[a, b] 1.0 co_occur[b, a] 1.0 # 阈值过滤去掉只出现一两次的巧合边 adj (co_occur 3).astype(np.float32) # 对称归一化D^-1/2 * (A I) * D^-1/2防止度大的节点数值被放大 degree adj.sum(axis1) 1e-5 degree_inv_sqrt np.power(degree, -0.5) norm_adj adj * degree_inv_sqrt[:, None] norm_adj norm_adj * degree_inv_sqrt[None, :]这段代码的逻辑说明先把共现矩阵填满再做阈值过滤最后做对称归一化。阈值的取值会影响图密度我一般先统计边权分布选一个能让图保持在「大部分节点有 38 个邻居」的值。阈值设太大会让图碎成很多孤立点设太小会引入噪声边推荐参数在 35。归一化这一步不能省GCN 对节点度数差异很敏感不归一化的话度高节点的特征在传播后数值会非常大。3.3 序列滑窗与时间划分数据预处理代码图建好后下一步是把原始日志切成固定长度的训练序列。原始日志是一行一条作答记录而模型输入是一段连续做题历史。常见的做法是滑窗每个学生以 20 步为一个窗口每次往后滑一步就生成一个样本。这样数据量会膨胀很多正好适合深度模型。def make_sequences(grouped_logs, window20): grouped_logs: dict, user_id - DataFrame已按作答时间排序 返回: question_seq, concept_seq, label_seq 三个二维数组 q_seq, c_seq, r_seq [], [], [] for u, df in grouped_logs.items(): qs df[problem_id].tolist() cs df[skill_id].tolist() rs df[correct].tolist() for t in range(len(qs) - window): q_seq.append(qs[t:t window]) c_seq.append(cs[t:t window]) r_seq.append(rs[t:t window]) return np.array(q_seq), np.array(c_seq), np.array(r_seq)参数说明window 取 20 是我常用的起步值低于 10 会让 LSTM 看不到足够长的依赖高于 50 会显著增加训练时间且收益递减。这里的 r_seq 同时承担两个角色在训练时是标签在输入时也会作为条件特征影响状态更新所以它既是 X 也是 y这个概念没搞清楚后面损失函数很容易写错。还有一个关键点这里的滑窗没有做任何跨学生的拼接一个人的序列不能和另一个人的序列接在一起否则模型会学到荒谬的「换了学生但状态延续」模式。到这里数据部分就绪。接下来是最核心的部分把 GIKT 模型本身写出来。4. 用 PyTorch 把 GIKT 实现出来4.1 图卷积消息传递概念 embedding 怎么「聊天」GIKT 的图卷积层负责让概念 embedding 互相传递信息。我用的实现基于 GCN 的对称归一化传播每个节点的新表示等于「自身表示」和「邻居表示」的加权平均。PyTorch Geometric 里的 GCNConv 内置了这套逻辑直接用就可以不用自己写消息函数但你要清楚它内部做了什么否则调参就是瞎试。import torch import torch.nn as nn import torch.nn.functional as F from torch_geometric.nn import GCNConv class ConceptEncoder(nn.Module): def __init__(self, num_concept, embed_dim, hidden_dim, dropout0.2): super().__init__() self.num_concept num_concept self.embed_dim embed_dim self.hidden_dim hidden_dim self.embedding nn.Embedding(num_concept 1, embed_dim, padding_idx0) self.gcn1 GCNConv(embed_dim, hidden_dim) self.gcn2 GCNConv(hidden_dim, embed_dim) self.dropout nn.Dropout(dropout) def forward(self, edge_index): # 概念节点的初始特征全部来自 embedding x self.embedding(torch.arange(self.num_concept 1, deviceedge_index.device)) x F.relu(self.gcn1(x, edge_index)) x self.dropout(x) x self.gcn2(x, edge_index) return x这段代码的逻辑说明embedding 层生成每个概念的初始向量经过两层 GCN 后输出还是每个概念一个向量但向量里已经混入了邻居概念的信息。这里有个细节值得注意我把 padding_idx 设成 0这样 ID 为 0 的占位概念始终是零向量不会参与梯度更新避免「无效概念」污染传播过程。dropout 加在两层 GCN 之间而不是最后这样既能正则化中间表示又不会破坏最终输出的尺度。参数说明embed_dim 和 hidden_dim 一般取 128 和 256。embed_dim 决定概念表示的容量取小了图传播的信息装不下取大了显存压力大而且在小数据集上容易过拟合。hidden_dim 是图卷积内部的扩张维度我习惯设成 embed_dim 的两倍这样中间层有足够容量做特征变换。dropout 0.2 是默认值如果发现 train loss 和 val loss 差距大可以考虑提高到 0.4。4.2 序列编码与预测头LSTM 状态怎么变成概率概念表示算完之后GIKT 的主体就是一个带图增强输入的 LSTM。每一步把「习题表示」和「概念表示」拼接喂给 LSTMLSTM 更新隐状态然后预测头把隐状态映射成对所有习题的正确概率。class GIKT(nn.Module): def __init__(self, num_question, num_concept, embed_dim128, hidden_dim256, dropout0.2): super().__init__() self.num_question num_question self.num_concept num_concept self.embed_dim embed_dim self.hidden_dim hidden_dim self.q_emb nn.Embedding(num_question 1, embed_dim, padding_idx0) self.concept_encoder ConceptEncoder(num_concept, embed_dim, hidden_dim*2, dropout) self.lstm nn.LSTM(embed_dim * 2, hidden_dim, batch_firstTrue) self.dropout nn.Dropout(dropout) # 预测头从隐状态映射到每个习题的 logit self.pred_head nn.Linear(hidden_dim, num_question 1) def forward(self, q_seq, c_seq, label_seq, edge_index): # c_seq: [batch, window]每个位置是对应题目的概念 ID concept_feat self.concept_encoder(edge_index) # [num_concept1, embed_dim] concept_feat concept_feat[c_seq] # [batch, window, embed_dim] # 习题表示与概念表示各取一半维度拼接作为 LSTM 输入 q_emb self.q_emb(q_seq) # [batch, window, embed_dim] x torch.cat([q_emb, concept_feat], dim-1) # [batch, window, 2*embed_dim] # label 也作为输入把正确/错误信息显式编码 label_emb label_seq.unsqueeze(-1).float() # [batch, window, 1] x torch.cat([x, label_emb], dim-1) # [batch, window, 2*embed_dim1] out, _ self.lstm(x) # [batch, window, hidden_dim] out self.dropout(out) logits self.pred_head(out) # [batch, window, num_question1] return logits这段代码的逻辑说明前向过程分成四步。第一步用概念编码器算好增强后的概念表示然后用「索引取数」的方式把每个时间步对应的概念向量取出来这一步代替了在序列里重复做图卷积训练速度能快一个量级。第二步把习题向量和概念向量拼接构成了 LSTM 每一步的输入。第三步把「是否正确」也作为特征拼进去这是知识追踪里很关键的设计模型需要知道学生上一步做错了还是做对了才能合理更新状态。最后一步输出每个时间步对所有习题的 logit值域是整个实数域配合 BCEWithLogitsLoss 使用。参数说明LSTM 的 hidden_dim 控制学生状态的容量256 是一个平衡点。如果你发现模型在长序列上表现差可以把 LSTM 改成两层并把 hidden_dim 降到 192如果数据集很小hidden_dim 不减反而容易过拟合。这里要把 embedding 维度和词表规模分清楚num_question 和 num_concept 是词表大小embed_dim 才是向量维度这两个概念在调参时很容易搞混我在避坑章节会再强调。4.3 训练循环与评估指标模型搭好后训练循环比我预想的要容易踩坑。原因在于输出是「每个时间步对每个习题」都预测一遍如果你不加 maskpadding 位置的 loss 会把模型带偏。我的做法是构造一个布尔掩码只对真实时间步的位置算损失。def train_one_epoch(model, loader, optimizer, criterion, clip5.0): model.train() total_loss 0.0 for batch in loader: q_seq batch[q_seq].to(device) # [batch, window] c_seq batch[c_seq].to(device) # [batch, window] label_seq batch[label_seq].to(device) # [batch, window] edge_index batch[edge_index].to(device) optimizer.zero_grad() logits model(q_seq, c_seq, label_seq, edge_index) # logits: [batch, window, num_question1] # 标签下一时间步的真实习题 ID 对应的位置置 1 target torch.zeros_like(logits) batch_range torch.arange(logits.size(0), devicedevice) for t in range(logits.size(1)): # 当前时间步预测的是 t1 步的习题 if t 1 q_seq.size(1): next_q q_seq[:, t 1] next_correct label_seq[:, t 1] target[batch_range, t, next_q] 1.0 if next_correct.sum() 0 else 0.0 # 简化写法仅示意核心逻辑实际用 gather 更高效 mask (q_seq ! 0) loss F.binary_cross_entropy_with_logits( logits[:, :-1, :], target[:, :-1, :], reductionnone ) loss (loss * mask.unsqueeze(-1)).sum() / mask.sum() loss.backward() nn.utils.clip_grad_norm_(model.parameters(), clip) optimizer.step() total_loss loss.item() return total_loss / len(loader)这段代码的逻辑说明每个时间步的预测目标是「下一步真实出现的习题」所以在 target 矩阵里预测位置(t, q) 的值等于第 t1 步真实标签。这里我还加了梯度裁剪对 LSTM 类模型这是标配不裁剪的话序列长了很容易梯度爆炸loss 直接变成 NaN。实际生产代码里我不会用循环去填 target而是用 PyTorch 的 scatter_ 做批量写入这里为了可读性保留了循环写法你要追求速度可以换成 scatter_。评估指标方面AUC 和 ACC 是论文标配但实际做推荐系统我还会多盯三个指标。第一个是知识点覆盖率推荐出去的题目涉及的概念占全部概念的比例防止系统总在少数几个概念上打转。第二个是连续答错后的挽回能力学生连续错三道后系统推的下一题是否正确率更高这个指标直接反映模型对负反馈的响应。第三个是冷启动表现前 5 道题还没什么历史时模型的 AUC往往比总体 AUC 低 5 个点以上。4.4 顺带一个推荐函数难度窗口 补短优先级模型训完后很多人卡在「怎么把预测概率变成推荐列表」。我常用的做法是先筛出学生没做过或者错过的题然后只保留预测正确率在 0.5 到 0.8 之间的题最后按「正确率越接近 0.65」排序加上一点探索随机性。def recommend(model, student_history, candidate_qids, k10, explore0.1): model.eval() with torch.no_grad(): # 用最近 20 步历史预测候选题的答对概率 probs model.predict_proba(student_history, candidate_qids) candidates [] for qid, p in zip(candidate_qids, probs): # 难度窗口太简单的不推太难的不推 if 0.5 p 0.8: candidates.append((qid, p)) candidates.sort(keylambda x: -abs(x[1] - 0.65)) recs [c[0] for c in candidates[:k]] # explore 概率下用随机题目替换最后一个推荐位 if random.random() explore and len(candidate_qids) k: recs[-1] random.choice(candidate_qids) return recs这段代码的逻辑说明推荐不是简单地把预测正确率最高的题推出去。如果只推最高概率学生永远在做简单题如果只推最低概率学生会被反复打击。0.5 到 0.8 的窗口意味着「学生有一定把握但还有提升空间」这个区间符合教育心理学里的「最近发展区」思想。按距离 0.65 排序是为了让推荐集中在一个最优难度点附近而 explore 参数是为了防止推荐列表固化给长尾题目留一点曝光机会。5. GIKT 落地避坑手记现象、原因与解决5.1 AUC 虚高的元凶随机切分把时间泄漏带进验证集现象训练出来的模型在验证集上 AUC 有 0.92看起来非常漂亮但实际一跑在线测试AUC 直接掉到 0.78推荐效果惨不忍睹。原因这是时间泄漏的典型症状。很多初学者在划分训练集和测试集时直接用 random_split同一个学生的记录被随机拆到两边。模型在训练时已经见过这个学生后续的答题结果到了验证集它不过是「回忆」而并不是「预测」。做知识追踪最忌讳的就是这种切分。解决按时间切分而不是按学生随机切分。具体的做法是先把所有学生的作答记录按时间戳排序取前 80% 的时间段作为训练集后 20% 作为测试集或者按学生编号切分保证同一个学生的所有记录都在同一侧。我一般还会在验证时额外加一条约束训练集里最后一个时间步的答案不能出现在验证集里。5.2 新题一预测就失灵冷启动概念的图邻居缺失现象对历史数据里的题目预测准确率尚可但题库每新增一道题预测结果就接近 0.5或者严重偏向 0 和 1 的极端值。原因新题对应的概念如果从来没在训练数据里出现那它在概念图里没有任何邻居GCN 传播之后它的表示基本还停留在随机初始化状态。LSTM 输出状态和这个随机表示做运算结果自然毫无意义。解决两种手段配合使用。第一种是在建图时给孤立概念加「虚拟邻居」用题目文本的 TF-IDF 特征计算新概念与已有概念的内容相似度把最相似的三个概念设为邻居。第二种是在推荐侧做拦截新概念至少在推荐列表里加一个探索位先让少量学生试做拿到真实作答数据后再回填到训练集里下一次更新模型时它就不再是冷启动了。5.3 推荐全是简单题评分函数没有难度约束现象推荐列表里的题目看起来「都做对了」学生的页面正确率很高但一周下来知识点覆盖率几乎没有变化学生也没有进步感。原因如果你直接用模型预测的概率排序那么模型会给简单题打出极高概率推荐列表被简单题淹没。这不是模型的问题是推荐策略的问题——模型只是在反映「这道题学生大概率能做对」并没有义务去判断「这题该不该推」。解决把难度约束加进推荐策略里。先离线统计每道题的历史平均正确率作为难度分然后在候选集过滤时要求题目难度落在 0.3 到 0.75 之间再叠加模型预测概率的 0.50.8 窗口。两道过滤一叠加推荐列表才真正落在「学生有挑战但能完成」的区间。5.4 重复作答让掌握度虚高样本去重与时间衰减现象某个概念的掌握概率在训练后期持续走高但实际测验成绩并没有提升模型对重复题型的预测比对新题型的预测明显乐观。原因很多数据集中学生会对同一道题做多次。如果把每一次作答都当成独立样本送进序列那么高频重复的题会反复强化模型「这道题掌握了」的信念尤其是连续做对几次之后模型几乎不可能再预测错。解决在生成序列之前做去重。常见的策略是同一道题在同一个会话内只保留第一次作答如果题目跨会话重复出现则对后续作答的时间权重做衰减比如最近一次作答的权重是第一次的 0.5。这个处理直接影响序列长度和样本质量我通常在数据预处理阶段处理而不是在模型里改。5.5 embedding 维度爆显存词表规模和向量大小是两回事现象明明数据量不大但训练时显存直接 OOM或者训练速度越来越慢。原因把 embedding 维度理解错了。有个常见的翻车操作是把 num_question 设成 1 万多死磕着把 embed_dim 也设成 512结果光习题 embedding 表就是一万乘五百一十二个浮点数两亿多个参数直接吃满显存。embedding 维度是「向量的长度」词表规模是「有多少个这样的向量」两者完全是两个概念。解决embed_dim 控制在 64 到 128 之间足够表达习题差异超过 256 在大部分教育数据集上都不会带来收益。另一个技巧是不要让预测头输出对所有习题的 logit改成 predict negative sampling每次只对真实下一题和随机采样的 100 个负样本做计算显存占用立刻降一个数量级。6. 进阶验证时间感知交叉验证与知识状态热力图模型能跑通之后最容易被追问的是「你怎么证明你模型真的学到了知识状态而不是死记硬背顺序」。这里有一个我每次必做的验证技巧时间感知交叉验证。它与普通 K 折交叉验证的区别是每一折都必须保证训练数据的时间全部早于验证数据。实现上按用户做 K 组切分每组内按时间分成前 80% 和后 20%训练集用前 80%验证用后 20%K 次取平均 AUC。这个结果才能真实反映线上表现。第二个值得做的验证是知识状态热力图。取一个学生的做题序列让模型逐步输出每个概念的掌握概率画一张横轴是时间步、纵轴是概念的热力图。正常模型的图应该像一条逐渐变亮的河流掌握的概念区域逐步升温相邻概念同时升温且被纠正后的错误概念会在后续几步内回温。如果看到热力图是「一横条全亮」或者「每隔几步闪一下」说明模型没有真正学到知识状态只是在记忆答题顺序。import matplotlib.pyplot as plt def plot_mastery_heatmap(model, student_data, concept_names): model.eval() with torch.no_grad(): probs model.predict_concept_probs(student_data) # probs: [time_steps, num_concept]取值 0~1 plt.figure(figsize(10, 8)) im plt.imshow(probs.T, aspectauto, cmapYlGnBu) plt.colorbar(im, labelMastery Probability) plt.xlabel(Time Step) plt.ylabel(Concept) plt.yticks(range(len(concept_names)), concept_names, fontsize8) plt.tight_layout() plt.savefig(mastery_heatmap.png, dpi150)参数说明这个可视化工具的价值在于排错而不只是展示。我遇到过模型输出全 0.5 的情况那就是概念图和序列没对齐也遇到过相邻概念颜色差距极大说明共现图没有把该连的边连上。相比只看 AUC 数字热力图能把模型脑子里「学生的状态长什么样」直接摊开给你看。最后说一句我的血泪经验这类毕设项目最容易翻车的地方不在模型代码而在数据切分。我曾经为了交差用随机切分刷到高 AUC答辩时被老师问了一句「这套系统什么时候能真正部署」当场就意识到问题所在。后来所有的实验一律按时间切分虽然 AUC 数字不好看了但每个数字都经得起推敲。做知识追踪也好做习题推荐也好模型再花哨最终要回答的问题始终是「这个学生下一步该做什么」想清楚这一点你的实现就不会跑偏。希望帮到你。本文还有配套的精品资源点击获取
