在线考试系统已经不是一个新话题,但把 AI 大模型接进去之后,整个系统的能力边界发生了明显变化:组卷可以自动化,主观题评分可以辅助完成,学情分析可以基于文本生成建议。真正有工程价值的点不在于“调用一次大模型接口”,而在于如何把大模型能力嵌入到考试业务闭环中,并保证数据可信、流程可控、逻辑可追溯。这篇内容会以 SpringBoot 为基础,结合常规权限框架和数据库设计,演示一套“题库管理 + 在线考试 + AI 组卷 + AI 辅助评分 + 学情分析”的最小可运行系统,同时把工程上最关键的环节讲清楚。
适合阅读这篇内容的开发者包括:已经掌握 SpringBoot 基础开发,想了解业务系统如何集成大模型;正在设计在线考试或培训考核类项目,需要把 AI 能力落到具体模块;以及在本地学习时希望有一套带完整数据结构和接口设计的参考项目。读完这篇内容后,你会得到一套可以直接在本地启动、调试和扩展的工程骨架,也能知道哪些环节应该信任大模型,哪些环节必须保留人工兜底。
1. 先理解“在线考试 + 大模型”要解决什么问题
1.1 在线考试系统的传统功能边界
一套常规在线考试系统,核心流程并不复杂:管理员录入题库,维护试卷;考生在规定时间内登录系统答题;交卷后系统自动判分;最后统计考试成绩。在传统实现中,以下能力往往比较薄弱:
- 组卷依赖人工选题,大型题库下很难保证每次试卷难度、知识点分布一致。
- 主观题只能人工评阅,系统只能管理答题记录,无法给出评分参考。
- 成绩统计停留在分数汇总层面,难以针对单个学生或单个班级生成可读的学习建议。
这些薄弱点正好是后续要接入大模型的位置。
1.2 大模型在考试系统中适合承担的角色
大模型不是替代整张试卷的生成器,也不是替代教师的最终评分人。在稳妥的工程设计中,大模型适合承担以下三类角色:
第一类是“题目生产辅助”。给定知识点范围、难度、题型等要求,模型生成候选题目,再由人工审核后进入题库。生成结果只能作为题源,不能绕过审核直接上卷。
第二类是“主观题评分参考”。模型按评分标准读取考生答案,输出得分点、扣分理由、参考得分。最终成绩必须由教师确认后生效,系统保留完整的评分过程记录。
第三类是“学情分析建议生成”。考试结束后,模型根据得分、做题时长、错误知识点,生成针对性的学习建议。这类文本本身风险较低,但依然要在产品上标注“仅供参考”。
这里要明确一个工程判断:大模型适合处理“非确定性内容生成”,不适合处理“必须精确一致的流程控制”。所有成绩计算、状态流转、权限校验、试卷锁定等动作,仍然要由 Java 业务代码完成。
1.3 AI 能力在系统中的四种接入形态
在实际项目里,大模型接入形态取决于系统规模和部署条件,常见有四种,整理如下。
| 接入形态 | 适用场景 | 优点 | 需要注意的问题 |
|---|---|---|---|
| 直接调用云端大模型接口 | 大多数中小型项目 | 接入快,模型能力强,无需自建算力 | 费用随调用量增长,依赖外部服务可用性 |
| 私有化部署开源模型 | 数据敏感、内网环境 | 数据不出内网,可长期运行 | 需要 GPU 资源,维护成本高 |
| 通过 API 网关统一封装 | 多模型替换、多项目复用 | 便于切换供应商,统一鉴权和限流 | 需要额外开发网关层 |
| 嵌入现有 AI 平台能力 | 企业内部已有模型平台 | 复用已有账号、审计和监控能力 | 需要遵循平台的接入规范 |
示例项目采用第一种形态,同时会在代码层做一层接口抽象,这样后续切换私有化部署时不需要改业务代码。
1.4 系统模块规划
按照考试业务闭环,系统分成五个模块:
- 用户与权限模块:管理管理员、教师、学生三类角色。
- 题库与试卷模块:维护题目分类、难度、题型;自动或人工组卷。
- 在线考试模块:创建考试、考生答题、交卷判分。
- AI 服务模块:统一封装大模型调用,处理组卷、评分、学情分析三类业务。
- 统计与展示模块:输出成绩列表、得分分布、知识点掌握情况。
这个模块划分也决定了后续代码结构,再叠加 SpringBoot 的分层设计,就可以直接开工了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境准备与技术选型
2.1 版本与依赖选择
示例项目采用目前主流稳定的技术组合。如果是拿来学习,建议先按下面版本对齐;如果是从零开始新项目,需要确认依赖最新发布状态后再使用。
| 组件 | 版本建议 | 说明 |
|---|---|---|
| JDK | 17 | 主流 SpringBoot 3.x 的基线版本 |
| SpringBoot | 3.2.x | 负责 Web、事务、校验、自动配置 |
| MyBatis-Plus | 3.5.x | 简化单表 CRUD,分页好用 |
| MySQL | 8.0 | 存储业务数据 |
| Redis | 可选 | 存储考试会话、验证码、热点数据 |
| 大模型服务 | 云端或私有化 REST 接口 | 统一走 HTTP 调用 |
引入依赖时,最核心的 pom 片段如下:
xml复制<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.5</version>
<relativePath/>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>3.5.5</version>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
</dependencies>
这里要注意一个坑:MyBatis-Plus 对 SpringBoot 3 有独立 starter,不要误用 mybatis-plus-boot-starter。这个坐标看似差一个单词,实际会导致自动配置完全失效。
2.2 应用配置:不要把大模型密钥写进代码
配置文件保持外置化思路,默认提供 application.yml、application-dev.yml、application-prod.yml 三份配置。开发环境可以本地写死,生产环境必须通过环境变量注入。
yaml复制spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/exam_ai?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true
username: root
password: ${MYSQL_PASSWORD:root}
mybatis-plus:
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
global-config:
db-config:
logic-delete-field: deleted
logic-delete-value: 1
logic-not-delete-value: 0
ai:
model:
base-url: ${AI_BASE_URL:https://your-model-service.example.com/v1}
api-key: ${AI_API_KEY:}
default-model: ${AI_MODEL:default-chat-model}
timeout-seconds: 30
max-tokens: 2048
需要注意:serverTimezone=Asia/Shanghai 不能省略,否则 LocalDateTime 在数据库时区与 JVM 时区不一致时会出现少 8 小时之类的问题。AI 相关配置统一放在 ai.model 节点下,方便后续扩展超时、重试、多模型优先级等参数。
2.3 大模型接口的抽象设计
直接在每个业务 Service 里注入某个大模型 SDK,会让代码被厂商绑定。更稳妥的做法是定义一个最小接口,只暴露“发送消息、获取回答”的能力。
java复制public interface AiChatClient {
/**
* 发送对话请求,返回文本内容
*/
String chat(String systemPrompt, String userPrompt);
/**
* 指定模型名称发送请求,用于不同任务选择不同模型
*/
String chat(String model, String systemPrompt, String userPrompt);
}
接口实现类负责处理 HTTP 调用、超时、重试和异常转换。业务层只依赖 AiChatClient,不知道底层是云端服务还是私有化服务。
REST 调用通常走 OpenAI 兼容格式,下面是一个简化实现思路:
java复制public class OpenAiCompatibleClient implements AiChatClient {
private final RestTemplate restTemplate;
private final AiModelProperties properties;
@Override
public String chat(String systemPrompt, String userPrompt) {
return chat(properties.getDefaultModel(), systemPrompt, userPrompt);
}
@Override
public String chat(String model, String systemPrompt, String userPrompt) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.setBearerAuth(properties.getApiKey());
Map<String, Object> body = new HashMap<>();
body.put("model", model);
body.put("messages", List.of(
Map.of("role", "system", "content", systemPrompt),
Map.of("role", "user", "content", userPrompt)
));
body.put("temperature", 0.2);
HttpEntity<Map<String, Object>> request = new HttpEntity<>(body, headers);
ResponseEntity<JsonNode> response =
restTemplate.postForEntity(properties.getBaseUrl() + "/chat/completions", request, JsonNode.class);
// 实际解析时还要处理 error、choices 为空、content 为空等情况
return response.getBody()
.path("choices")
.path(0)
.path("message")
.path("content")
.asText();
}
}
生产实现里不允许这样一笔带过,必须检查 HTTP 状态码、解析异常、熔断和日志。学习阶段可以先用 RestTemplate,架构上后续可替换为 WebClient 或 OkHttp。
3. 数据库设计是关键:先画清楚关系,再写业务代码
3.1 核心表结构与关系
考试系统涉及的对象非常多,如果一上来就建二十张表,很容易迷失。最小闭环只需要六张表:
sys_user:用户表,包含管理员、教师、学生,通过role字段区分。exam_question:题目表,包含题干、选项 JSON、答案、题型、难度、知识点。exam_paper:试卷表,保存试卷基本信息。exam_paper_question:试卷题目关联表,记录每道题在试卷中的分值、顺序。exam_record:考试记录表,记录考生某次考试的状态、开始时间和得分。exam_answer:答题明细表,保存学生对每道题的答案。
核心关系如下:
- 一个用户可以参加多场考试,所以
exam_record关联sys_user和exam_paper。 - 一张试卷包含多道题,通过
exam_paper_question关联。 - 一场考试有多道答题明细,通过
exam_answer关联exam_record和exam_question。
3.2 关键 DDL 片段
下面是题目表和考试记录表的设计参考:
sql复制CREATE TABLE `exam_question` (
`id` bigint NOT NULL AUTO_INCREMENT,
`type` varchar(20) NOT NULL COMMENT 'single/multiple/true_false/subjective',
`content` text NOT NULL COMMENT '题干',
`options` json DEFAULT NULL COMMENT '单选题和多选题的选项',
`answer` text DEFAULT NULL COMMENT '客观题答案;主观题可不填',
`analysis` text DEFAULT NULL COMMENT '解析,主观题可存评分要点',
`score` int NOT NULL DEFAULT '5' COMMENT '默认分值',
`difficulty` tinyint NOT NULL DEFAULT '3' COMMENT '1-5',
`knowledge_point` varchar(100) DEFAULT NULL COMMENT '知识点标签',
`status` tinyint NOT NULL DEFAULT '1' COMMENT '0禁用 1启用',
`created_by` bigint DEFAULT NULL,
`create_time` datetime DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
`deleted` tinyint DEFAULT '0',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='题目表';
sql复制CREATE TABLE `exam_record` (
`id` bigint NOT NULL AUTO_INCREMENT,
`exam_no` varchar(64) NOT NULL COMMENT '考试编号,业务展示用',
`user_id` bigint NOT NULL,
`paper_id` bigint NOT NULL,
`total_score` int DEFAULT NULL COMMENT '最终得分',
`objective_score` int DEFAULT NULL COMMENT '客观题得分',
`subjective_score` int DEFAULT NULL COMMENT '主观题得分,默认存教师确认后的分值',
`status` tinyint NOT NULL DEFAULT '0' COMMENT '0未开始 1答题中 2待批改 3已完成',
`start_time` datetime DEFAULT NULL,
`submit_time` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_exam_no` (`exam_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='考试记录表';
设计时有两个地方值得注意。第一个是主观题答案和成绩:主观题评分不一定采用分数字段直接覆盖,因为大模型给出的参考分和教师确认分可能存在差异,所以 subjective_score 语义要定义为“最终生效分数”,大模型分数另外存人工审核日志或相关子表。第二个是 exam_no:业务上用于查询的编号不要依赖自增主键,在创建考试时生成并保证唯一,便于后续对接外部系统。
3.3 题目关联试卷时的扩展考虑
设计 exam_paper_question 时,最简单模型只记录 paper_id 和 question_id。实际开发中建议增加 sort_no、actual_score 字段:sort_no 控制展示顺序,actual_score 允许同一道题在不同试卷中分值不同。
sql复制CREATE TABLE `exam_paper_question` (
`id` bigint NOT NULL AUTO_INCREMENT,
`paper_id` bigint NOT NULL,
`question_id` bigint NOT NULL,
`sort_no` int NOT NULL DEFAULT '0',
`actual_score` int NOT NULL DEFAULT '5',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_paper_question` (`paper_id`, `question_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='试卷题目关联表';
这种中间表设计避免把题目和试卷耦合在同一张表里,后续即使调整题目顺序、替换某道题,也不会影响其他试卷。
4. 基础业务模块:从登录到组卷到考试的完整闭环
4.1 用户认证与角色权限
考试系统必须区分角色权限。这里采用 Spring Security + JWT 的方式,不引入复杂的权限框架,便于学习且足以支撑小中型系统。
核心实现包含几个部分:
JwtAuthenticationFilter:每次请求解析 Header 中的 Token,将用户信息放入 SecurityContext。UserDetailsService:从数据库加载用户。- 接口权限注解:例如
@PreAuthorize("hasRole('ADMIN')")。 - 登录接口:校验用户名密码后签发 JWT。
JWT 工具类参考:
java复制@Component
public class JwtUtils {
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.expire-hours:24}")
private Long expireHours;
public String generateToken(Long userId, String username, String role) {
return Jwts.builder()
.setSubject(username)
.claim("userId", userId)
.claim("role", role)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + expireHours * 3600 * 1000))
.signWith(SignatureAlgorithm.HS256, secret)
.compact();
}
}
这里不要直接把用户密码存储为明文,推荐使用 BCryptPasswordEncoder,这也是 Spring Security 内置的加密方案。
4.2 题库管理:AI 生成题目的两个约束
题库管理本身是普通 CRUD,但加上 AI 之后要注意两条约束:
第一,AI 生成的题目不能直接写入正式题库。正确流程是进入“待审核”状态,由教师确认后才置为启用。
第二,AI 生成题目时,需要要求模型按固定 JSON 结构返回,然后由代码解析并做字段校验。解析失败必须记录日志并走人工处理,不能直接跳过或默认异常。
新增题目接口的核心逻辑:
java复制public Long addQuestion(QuestionSaveRequest request) {
validateQuestion(request);
ExamQuestion question = new ExamQuestion();
question.setType(request.getType());
question.setContent(request.getContent());
question.setOptions(request.getOptions());
question.setAnswer(request.getAnswer());
question.setAnalysis(request.getAnalysis());
question.setScore(request.getScore());
question.setDifficulty(request.getDifficulty());
question.setKnowledgePoint(request.getKnowledgePoint());
question.setStatus(0); // 默认待审核
questionMapper.insert(question);
return question.getId();
}
private void validateQuestion(QuestionSaveRequest request) {
if (StringUtils.hasText(request.getContent()) == false) {
throw new BusinessException("题干不能为空");
}
if ("single".equals(request.getType()) && CollectionUtils.isEmpty(request.getOptions())) {
throw new BusinessException("单选题必须包含选项");
}
if ("single".equals(request.getType()) && StringUtils.hasText(request.getAnswer()) == false) {
throw new BusinessException("单选题必须设置答案");
}
}
4.3 组卷策略:固定试题、随机抽题与 AI 生成组合
组卷策略不必一开始就做得很复杂,但至少要支持三种模式:
- 固定试卷:管理员直接挑选题目,适用于重要考试。
- 随机抽题:按知识点、难度、题型比例从题库抽取,适用于日常练习。
- AI 辅助出题:先由管理员指定知识点和难度,调用大模型生成候选题,人工确认后进入试卷。
随机抽题可以用 MyBatis-Plus 的查询条件,也可以直接使用 SQL 按知识点分组随机抽取。SQL 方式在数据量较大时更可控:
sql复制SELECT id, content, options, answer, score, difficulty, knowledge_point
FROM exam_question
WHERE status = 1
AND type = #{type}
AND difficulty = #{difficulty}
AND knowledge_point = #{knowledgePoint}
ORDER BY RAND()
LIMIT #{limit}
ORDER BY RAND() 在小数据量下没有问题,大数据量下性能会随表变大而下降。若题库超过十万条,建议改成“先随机取主键,再回表查询”的方式。
AI 辅助组卷的流程必须设计成异步任务,因为生成题目通常耗时数秒甚至更久,不能让 HTTP 请求一直阻塞。实现步骤为:
- 管理员提交组卷任务,保存到
paper_gen_task表。 - 后台线程或消息队列中调用
AiChatClient。 - 将生成结果转成
QuestionSaveRequest,保存为待审核题目。 - 管理员审核通过后,再选择加入试卷。
异步处理在代码层面的最小实现可以使用 @Async,但生产环境需要考虑任务状态持久化、失败重试和人工介入入口。
4.4 在线考试核心流程
在线考试流程的状态机很清楚:未开始 -> 答题中 -> 待批改 -> 已完成。
考生进入考试时做两件事:创建 exam_record,生成考试快照。考试快照的作用是防止试题在考试期间被管理员修改。最简单快照方式是在 exam_record 里保存试卷题目的 JSON 数组,或者在 exam_answer 中预置所有题目记录。
推荐使用后者:考生开始考试时,按照 exam_paper_question 查询所有题目,并为每道题创建一条 exam_answer。这样交卷时只需要按记录更新答案和得分,不需要关心实时查询试卷表。
交卷接口需要额外处理两个问题。
第一个是幂等。考生可能双击或重复请求交卷,必须通过状态判断来保证只处理一次:
java复制@Transactional
public SubmitResult submit(SubmitRequest request) {
ExamRecord record = getRecordForUpdate(request.getRecordId(), request.getUserId());
if (record.getStatus() == ExamStatus.SUBMITTED || record.getStatus() == ExamStatus.FINISHED) {
return SubmitResult.alreadySubmitted(record);
}
if (record.getStatus() != ExamStatus.IN_PROGRESS) {
throw new BusinessException("当前状态不允许交卷");
}
// 更新答题明细、计算客观题得分、判断是否有主观题
record.setStatus(hasSubjective ? ExamStatus.PENDING_REVIEW : ExamStatus.FINISHED);
record.setSubmitTime(LocalDateTime.now());
examRecordMapper.updateById(record);
return SubmitResult.success(record);
}
第二个是超时判定。前端倒计时结束后,后端仍然要再次校验当前时间是否晚于考试截止时间,不能只相信前端参数。服务端判定的实现是在查询 exam_record 时比较 start_time + duration_minutes 与当前时间。
4.5 客观题自动判分与主观题人工/辅助评分
客观题判分逻辑很简单:读取 exam_answer 中的答案,与 exam_question.answer 做精确比较。选择题答案建议用固定分隔符存储,例如多选题答案存为 A,B,C,判分时先拆分再比对。
主观题评分是本项目接入大模型的主要场景之一。流程设计如下:
- 交卷后,系统把主观题答案、题干、评分要点、参考解析组装成提示词。
- 调用大模型获取 JSON 格式评分结果,包含建议分数、得分点、扣分点。
- 评分结果先写入
exam_answer的ai_review_result字段,不直接覆盖最终得分。 - 教师在管理端查看 AI 参考评分,可采纳或修改,确认后更新最终得分。
这里要考虑大模型调用失败不阻塞交卷:主观题评分放在异步流程中,交卷接口立即返回成功,只是状态停留在“待批改”。
AI 评分提示词示例:
text复制你是一位严谨的考试阅卷教师。请根据题干、参考答案和学生答案,从内容准确性、要点覆盖、表达清晰度三方面评价。
要求:
1. 输出 JSON,格式为
{"suggestScore": 0, "scoreReasons": ["..."], "deductReasons": ["..."], "comment": "一句话总结"}
2. suggestScore 必须是数字,范围 0 到 {maxScore}
3. 如果学生答案与题目无关,suggestScore 必须为 0
4. 不要输出 JSON 以外的内容
题干:
{questionContent}
参考答案:
{referenceAnswer}
学生答案:
{studentAnswer}
工程上有一个容易被忽略的细节:大模型输出偶尔会在 JSON 前后增加解释文本、代码块标记,甚至直接输出 Markdown。解析时必须先做清洗,再使用 JSON 解析库,解析失败时把原始输出记录到日志,方便排查。建议在 AiReviewParser 中组合使用正则清理、JSON 异常捕获和默认降级策略。
5. 大模型接入的工程化封装
5.1 为什么业务代码里不能直接写 Prompt
很多初学者喜欢在 Service 里拼接 String prompt = "请帮我出题" + request.getKnowledgePoint();,这种做法在演示阶段可以,但在工程化项目中会造成三个问题:
- 提示词没有版本管理,线上调整文案要重新发布代码。
- 不同业务重复拼装,格式不一致,解析结果质量不稳定。
- 模型供应商或模型版本变更后,提示词策略可能失效,但代码层无法快速感知。
正确做法是把提示词模板放入独立资源目录,如 resources/prompt/*.txt。代码读取模板后,用 {} 占位符替换参数。简单实现可以使用 Spring 的 Resource 加载或 String.replace;复杂场景可以使用模板引擎。
java复制public class PromptTemplateLoader {
public String render(String templateName, Map<String, Object> context) {
String template = loadTemplate(templateName);
for (Map.Entry<String, Object> entry : context.entrySet()) {
template = template.replace("{" + entry.getKey() + "}", String.valueOf(entry.getValue()));
}
return template;
}
}
5.2 结构化输出与失败降级
大模型接口返回 String 之后,业务层要迅速把它转成可靠对象。下面是一个解析 AI 组卷结果的方法:
java复制public List<QuestionSaveRequest> parseGeneratedQuestions(String aiOutput) {
String cleaned = cleanJson(aiOutput);
JsonNode root;
try {
root = objectMapper.readTree(cleaned);
} catch (JsonProcessingException e) {
log.warn("AI 输出解析失败,原始内容={}", aiOutput);
throw new AiResponseParseException("AI 返回内容无法解析为 JSON");
}
JsonNode questionsNode = root.get("questions");
if (questionsNode == null || !questionsNode.isArray()) {
throw new AiResponseParseException("AI 返回内容缺少 questions 数组");
}
List<QuestionSaveRequest> result = new ArrayList<>();
for (JsonNode node : questionsNode) {
QuestionSaveRequest request = new QuestionSaveRequest();
request.setType(node.path("type").asText("single"));
request.setContent(node.path("content").asText());
request.setAnswer(node.path("answer").asText());
request.setAnalysis(node.path("analysis").asText());
request.setDifficulty(node.path("difficulty").asInt(3));
request.setKnowledgePoint(node.path("knowledgePoint").asText());
// 字段级校验,忽略非法题目
if (StringUtils.hasText(request.getContent())) {
result.add(request);
}
}
return result;
}
关键点在于:永远不要相信模型第一次输出的内容。parseGeneratedQuestions 里的字段级过滤、类型转换和日志记录,是对线上数据质量的第一道防线。
5.3 调用策略:超时、重试与并发限制
大模型接口不像数据库查询,它可能因为服务端负载、网络波动或内容审核返回较慢。生产环境必须设置明确策略。
| 策略项 | 开发环境建议 | 生产环境建议 |
|---|---|---|
| 建立连接超时 | 5 秒 | 5 秒 |
| 读取响应超时 | 30 秒 | 30 到 60 秒 |
| 重试次数 | 1 次或不重试 | 2 次,注意幂等 |
| 并发上限 | 不限制,便于调试 | 按供应商配额设置信号量或线程池 |
| 失败兜底 | 打印完整错误 | 转人工处理队列,页面提示“评分暂不可用” |
使用信号量控制并发是一种相对轻量的方式:
java复制@Component
public class AiRateLimiter {
private final Semaphore semaphore = new Semaphore(5);
public void acquire() {
try {
if (!semaphore.tryAcquire(3, TimeUnit.SECONDS)) {
throw new BusinessException("AI 服务繁忙,请稍后重试");
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new BusinessException("请求被中断");
}
}
public void release() {
semaphore.release();
}
}
调用结束后必须在 finally 中释放信号量,防止线程积累导致系统不可用。
6. 学情分析与数据展示
6.1 学情分析的数据来源
成绩只是结果,学情分析要有过程数据。最容易利用的数据包括:
- 各题型得分率。
- 各知识点错误率。
- 答题时长与题目分值之间的关系。
- 主观题高频扣分原因。
这些数据不需要大模型也能算,但大模型可以把统计结果转成自然语言建议。例如系统统计出“二次函数相关题目得分率不足 40%”,模型可以生成一份 150 字的学习建议,推荐相关练习方向。
6.2 分析接口设计
/api/analysis/student/{userId} 接口返回 JSON,结构大致如下:
json复制{
"userId": 1001,
"totalExams": 12,
"averageScore": 86.5,
"weakKnowledgePoints": [
{
"point": "二次函数",
"accuracy": 0.36,
"advice": "建议加强函数图象与性质专项练习,重点理解顶点式与交点式的应用场景。"
}
]
}
生成建议的代码不要写成长 SQL 加超长 Java 逻辑,而是拆成两步:先查询统计结果,再判断是否满足调用大模型的阈值,如薄弱知识点数量大于 2 且最近考试数量大于 3。这样避免每次请求都消耗模型调用费用。
7. 运行验证与常见问题排查
7.1 初始化数据与启动检查
项目启动前,需要先初始化数据库和基础账号。建议准备一个 data.sql,包含一个管理员、一个教师、一个学生账号,以及少量题目数据。
启动后按以下顺序验证:
- 访问 Swagger 或 Knife4j 地址,确认接口文档加载。
- 调用登录接口获取 Token。
- 使用 Token 调用用户信息接口。
- 创建一场考试,考生进入考试。
- 交卷后确认客观题分数自动生成。
- 对有主观题的考试触发 AI 评分任务,查看日志中的模型返回内容。
推荐把这一操作序列整理成 Postman 或 Apifox 集合,作为回归测试脚本的一部分。
7.2 常见问题排查表
下表汇总了示例项目中最容易踩到的六个问题,均配有现象、原因和检查路径。
| 问题现象 | 常见原因 | 检查方式 | 处理建议 |
|---|---|---|---|
| 接口返回 401 | JWT 过期、Token 未带、用户角色被禁用 | 检查 Header、Token 解析日志 | 确认认证过滤器执行顺序与白名单配置 |
| 登录成功但访问某些接口 403 | 角色权限配置不匹配 | 查看 Security 日志和 @PreAuthorize 注解 |
按角色分配权限,不直接放行全部接口 |
| 数据库时间比当前时间少 8 小时 | JDBC URL 缺少 serverTimezone |
查询数据库时间和 JVM 时间 | 统一设置为 Asia/Shanghai |
| AI 调用超时 | 网络问题、模型服务负载高、参数过大 | 检查调用日志耗时和错误堆栈 | 增加超时时间,开启重试,接入异步任务 |
| AI 输出无法解析 | 模型没有按 JSON 格式返回 | 打印原始输出到日志 | 在解析层增加清洗逻辑,并转人工兜底 |
| 交卷后分数少算或重复算 | 事务边界不对、状态未锁定 | 检查 @Transactional 与状态判断逻辑 |
记录锁定采用 select ... for update 或状态版本号 |
7.3 结合日志定位 AI 评分失败
AI 相关功能失败时,日志必须能回答四个问题:
- 调用哪个模型,传入哪个模板。
- 请求耗时是多少。
- 原始返回内容是什么。
- 解析失败发生在哪个字段。
因此,在 AiChatClient 实现类里至少记录以下日志:
text复制[AiChatClient] model={}, promptTemplate={}, costMs={}, output={}
不要在大模型接口这类外部依赖上省日志。如果线上出现大批量调用失败,没有这一步会非常被动。
8. 生产环境落地的额外保障
8.1 安全与合规边界
在线考试系统涉及学生数据、成绩数据和答题内容,生产环境需要注意三个基本问题:
- 数据库连接、大模型密钥都通过环境变量或密钥管理服务注入,不持久化到代码仓库。
- 用户密码使用 BCrypt 加密,JWT 密钥定期轮换。
- AI 生成内容只能作为草稿和参考,不能直接作为正式试题或最终评分,保留人工审核环节。
从产品设计上,还应该在页面对 AI 生成结果做出明确标识,例如“本题由 AI 生成,尚未审核”或“评分为 AI 参考建议,最终以教师批改为准”。
8.2 性能与资源优化
因为引入了外部大模型调用,系统性能瓶颈往往不在数据库,而在 AI 调用链路。核心优化手段有三个。
第一个是异步化。组卷、评分、学情建议都放进异步任务中执行,让 HTTP 请求快速响应。
第二个是缓存。题库列表、试卷元数据、学生基本信息等读多写少的数据,可以使用本地缓存或 Redis 缓存。AI 生成结果短时间重复请求的情况较少,不建议缓存,但同一场考试的评分结果可以按答题记录缓存。
第三个是数据库层优化。exam_answer 表按 record_id 建立索引,exam_record 表按 user_id 和 status 建立联合索引,避免全表扫描。
8.3 可复用清单:上线前检查表
下面的清单可以直接打印成文档,在测试环境验收时逐项确认。
- 固定题目试卷能否正常创建、编辑、发布。
- 考生在答题中途刷新页面,答题记录是否保留。
- 考试截止时间到达后,后端是否自动终止答题。
- 重复提交交卷请求,系统是否只处理一次。
- 客观题分数计算是否与预期一致。
- 主观题 AI 评分是否进入待审核状态,教师确认后是否更新最终分。
- AI 服务不可用或超时时,考试主流程是否不受影响。
- 管理员禁用试卷后,考生是否无法继续进入考试。
- 所有外部密钥是否通过环境变量注入。
- 日志中能否看到 AI 请求的耗时与原始返回。
8.4 后续扩展方向
示例项目保留了清晰的扩展点。如果要在真实项目中使用,建议按以下优先级演进:
- 引入 Redis 存储考试会话和答题缓存,降低数据库压力。
- 引入消息队列处理 AI 组卷和评分任务,支持失败重试。
- 增加试卷审批流,保证考试发布前有复核环节。
- 把 AI 调用接入统一网关,支持多模型切换、用量统计和配额控制。
- 增加防作弊能力,包括切屏记录、答题轨迹分析、IP 异常检测。
- 基于考试数据持续迭代提示词模板,建立不同科目、不同题型的提示词版本库。
扩展的前提是先保证核心考试链路稳定。AI 是增强能力,不是业务底座。先把权限、状态机、数据一致性这些基本功做扎实,再让大模型放大系统的自动化能力,整个项目才会既可用又可维护。
