资讯动态

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

发布时间:2026/10/27 10:33:25 来源:尧图企业网站定制
一个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 MapString, 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); } ListString 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; } ListExamGenJob 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(该资料在向量库里没有可用的原文分片请先「加入增强检索」再出题); } // 已经用过的片段每批随机取样后据此去重相邻批次尽量不考同一处 SetString usedChunks new HashSet(); report(listener, 10, 准备出题, 向量库共 totalChunks 片原文每批随机取样上下文上限 CONTEXT_TOTAL_CHARS 字即将调用大模型); // 2. 分批出题 // 批次上限模型偶尔只吐出 1~2 道没按要求的条数来所以要允许比理论批数多跑几批 // 但必须有上限否则模型一直吐空结果就会无限调用下去。 ListSysExamQuestion result new ArrayList(want); ListString 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; // 每批重新随机取切片上下文长度恒定不同批次覆盖不同章节 ListString 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(); ListSysExamQuestion 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, ListSysExamQuestion 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函数分批次查询随机的分片内容给大模型当作参考资料模型根据提示词要求返回对应的结果。演示地址针灸推拿学数字教学系统。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑