简介:在线编程平台的核心竞争力在于题量、评测准确性与用户体验。传统的代码自动评测系统依赖人工出题,成本高且难以个性化;而AIGC技术能够低成本生成题目与测试用例,但需要严格的工程保障才能落地。本文从系统架构视角出发,讲解如何将AIGC与在线编程评测深度融合:通过Docker沙箱实现代码隔离执行,借助异步消息队列解耦评测任务,利用数据缓存提升热点数据访问效率,并采用SSE实现近乎实时的评测状态反馈。同时,AIGC生成的内容需经过规则校验与人工审核,确保题目质量。该方案不仅适用于在线编程平台,也为AIGC在业务系统中的工程化落地提供了可复用的参考。 最近把一个基于 AIGC 的在线编程题目评测系统从零到一跑通了。这个项目最有意思的地方在于:它不是把 Online Judge 那套传统判题逻辑简单复刻一遍,而是把 AIGC 塞进题目生产、学习路径推荐、实时反馈这些环节里,最终形成一个能持续自产题目、能根据用户答题情况调整学习计划的闭环平台。对于正在做全栈项目、毕业设计,或者想了解 AIGC 怎么落地到真实业务场景的同学来说,这套系统的拆解思路应该能提供不少可复用的参考。
我要说明一下,这个系统的核心关键词其实就五个:AIGC、在线编程、代码自动评测、数据缓存、异步消息。围绕这五个点,我会把整个项目的设计逻辑、实现细节、踩坑记录完整讲一遍。文章不追求面面俱到,但每一个部分都会尽量给出可以落地的做法和我自己实测后的体会。
1. 从痛点出发:在线编程平台为什么需要 AIGC 参与
1.1 传统题库的三个老大难问题
很多在线编程平台,骨架其实是经典的 Online Judge:用户拿一道题,写代码,提交,系统跑测试用例,返回结果。这个模式本身没有硬伤,真正麻烦的是题目运营。
我自己维护一个小型题库的时候就深有体会。第一,题量增长慢。人工出题是非常耗时的事情,一道好题不仅需要题目描述、输入输出格式,还需要准备至少七八组测试用例,还得保证数据不弱、不歧义、不出现多解判错的情况。一个月能稳定上架二三十道题已经算高产了,但对一个要持续吸引用户的平台来说,这个速度远远不够。
第二,题目同质化严重。人工出题时,出题人往往会不自觉地集中在几个自己熟悉的专题上,导致动态规划、字符串处理这类常规题特别多,而冷门但好用的知识点长期缺位。用户刷着刷着就会发现“怎么又是这种题”,留存率就会掉。
第三,缺少个性化。传统题库就像一个巨型 ATM,所有用户面对的都是同一个题单,系统并不知道你更需要练链表还是更需要练图论。用户只能靠自己的主观判断去选题,效率很低。
1.2 AIGC 的定位:不是替代阅卷老师,而是当出题助教
AIGC 在这个系统里承担什么角色,我一开始想过很多方案。最激进的想法是:用模型直接生成题目,同时生成参考代码和所有测试用例,然后全部自动发布。试了几轮之后发现行不通,模型生成的测试用例经常出现“覆盖不够”或者“输入输出格式和描述不一致”的问题,如果没人审核就直接上线,用户会被不严谨的评测结果折磨到弃用。
最后我调整了定位:AIGC 是出题助教,不是阅卷老师。它可以低成本地生成题目初稿、参考解法、测试用例草稿、以及用户错误代码的提示语,但所有生成内容都必须经过一个规则校验层,严重不达标的内容直接丢弃,接近达标的进入人工审核队列。这个定位下来之后,整个系统稳定了很多。
1.3 系统功能全景与用户视角
从用户视角看,系统的使用链路是这样的:注册登录后,系统会先给用户推荐几道入门题;用户点进一道题,看到题目描述和示例,写完代码提交;提交后不到一秒,页面上的状态从“排队中”变成“编译中”,再到“运行中”,最后显示“通过”或者“未通过”;如果未通过,系统会给出失败用例以及 AIGC 生成的提示;做完几道题之后,学习路径推荐模块会根据答题表现,自动生成下一阶段的知识点建议。
这个平台服务两类人:一类是想刷题准备面试的普通用户,另一类是平台的内容管理员。普通用户关注题量和反馈体验,管理员关注题目审核和系统运营效率。
2. 系统架构与模块划分:先想清楚再动手
2.1 整体数据流
我在动手之前先画了一张数据流图,不夸张地说,这张图决定了后面所有开发的效率。
用户通过浏览器访问前端页面,前端请求先到网关层,经过认证鉴权后到达业务 API。API 层有两个重要出口:一个连接 MySQL 和 Redis,负责读写用户信息、题目详情、做题记录;另一个连接 RabbitMQ,把代码评测请求异步地交给评测 Worker 去处理。
评测 Worker 从队列里拉取提交任务后,会启动一个隔离的 Docker 容器,在容器里完成编译和运行,然后把运行结果写回 Redis 和 MySQL。前端这边通过 SSE 长连接接收评测状态更新,所以用户不需要一直刷页面。
题目生成这条链路是独立于评测的。当管理员点击“生成题目”时,后端调用 AIGC 服务生成 JSON 格式的题目草稿,经过规则校验后进入审核队列,管理员审核通过后题目才正式上架。
2.2 核心模块职责清单
为了不让自己写着写着绕晕,我把系统拆成了六个模块:
| 模块 | 核心职责 | 关键依赖 |
|---|---|---|
| 用户认证模块 | 注册、登录、JWT 签发与校验、角色权限 | MySQL、Redis |
| 题目管理模块 | 题目 CRUD、审核状态流转、题目标签维护 | MySQL |
| AIGC 生成模块 | 生成题目、生成提示语、生成测试用例草稿 | 大模型 API |
| 评测模块 | 拉取评测任务、运行沙箱、执行测试用例、记录结果 | Docker、RabbitMQ |
| 实时反馈模块 | SSE 推送评测状态、通知用户结果 | Redis、SSE |
| 学习路径模块 | 构建用户画像、知识点掌握度、题目推荐 | MySQL、Redis |
2.3 技术栈选型的几个判断
后端我用的是 Java Spring Boot 3.x。原因不是 Java 在这个场景里碾压其他语言,而是它的生态成熟、多人协作时边界清晰,而且评测 Worker 本身需要比较强的并发处理,Java 的虚拟线程和容器化部署结合在一起很顺手。如果你想用 Go 或 Python FastAPI 替代也不冲突,核心架构是一样的。
前端选了 Vue 3 + Element Plus。因为系统里大量页面是表格和表单,Element Plus 能省很多事。评测详情页需要显示代码状态流,我用了一个简单的 SSE 客户端接收消息,没有引入太重的东西。
数据库层面:MySQL 存放用户、题目、提交记录和知识点实体;Redis 存放用户会话、热点题目详情、最近评测结果;RabbitMQ 承担评测任务的异步解耦。整个容器编排使用 docker-compose,每个评测任务再动态创建专用沙箱容器。
选型时有几条判断标准我很坚持:
- 评测部分必须有隔离能力,Docker 是成本最低的方案。
- 消息队列不能省,因为代码评测的时间不可控,直接同步接口会让用户体验和系统吞吐都变差。
- 推荐模块初期不上复杂模型,先用可解释的规则,给后续迭代留出空间。
3. 代码自动评测:从提交到出分的完整链路
3.1 评测沙箱:为什么必须隔离
代码自动评测是整个系统的地基,也是最容易出事故的地方。我第一次做沙箱时差点抄近路,直接在服务器上 fork 出一个子进程去编译运行用户代码,结果被同事提醒:如果用户提交的代码里有rm -rf /呢?如果代码里写一个死循环呢?如果它尝试读取服务器上的/etc/passwd呢?
所以评测必须隔离。每个提交任务都对应一个临时容器,容器里只装运行时环境(JDK、Python、GCC 等),并且通过 Docker 的资源限制参数约束 CPU、内存、磁盘和网络。
我用的方案是:评测 Worker 收到任务后,基于一个预设的沙箱镜像创建容器,挂载一个临时目录用于存放用户提交的代码文件,等评测结束就销毁容器。这样即使代码里写了非常危险的操作,也影响不到宿主机。
3.2 评测执行流程与资源限制
一个完整的评测流程分四步。
第一步,编译。根据提交代码的语言,调用对应的编译器或解释器,比如 Java 用javac,C++ 用g++,Python 直接解释执行。编译必须设置超时时间,我设了 15 秒,超过就直接判编译超时。
第二步,准备测试用例。评分脚本从 MySQL 里读取这道题的所有测试用例,然后逐个运行程序并比对输出。这里有个关键点:比对不能简单用字符串相等,因为有些题目允许行尾空格不一样,有些题目要求浮点数误差在 1e-6 以内,所以我会为每道题配置一个比较器规则。
第三步,运行。容器内存限制为 256MB,CPU 时间限制根据难度和题目复杂度在 1~3 秒之间。运行过程中如果子进程退出码非零,说明程序崩了;如果超过时间限制,直接杀掉进程并返回 TLE;如果内存超过,返回 MLE。
第四步,汇总结果。把所有测试用例的通过情况汇总,只要有一个用例失败,整道题就不通过,并把第一个失败的用例输入输出返回给用户。
3.3 实时反馈:SSE 比轮询更合适
用户提交代码后,界面上不能什么都不显示,用户的耐心非常有限。传统做法是前端每 2 秒轮询一次提交接口,但在高并发情况下,轮询会把系统压得很累。
我对比过三种方案:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 前端轮询 | 实现简单 | 延迟高,服务器压力大 | 低频小规模 |
| WebSocket 双向推送 | 实时性强,可做双向通信 | 连接维护复杂,服务端改造大 | 聊天、协同编辑 |
| SSE 单向推送 | 实时性好,协议简单,天然重连 | 只支持服务端到客户端 | 评测状态流、通知 |
评测状态本来就是服务端往客户端单向推流的场景,所以 SSE 是最合适的。前端只需要用EventSource订阅一个专属路径,后端在评测状态变化时把消息推到缓存里,再由一个专门的长连接处理器转发给前端。
实测下来,从用户提交到页面看到“运行中”的状态,延迟基本在 200~400 毫秒之间,体感接近实时。
4. AIGC 题目生成:让大模型当出题老师,但保持人审兜底
4.1 生成题目不是单纯调接口
很多第一次做 AIGC 功能的人会以为,就是调用大模型接口,输入“请帮我出一道动态规划题”,然后输出就完事了。实际上这样做出来的东西根本没法用。
一是格式不可控。模型可能给出一段散文式描述,根本没有结构化的输入输出格式。二是难度不可控。同一个 Prompt 可能生成一道入门题,也可能生成一道红白机难度的竞赛题。三是测试用例质量不可控。模型生成的用例经常只有一组两组,而且样例格式和题目描述完全对不上。
我的做法是,把题目生成拆成三个子任务:题干生成、参考解法生成、测试用例生成。每个子任务单独设计 Prompt,并用 JSON Schema 约束输出结构。
4.2 结构化输出约束:JSON Schema 加温度控制
以题干生成为例,我定义了一个稳定的 Prompt 模板:
你是一位资深算法出题人。请根据以下要求生成一道算法题。 要求: - 知识点:动态规划 - 难度:中等 - 题型:数组路径问题 - 输入输出均从标准输入读取,输出到标准输出 请严格按照以下 JSON 格式返回: { "title": "题目名称", "difficulty": "EASY | MEDIUM | HARD", "description": "题目描述,包含输入输出格式说明", "input_format": "输入格式说明", "output_format": "输出格式说明", "samples": [ {"input": "样例输入1", "output": "样例输出1", "explanation": "可选解释"} ], "tags": ["动态规划", "数组"], "reference_solution": "参考解法代码(Python或Java)" }同时,生成题干时温度参数设成 0.7~0.9,让题目更有变化;生成测试用例时温度降到 0.2 以下,保证结果更确定。
为什么这么区分?因为题干生成需要发散性,温度太低会显得呆板,每道题都是同一个套路。测试用例生成则要求准确率,发散一多就容易出现格式不匹配。
4.3 生成结果的校验与自动重试
AIGC 生成的内容不能直接进题库,这是我在这个项目里最核心的经验。我写了一个校验服务,专门做以下检查:
- JSON 格式是否正确,字段是否完整。
- 题目描述是否为空,输入输出格式是否包含关键字。
- 样例是否能在参考解法上跑通。
- 参考解法是否真的通过所有样例。
- 题目标签是否在预设知识点集合内,避免出现稀奇古怪的分类。
如果生成结果没有通过校验,我会自动重试一次,把第一次失败的原因拼到 Prompt 里,让模型自己修正。比如“你上一次生成的参考解法无法通过样例,请检查逻辑”。这样做的成功率会提升不少。
如果重试两次仍然失败,这条生成记录会被丢进人工审核队列,不会出现在任何用户面前。靠着这道关卡,我后面上线的题目基本没有出过严重的逻辑错误。
5. 学习路径推荐:从做完题到知道下一步该学什么
5.1 知识点标注:题目和用户之间的连接点
学习路径推荐的前提是,题目标注了知识点,用户答题记录能和知识点关联起来。我在题目管理模块里内置了一套知识点体系,覆盖数组、链表、栈、队列、哈希表、树、图、贪心、动态规划、二分查找、字符串、数学等二十几个基础分类。
AIGC 生成的题目标签可能是“这个题目适合练习二维数组的横向遍历”,这太细,不利于统计。我在校验环节加了一步标签归一化,把模型输出的细粒度标签映射到预设的知识点体系里,保证同一个知识点在不同题目上的名称是统一的。
5.2 用户画像:用提交记录算掌握度
用户每提交一次代码,系统都会记录一个动作:题目 ID、是否通过、用时、错误次数、是否看了提示。基于这些记录,我计算每个知识点的掌握度:
- 正确率:该知识点下题目通过次数 / 提交次数。
- 熟练度:最近一次解题耗时与平均耗时的比值,越短越熟练。
- 活跃度:该知识点下最近一周的做题数量。
然后把这三项加权成 0~100 的掌握度分数。这种算法不复杂,但解释性很强。用户能在个人中心看到“你的动态规划掌握度是 68”,清楚知道短板在哪里。
5.3 推荐策略:先规则,后模型
在做推荐模块时我没有一上来就上机器学习模型。原因很简单:用户行为数据不够,模型训练不起来,而且冷启动问题也绕不开。我采用了一种分层推荐策略:
第一层,冷启动推荐。新用户刚注册,没有历史行为数据,系统直接推荐知识点体系里面难度为 EASY 的题目,并按照题目热度排序。热度由提交次数和通过率共同决定,避免推荐一些没人做的偏题。
第二层,基于规则的补齐推荐。当用户已经做过一定数量的题目,系统会找出掌握度最低的三个知识点,从这些知识点中优先挑选用户没做过、难度为 MEDIUM 的题目。
第三层,相似用户协同。随着数据量增长,我会基于用户画像向量计算相似用户,把相似用户做过的题推荐给当前用户。这个可以后续再迭代,前期先把规则链路跑通,就能解决大多数用户的个性化需求。
实操中有一个细节要注意:推荐结果必须做去重和难度控制。如果用户已经连续做挂了五道难题,系统再连续推三道难题,体验就很差。我会在推荐结果里加入一个“信心指数”,当用户近期错误率超过 60%,优先推简单题,让用户先找回正反馈。
6. 缓存与异步消息:撑住高并发的后台设计
6.1 Redis 缓存策略
在线编程平台里,最热的数据是题目详情和已通过题目的参考代码。题目详情存在 MySQL,但每次用户点击题目都要查一次,流量一大数据库连接会先扛不住。我用 Redis 做了两层缓存:
- 热点题目缓存:key 是
problem:detail:{id},value 是题目 JSON,过期时间 30 分钟。读取时先查缓存,命中直接返回;未命中回源数据库,再异步写回缓存。 - 首页题单缓存:key 是
problem:list:page:{page},根据题单变化主动失效。
缓存更新采用 Cache Aside 模式,也就是先更新数据库,再删除缓存。为什么是删除而不是更新?因为更新缓存需要额外计算 JSON 和标签信息,删除的成本更低,下一次查询时自然会把新数据写回。
6.2 RabbitMQ 异步评测:提交请求不阻塞
如果没有消息队列,用户提交代码后,HTTP 请求会一直等待评测完成再返回。评测一个 C++ 程序可能只要几百毫秒,但如果代码里有 3 秒超时,用户请求就会被占住 3 秒,一旦并发上来,Tomcat 线程池直接被打满。
我引入了 RabbitMQ,提交接口只做两件事:把评测任务封装成消息发进队列,然后返回一个 submissionId。前端拿着 submissionId 通过 SSE 订阅结果。评测 Worker 从队列消费任务,处理完再通过 SSE 推送结果。
队列配置我用了 topic 交换机,路由键设计成judge.{language}.{difficulty},这样后续如果要针对 Java 任务单独扩展 Worker,只需要绑定对应的路由键即可。
6.3 消息可靠性、重复消费与死信处理
消息队列最怕三类问题:消息丢失、消息重复、消息积压。
消息丢失方面,生产端开启 confirm 模式,RabbitMQ 返回 ack 才认为发布成功;消费端关闭自动 ack,改为手动 ack,评测成功后再确认。
消息重复方面,我会在 Redis 里记录每个 submissionId 的处理状态。Worker 消费前先检查这个状态是否为“已完成”,如果已完成就直接跳过,保证评测逻辑的幂等性。
消息积压方面,我设置了死信队列。如果一条评测消息重试三次仍然失败,会进入死信队列,管理员可以通过后台手动重新放回主队列,避免脏消息反复卡住消费进度。
7. 用户认证授权:JWT 加 RBAC 的组合拳
7.1 JWT 为什么更贴合前后端分离
这个系统的前端部署在 Nginx 上,后端是独立的 Spring Boot 服务,天然是前后端分离架构。如果还用 Session,就得考虑 Session 同步、跨域 Cookie 等一系列问题,很不划算。
我选了 JWT 方案:用户登录成功后,后端签发一个 accessToken,有效期 2 小时;同时签发一个 refreshToken,有效期 7 天。前端把 token 存在内存里或者 localStorage 中,每次请求在 Authorization 头带上。
JWT 的好处是服务端无状态,用户量增大后水平扩展时不需要集中管理会话。但无状态也意味着 token 一旦泄露就很难主动收回,所以我在网关层加了 token 黑名单机制:如果用户修改密码或管理员封禁账号,会把旧 token 的 jti(token ID)加入 Redis 黑名单,有效期直到原 token 过期。
7.2 角色权限设计
系统里有四种角色:普通用户、题目审核员、内容管理员、系统管理员。
普通用户能提交代码、查看题目、查看自己的做题记录。题目审核员能进入 AIGC 生成审核队列,对模型生成的题目做通过或退回操作。内容管理员能直接编辑题目、调整推荐策略参数。系统管理员除了内容权限外,还能查看运行日志、管理用户状态。
权限控制我用的是 RBAC 模型,在 Spring Security 中通过注解@PreAuthorize("hasRole('ADMIN')")修饰接口,所有权限判断集中在网关层和接口层,业务代码里不用到处写权限判断。
7.3 安全细节
认证授权部分有几个常规但必须做的点:
- 密码存储使用 BCrypt 加盐哈希,明文密码永远不进数据库。
- 登录接口做限流,同一个 IP 每分钟最多尝试 10 次登录,防止暴力破解。
- 刷新 token 和访问 token 分开存储,访问 token 过期后通过 refreshToken 接口换取新 token。
- 所有需要操作数据的接口都做越权校验,比如用户只能查看自己的提交记录,不能通过构造 ID 查看别人的代码。
越权问题是很多新手容易忽略的点,JWT 只能解决“你是谁”,不能解决“你能不能看这条数据”。我在每个 service 层都显式传入了当前用户 ID,SQL 查询里也会强制带上 user_id 条件。
8. 踩坑记录:真正需要花时间的五个地方
8.1 沙箱里的内存统计不准
第一次跑评测时,我用 Docker 的 Memory Limit 限制 256MB,结果发现不少合法代码被判成 MLE(内存超限)。原因是 JVM 启动时的内存占用比普通程序高,一个空 Java 进程都能吃掉 100MB 左右。后来我给 Java 单独调整了内存上限到 512MB,并且使用-Xmx256m控制堆栈大小。C++ 和 Python 的程序则维持 256MB。
这个经验提醒我:不同语言的沙箱配置不能一把尺子量到底,资源限制要根据语言特性和题目规模做差异化管理。
8.2 AIGC 生成代码偶尔会“作弊”
有一类很刁钻的测试用例,模型生成的参考代码能通过样例,但其实是“硬编码”的。比如模型生成了一道“判断两个数是否相等”的题,参考代码里直接就写return true,刚好样例也都是相等的数字,结果样例确实通过了。
这种肯定不能上线。我后来在校验层加了一个策略:除了跑样例,还要跑一组隐藏的随机测试数据,并且要求参考解法能在这组数据上通过。这能过滤掉大部分为了凑样例而硬编码的问题。
8.3 并发判题的线程池耗尽
一开始评测 Worker 是单线程消费一条条消息,后来发现并发稍微一高,队列就积压严重。给 Worker 加了线程池之后,又遇到新的问题:线程池默认拒绝策略是 AbortPolicy,任务一多直接抛异常,消息反复重投。
我采用的方案是:线程池的核心线程数设置为 CPU 核数的两倍,最大线程数保持和队列容量平衡,拒绝策略改成 CallerRunsPolicy——如果线程池满了,让生产者线程自己执行任务,起到自然限流的作用。配合 Docker 沙箱的数量限制,整体稳定性提高了不少。
8.4 学习路径推荐的冷启动不要硬套模型
早期我想直接上一个协同过滤算法,结果用户行为数据只有几十条,推荐出来的东西完全没有区分度。后来我把推荐逻辑退回规则驱动,把“掌握度低的知识点”作为主要推荐依据,效果反而立竿见影。
这也验证了一个观点:好推荐的核心不是算法多复杂,而是能不能给出用户“为什么推荐这道题”的合理解释。规则驱动的推荐天然自带解释性,这一点对用户信任很重要。
8.5 缓存穿透:当用户疯狂刷不存在的题目 ID
有人会故意构造不存在的题目 ID 去请求详情接口,每次都会打到 MySQL,造成缓存穿透。我的解决方案很简单:缓存里存空值,同时设置 5 分钟短过期时间。这样同一个不存在的 ID 在 5 分钟内只会穿透一次。
如果攻击者用大量随机 ID,空值缓存也会占用大量内存。所以我还加了一个布隆过滤器,把所有有效题目 ID 加载进去,查询前先判断 ID 是否可能存在,不存在就直接返回空结果,完全不进数据库。这个方案在我的场景里非常有效。
整个项目做下来,我最大的一个体会是:AIGC 和传统的在线编程评测不是两个孤立的技术栈,把它们捏在一起的核心思路,是让 AI 负责“内容的低成本生产”,让工程系统负责“质量的确定性保障”。AI 生成题目的速度再快,如果没有评测沙箱、缓存、消息队列这些后端能力兜底,它也只能停留在 Demo 阶段。
如果后续要继续扩展,我会优先做两个方向:一个是为评测模块增加更多编程语言支持,跟着容器镜像一个个补;另一个是把学习路径推荐从规则驱动转向特征工程加模型的混合架构,让推荐结果更平滑。这个项目整体架构已经替我把路铺好了,后面每一步都有的放矢。
本文还有配套的精品资源,点击获取