时序图这东西,画起来看着简单,真要画准了,背后藏着一堆决策。我最近在整理一个基于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, List<String> hitKeyPoints, List<String> 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的不确定性收敛到业务可接受的范围里。时序图在里面起的作用,是让你在设计阶段把这种不确定性用一条条分支明确画出来——哪些路径是正常的,哪些路径是异常的,异常时系统往哪里走,画清楚了,代码怎么写都不会跑偏。后面如果再扩展人工复核、成绩申诉这些功能,只需要在最外层加角色,内部的消息流转完全不用动,这套设计就能继续撑下去。