一个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函数,分批次查询随机的分片内容给大模型当作参考资料,模型根据提示词要求,返回对应的结果。
演示地址:针灸推拿学数字教学系统。