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