news 2026/10/27 10:33:21

AI辅助教学:调用大模型从教材中生成习题,方便学员练习

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
AI辅助教学:调用大模型从教材中生成习题,方便学员练习

一个AI辅助教学的小项目,记录一下。

先看效果,用户在选择资料库上传文件,勾选加入增强检索。

系统上传文件并将切片存入neo4j,用户选择题库,点击添加问题,系统按照选择题型、难度和数量生成问题并说明来源。

技术栈:spring boot 3.0.3 + neo4j-community-5.26.32 + spring-ai ,模型选择qwen3-4b(本地测试)。

ExamGenService负责出题的后台任务:
package com.magicdata.web.service; import com.magicdata.system.utils.SnowFlakeUtils; import com.magicdata.web.domain.ExamGenJob; import com.magicdata.web.domain.SysExamQuestion; import com.magicdata.web.domain.SysKnowledge; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.ThreadFactory; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; /** * 练习题生成后台任务 * * <p>出题要读向量库、按批调用大模型再解析入库,在线模型一跑就是几十秒到几分钟。 * 与「加入增强检索」同理,直接在 HTTP 请求里同步做很容易被网关/代理超时掐断, * 用户只看到一句「失败」。所以:</p> * * <pre> * 点击生成 → 立即返回 taskId → 后台线程出题 → 前端轮询 /knowledge/examProgress 显示进度 * </pre> * * <p>任务状态只放内存({@link #jobs}):进度只对当次操作有意义,重启后丢失可接受。 * 已结束的任务保留 {@link #JOB_KEEP_MS},方便前端补取题目列表。</p> * * <p>执行线程只有一个:出题是 IO 密集且依赖同一个大模型服务,并发跑多个任务只会互相拖慢。</p> * * @author magicdata */ @Service public class ExamGenService { private static final Logger log = LoggerFactory.getLogger(ExamGenService.class); /** 已结束任务的保留时长:前端轮询 1 秒一次,留 30 分钟足够用户慢慢看结果 */ private static final long JOB_KEEP_MS = 30 * 60 * 1000L; /** 内存里最多保留的任务数(超出时丢最旧的已结束任务) */ private static final int MAX_JOBS = 50; private final Map<String, ExamGenJob> jobs = new ConcurrentHashMap<>(); private final ThreadPoolExecutor executor; private final SysKnowledgeService knowledgeService; private final ExamQuestionService examQuestionService; public ExamGenService(SysKnowledgeService knowledgeService, ExamQuestionService examQuestionService) { this.knowledgeService = knowledgeService; this.examQuestionService = examQuestionService; this.executor = new ThreadPoolExecutor(1, 1, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue<>(), threadFactory()); this.executor.allowCoreThreadTimeOut(true); } /** * 提交出题任务 * * @param knowledge 资料来源(已被校验存在且已加入增强检索) * @param bankId 归属题库编号(可空;从「题库管理 → 添加问题」进来时必填) * @param questionType 题型:single / multiple / judge * @param difficulty 难度:easy / normal / hard * @param count 题量(1~{@link ExamQuestionService#MAX_COUNT}) * @param operator 操作人 * @return 任务对象(前端拿 taskId 轮询) */ public ExamGenJob submit(SysKnowledge knowledge, Long bankId, String questionType, String difficulty, int count, String operator) { cleanExpired(); ExamGenJob job = new ExamGenJob(nextTaskId(), knowledge.getId(), knowledge.getKnowledgeName(), questionType, difficulty, count); jobs.put(job.getTaskId(), job); try { executor.execute(() -> run(job, knowledge.getId(), bankId, questionType, difficulty, count, operator)); } catch (Exception e) { log.error("出题任务提交失败:taskId={}", job.getTaskId(), e); job.reject("任务提交失败:" + e.getMessage()); } return job; } /** * 取任务状态 * * @param taskId 任务编号;为空时返回「最近一个任务」 * @return 任务对象;不存在返回 {@code null} */ public ExamGenJob get(String taskId) { cleanExpired(); if (taskId != null && !taskId.isBlank()) { return jobs.get(taskId.trim()); } return jobs.values().stream() .max(Comparator.comparingLong(ExamGenJob::getCreateTime)) .orElse(null); } /** 当前是否有出题任务在跑 */ public boolean hasRunning() { return jobs.values().stream().anyMatch(ExamGenJob::isRunning); } /** * 后台执行:调用 {@link ExamQuestionService} 出题,进度写回任务对象 */ private void run(ExamGenJob job, Long knowledgeId, Long bankId, String questionType, String difficulty, int count, String operator) { job.markRunning(); log.info("出题任务开始:taskId={},资料={},题库={},题型={},难度={},题量={}", job.getTaskId(), knowledgeId, bankId, questionType, difficulty, count); try { SysKnowledge knowledge = knowledgeService.getById(knowledgeId); if (knowledge == null) { job.reject("资料不存在,可能已被删除"); return; } ExamQuestionService.GenResult result = examQuestionService.generate(knowledge, bankId, questionType, difficulty, count, operator, (percent, stage, detail) -> job.report(percent, stage, detail)); for (SysExamQuestion question : result.getQuestions()) { job.addQuestion(question); } List<String> failures = result.getFailures(); int ok = result.getQuestions().size(); for (String failure : failures) { job.addFailure(failure); } job.finish(ok, failures.size()); job.setMessage(buildMessage(job, ok, failures.size())); log.info("出题任务结束:taskId={},成功 {} 道,失败 {} 批", job.getTaskId(), ok, failures.size()); } catch (Exception e) { String reason = shortMessage(e); log.error("出题任务失败:taskId={}", job.getTaskId(), e); job.addFailure(reason); job.finish(0, 1); job.setMessage("生成练习题失败:" + reason); } } /** 结束文案:全部成功 / 部分失败两种口吻 */ private String buildMessage(ExamGenJob job, int ok, int fail) { String scope = typeLabel(job.getQuestionType()) + "·" + difficultyLabel(job.getDifficulty()); if (fail == 0) { return "生成练习题完成:" + scope + " 共 " + ok + " 道,已保存到题库"; } if (ok == 0) { return "生成练习题失败:" + String.join(";", job.getFailures()); } return "生成练习题完成 " + ok + " 道," + fail + " 批失败:" + String.join(";", job.getFailures()); } /** 题型中文名 */ public static String typeLabel(String type) { if (SysExamQuestion.TYPE_MULTIPLE.equals(type)) { return "多选题"; } if (SysExamQuestion.TYPE_JUDGE.equals(type)) { return "判断题"; } return "单选题"; } /** 难度中文名 */ public static String difficultyLabel(String difficulty) { if (SysExamQuestion.DIFFICULTY_EASY.equals(difficulty)) { return "简单"; } if (SysExamQuestion.DIFFICULTY_HARD.equals(difficulty)) { return "困难"; } return "正常"; } /** 异常信息压成一行 */ private String shortMessage(Exception e) { String text = e.getMessage(); if (text == null || text.isBlank()) { text = e.getClass().getSimpleName(); } text = text.replace('\n', ' ').trim(); return text.length() > 260 ? text.substring(0, 260) + "..." : text; } /** * 清理过期任务:已结束且超过保留时长的删掉;任务数超过上限时从最旧的已结束任务开始删 */ private void cleanExpired() { long now = System.currentTimeMillis(); jobs.values().removeIf(job -> job.isFinished() && now - job.getEndTime() > JOB_KEEP_MS); if (jobs.size() <= MAX_JOBS) { return; } List<ExamGenJob> finished = jobs.values().stream() .filter(ExamGenJob::isFinished) .sorted(Comparator.comparingLong(ExamGenJob::getCreateTime)) .toList(); for (ExamGenJob job : finished) { if (jobs.size() <= MAX_JOBS) { break; } jobs.remove(job.getTaskId()); } } private String nextTaskId() { return "ex-" + SnowFlakeUtils.snowFlakeId(); } private ThreadFactory threadFactory() { AtomicInteger seq = new AtomicInteger(1); return runnable -> { Thread thread = new Thread(runnable, "exam-gen-" + seq.getAndIncrement()); thread.setDaemon(true); return thread; }; } }
examQuestionService负责出题:
/** * 为某篇资料生成练习题并入库 * * @param knowledge 资料来源(必须已加入增强检索) * @param bankId 归属题库编号(可空;空表示未归类) * @param questionType 题型:single / multiple / judge * @param difficulty 难度:easy / normal / hard * @param count 期望题量(1~{@link #MAX_COUNT}) * @param operator 操作人 * @param listener 进度回调,可为 {@code null} * @return 出题结果(题目 + 失败原因) * @throws Exception 取不到原文 / 一道题都没出成时抛出,异常信息可直接回显 */ public GenResult generate(SysKnowledge knowledge, Long bankId, String questionType, String difficulty, int count, String operator, ProgressListener listener) throws Exception { String type = normalizeType(questionType); String level = normalizeDifficulty(difficulty); int want = Math.max(1, Math.min(count, MAX_COUNT)); // 1. 确认向量库里确实有原文(真正的取样放在每批循环里做,每次随机取一小段) report(listener, 2, "读取资料原文", "正在从向量库取回《" + knowledge.getKnowledgeName() + "》的原文片段"); long totalChunks; try { totalChunks = graphService.countChunks(knowledge.getId()); } catch (Exception e) { throw new IllegalStateException("读取向量库失败:" + shortMessage(e)); } if (totalChunks <= 0) { throw new IllegalStateException("该资料在向量库里没有可用的原文分片,请先「加入增强检索」再出题"); } // 已经用过的片段:每批随机取样后据此去重,相邻批次尽量不考同一处 Set<String> usedChunks = new HashSet<>(); report(listener, 10, "准备出题", "向量库共 " + totalChunks + " 片原文,每批随机取样(上下文上限 " + CONTEXT_TOTAL_CHARS + " 字),即将调用大模型"); // 2. 分批出题 // 批次上限:模型偶尔只吐出 1~2 道(没按要求的条数来),所以要允许比理论批数多跑几批; // 但必须有上限,否则模型一直吐空结果就会无限调用下去。 List<SysExamQuestion> result = new ArrayList<>(want); List<String> failures = new ArrayList<>(); int maxBatches = (want + BATCH_SIZE - 1) / BATCH_SIZE + 3; int batchIndex = 0; while (result.size() < want && batchIndex < maxBatches) { int batchCount = Math.min(want - result.size(), BATCH_SIZE); batchIndex++; // 每批重新随机取切片:上下文长度恒定,不同批次覆盖不同章节 List<String> chunks = pickChunks(knowledge.getId(), usedChunks); String context = joinContext(chunks); if (context.isEmpty()) { failures.add("第 " + batchIndex + " 批:未取到原文片段"); logger.warn("出题批次 {} 未取到原文片段,停止出题", batchIndex); break; } report(listener, progress(result.size(), want), "调用大模型出题", "已生成 " + result.size() + "/" + want + " 道(第 " + batchIndex + " 批,依据 " + chunks.size() + " 片,原文约 " + context.length() + " 字)"); logger.info("出题批次 {}:随机取到 {} 片原文,上下文 {} 字", batchIndex, chunks.size(), context.length()); String promptText = buildPrompt(knowledge.getKnowledgeName(), type, level, batchCount, context, result); try { // 出题固定用「增强检索」里配置的对话模型:与学伴问答(会话模型)完全解耦 String raw = ragConfigHolder.chatClient().prompt(new Prompt(promptText)).call().content(); List<SysExamQuestion> batch = parseQuestions(raw, type, level); if (batch.isEmpty()) { failures.add("第 " + batchIndex + " 批未解析出题目"); logger.warn("出题批次 {} 未解析出题目,模型原始输出:{}", batchIndex, abbreviate(raw, 500)); } else { for (SysExamQuestion question : batch) { SysExamQuestion saved = persist(question, knowledge, bankId, type, level, operator); result.add(saved); if (result.size() >= want) { break; } } } } catch (Exception e) { failures.add("第 " + batchIndex + " 批:" + shortMessage(e)); logger.error("出题批次 {} 失败", batchIndex, e); } } // 3. 收尾:一道都没出成要把原因带出去 if (result.isEmpty()) { throw new IllegalStateException("出题失败:" + String.join(";", failures)); } report(listener, 100, "完成", "共生成 " + result.size() + " 道题"); logger.info("资料 [{}]({})出题完成:题型 {},难度 {},请求 {} 道,实际 {} 道", knowledge.getKnowledgeName(), knowledge.getId(), type, level, want, result.size()); if (!failures.isEmpty()) { logger.warn("出题存在失败批次:{}", String.join(";", failures)); } return new GenResult(result, failures); }
/** * 组装命题 Prompt(用字符串拼接,避免与 JSON 里的大括号冲突) */ private String buildPrompt(String knowledgeName, String type, String level, int batchCount, String context, List<SysExamQuestion> generated) { StringBuilder sb = new StringBuilder(4096); sb.append("你是一位中医针灸学专业的命题专家,正在为《").append(knowledgeName).append("》命制练习题。\n\n"); sb.append("出题要求:\n"); sb.append("1. 题目必须严格依据下面提供的资料片段,考查资料中确实出现的知识点,不得编造资料之外的内容。\n"); sb.append("2. 题干表述严谨、完整、无歧义;选项之间要有区分度,错误选项要合理但确实不对,如引用切片或片段内容,不要说来自XX切片或片段,要引用切片或片段的相关内容。\n"); sb.append("3. 本次共出 ").append(batchCount).append(" 道题,难度为:").append(difficultyText(level)).append("。\n"); sb.append("4. 只输出一个 JSON 数组,不要输出任何解释性文字,不要用 markdown 代码块包裹,不要输出数组以外的内容。\n\n"); sb.append("5. 题目解析清晰明了,如引用切片或片段内容,不要说来自XX切片或片段,要引用切片或片段相关内容并说明正确答案的依据。\n\n"); sb.append("6. 返回内容中不要出现片段XX中、切片XX中等含义模糊的词语。\n\n"); sb.append("JSON 数组里每个元素的结构如下:\n"); sb.append("{\"stem\": \"题干\", \"options\": [{\"key\": \"A\", \"content\": \"选项内容\"}, ") .append("{\"key\": \"B\", \"content\": \"选项内容\"}], \"answer\": \"A\", ") .append("\"analysis\": \"解析:说明正确答案的依据,并简要说明其它选项为什么不对\", ") .append("\"source\": \"本题依据的资料原文片段(40 字以内)\"}\n\n"); sb.append("题型要求:").append(answerRule(type)).append("\n\n"); //加了这个,上下文会超出模型限制 /*if (!generated.isEmpty()) { sb.append("已经出过的题目如下,请不要重复出相同的考点:\n"); for (SysExamQuestion question : generated) { sb.append("- ").append(abbreviate(question.getStem(), 60)).append("\n"); } sb.append("\n"); }*/ sb.append("资料片段:\n").append(context).append("\n"); return sb.toString(); } /** 各题型的答题规则说明 */ private String answerRule(String type) { switch (type) { case TYPE_MULTIPLE: return "本批全部是多选题。options 给出 A/B/C/D 四个选项;answer 填 2~4 个正确选项的字母," + "用英文逗号分隔,例如 \"A,C\"(不要加空格)。"; case TYPE_JUDGE: return "本批全部是判断题。options 固定为空数组 [];answer 只能填 \"正确\" 或 \"错误\"。"; case TYPE_SINGLE: default: return "本批全部是单选题。options 给出 A/B/C/D 四个选项;answer 只填一个正确选项的字母,例如 \"A\"。"; } }

调用大模型出题是generate函数,分批次查询随机的分片内容给大模型当作参考资料,模型根据提示词要求,返回对应的结果。

演示地址:针灸推拿学数字教学系统。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/27 10:33:19

Python 基础学习(一):一张图梳理 Python 基础知识框架

开始学习 Python 时&#xff0c;我们会陆续接触变量、字符串、列表、循环、函数和类等概念。先了解这些知识在整体框架中的位置&#xff0c;后面学习具体语法时&#xff0c;就更容易把它们联系起来。 这篇作为“Python 学习&#xff0c;从入门到精通”专栏的开篇&#xff0c;先…

作者头像 李华
网站建设 2026/10/27 10:27:40

SAM-DAQ:深度适配SAM2查询,RGB-D视频SOD达0.913 Eξ

&#x1f525; 本文定位&#xff1a;CSDN 原创干货 | AAAI 2026 | RGB-D 视频显著性检测 &#x1f3af; 核心收益&#xff1a;围绕RGB-D Video Salient Object Detection的真实瓶颈&#xff0c;拆开复现 SAM-DAQ 的数据、特征和决策路径。论文最可核对的结果是&#xff1a;其消…

作者头像 李华
网站建设 2026/10/27 10:26:43

当 Agent 认定靶标是模拟的:一次评测事故复盘

当 Agent 认定靶标是模拟的&#xff1a;一次评测事故复盘原文&#xff1a;Investigating alignment failures in AISI’s incident&#xff08;https://www.aisi.gov.uk/blog/investigating-alignment-failures-in-aisis-incident&#xff09;事故不长&#xff0c;但每一层都值…

作者头像 李华
网站建设 2026/10/27 10:24:37

C#---CSV文件读写

csv.WriteRecord()添加单条数据&#xff0c;不会自动获得表头&#xff1b;不会自动换行&#xff0c;要使用csv.NextRecord(); 方法换行如果写入时不自动追加查询时会报错&#xff0c;所以这里要改成自动追加&#xff0c;示例代码如下&#xff1a;User usernew User(int.Parse(t…

作者头像 李华