SpringAI实战:在线考试阅卷评分模块设计与时序图拆解

发布时间:2026/10/14 13:27:49
SpringAI实战:在线考试阅卷评分模块设计与时序图拆解
时序图这东西画起来看着简单真要画准了背后藏着一堆决策。我最近在整理一个基于SpringAI的在线考试系统的阅卷评分模块项目名后缀挂着“时序图”三个字但我做完之后最大的感受是真正难的不是图是图背后的流程拆分、AI调用可靠性设计和兜底策略。这篇文章就把这个模块从设计到落地的全过程拆开讲一遍适合正在用SpringAI做业务功能、或者准备在考试/测评类系统里引入AI评分能力的Java工程师参考。1. 模块定位与核心设计思路1.1 阅卷评分模块到底承担什么职责在线考试系统里的阅卷评分模块听着就是“交卷后打个分”但在真实的考试业务里这模块承担的事情比想象中多得多。首先是判分要区分配置题还是主观题选择题、判断题、填空题这些客观题走纯规则比对就行简答题、论述题、编程题才需要AI介入。其次要处理评分后的结果得分明细、题目维度分析、试卷总分合成、成绩落库以及后续教师人工复核的入口。我当时的做法是把整个模块拆成三层职责。第一层是客观题判分器第二层是AI主观题评分子服务第三层是成绩聚合与落库逻辑。时序图里如果把这三层混在一起画交互关系会乱成一团。模块边界一旦划清楚画出来的图才有可读性出了问题也才定位得到。这个模块解决的核心问题一句话概括就是在尽量少消耗算力、尽量不让学生长时间等待的前提下把客观题和主观题统一处理成一个标准化的评分结果并且保证AI不可用时系统仍然能用。1.2 为什么选SpringAI而不是自己封装HTTP调用很多团队第一次接大模型习惯直接写一个HttpClient工具类POST到模型服务的接口解析返回JSON。小范围试验没问题但放到考试系统这种业务稳定性和可维护性要求高的项目里我会建议用SpringAI这一层抽象。SpringAI给我的核心价值不仅仅是少写几行请求代码而是它提供了一套和模型厂商无关的编程模型。你定义ChatClient、构建Prompt、调用接口然后底层用哪个模型实现是可以在配置层面切换的。考试系统这种项目往往会因为成本、合规、私有化部署等因素需要换模型供应商如果没有这层抽象换一次模型等于重写一遍调用代码这种痛我踩过一次就不想踩第二次。另外一个理由是SpringAI对结构化输出的支持。它会内置一套把模型返回内容转换成Java对象的方法这一点对于阅卷评分来说太关键了。因为模型输出的评分结果通常需要Score、Comment、KeyPoints这类字段如果不做结构化约束解析散文一样的结果会让下游头疼。我们团队最终选择SpringAI本质上是选择一个更可控的LLM应用框架。1.3 时序图在模块设计中的位置做时序图不是为了画给文档看而是为了在设计阶段强制自己把每个消息的流向、返回、异常分支都过一遍。阅卷评分模块里最容易出问题的地方恰恰在这些消息交互的间隙里并发交卷时消息堆积、AI服务超时、限流导致评分失败、人工复核与自动评分状态冲突。时序图能强迫你回答几个关键问题谁的主动作是同步的谁的被动反应是异步的调用失败之后消息是重试还是走降级数据落库发生在评分成功之后还是解析成功后。这些问题一旦在画图阶段捋清楚到写代码的时候基本不会出现“这里到底应该谁来调谁”的犹豫。2. 核心流程拆解从交卷到成绩落库2.1 客观题评分规则引擎先行客观题这部分本来可以很简单但实际要处理不少变体。比如一套试卷里有AB卷混排同一个题目在不同试卷里选项顺序不一样再比如填空题的答案是按匹配度给分的需要一个标准答案的多个同义描述或者包含通配符。这些场景如果硬让AI去判断既浪费token又增加了延迟。我的做法是在进入AI评分之前先用规则评分器把所有客观题处理掉。评分器会读取试卷配置和标准答案表对每个题目做比对返回已经标准化好的ScoreDetail列表。这样做的好处很明显客观题通常占整套试卷的40%到60%走纯规则逻辑稳稳几百毫秒内搞定不需要等待模型响应。规则评分器内部还加了一层容错比如判断题“对/错”和“正确/错误”要归一化填空题多个空格时要按空格拆分后逐空判分题目缺答案配置时不能抛异常要标记为“待人工处理”。这些细节写进去之后评分模块才不会遇到一个没见过的配置就直接崩掉。2.2 主观题AI评分提示词按题型动态组装主观题是AI评分模块的主角也是最容易翻车的地方。评分的核心不是让模型“大概给个分”而是要严格按照评分标准来给出一个稳定的、可解释的分数。要做到这一点提示词组装就不能是一套模板走天下。我把主观题分成了三类不同类型使用不同的提示词结构简答题重在看踩分点是否齐全语义是否基本匹配。提示词里会要求模型把答案与参考答案中的关键点逐个比对并且输出每个关键点的命中状态。论述题重点在逻辑结构、论述深度、论据使用。提示词里会给一个评分维度表要求模型按维度打分再汇总。编程题重点看代码正确性、边界条件处理、代码风格。提示词里会带上测试用例的执行结果以及人工预设的代码质量检查项。模板组装过程中需要把题面、标准答案、学生答案、分值、评分标准填充进去。这里有一个我强烈建议的方式别把一大段Prompt写到Java代码字符串里而是用SpringAI的PromptTemplate去管理。把提示词文本放到资源目录下用模板变量去替换后续调整评分策略根本不用改代码。2.3 结构化输出解析让模型返回JSON而不是散文AI评分模块如果让模型输出“我觉得本题可得6分”下游程序就得做一堆字符串匹配。我们在这个项目里要求模型必须严格按照约定的JSON结构返回借助SpringAI的结构化输出能力来处理。我会定义一个评分结果对象包含分数、简要评语、关键点命中列表然后让ChatClient的调用直接映射成这个对象。这么做之后解析流程从“面对一段未知文本猜来猜去”变成“拿到一个强类型对象放心使用”整个流程的健壮性完全不是一个量级。这里有个容易踩的坑模型输出JSON时偶尔会包一些Markdown代码块标记或者在前面加一句“好的根据评分标准我给出如下结果”。如果不做预处理直接把content字符串反序列化大概率抛异常。为此我会在解析层加一道清洗逻辑把json和这类包裹符号剥离然后再交给转换器。2.4 兜底策略AI不可用时怎么办考试系统的可用性要求通常很高某个时间段大量学生同时交卷如果AI服务这时候超时或者触发限流不能让几十个学生的试卷卡在队列里出不了成绩。所以我在设计之初就规划好了一条降级链路。降级方案分三层。第一层AI评分请求连续失败超过一定阈值后动态切换到本地算法评分模式用关键词匹配加权的方式给出参考分同时标记为“需要人工复核”。第二层成绩在人工复核界面明确展示自动评分置信度教师可以一键修改。第三层控制面板上增加一个闸刀管理员在考前或考试期间发现AI服务异常时可以手动把系统切到纯规则模式。兜底策略看起来是额外工作量但在真实考试业务里这决定了整个系统能不能扛住预期外的故障。老师不会管你是不是模型供应商的限流问题他们只知道学生交完卷查不到成绩反馈上来就是事故。3. 时序图设计与关键代码实现3.1 时序图对象模型与消息清单动手画时序图之前我把这个场景里涉及的对象先列了个清单每个对象都有边界清晰的职责学生端浏览器负责提交整张试卷的答案。考试服务容器接收试卷提交请求完成基础校验。评分编排器整个评分流程的入口调度类负责编排客观题评分和AI评分。规则评分器只处理客观题不依赖任何外部服务。AI评分子服务处理主观题评分的业务逻辑设计成异步可降级。模型对话客户端SpringAI的ChatClient面向大模型接口。模型服务外部大模型API网关可以理解为对接模型供应商的适配层。成绩存储库存放评分明细和总分。消息通知组件无论评分成功还是失败都要把状态变更事件推送出去给前端或教师端消费。消息清单里最关键的几个消息是提交试卷、请求评分任务、返回客观题评分结果、组装主观题提示词、调用模型对话接口、返回结构化评分对象、持久化评分明细、发布评分完成事件。3.2 核心时序分支正常评分流程正常流程是主路径其他分支都围绕它转。我画图的时候习惯用PlantUML语法的文本片段来描述时序方便放入文档和代码库中维护。下面这段就是从项目里抽出来的核心时序片段改写版startuml actor Student participant Exam API as ExamApi participant GradingOrchestrator as Orchestrator participant RuleGrader as RuleGrader participant AiGradingService as AiService participant ChatClient as ChatClient participant LLM Service as LLM participant ScoreRepository as ScoreRepo participant Notification as Notify Student - ExamApi : 提交试卷答案 ExamApi - ExamApi : 校验试卷快照是否有效 ExamApi - Orchestrator : 提交评分任务 Orchestrator - RuleGrader : 处理所有客观题 RuleGrader -- Orchestrator : 客观题得分明细列表 alt 存在主观题 Orchestrator - AiService : 提交主观题评分任务 AiService - AiService : 组装题型对应的Prompt模板 AiService - ChatClient : 调用chatClient.prompt()\n并声明结构化返回类型 ChatClient - LLM : 发送系统提示词 学生答案 LLM -- ChatClient : 返回message内容 ChatClient -- AiService : 结构化转换后的评分结果对象 AiService - ScoreRepo : 保存主观题评分明细 end Orchestrator - ScoreRepo : 聚合总分并落库 Orchestrator - Notify : 发布成绩生成完成事件 Notify -- ExamApi : 异步通知状态已更新 ExamApi -- Student : 交卷成功(评分处理中) enduml正常流程里体现出来的核心思想是哪些环节同步、哪些环节异步要在图里明确区分。对学生端来说交卷请求必须快速得到“我收到了”的响应所以模型评分接口绝对不能同步阻塞在HTTP请求链路上。我们在实际代码里把整个评分任务丢给的异步线程池学生先看到“交卷成功成绩稍后刷新”评分完成后通过通知机制把状态翻成“已出分”。3.3 关键代码实现AI评分调用与结构化输出写AI评分代码时最核心的一段就是ChatClient的拼装和结构化结果映射。我在项目里使用SpringAI来管理调用代码大致是这样的public AiScoreResult scoreSubjectiveQuestion(SubjectiveAnswerAnswer content) { PromptTemplate template new PromptTemplate( promptTemplateLoader.load(subjective-question-template), Map.of( questionContent, answer.getQuestionContent(), standardAnswer, answer.getStandardAnswer(), studentAnswer, answer.getStudentAnswer(), fullScore, answer.getFullScore(), scoringStandard, answer.getScoringStandard() ) ); Message systemMessage template.createMessage(); UserMessage userMessage new UserMessage(buildStructuredOutputInstruction()); ChatClient chatClient ChatClient.builder(model).build(); GradeResult result chatClient.prompt() .messages(systemMessage, userMessage) .call() .entity(GradeResult.class); return convertResultToDomain(result); }这里有一个细节值得说我们专门单独创建了一个携带结构化输出说明体系的UserMessage告诉模型必须返回指定字段的JSON不能输出多余解释。实际模型不一定每次都能乖乖听话所以我在convertResultToDomain里还会再做一层字段合理性校验比如分数范围是否在0到满分之间、评语是否为空白字符串等。GradeResult这个对象也要设计得尽量简单public record GradeResult( double score, String comment, ListString hitKeyPoints, ListString missedKeyPoints ) {}把字段收敛到这四类下游在生成成绩报告时既能看到总得分又能展示具体的得分依据教师复核时也能快速定位AI评价的来源。3.4 并发与异步设计考试批次场景下的伸缩在线考试系统最真实的情况是一个年级一个批次几百上千人同时交卷如果等AI逐个调用整个评分队列会越积越长。这里不能光靠加大线程池去硬扛因为模型服务侧往往有严格的限流策略。我是这样设计并发模型的。评分任务先落到一个独立的线程池里这个线程池的核心参数不算大因为真正限制吞吐的瓶颈是模型服务的每分钟请求数。线程池后面挂一个请求调度器它会根据模型服务的限流配额用令牌桶算法控制请求发出速率。每道题调用模型前先尝试获取令牌没有令牌就在本地等待避免直接把客户端请求打挂。同时我对系统做了两种批量作业的区分。如果是大规模统一考试成绩不要求实时出评分任务走批处理模式交卷之后排队、分批调用模型定时任务扫描后更新成绩状态。如果是随堂测验、在线练习这种低并发场景就走实时评分模式学生提交后几十秒内能看到结果。这两种模式在时序图中实际上对应了两条分支路径不至于让架构在极端场景下被压垮。当时做压测时发现线程池调太大反而会导致模型侧拒绝请求更严重所以还不如把并发数和令牌桶速率绑定起来让系统在模型能力范围内尽可能吞吐。这个思路在画时序图时就要体现出来否则落到代码上你根本说不出清楚“到底是AI慢了才排队还是任务调多了才排队”。4. 常见问题与排查技巧实录4.1 模型返回超时后的系统表现接入大模型后第一个绕不开的问题就是超时。模型服务再稳定也有被负载突然打高的可能。我们遇到过最典型的场景是某道题刚好命中一个长上下文窗口边界模型响应一下子从3秒飙到30秒导致线程池里的工作线程全部卡住。排查技巧不要只看平均值要看P95和P99。我们会把每次模型调用的耗时时长记录到日志系统里按题目类型聚合。发现某个题目类型平均耗时明显偏高时就会去看是不是提示词把不必要的上下文都塞进去了。比如学生答案字数很多时可以提前裁剪到模型上下文允许的安全范围或者单独配置一个答题字数上限。超时链路本身还要设计成两层HTTP连接层面设置连接超时和读取超时业务层面设置评分任务的最大等待时间比如30秒。超时后的结果不是直接失败而是把该题标记为“AI模型无响应”走本地算法兜底同时通知管理员模型状态异常。4.2 解析失败JSON字符串里有多余的说明文字模型输出里夹杂说明文字是结构化输出落地时最容易踩的坑之一。有一次排查线上问题发现某个班级的评分结果大面积失败日志里的异常信息是指定分隔符找不到原因是那个模型在高分数段时自作主张写了句“这道题目答得很好但是”然后才开始输出JSON。解决办法有两个维度。第一个是解析层的兜底清洗先用正则把只要长得像JSON对象的部分整段提取出来再去掉可能的Markdown代码块前缀、后缀最后才交给反序列化。第二个是提示词层面的约束加强在动态指令里显式告诉模型“禁止输出以外的任何解释性文字直接输出原始JSON”并且把指令放在系统提示词的末尾因为模型往往对末尾指令的遵从性更高。这种问题不会因为模型升级就彻底消失所以每次新模型上线都要拿一批历史真实考卷答案做回归测试重点看解析成功率是否下降。4.3 分数忽高忽低温度参数与评分标准的博弈AI评分最让人头疼的是“不稳定性”。同一份答案在不同时间提交很可能拿到两个不同的分数。这里面的关键影响因素就是大模型解码时的随机性。在阅卷这种要求标准化和可解释性的场景里随机波动是不能接受的。我们的处理方式是在模型调用参数里把温度参数调到最低值让模型解码结果尽可能稳定同时组织一次多次采样取中位数的策略。比如对于得分争议比较大的题目请求模型三次评分取中位数作为最终参考分并把这三次分数都记录到评分明细里。这样做会提高一点成本但它解决的是比成本更致命的信任问题。还有一手准备是评分锚点。在提示词里明确写出分数档位映射关系比如8到10分需要完整答出所有关键点且表述清晰5到7分答出大部分关键点但表述笼统0到4分只涉及少量关键点。当评分档位清晰时模型分数跳动剧烈的情况会明显减少。4.4 限流问题并发一多就429考完试那一瞬间全班几十上百人同时交卷评分请求源源不断打到模型服务上如果不做前端限流很容易触发模型供应商的429限流。第一次遇到时我以为是网络出了问题后来查看模型服务响应码发现全是限流错误。解决思路是预判式限流。在评分服务启动之前根据已经配置好的并发数、单题预估请求量、单请求预估token数估算出当前考试批次的模型服务负载然后动态调整评分任务的发送速率。本地做一个令牌桶每秒钟按照估算的预算放入一定数量的令牌请求获取不到令牌就排队等待而不是直接失败丢失。同时要补上重试机制。对于429错误做指数退避重试比如第一次等1秒第二次等2秒第三次等4秒最多重试3次。重试几次之后仍然失败就立刻进入降级流程。整个限流和重试的过程要打印出关键日志方便事后复盘是哪一层拦截住了请求。4.5 问题与解决方案速查表下面这个表是我在实际项目里整理出来的基本可以拿来就用。现象根因处理方式评分请求大面积超时上下文过长或模型服务负载增高裁剪上下文、设置读取超时、任务级超时后降级JSON解析失败模型输出里混入解释性文字剥除Markdown包裹、正则提取JSON块、强约束系统提示词同一答案分数波动大解码随机性、评分标准不明确温度调到最低、多次采样取中位数、提示词写入分档锚点交卷高峰期大量429并发超过模型服务限制令牌桶限速、指数退避重试、超过阈值走兜底算法提示词更新后分数变化异常覆盖维度和历史标准不一致更新前用历史考卷做回归测试、对比分数分布线程池轻微耗尽大量评分任务排队等待模型区分批处理与实时评分模式、隔离不同考试批次的资源还有一点经验想分享所有这些排查手段前提都是要有足够详细的日志和监控。模型请求的前后上下文、响应码、消耗token数、模型文本内容至少要记录到可观测平台不然踩到问题就只能靠猜。我自己在这套系统上最大的体会是AI评分模块真正难的地方不在于调通接口而在于把AI的不确定性收敛到业务可接受的范围里。时序图在里面起的作用是让你在设计阶段把这种不确定性用一条条分支明确画出来——哪些路径是正常的哪些路径是异常的异常时系统往哪里走画清楚了代码怎么写都不会跑偏。后面如果再扩展人工复核、成绩申诉这些功能只需要在最外层加角色内部的消息流转完全不用动这套设计就能继续撑下去。