基于SpringBoot+AI大模型的在线考试系统,是最近后台开发里被问得比较多的一类项目。很多同学不是找不到 SpringBoot 考试系统源码,而是找不到把大模型能力真正用起来的版本。实际上,这个项目最大的价值不在“在线考试”这四个字,而在 AI 如何参与出题、批改和学情分析,并且让这些能力在一个完整的考试流程里稳定落地。
如果你是做毕业设计,或者企业想搭内部培训考试平台,这篇文章会从系统边界讲到表结构、AI 接口封装、批量任务、性能排查和生产化改造,尽量按真实开发顺序来写。先说明一点:这个系统不是把大模型接口随便串起来展示一下就完成了,真正花时间的往往是 AI 返回解析、考试状态控制、异步任务和低配置环境下的取舍。下面直接开始。
1. 先想清楚:这套系统真正解决的问题是什么
1.1 别把它做成高级 CRUD
如果只做一个 SpringBoot 后台,加上用户表、题库表、试卷表、成绩表,再配一个简单前端,那就是常规在线考试系统。这类系统网上非常多,技术难度不高,但很难体现出“AI 大模型”的价值。
这个项目的核心变化,是让 AI 介入下面这几个关键动作:
- 智能出题:输入知识点、难度、题型,批量生成题目和答案解析。
- 题目审核与去重:用大模型检查题目是否重复、表述是否清晰、答案是否正确。
- 主观题批改:不再只按关键词匹配,而是结合参考答案和评分标准给出分数与评分理由。
- 学情分析:根据学生历史考试结果,生成个性化弱项分析报告。
客观题其实不需要大模型参与。学生选了 A,答案是 A,数据库直接对比就能判分。真正需要 AI 的是主观题、填空题、场景分析题,以及从整体考试数据里提炼结论。
所以你在设计项目时,先不要急着写大段 AI 代码。先把上面四个场景单独列出来,想清楚每个场景的输入是什么、输出是什么、失败时怎么处理。
1.2 AI 接入在线考试的常见位置
第一个位置是题库生成。教师创建一个试题生成任务,指定考试科目、知识点范围、题目数量、难度分布和题型,后端把这些参数整理成提示词,发给大模型,收到结构化 JSON 后写入题库。
第二个位置是题目审核。教师手动录入的题目,或者 AI 生成的题目,可以通过大模型先做一遍校验。比如题干是否完整,选项是否有干扰性,正确答案是否明确,解析是否合理。
第三个位置是主观题批改。学生在交卷后提交主观题答案,系统把题目、参考答案、评分标准、学生答案一起发给大模型,大模型返回分数和评分理由。这个分数可以作为初评分数,教师再人工复核。
第四个位置是学情分析。考试结束后,系统需要统计学生的总分、各知识点得分率、题型得分率、耗时等指标。大模型基于这些统计结果生成文字报告,指出学生的问题并给出学习建议。
这些场景有一个共同点:AI 只是辅助,不是考试系统的唯一依赖。系统必须在 AI 请求失败时仍然能够完成考试流程。
1.3 最小可用范围应该怎么划
功能范围划得太大会让项目很难落地。如果你只想在本地跑通,我建议按这条边界来设计。
学生端:
- 登录和查看自己的考试列表。
- 开始考试,加载题目。
- 答题过程中暂存答案。
- 交卷后查看考试结果和学情报告。
教师端:
- 创建试卷。
- 选择出题范围,调用 AI 生成题目。
- 查看 AI 生成的题目,进行人工审核。
- 批改主观题,可以修改 AI 初评分数。
管理员端:
- 用户管理。
- 题目审核与下架。
- 查看系统运行日志。
不要一开始就想做在线监考、人脸识别、移动端 App、分布式部署。先把单机版业务链路跑通,后面再扩展。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术选型和环境准备:先把骨架搭得稳
2.1 后端技术栈怎么选
这个项目的主框架是 SpringBoot,按照实际开发习惯,可以用 Java 17 + Spring Boot 3.x。如果你之前用的是 Spring Boot 2.7,也可以保留,只是部分依赖需要进行兼容调整。
配套组件通常包括:
- MySQL:存业务数据,比如用户、题库、试卷、考试记录。
- Redis:缓存登录态、题目列表、考生答案暂存、任务进度。
- MyBatis-Plus 或 Spring Data JPA:操作数据库。国内项目用 MyBatis-Plus 比较多,因为写单表查询很方便。
- JWT + Spring Security 或自定义拦截器:做登录和权限控制。如果项目规模不大,自定义拦截器更轻量。
- OkHttp 或 WebClient:调用外部大模型接口。OkHttp 简单直观,WebClient 适合高并发场景。
- Knife4j 或 Swagger:生成接口文档,方便和前端联调。
核心依赖可以参考下面这段。注意,版本号以你当前用的 Spring Boot 版本为准,不要照抄。
xml复制<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>替换成实际可用版本</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>替换成实际可用版本</version>
</dependency>
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<version>替换成实际可用版本</version>
</dependency>
</dependencies>
2.2 大模型接入:API 优先,还是本地部署
这里要先做一次判断。如果你手里没有合适的 GPU 服务器,本地部署大模型不是明智选择。常见的 API 接入方式开发速度快、硬件要求低,只要在网络可用的环境里拿到接口地址和密钥就能跑。
如果系统只用于学习或内部演示,用大模型 API 的 OpenAI 兼容接口是最省事的方式。你只需要在配置文件中维护 baseUrl、apiKey、model、maxTokens、temperature 等参数。
如果企业内部有保密要求,比如考卷不能外传,那就需要把大模型部署在专有网络内,或者使用企业内部提供的大模型服务。但这种方案的落地成本明显更高,需要考虑显存、内存、并发请求量和服务稳定性。
配置文件可以这样设计:
yaml复制spring:
datasource:
url: jdbc:mysql://localhost:3306/exam_ai?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai
username: root
password: 你的密码
data:
redis:
host: localhost
port: 6379
exam:
ai:
base-url: https://你的大模型接口
api-key: 你的密钥
model: 具体模型名称
max-tokens: 2048
temperature: 0.7
connect-timeout-seconds: 5
read-timeout-seconds: 60
apiKey 这类敏感信息不要硬编码在代码里。本地开发可以放到配置文件,生产环境建议通过环境变量或者配置中心来注入。
2.3 基础工程结构怎么搭
项目初期保持单模块结构就好,不用拆微服务。比较清晰的结构是这样:
text复制com.example.exam
├── common // 通用返回结果、异常处理、常量
├── config // Spring MVC 配置、拦截器配置、线程池配置
├── controller // 接口层
├── service // 业务逻辑层
│ ├── ai // 大模型相关服务
│ ├── exam // 考试流程服务
│ └── user // 用户服务
├── mapper // 数据库访问层
├── entity // 数据库实体
├── dto // 请求和响应对象
└── task // 异步任务
我建议把 AI 相关代码独立出来,不要和考试业务搅在一起。否则后面更换模型,或者修改提示词,都要在一堆业务代码里到处找,排查成本很高。
3. 从零搭起最小在线考试系统:表结构、权限与第一版接口
3.1 数据库表结构先按这个最小集设计
最小系统里至少要有这几张表。
用户表:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| username | varchar | 用户名 |
| password | varchar | BCrypt 加密后的密码 |
| real_name | varchar | 真实姓名 |
| role | varchar | 可选值:STUDENT / TEACHER / ADMIN |
| status | int | 1 可用,0 禁用 |
题库表 question:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| question_type | varchar | objective / subjective |
| content | text | 题干内容 |
| options | text | 单选题选项,JSON 字符串 |
| correct_answer | text | 正确答案 |
| analysis | text | 解析 |
| point_name | varchar | 知识点名称 |
| difficulty | varchar | 简单 / 中等 / 困难 |
| source | varchar | manual / ai |
| audit_status | int | 0 待审核,1 通过,2 拒绝 |
试卷表 exam_paper 和试卷题目关联表 exam_question:
exam_paper 保存试卷名称、总分、时长、发布状态。exam_question 保存试卷与题目 id 的关联关系,以及每题的分值。
考试记录表 exam_record:
- user_id:学生 id
- paper_id:试卷 id
- status:表示考试状态
- start_time:开始时间
- submit_time:提交时间
- objective_score:客观题得分
- subjective_score:主观题得分
- total_score:总分
答题记录表 answer_record:
- exam_record_id:考试记录 id
- question_id:题目 id
- student_answer:学生答案
- ai_score:AI 初评分数
- ai_reason:AI 评分理由
- teacher_score:教师复核分数
- review_status:0 未批改,1 AI 已初评,2 教师已复核
建议给 exam_record 表加唯一约束,确保同一学生同一试卷只能有一条有效考试记录。这样可以从数据库层面防止重复考试和重复交卷。
3.2 第一版考试流程怎么定义
考试流程不一定需要 AI 参与才能测通。我建议先把纯考试链路跑通,再接入 AI。
接口列表可以先这样设计:
- POST /api/auth/login:登录并返回 token。
- GET /api/paper/available:学生查看可参加的考试。
- POST /api/exam/start:学生开始考试,生成考试记录。
- GET /api/exam/paper/{examRecordId}:学生加载题目。
- POST /api/exam/save:暂存答案。
- POST /api/exam/submit:交卷。
- GET /api/exam/result/{examRecordId}:查看成绩。
这里面有一个关键点:学生加载题目时,接口返回的是题目内容,正确答案绝对不能让前端拿到。客观题答案可以保存在后端,交卷时后端自己判分。主观题答案只需要返回题干、分值和作答区域。
另一个关键点是考试状态。
学生考试记录的状态变化可以这样定义:
text复制未开始 -> 进行中 -> 已交卷 -> 已批改 -> 已完成
开始考试时,状态从未开始变成进行中。交卷时,只有状态为进行中才允许提交。交卷完成后,状态变成已交卷。AI 主观题批改完成并经过教师确认后,状态变成已批改。
状态切换不能只靠前端隐藏按钮,后端必须在每次操作时校验当前状态。比如交卷接口里就要判断记录是否存在、是否属于当前用户、当前状态是否允许交卷。
3.3 用户认证和权限控制
用户认证用 JWT 比较简单。用户登录成功后,后端签发一个 token,里面包含用户 id、用户名和角色。前端每次请求把 token 放在请求头里,后端通过拦截器解析。
自定义拦截器里可以做三件事:
- 从请求头读取 token。
- 解析 token 并判断用户是否有效。
- 把用户信息放入请求上下文,供后续业务代码使用。
然后给接口加权限注解。比如:
- 学生接口:/api/exam/**,只有 STUDENT 角色能访问。
- 教师接口:/api/admin/question/**,只有 TEACHER 和 ADMIN 角色能访问。
- 管理员接口:/api/admin/user/**,只有 ADMIN 角色能访问。
权限不一定要上 Spring Security。如果你对 Spring Security 不熟,可以用一个自定义注解 @RequireRole("TEACHER"),再在拦截器里根据注解判断。项目跑通后,再考虑把权限模块做得更完整。
4. 把 AI 能力落地:智能出题、自动评分与学情分析
4.1 智能出题:提示词、结构化返回和校验
智能出题是这个项目里最容易做出效果的功能。但很多人的做法是把一段提示词发给大模型,然后返回一大段文字,后端把文字存进题库。这样问题很多,因为题目无法结构化,组卷时很难处理。
正确做法是要求大模型返回严格 JSON。
提示词可以这样写:
text复制你是一个在线考试系统的出题助手。请基于知识点“Java 异常体系”,难度“中等”,题型“单选题”,生成 5 道单选题。
要求:
1. 每道题包含题干、4 个选项、正确答案、解析。
2. 正确答案必须是 A、B、C、D 中的一项。
3. 输出 JSON 数组,不要输出多余文字。
输出格式:
[
{
"stem": "题干",
"options": ["选项 A", "选项 B", "选项 C", "选项 D"],
"correctAnswer": "A",
"analysis": "解析"
}
]
后端拿到响应后直接解析 JSON 数组,再逐条做字段校验。
这里一定要做二次校验,不能直接把大模型返回结果写库。常见校验规则包括:
- stem、options、correctAnswer、analysis 是否非空。
- options 数量是否达到要求。
- correctAnswer 是否在选项编号范围内。
- 题干是否和知识点匹配。
- 选项中是否有重复内容。
校验失败时,可以把题目标记为待审核,也可以重新调用一次大模型。如果连续两次都失败,就停止该批次任务,并记录错误日志。
4.2 主观题自动评分:评分标准前置、结果可解释
主观题评分不能简单地把“大模型给出的分数”当成最终分数。更合理的做法是让大模型返回分数、评分理由和置信度。
出题时,教师在后台录入参考答案和评分标准。例如:
text复制题目:请解释 Java 中 checked exception 和 unchecked exception 的区别。
参考答案:checked exception 在编译期被检查,编译器要求调用方捕获或声明;unchecked exception 是运行时异常,编译器不强制处理。
评分标准:说出编译期检查得 3 分,说出运行时异常得 3 分,给出例子得 2 分,表达清晰完整得 2 分,总分 10 分。
批改时,后端把题目原文、参考答案、评分标准、学生答案一起组装成提示词发给大模型。
提示词里还要加入输出 JSON 的要求:
json复制{
"score": 8,
"reason": "学生答案准确说出了编译期检查的要求,并且提到了运行时异常,但没有给出具体例子,因此扣2分",
"confidence": "high"
}
AI 初评结果写入 answer_record,状态变为 AI 已初评。之后教师可以进入人工复核列表,确认或者修改分数。
如果大模型返回的 confidence 是 low,或者 reason 内容很短,前端应该优先把这道题放入人工复核列表。这样可以减少 AI 误判对最终成绩的影响。
4.3 学情分析:从考试结果生成可用报告
学情分析模块最忌讳的做法是把所有学生答题记录都发给大模型,然后让大模型“看着办”。数据量大,token 贵,返回效果也不稳定。
更稳妥的思路是:先用 SQL 在本地统计好数据,再把统计结果传给大模型生成文字建议。
统计维度可以包括:
- 考生本次考试总分和班级排名区间。
- 各知识点的得分率。
- 客观题和主观题得分情况。
- 用时情况:是否接近考试结束才交卷。
- 主观题中得分较低的题目类型。
后端先算出这些数据,然后封装成一个分析输入对象:
json复制{
"studentId": "1001",
"totalScore": 72,
"classAverage": 68,
"pointScores": [
{"pointName": "Java 异常体系", "rate": 0.4},
{"pointName": "集合框架", "rate": 0.8}
],
"subjectiveAverageScore": 5.2
}
大模型基于这些结构化数据生成一段学习建议。输出格式也要求是 JSON,比如:
json复制{
"summary": "整体水平接近班级平均分,但异常体系相关题目失分较多。",
"weakPoints": ["Java 异常体系"],
"suggestions": "建议重新复习异常分类、try catch 与 throws 的使用场景,并完成 5 道相关练习题。"
}
4.4 大模型请求的超时、重试和降级
大模型请求是外部依赖,网络抖动、接口超时、返回内容异常都可能会出现。如果不处理,学生答主观题时遇到 AI 批改超时,就可能一直等不到成绩。
所以第一原则是:不要在交卷的主事务里同步调用大模型批改。
交卷时只做两件事:
- 保存学生答案。
- 对客观题进行本地判分。
- 把考试记录状态改为已交卷。
主观题批改放到异步任务里执行。每道主观题可以单独发起请求,也可以按整份试卷组装一个批改请求。建议前者,因为单题失败时重试成本低。
如果是同步出题接口,比如教师点击“AI 生成题目”,一次请求生成 5 道题,那可以在接口里同步调用大模型,但前端要接受 5 到 15 秒的等待。如果一次要生成 50 道题,就不能同步等待了,必须走任务队列。
超时和重试可以这样设计:
text复制连接超时:5 秒
读取超时:60 秒
重试次数:2 次
重试间隔:1 秒、3 秒
如果重试后仍然失败,调用方不要直接报 500。更好的做法是返回一个明确提示:“AI 服务暂时不可用,请稍后重试”或者“题目已保存为待审核状态,可稍后再次生成”。
5. 批量生成题目、接口封装与考试状态管理
5.1 批量生成题目:别把大批量请求直接跑在 HTTP 线程里
批量出题是这个项目真正容易踩坑的地方。一次生成 50 道题,如果按每 5 道题调用一次大模型,总共要调用 10 次。每次耗时 5 秒,总共 50 秒。你不可能让管理端页面一直等 50 秒。
解决办法是引入一个任务表。
任务表可以叫 ai_task:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| type | varchar | generate_question / review_subjective / generate_analysis |
| params | text | 请求参数 JSON |
| status | varchar | pending / running / success / failed |
| total_count | int | 总处理数量 |
| success_count | int | 成功数量 |
| fail_count | int | 失败数量 |
| detail | text | 错误明细 |
| create_time | datetime | 创建时间 |
教师提交生成任务后,后端先把任务写入数据库,然后通过线程池异步执行。前端通过任务 id 轮询进度,页面显示“已生成 30/50 道题”。
线程池不要直接用默认的。至少把队列长度和最大线程数限制住。例如:
java复制ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(4);
executor.setQueueCapacity(20);
executor.setThreadNamePrefix("ai-task-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
CallerRunsPolicy 表示线程池满时把任务交给调用线程执行,虽然会阻塞,但不会丢任务。学习项目里这个策略比较稳妥。
5.2 统一大模型客户端封装
大模型调用最好封装成一个独立组件,不要让每个 Service 直接去写 OkHttp 请求。
可以定义一个 AIClient:
java复制public class AIClient {
private final OkHttpClient httpClient;
private final AiProperties properties;
public String chat(String prompt) {
// 构造请求体,调用大模型接口
// 返回原始文本
}
public <T> T chatForObject(String prompt, Class<T> clazz) {
String content = chat(prompt);
// 清洗文本,提取 JSON
return JSON.parseObject(content, clazz);
}
}
然后定义三个服务:
- QuestionGenerationService:负责智能出题。
- SubjectiveReviewService:负责主观题批改。
- AnalysisService:负责学情分析。
这三个服务都依赖 AIClient,但各自的提示词和返回结构不同。以后要更换模型,只需要改 AIClient 的配置和请求格式,业务代码不需要大改。
5.3 考试过程中的关键接口和状态变更
考试过程和普通管理系统不太一样,它有几个必须保证的状态约束。
开始考试接口:
- 校验学生身份。
- 校验试卷是否存在且已发布。
- 查询考试记录,如果没有则创建,状态为进行中。
- 返回题目列表,注意不返回答案。
暂存答案接口:
- 将题号和答案写入 Redis,key 可以设计为 exam:record:{recordId}:answers。
- 暂存操作不修改数据库,减少读写压力。
- 学生退出恢复页面时,从 Redis 读取最近一次暂存内容。
交卷接口:
- 校验考试记录状态是否为进行中。
- 从 Redis 读取暂存答案,合并本次提交答案。
- 将答案保存到 answer_record。
- 计算客观题得分。
- 将考试记录状态改为已交卷。
- 删除 Redis 中的暂存数据。
- 异步触发主观题批改任务。
这里要特别注意重复提交。如果前端卡住然后用户点了两次交卷,后端必须保证第二次请求不会覆盖第一次的结果。最简单的做法是在 update 语句里加状态条件:
sql复制UPDATE exam_record
SET status = 3, submit_time = NOW()
WHERE id = #{recordId} AND user_id = #{userId} AND status = 2
受影响行数为 0,说明记录状态已经不是进行中,直接拒绝。
6. 性能和稳定性:资源条件、并发与大模型延迟的判断标准
6.1 先分清瓶颈在业务数据库还是 AI 请求
很多同学在项目跑不快时第一反应是加 Redis、换数据库、调线程池。但实际瓶颈不一定是数据库。
如果“AI 生成题目”接口耗时很长,瓶颈在大模型请求,而不是代码。如果“交卷”接口在并发 20 个用户时变慢,要分两段看:
- 交卷接口中是否有同步的 AI 调用。
- 数据库 update 是否存在锁等待。
我一般会先打印每个接口的耗时日志。把 AI 调用耗时、数据库操作耗时分别统计,这样很快能看出问题出在哪个环节。
性能合格的标准不应该是“接口一秒内返回”,而是:
- 单条出题请求在可接受时间内返回。
- 批量任务在后台稳定推进,不丢失。
- 交卷操作在高并发下不会写错状态。
- AI 请求失败后不会阻塞整个考试流程。
6.2 缓存与异步怎么加
题库数据属于读多写少,适合加缓存。教师发布试卷前,组卷时需要读取题目列表,可以把热点题目的基本信息缓存到 Redis。题目被修改后,再删除对应缓存。
学生考试时,每次加载的试卷内容也可以缓存。卷子一旦发布,题目内容基本不变,缓存 10 到 30 分钟都问题不大。
异步主要用于三个场景:
- 批量出题任务。
- 主观题批量批改。
- 学情报告生成。
异步任务最好配合任务状态表一起使用,而不是只用 @Async 扔出去就结束。任务状态表可以让你在管理端页面上看到进度和失败原因。
6.3 低配置环境怎么控制并发和超时时间
如果只是在本地开发机上跑,内存 16GB,没有独立显卡,这个项目完全可以运行。前提是不要把并发参数拉高。
建议初始参数:
- 批量出题最大并发数:2。
- 主观题批改最大并发数:3。
- AI 同步请求接口读超时:60 秒。
- 批量任务轮询间隔:3 到 5 秒。
如果一次生成 50 道题,以每次 5 道题调用一次计算,总共 10 次请求,每个请求 5 到 10 秒,在 2 个并发下大约耗时 30 到 50 秒。这个等待时间是正常的,不要误解为卡死。
低配置环境下不需要启动太多中间件。MySQL 和 Redis 是核心,搜索引擎、消息队列、监控平台都可以暂时省略。
7. 常见报错与排查顺序:很多问题不是模型的问题
7.1 项目启动失败先查什么
SpringBoot 项目启动失败,不要第一时间怀疑大模型。先按这个顺序排查:
- 启动端口是否被占用。8080 被占用时换端口,或者杀掉占用进程。
- MySQL 是否能连通。检查数据库 url、用户名、密码、时区参数。
- Redis 是否能连通。检查 redis 配置和本地 Redis 进程。
- Maven 依赖是否缺包。重点看 MyBatis-Plus、MySQL 驱动版本是否匹配。
- 包扫描路径是否覆盖 mapper 和 config。如果扫描不到,会报 Bean 注入失败。
最常见的问题往往是数据库连接参数。url 里加上 characterEncoding=utf8 和 serverTimezone=Asia/Shanghai,可以避免很多中文乱码和时区错误。
7.2 AI 返回内容解析失败
这是项目里出现频率最高的报错。大模型返回的明明是“JSON”,但解析时却失败。
看到 JSON 解析异常时,不要急着改代码。先把真正的大模型返回内容打印出来看,大概率是下面几种情况:
- 返回文本前后带了对 JSON 的解释,比如“好的,以下是你要的题目:”然后才是 JSON。
- 返回过程中被截断,JSON 不完整。
- 模型返回了 Markdown 格式的 JSON 代码块。
- 字段名漏写、多写、类型不对。
应急方案是在解析前做清洗:
java复制String text = rawContent.trim();
int start = Math.min(text.indexOf('['), text.indexOf('{'));
int end = Math.max(text.lastIndexOf(']'), text.lastIndexOf('}'));
if (start >= 0 && end >= start) {
text = text.substring(start, end + 1);
}
更彻底的方案是在提示词里要求“只输出 JSON,不要输出任何其他内容”。大部分兼容接口都会遵守,但偶尔还会有不稳定情况,所以后端也不能只做一次 JSON 解析就把结果写库。
7.3 考试交卷超时或重复提交
交卷超时,先确认交卷方法里是否调用了大模型。如果主观题批改是在交卷线程里同步进行的,那就一定会慢。解决办法是把 AI 批改拆成异步任务。
重复提交的问题,核心是状态校验。前端防一次双击不够,后端也要做幂等。具体做法:
- exam_record 表加唯一索引。
- 更新状态时使用条件 update。
- 交卷接口入口处先加分布式锁,防止同一用户并发请求。
测试方法也很简单。用一个脚本模拟同一用户在 1 秒内发送 5 个交卷请求,观察最终考试记录状态是否正常,答题记录是否存在重复数据。
7.4 生成题目内容重复、质量不稳定
AI 生成题目的随机性较高。如果你发现同一批题目里有两道题高度相似,可能有几个原因。
第一个是 temperature 设置过高。temperature 调高,模型输出更多样,但也更容易不稳定。生成题目时设置 0.7 到 0.9,评分时设置 0.1 到 0.3。
第二个是提示词里没有提供“已生成题目”的上下文。你可以把本次任务已经生成的知识点或题干摘要传给模型,让它避开重复内容。
第三个是缺少去重逻辑。学习项目可以先把题目内容做 SimHash 或者简单分词相似度计算。发现相似度超过一定阈值,就把新题标为“重复待审核”,不直接入正式题库。
8. 从学习项目到生产化改造,还要补哪些东西
8.1 日志、审计与考试记录留痕
考试系统最怕的是成绩说不清楚。谁在什么时间交卷,AI 为什么给了这个分数,教师有没有改过分数,这些都需要留痕。
建议增加以下几类日志:
- 登录日志:记录登录时间、IP、账号、登录结果。
- 考试日志:记录开始时间、交卷时间、试卷 id、状态变化。
- AI 调用日志:记录请求参数、返回内容、耗时、token 数、重试次数。
- 人工复核日志:记录教师修改前后分数和操作时间。
日志里不要记录明文密码、完整身份证号、手机号这类敏感信息。学生姓名可以做脱敏处理。
8.2 数据安全与备份
这个项目涉及教育数据,安全要求不能低。
用户密码必须用 BCrypt 加密存储,不要用 MD5。MD5 被碰撞的概率太高,不能作为密码加密方案。
管理端接口要防止越权。教师只能操作自己创建的试卷,管理员才能操作用户数据。
数据库备份在考试系统里尤其重要。考试成绩丢失意味着整个考试流程白费。可以配置每天定时备份 MySQL,备份文件至少保留一周。
Redis 中的数据可以丢失,但数据库里的考试记录不能丢。所以凡是已经交卷的数据,必须落库,不能只放在 Redis 里。
8.3 后续值得扩展的方向
整个项目跑通之后,可以继续扩展的方向有很多。
第一,基于知识图谱的智能组卷。先给题目打上知识点标签,再根据考试大纲和难度要求,从题库里自动选择题目,AI 只负责补齐新题。
第二,基于向量数据库的题目去重。将题干转成向量表示,再计算余弦相似度,比 SimHash 更准确。
第三,编程题自动判分。可以结合沙箱环境,让用户的代码在隔离容器里运行,再结合单元测试结果和大模型代码评价,生成综合得分。
第四,全链路压测和分布式部署。如果系统要支持企业几千人同时考试,就需要引入网关、消息队列、分布式锁和多实例部署。
这些方向不是必须一步做完。先把基础考试流程和 AI 能力稳定下来,后面每一项都可以单独成为一个优化项目。
最后说一点个人经验。这类系统真正落地时,最该盯住的不是 AI 生成功能有多亮眼,而是 AI 请求失败后考试链路是否还正常、批量任务断点后是否能继续、交卷接口在并发下会不会产生脏数据。把这三个基础问题解决掉,再谈智能出题和学情报告才有意义。否则模型再强,也会被不稳定的流程拖垮。
