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函数,分批次查询随机的分片内容给大模型当作参考资料,模型根据提示词要求,返回对应的结果。

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

相关推荐
香蕉大香蕉(06敬畏市场)1 小时前
高频八股 · Java 并发编程(10):异步、ThreadLocal 与虚拟线程(收官)
java·开发语言·网络·jvm·python·ai·实习
想做小南娘,发现自己是女生喵1 小时前
LAN Device Monitor 第二周学习文档
java·服务器·数据库
Wang's Blog1 小时前
Java 项目实战: 交友项目 - 查询推荐动态
java·开发语言·交友
不识君1 小时前
esl-client 2.0.0 发布:Java 项目接入 FreeSWITCH,从引入到使用
spring boot·后端·开源
jaysee-sjc1 小时前
【苍穹外卖】Day10:定时任务与 WebSocket 实时提醒实战
java·开发语言·javascript·数据库·vue.js·websocket·网络协议
独隅1 小时前
Claude 高效应用实战指南
android·java·开发语言·claude·claude code
8486981191 小时前
RAGFlow v1.0 企业级 RAG 落地实录:从零部署到 RAGAS 评估闭环(全踩坑记录)
人工智能·大模型·rag·ragflow·ragas
WJ.Polar2 小时前
异常、泛型、集合、Stream流
java·数据结构
牧瀬クリスだ2 小时前
Redis五大数据类型全解析:从入门到实战
java·数据库·redis·后端·缓存