大模型应用实战:教你从0到1打造 “AI 作业批改系统”(支持数学公式 + 编程题批改)
传统的作业批改,无论对于教师还是学生,都是一个充满痛点的环节。教师需要投入大量时间和精力进行重复性劳动,评分标准难免带入主观色彩,反馈也往往不够及时;而学生则焦急地等待着结果,得到的可能只是一个冷冰冰的分数,却不清楚自己究竟错在哪里,对应的知识点又是什么。
这篇教程,正是要带你亲手终结这个痛点。我们将从零开始,一步步构建一个智能的、全能的“AI 作业批改系统”。它不仅仅是一个玩具项目,更是一个能处理文本题、数学公式、甚至编程题的实战级应用。你将像一位真正的 AI 工程师一样,思考需求的边界,选择合适的技术,并用代码将想法变为现实。
读完这篇教程,你将收获的不仅仅是一个能运行的项目,更是一套完整的 AI 应用开发“方法论”。你将掌握:
- 大模型 API 调用与微调:从直接使用强大的预训练模型,到用自有数据“驯化”模型,让它更懂你的业务。
- 多模态信息处理:学习如何让程序同时理解文本、图片(数学公式)和代码,这是构建高级 AI 应用的必备技能。
- 后端服务开发:使用业界流行的 Django 框架,搭建一个稳定、可扩展的教师端后台,完成从算法到产品的闭环。
- 工程化思维:学习如何拆解复杂问题,设计项目架构,并一步步完成功能模块的开发与集成。
这趟旅程不需要你具备深厚的算法背景,只需要你带着一颗好奇和敢于动手的心。现在,就让我们一起,为这个世界创造一个更智能、更高效的“AI 助教”吧!
万丈高楼平地起:环境准备与项目初始化
在正式敲下第一行代码之前,我们需要先搭建好开发环境,并规划好整个项目的“蓝图”。这个过程就像建造一栋大楼前,必须先打好地基、备好建材一样,至关重要。
技术栈概览
所谓“工欲善其事,必先利其器”。我们这个项目会用到一系列当前 AI 和 Web 开发领域最主流的技术。为了让你有一个宏观的认识,我用一张图来展示它们之间的关系:
这张图清晰地展示了项目的全貌:教师通过浏览器与 Django 后端交互,后端接收到批改请求后,会分发给不同的 AI 核心服务进行处理,而这些服务底层则依赖于像 BERT、Mathpix、Judge0 这样强大的模型和 API。
Python 环境配置
为什么选择 Python?
在 AI 领域,Python 是当之无愧的“王者语言”。这得益于它简洁的语法、强大的社区支持以及无与伦比的生态系统。几乎所有主流的深度学习框架(如 PyTorch、TensorFlow)和科学计算库(如 NumPy、SciPy)都提供了完善的 Python 接口。选择 Python,意味着我们站在了巨人的肩膀上,可以把精力更多地聚焦于业务逻辑,而非底层的繁琐实现。
创建虚拟环境
在开发项目时,一个最佳实践是为每个项目创建一个独立的 Python 环境。这样做可以避免不同项目之间的依赖冲突。我们将使用 Python 内置的 venv 工具来创建虚拟环境。
-
打开你的终端(Terminal)。
-
创建一个项目目录并进入。
# 创建一个名为 AIGradingSystem 的文件夹 mkdir AIGradingSystem # 进入这个文件夹 cd AIGradingSystem- 为什么这么做?:将所有项目相关文件都放在一个独立的文件夹里,能让项目结构保持清晰、整洁。
-
创建虚拟环境。
# 使用 python3.9 创建一个名为 "venv" 的虚拟环境 python3.9 -m venv venv- 命令解释:
python3.9:明确指定使用 Python 3.9 版本。-m venv:-m参数告诉 Python 以模块(module)的方式运行venv工具。venv:这是我们给虚拟环境取的名字,是一个通用的习惯。执行后,你会在当前目录下看到一个名为venv的新文件夹。
- 命令解释:
-
激活虚拟环境。
- 在 macOS 或 Linux 上:
source venv/bin/activate - 在 Windows 上:
.\venv\Scripts\activate - 为什么需要激活?:激活虚拟环境后,你的终端提示符前面会出现
(venv)的字样。这意味着,后续你所有安装库的命令(如pip install)和执行 Python 脚本的命令,都将在这个独立的环境中进行,不会影响到系统的全局 Python 环境。
- 在 macOS 或 Linux 上:
安装核心依赖
环境准备好后,我们来安装项目所需的核心库。我们会将所有依赖项记录在一个 requirements.txt 文件中,方便管理和分享。
-
创建
requirements.txt文件。touch requirements.txt -
将以下内容写入
requirements.txt文件:# Django 框架 Django==4.2.0 # Hugging Face 生态,用于加载 BERT 和 Qwen 模型 transformers==4.30.2 torch==2.0.1 accelerate==0.20.3 # 数学公式计算 sympy==1.12 # HTTP 请求,用于调用 API requests==2.31.0 # 其他辅助库 Pillow==9.5.0 # 图像处理 python-dotenv==1.0.0 # 环境变量管理- 为什么使用
==指定版本?:这可以确保协作开发者或者未来的你,在部署项目时使用的库版本是完全一致的,避免了因版本不兼容导致的问题。
- 为什么使用
-
使用
pip安装所有依赖。pip install -r requirements.txt- 命令解释:
-r参数告诉pip从指定的文件中读取并安装所有列出的库。
- 命令解释:
项目结构创建
依赖安装完毕,我们开始用 Django 来搭建项目的骨架。
-
创建 Django 项目。
django-admin startproject grading_system .- 命令解释:
django-admin:Django 的命令行工具。startproject:创建一个新项目的命令。grading_system:我们给项目取的名字。.:一个点,表示在当前目录创建项目,而不是再新建一个子目录。这能让项目结构更扁平化。
执行后,你的目录结构会变成这样:
AIGradingSystem/ ├── grading_system/ │ ├── __init__.py │ ├── asgi.py │ ├── settings.py │ ├── urls.py │ └── wsgi.py ├── manage.py ├── requirements.txt └── venv/ - 命令解释:
-
创建我们的核心应用(App)。
在 Django 中,“项目(Project)”代表整个网站,而“应用(App)”则是一个个功能独立的模块(比如用户模块、批改模块)。
python manage.py startapp core- 命令解释:
python manage.py:这是之后你会一直用到的 Django 项目管理入口。startapp core:创建一个名为core的应用。我们将把核心的批改逻辑都放在这里。
现在,目录结构新增了一个
core文件夹:AIGradingSystem/ ├── core/ │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── migrations/ │ ├── models.py │ ├── tests.py │ └── views.py ├── grading_system/ ... - 命令解释:
-
注册应用。
创建了应用之后,我们必须告诉 Django 项目:“嘿,我有一个新应用叫
core,请把它纳入管理。”打开
grading_system/settings.py文件,找到INSTALLED_APPS列表,在末尾加上我们的core应用:# grading_system/settings.py INSTALLED_APPS = [ 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', 'core', # <-- 添加这一行 ]
到这里,我们的项目地基就已经全部打好了!接下来,我们将进入最激动人心的部分——开始逐一实现 AI 批改的核心功能。
核心功能篇(一):让 AI 读懂“文字”——文本题批改
文本题,如名词解释、简答题等,是作业中最常见的题型。传统方法通常依赖于简单的“关键词匹配”,这种方法非常机械,无法理解语义上的相似性。例如,学生回答“太阳系的中心天体”和标准答案“一个由氢和氦组成的巨大等离子体球”,虽然字面上毫无关系,但都指向“太阳”。
为了让我们的 AI 更“聪明”,我们将使用自然语言处理(NLP)领域的利器——BERT 模型,来实现真正的语义匹配。
语义相似度背后的“魔法”:句向量
什么是句向量?
想象一下,我们想让计算机理解“国王”和“女王”的关系,就像我们理解“男人”和“女人”的关系一样。词向量(Word Embedding)技术就实现了这一点,它将每个词语映射到一个多维空间中的一个点(即一个向量),并且词语之间的空间关系能够反映它们在语义上的关系。比如 vector('国王') - vector('男人') + vector('女人') 的计算结果,会非常接近 vector('女王')。
句向量(Sentence Embedding)则是这个思想的延伸,它将一整个句子映射成一个固定维度的向量,这个向量就代表了整个句子的“语义”。如果两个句子的意思相近,那么它们的句向量在空间中的距离也会非常接近。
我们如何利用它?
批改文本题的逻辑就变得异常简单了:
- 编码:分别将“标准答案”和“学生答案”输入到 BERT 模型中,得到它们各自的句向量。
- 计算:计算这两个向量之间的“余弦相似度”。
- 判断:设定一个相似度阈值(比如 0.8),如果计算出的相似度高于这个阈值,我们就认为学生回答正确。
代码实战:构建文本批改服务
现在,让我们在 core 应用中创建一个新的文件 services.py,专门用来存放我们的 AI 批改逻辑。
-
创建
core/services.py文件。 -
编写文本相似度计算服务。
# core/services.py from transformers import BertTokenizer, BertModel import torch from torch.nn.functional import cosine_similarity # 定义一个单例模式的装饰器,确保模型只被加载一次 def singleton(cls): instances = {} def get_instance(*args, **kwargs): if cls not in instances: instances[cls] = cls(*args, **kwargs) return instances[cls] return get_instance @singleton class TextGrader: def __init__(self): """ 初始化函数,在服务第一次被调用时执行。 这里会加载预训练的 BERT 模型和分词器到内存中。 这是一个耗时操作,所以我们使用单例模式确保它只执行一次。 """ print("正在加载 BERT 模型...") # 我们选择一个中文的预训练模型 model_name = 'bert-base-chinese' self.tokenizer = BertTokenizer.from_pretrained(model_name) self.model = BertModel.from_pretrained(model_name) # 将模型设置为评估模式 self.model.eval() print("BERT 模型加载完成。") def _get_sentence_embedding(self, text): """ 私有方法,用于将单个句子转换为句向量。 """ # 1. 分词并添加特殊标记 inputs = self.tokenizer(text, return_tensors='pt', padding=True, truncation=True, max_length=512) # 2. 模型推理 # torch.no_grad() 表示我们不需要计算梯度,这能加速计算并节省内存 with torch.no_grad(): outputs = self.model(**inputs) # 3. 获取句向量 # 我们使用 [CLS] token 的输出来代表整个句子的语义 embedding = outputs.last_hidden_state[:, 0, :] return embedding def grade_text_answer(self, student_answer: str, standard_answer: str) -> float: """ 公开方法,用于计算两个文本的相似度得分。 :param student_answer: 学生提交的答案 :param standard_answer: 标准答案 :return: 返回一个 0 到 1 之间的浮点数,表示相似度 """ # 分别获取两个句子的向量表示 student_embedding = self._get_sentence_embedding(student_answer) standard_embedding = self._get_sentence_embedding(standard_answer) # 计算余弦相似度 # cosine_similarity 的输入需要是二维的,所以我们保持 [1, embedding_dim] 的形状 score = cosine_similarity(student_embedding, standard_embedding).item() return score # --- 如何使用 --- if __name__ == '__main__': grader = TextGrader() # 案例1:语义相似,但字面不同 student_ans_1 = "他是太阳系的中心" std_ans_1 = "太阳是主要由氢和氦组成的巨大等离子体球" score1 = grader.grade_text_answer(student_ans_1, std_ans_1) print(f"案例1 - 学生答案: '{student_ans_1}'") print(f"案例1 - 标准答案: '{std_ans_1}'") print(f"案例1 - 相似度得分: {score1:.4f}") # 案例2:完全不相关 student_ans_2 = "月亮是地球的卫星" std_ans_2 = "太阳是主要由氢和氦组成的巨大等离子体球" score2 = grader.grade_text_answer(student_ans_2, std_ans_2) print(f"\n案例2 - 学生答案: '{student_ans_2}'") print(f"案例2 - 标准答案: '{std_ans_2}'") print(f"案例2 - 相似度得分: {score2:.4f}")
代码解释:
singleton装饰器:BERT 模型很大,加载一次可能需要几秒钟甚至更长时间。我们不希望每次批改作业都重新加载一次模型。单例模式确保TextGrader类在整个应用生命周期中只有一个实例,模型也只会被加载一次。bert-base-chinese:这是一个由 Google 发布的、在大量中文语料上预训练过的 BERT 模型,它非常擅长理解中文语义。tokenizer:分词器,负责将我们的文本切分成一个个的“词元(token)”,并转换成模型能理解的数字 ID。model.eval():告诉模型现在是“评估模式”,而不是“训练模式”。这会关闭一些只在训练时使用的层,比如 Dropout,让模型的输出是确定性的。[CLS]token:BERT 在处理句子时,会在开头自动添加一个特殊的[CLS]标记。这个标记对应的输出向量,通常被认为是整个输入序列的聚合语义表示,因此我们用它来作为句向量。
现在,你可以直接运行 core/services.py 文件(python core/services.py),看到模型第一次被加载,并输出两个案例的相似度得分。你会发现,案例1 的得分远高于案例2,证明我们的方法是有效的!
核心功能篇(二):挑战“视觉”与“逻辑”——数学公式批改
数学题的批改是 AI 助教皇冠上的明珠,它同时考验了系统的“视觉识别”和“逻辑推理”能力。学生提交的数学作业往往是手写的图片,我们需要先将图片中的公式“翻译”成计算机能懂的语言,然后再去判断这个公式是否正确。
我们将这个过程拆解为两步:
- 公式识别:借助第三方的 OCR(光学字符识别)服务——Mathpix API,将公式图片转换为 LaTeX 字符串。
- 逻辑验证:使用 Python 强大的科学计算库 SymPy,来“计算”和“化简”公式,从而判断学生的答案与标准答案是否在数学上等价。
graph TD
subgraph Step 1: 公式识别
A[手写公式图片] --> B{Mathpix API};
B --> C[识别出的 LaTeX 字符串<br>例如: "x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}"];
end
subgraph Step 2: 逻辑验证
C --> D{SymPy 解析器};
E[标准答案 LaTeX] --> D;
D --> F[学生答案的数学表达式];
D --> G[标准答案的数学表达式];
F & G --> H{SymPy 化简与等价判断};
H --> I{判断是否等价};
I -- 是 --> J[判定为正确];
I -- 否 --> K[判定为错误];
end
集成 Mathpix API 进行公式识别
Mathpix 是目前市面上公认的、识别效果最好的数学公式 OCR 服务。它提供了非常简单的 RESTful API,我们只需要将图片数据发送过去,就能收到识别结果。
准备工作:
-
前往 Mathpix 官网 注册一个账号。
-
在你的账户后台,找到 API Keys,获取你的
App ID和App Key。 -
为了安全地管理这些敏感信息,我们在项目根目录下创建一个
.env文件,并将密钥存放在里面。# .env 文件 MATHPIX_APP_ID=your_app_id MATHPIX_APP_KEY=your_app_key同时,别忘了将
.env文件添加到.gitignore中,避免将密钥上传到代码仓库。
代码实现:
我们继续在 core/services.py 文件中添加新的服务。
# core/services.py
# ... (之前的 TextGrader 代码) ...
import requests
import json
import base64
import os
from dotenv import load_dotenv
# 加载 .env 文件中的环境变量
load_dotenv()
@singleton
class MathGrader:
def __init__(self):
"""
初始化函数,加载 Mathpix API 的凭证。
"""
self.app_id = os.getenv("MATHPIX_APP_ID")
self.app_key = os.getenv("MATHPIX_APP_KEY")
if not self.app_id or not self.app_key:
raise ValueError("请在 .env 文件中设置 MATHPIX_APP_ID 和 MATHPIX_APP_KEY")
self.api_url = "https://api.mathpix.com/v3/text"
def recognize_formula_from_image(self, image_path: str) -> str:
"""
调用 Mathpix API 从图片中识别 LaTeX 公式。
:param image_path: 图片的本地路径
:return: 识别出的 LaTeX 字符串,如果失败则返回 None
"""
try:
with open(image_path, "rb") as f:
image_data = f.read()
# 将图片数据转换为 Base64 编码的字符串
image_uri = "data:image/jpg;base64," + base64.b64encode(image_data).decode()
headers = {
"app_id": self.app_id,
"app_key": self.app_key,
"Content-type": "application/json"
}
payload = {
"src": image_uri,
"formats": ["latex_simplified"]
}
response = requests.post(self.api_url, headers=headers, data=json.dumps(payload))
response.raise_for_status() # 如果请求失败(非2xx状态码),则抛出异常
result = response.json()
if "latex_simplified" in result:
return result["latex_simplified"]
return None
except requests.exceptions.RequestException as e:
print(f"调用 Mathpix API 出错: {e}")
return None
except Exception as e:
print(f"处理图片或解析响应时出错: {e}")
return None
# --- 如何使用 ---
# if __name__ == '__main__':
# # ... (之前的 TextGrader 测试代码) ...
# # 假设你有一张名为 formula.png 的图片在项目根目录
# math_grader = MathGrader()
# latex_result = math_grader.recognize_formula_from_image("formula.png")
# if latex_result:
# print(f"\n数学公式识别结果: {latex_result}")
代码解释:
python-dotenv:这个库能帮助我们方便地从.env文件中加载环境变量,避免了硬编码。- Base64 编码:HTTP 协议传输的是文本数据。为了能在 JSON 请求体中嵌入图片这种二进制数据,我们需要先将其编码成一个纯文本的字符串,Base64 就是最常用的编码方式之一。
"formats": ["latex_simplified"]:我们告诉 Mathpix API,我们只需要“简化版”的 LaTeX 结果,这对于后续的程序处理更友好。
效果对比:
为了让你更直观地感受 Mathpix 的强大,这里有一张对比图。对于复杂的手写公式,它的识别准确率远超常规的 OCR 工具。
| 输入图片 | 常规 OCR 结果 | Mathpix 识别结果 (LaTeX) |
|---|---|---|
![]() | x=-b±Vb-4ac / 2a | x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a} |
| (注意:此处为示例,你需要替换为真实的图片链接才能显示) |
使用 SymPy 进行符号逻辑验证
拿到了 LaTeX 字符串后,我们就进入了纯粹的数学逻辑层面。SymPy 是一个用于符号数学的 Python 库,它能像人一样理解和操作代数表达式,而不是仅仅进行数值计算。
比如,对于 (x+1)**2,SymPy 知道它可以被展开为 x**2 + 2*x + 1,并且能判断这两个表达式是等价的。这正是我们批改数学题所需要的核心能力。
代码实现:
继续在 MathGrader 类中添加我们的验证逻辑。
# core/services.py
# ... (之前的 MathGrader 代码) ...
from sympy import sympify, simplify, Eq
class MathGrader:
# ... (之前的 __init__ 和 recognize_formula_from_image 方法) ...
def _parse_latex(self, latex_str: str):
"""
私有方法,将 LaTeX 字符串解析为 SymPy 表达式对象。
"""
try:
# sympify 是一个健壮的解析器,能处理多种形式的输入
return sympify(latex_str, transformations='all')
except Exception as e:
print(f"SymPy 解析 LaTeX 失败: '{latex_str}', 错误: {e}")
return None
def grade_math_answer(self, student_latex: str, standard_latex: str) -> bool:
"""
公开方法,判断两个 LaTeX 表达式在数学上是否等价。
:param student_latex: 学生的答案 (LaTeX)
:param standard_latex: 标准答案 (LaTeX)
:return: 如果等价则返回 True, 否则返回 False
"""
# 1. 解析表达式
student_expr = self._parse_latex(student_latex)
standard_expr = self._parse_latex(standard_latex)
if student_expr is None or standard_expr is None:
return False # 如果有任何一个解析失败,直接判定为错误
# 2. 判断等价性
# 核心逻辑:如果两个表达式等价,那么它们相减后化简的结果应该为 0。
# a. 处理方程式的情况(包含等号)
if isinstance(student_expr, Eq) and isinstance(standard_expr, Eq):
# 对于方程式 a=b 和 c=d,我们判断 a-c 和 b-d 是否都为0
# 或者更通用的方法是判断 (a-b) - (c-d) 是否为 0
diff = (student_expr.lhs - student_expr.rhs) - (standard_expr.lhs - standard_expr.rhs)
elif isinstance(student_expr, Eq) or isinstance(standard_expr, Eq):
# 一个是表达式,一个是方程式,认为不等价
return False
else:
# b. 处理普通表达式的情况
diff = student_expr - standard_expr
# 使用 simplify 函数进行化简
try:
simplified_diff = simplify(diff)
except Exception:
# 如果化简过程中出错,也认为是不等价
return False
# 3. 返回结果
return simplified_diff == 0
# --- 如何使用 ---
if __name__ == '__main__':
# ... (之前的 TextGrader 和 Mathpix 测试代码) ...
math_grader = MathGrader()
# 案例1: 等价,但形式不同
student_ans_1 = "x**2 + 2*x + 1"
std_ans_1 = "(x+1)**2"
is_correct_1 = math_grader.grade_math_answer(student_ans_1, std_ans_1)
print(f"\n数学逻辑验证 - 案例1: 学生答案 '{student_ans_1}', 标准答案 '{std_ans_1}' -> 批改结果: {'正确' if is_correct_1 else '错误'}")
# 案例2: 不等价
student_ans_2 = "x**2 - 1"
std_ans_2 = "(x-1)**2"
is_correct_2 = math_grader.grade_math_answer(student_ans_2, std_ans_2)
print(f"数学逻辑验证 - 案例2: 学生答案 '{student_ans_2}', 标准答案 '{std_ans_2}' -> 批改结果: {'正确' if is_correct_2 else '错误'}")
# 案例3: 方程式等价
student_ans_3 = "y = 2*x + 1"
std_ans_3 = "2*x - y + 1 = 0"
is_correct_3 = math_grader.grade_math_answer(student_ans_3, std_ans_3)
print(f"数学逻辑验证 - 案例3: 学生答案 '{student_ans_3}', 标准答案 '{std_ans_3}' -> 批改结果: {'正确' if is_correct_3 else '错误'}")
代码解释:
sympify:这是一个非常强大的函数,它能尝试将各种格式的字符串(包括一些 LaTeX 语法)转换成 SymPy 内部的表达式对象。我们添加了transformations='all'来增强其解析能力。simplify:SymPy 的核心函数之一。它会运用一系列代数规则,尝试将一个复杂的表达式化简到其“最简形式”。- 核心判断逻辑:我们利用了一个非常巧妙的数学原理:如果两个表达式
A和B是等价的,那么simplify(A - B)的结果必然是0。这比直接比较simplify(A) == simplify(B)要更可靠,因为“最简形式”有时不是唯一的。对于方程式LHS1 = RHS1和LHS2 = RHS2,我们也是将其转化为(LHS1 - RHS1) - (LHS2 - RHS2)是否为0来判断。
至此,我们已经攻克了数学题批改这个难关。它完美地展示了如何组合外部 API 和强大的内部库,来解决一个复杂的、跨领域的问题。
核心功能篇(三):终极挑战——编程题自动评测
编程题的批改是所有题型中最复杂,但也是最“客观”的。它的正确性不依赖于语义或符号,而依赖于唯一的标准:代码在给定输入下,能否产生预期的输出。
直接在我们的服务器上执行学生提交的、不受信任的代码是极其危险的,可能会导致安全漏洞或资源耗尽。因此,我们必须借助一个专业的、沙箱化的在线代码评测系统(Online Judge)。本项目选用的是 Judge0。
为什么是 Judge0?
Judge0 是一个开源的、支持超过 60 种编程语言的在线代码执行系统。它提供了一个稳定、安全的 API,让我们只需提交代码和测试用例,就能获得详细的评测结果,包括编译是否成功、运行是否超时、内存是否超限、以及输出是否正确等。
编程题评测工作流
整个过程可以概括为“提交-轮询-获取结果”的异步流程:
graph TD
subgraph 我们的后端服务
A[学生代码 + 测试用例] --> B{提交到 Judge0 API};
B -- 返回 submission_token --> C{存储 Token};
C --> D{定时轮询 Judge0 API <br> (使用 Token 查询)};
end
subgraph Judge0 服务
E[接收代码] --> F[加入评测队列];
F --> G[编译 & 执行];
G --> H[比对输出结果];
H --> I[生成评测报告];
end
subgraph 结果获取
D -- "状态是否为'处理中'?" --> J{处理中};
J -- 是 --> D;
J -- 否 --> K[获取最终评测结果];
end
I --> K
这个异步流程非常关键,因为代码评测可能需要几秒钟时间。通过提交后轮询的方式,可以避免我们的后端服务长时间阻塞等待。
代码实战:集成 Judge0 API
准备工作:
-
你可以使用 Judge0 的免费 RapidAPI 接口进行快速测试,也可以按照其官方文档自行部署一个 Judge0 实例。
-
我们同样将 Judge0 的 API 地址和密钥(如果有)存放在
.env文件中。# .env 文件 JUDGE0_API_URL=https://judge0-ce.p.rapidapi.com JUDGE0_API_KEY=your_rapidapi_key
代码实现:
在 core/services.py 中添加 CodeGrader 服务。
# core/services.py
# ... (之前的代码) ...
import time
# ... (MathGrader 类定义) ...
@singleton
class CodeGrader:
def __init__(self):
self.api_url = os.getenv("JUDGE0_API_URL")
self.api_key = os.getenv("JUDGE0_API_KEY")
if not self.api_url:
raise ValueError("请在 .env 文件中设置 JUDGE0_API_URL")
self.headers = {
"Content-Type": "application/json",
"X-RapidAPI-Host": "judge0-ce.p.rapidapi.com", # 根据你使用的 Judge0 Host 修改
"X-RapidAPI-Key": self.api_key
}
def submit_code(self, source_code: str, language_id: int, stdin: str, expected_output: str) -> dict:
"""
提交代码进行评测。这是一个异步过程,此函数立即返回一个 submission token。
:param source_code: 学生提交的源代码
:param language_id: 语言 ID (e.g., 71 for Python 3)
:param stdin: 标准输入
:param expected_output: 预期的标准输出
:return: 包含 submission token 的字典,或包含错误的字典
"""
payload = {
"source_code": source_code,
"language_id": language_id,
"stdin": stdin,
"expected_output": expected_output,
}
try:
# base_url/submissions?base64_encoded=false&wait=false
# wait=false 表示我们不等待评测结果,立即返回
response = requests.post(f"{self.api_url}/submissions", params={"base64_encoded": "false", "wait": "false"}, headers=self.headers, json=payload)
response.raise_for_status()
return response.json() # 应该会返回 {"token": "..."}
except requests.exceptions.RequestException as e:
return {"error": str(e)}
def get_submission_result(self, token: str) -> dict:
"""
使用 token 查询评测结果。
:param token: 提交代码后获得的 token
:return: 包含评测结果的详细字典
"""
try:
# base_url/submissions/{token}?base64_encoded=false
response = requests.get(f"{self.api_url}/submissions/{token}", params={"base64_encoded": "false"}, headers=self.headers)
response.raise_for_status()
return response.json()
except requests.exceptions.RequestException as e:
return {"error": str(e)}
def grade_code_answer(self, source_code: str, language_id: int, test_cases: list) -> dict:
"""
公开方法,对一个代码进行完整的评测,包含多个测试用例。
:param source_code: 源代码
:param language_id: 语言 ID
:param test_cases: 测试用例列表,格式为 [{"stdin": "...", "expected_output": "..."}, ...]
:return: 一个包含总体结果和每个测试用例详情的字典
"""
results = {
"compile_error": None,
"all_passed": True,
"test_case_results": []
}
for i, case in enumerate(test_cases):
submission = self.submit_code(source_code, language_id, case["stdin"], case["expected_output"])
if "error" in submission:
results["all_passed"] = False
results["test_case_results"].append({"case": i+1, "status": "Submission Error", "details": submission["error"]})
continue
token = submission["token"]
# 轮询获取结果
while True:
result = self.get_submission_result(token)
if "error" in result:
results["all_passed"] = False
results["test_case_results"].append({"case": i+1, "status": "API Error", "details": result["error"]})
break
status_id = result.get("status", {}).get("id")
# 状态 1 (In Queue) 和 2 (Processing) 表示还在处理中
if status_id not in [1, 2]:
# 编译错误
if status_id == 6:
results["compile_error"] = result.get("compile_output") or "Unknown compile error"
results["all_passed"] = False
# 答案错误
if status_id != 3: # 3 表示 "Accepted"
results["all_passed"] = False
results["test_case_results"].append({
"case": i+1,
"status_id": status_id,
"status_description": result.get("status", {}).get("description"),
"stdout": result.get("stdout"),
"stderr": result.get("stderr"),
"time": result.get("time"),
"memory": result.get("memory"),
})
break
time.sleep(0.5) # 等待 0.5 秒后再次查询
# 如果出现编译错误,后续的测试用例就没必要跑了
if results["compile_error"]:
break
return results
# --- 如何使用 ---
if __name__ == '__main__':
# ... (之前的测试代码) ...
code_grader = CodeGrader()
# 案例:一个正确的 Python 代码,解决 A+B 问题
python_code = """
a, b = map(int, input().split())
print(a + b)
"""
test_cases_A = [
{"stdin": "1 2", "expected_output": "3\n"},
{"stdin": "100 200", "expected_output": "300\n"}
]
# Judge0 中 Python 3 的 ID 是 71
grading_result = code_grader.grade_code_answer(python_code, 71, test_cases_A)
print("\n--- 编程题批改结果 ---")
import json
print(json.dumps(grading_result, indent=2, ensure_ascii=False))
代码解释:
- 异步逻辑:
submit_code函数通过设置wait=false参数,让 Judge0 API 立即返回一个token,而不是等待评测完成。 - 轮询:
grade_code_answer方法内部有一个while True循环,它会不断地调用get_submission_result来查询状态,直到状态变为“已完成”(即status_id不再是 1 或 2)。 - 状态 ID:Judge0 用数字 ID 来表示不同的评测状态,例如:
3: Accepted (答案正确)4: Wrong Answer (答案错误)5: Time Limit Exceeded (运行超时)6: Compilation Error (编译错误)- 还有其他状态表示运行时错误、内存超限等。
- 测试用例驱动:
grade_code_answer接受一个test_cases列表,它会遍历这个列表,为每一个测试用例都进行一次独立的“提交-评测”流程。只要有一个测试用例失败,all_passed标志就会被设为False。
编程题的灵魂:测试用例设计规范
工具只是手段,真正决定评测系统效果的,是高质量的测试用例。在我们的教师端后台,应该为老师提供清晰的测试用例设计指导。
一个好的测试用例应遵循以下原则:
- 准确性:
stdin和expected_output必须完全匹配。尤其要注意expected_output,它包含所有的换行符(\n)、空格等。程序输出必须与它一字不差。 - 覆盖度:
- 常规用例:覆盖题目最基本、最常见的情况。
- 边界用例:测试数据规模的临界值,例如数组为空、数值为0或最大/最小值等。
- 异常用例:测试一些可能的非法输入,考验程序的健壮性(虽然在竞赛型题目中较少)。
- 独立性:每个测试用例之间不应有依赖关系。
示例:A+B 问题的测试用例设计
| stdin | expected_output | 考察点 |
|---|---|---|
1 2 | 3\n | 常规正整数 |
0 0 | 0\n | 边界值:0 |
-10 5 | -5\n | 负数输入 |
1000000000 2000000000 | 3000000000\n | 大数,是否需要用64位整型 |
通过这套组合拳,我们便拥有了一个强大且安全的编程题自动评测引擎。至此,我们系统最核心的三大批改功能已经全部设计和实现完毕。下一章,我们将探讨如何让系统变得更“智能”——引入大语言模型并对其进行微调,以处理更复杂的主观题。
进阶篇(一):赋予 AI “灵魂”——大模型微调与主观题批改
目前为止,我们处理的都是有明确“标准答案”的客观题。但教育场景中充满了开放性、主观性更强的问题,比如“请分析这首诗的意境”、“请论述市场经济的优缺点”等。这类问题的答案没有固定的模式,批改时更侧重于考察学生的逻辑、观点和论述的充分性。
这是通用模型(如BERT)的局限所在,也是大语言模型(LLM)大放异彩的舞台。然而,即便是像 GPT-4 这样强大的通用 LLM,如果不对其进行“教导”,它也很难完全理解我们特定课程、特定题目的评分标准。
为什么需要微调(Fine-tuning)?
想象一下,你招聘了一位非常聪明的大学毕业生(预训练 LLM),他知识渊博,但对你公司的具体业务流程一无所知。你需要用公司的内部文件和案例对他进行培训(微调),然后他才能成为一名合格的员工。
微调就是这样一个过程:我们使用自己准备的、高质量的、领域相关的“标注数据”,在一个强大的预训练模型的基础上继续进行训练。这能让模型“学习”到我们这个特定任务的模式、口吻和评价标准,从而表现得像一个该领域的专家。
对于我们的项目,微调的目标就是让 Qwen-7B 模型学会:在给定“问题”、“参考答案”和“学生答案”后,能像一位经验丰富的老师一样,给出“分数”和“评语”。
微调实战:驯化你的 Qwen-7B
微调是一个资源密集型的任务,通常需要带 GPU 的服务器环境。本教程将重点讲解其核心思想和流程,并提供伪代码,让你理解其原理,而非一步步地进行实际操作。
第一步:数据是王道——准备高质量标注数据
微调的效果,百分之八十取决于数据的质量。我们需要创建一个包含大量样本的数据集,每个样本都包含“输入”和“期望的输出”。对于我们的任务,数据格式可以设计如下(通常保存为 JSONL 文件):
{"prompt": "问题:请简述什么是“光合作用”?\n参考答案:光合作用是植物、藻类和某些细菌利用光能,将二氧化碳和水转化为富能有机物,并释放出氧气的过程。\n学生答案:植物通过太阳光,把二氧化碳变成氧气。", "response": "得分:6/10\n评语:回答基本正确,提到了核心要素“太阳光”、“二氧化碳”和“氧气”。但未能准确描述其化学本质(将无机物转化为有机物),也未提及反应的另一重要原料“水”。知识点掌握不够全面。"}
{"prompt": "问题:请简述什么是“光合作用”?\n参考答案:光合作用是植物、藻类和某些细菌利用光能,将二氧化碳和水转化为富能有机物,并释放出氧气的过程。\n学生答案:光合作用就是植物晒太阳。", "response": "得分:2/10\n评语:回答过于口语化且不准确。虽然提到了“晒太阳”,但完全没有涉及光合作用的原料、产物和能量转化过程。建议复习相关章节。"}
... (此处省略 5 万条类似数据)
为什么是 5 万条? 这是一个经验数值。对于一个严肃的微调任务,上万条高质量的数据是保证模型泛化能力的起点。
第二步:技术选型——Hugging Face 生态
Hugging Face 已经成为 AI 自然语言处理领域的“GitHub”,它提供了一整套完善的工具链,让模型微调变得标准化。
transformers: 核心库,用于加载模型和分词器。datasets: 用于高效地加载和处理大规模数据集。peft: 参数高效性微调(Parameter-Efficient Fine-Tuning)库,让我们可以在消费级 GPU 上微调大模型。它通过只训练模型的一小部分(例如 LoRA 层),来大幅降低显存和计算需求。accelerate: 简化多 GPU 或 TPU 训练的配置。
第三步:微调流程与伪代码
下面是一个使用 Hugging Face SFTTrainer (Supervised Fine-tuning Trainer) 进行微调的简化流程和代码示例。
graph TD
A[准备 5 万条 JSONL 格式数据] --> B[上传到服务器或 Hugging Face Hub];
B --> C{编写微调脚本};
subgraph C
C1[加载 Qwen-7B 模型和 Tokenizer];
C2[加载数据集];
C3[配置 PEFT (LoRA)];
C4[设置训练参数 TrainingArguments];
C5[初始化 SFTTrainer];
C6[启动训练 trainer.train()];
end
C --> D{开始模型微调};
D -- 在 GPU 上训练数小时 --> E[生成微调后的模型权重];
E --> F[合并权重并保存];
F --> G[部署模型以供 API 调用];
伪代码示例 (finetune.py):
# 这部分代码仅为演示,实际运行需要配置好环境和数据
import torch
from datasets import load_dataset
from transformers import AutoTokenizer, AutoModelForCausalLM, TrainingArguments
from peft import LoraConfig
from trl import SFTTrainer
# 1. 加载模型和 Tokenizer
model_name = "Qwen/Qwen-7B"
tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModelForCausalLM.from_pretrained(model_name, trust_remote_code=True, torch_dtype=torch.bfloat16)
# 2. 加载数据集
# 假设我们已经将 jsonl 文件上传到了 Hugging Face Hub
dataset = load_dataset("your_username/aigrading_dataset", split="train")
# 3. 配置 PEFT (LoRA)
lora_config = LoraConfig(
r=8,
lora_alpha=32,
lora_dropout=0.1,
target_modules=["c_attn", "c_proj", "w1", "w2"], # 针对 Qwen 模型的特定层
task_type="CAUSAL_LM",
)
# 4. 配置训练参数
training_args = TrainingArguments(
output_dir="./qwen-7b-aigrading",
per_device_train_batch_size=4,
gradient_accumulation_steps=4,
learning_rate=2e-4,
max_steps=1000, # 训练步数,根据数据集大小调整
logging_steps=50,
save_steps=200,
fp16=True, # 开启混合精度训练
)
# 5. 初始化 Trainer
trainer = SFTTrainer(
model=model,
tokenizer=tokenizer,
train_dataset=dataset,
dataset_text_field="prompt", # 指定数据集中哪个字段是输入
# response_field="response" # 在 SFTTrainer 中,通常将 prompt 和 response 拼接在一起
peft_config=lora_config,
args=training_args,
max_seq_length=1024,
)
# 6. 开始训练
print("开始微调...")
trainer.train()
print("微调完成!")
# 7. 保存模型
trainer.save_model("./qwen-7b-aigrading-final")
微调完成后,我们就得到了一个“领域专家”模型。它可以部署为一个独立的 API 服务,等待我们的 Django 后端前来调用。
进阶篇(二):从“判对错”到“教知识”——智能反馈生成
一个优秀的老师,绝不仅仅是给出分数,更重要的是指出学生的问题所在,并引导他们思考。我们的 AI 助教也应如此。
借助微调后的 LLM,我们可以设计更高级的 prompt,让它不仅仅输出分数和评语,而是输出结构化的、更有价值的反馈信息。
设计一个“高级反馈”的 Prompt
我们可以调整微调数据的 response 格式,或者在调用模型时使用更复杂的指令,来引导模型产生我们想要的输出。
Prompt 示例:
你是一位经验丰富的高中生物老师。请根据以下“问题描述”、“参考答案”和“学生答案”,完成三项任务:
1. 对学生的回答进行评分(满分10分)。
2. 指出学生回答中的具体错误或遗漏点。
3. 针对学生的遗漏点,给出“知识点提示”,引导学生自行找出正确答案,而不是直接告诉他答案。
请以严格的 JSON 格式输出,包含 `score`, `error_analysis`, `knowledge_hint` 三个字段。
问题描述:请简述什么是“光合作用”?
参考答案:光合作用是植物、藻类和某些细菌利用光能,将二氧化碳和水转化为富能有机物,并释放出氧气的过程。
学生答案:植物通过太阳光,把二氧化碳变成氧气。
模型期望的输出:
{
"score": 6,
"error_analysis": "学生的回答遗漏了两个关键点:1. 光合作用的另一个重要反应物是“水”;2. 光合作用的本质是将无机物(二氧化碳、水)转化为储存能量的“有机物”,而不仅仅是产生了氧气。",
"knowledge_hint": "思考一下,植物生长除了需要空气和阳光,还需要从根部吸收什么物质?另外,光合作用的产物除了我们呼吸需要的氧气外,更重要的是产生了什么物质来供植物自身生长呢?可以从“能量转化”的角度再思考一下。"
}
这种“知识点提示”式的反馈,远比一个冷冰冰的分数或简单的“回答不完整”要有价值得多。它保护了学生的自尊心,并激发了他们主动探究的欲望,真正实现了 AI 的“辅助教学”价值。
产品化篇:Django 教师端后台搭建
至此,我们已经拥有了强大的“AI 内核”。现在,我们需要为它打造一个“身体”——一个能让老师方便使用的 Web 应用。
后端架构与核心模型
我们将使用 Django REST Framework (DRF) 来构建 API,为未来的前后端分离开发打下基础。
graph TD
subgraph 浏览器/客户端
A[教师操作]
end
subgraph Django 后端
B[URL Dispatcher (urls.py)] --> C{Views (views.py)};
C -- "操作数据" --> D[Models (models.py)];
C -- "序列化" --> E[Serializers (DRF)];
D -- "ORM" --> F[数据库 (SQLite/PostgreSQL)];
C -- "调用批改服务" --> G{AI Services (services.py)};
end
subgraph AI 内核
G --> H[TextGrader];
G --> I[MathGrader];
G --> J[CodeGrader];
G --> K[FineTunedGrader];
end
A --> B;
E --> A;
核心数据模型 (core/models.py)
# core/models.py
from django.db import models
from django.contrib.auth.models import User
class Assignment(models.Model):
title = models.CharField(max_length=200)
description = models.TextField()
teacher = models.ForeignKey(User, on_delete=models.CASCADE)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.title
class Question(models.Model):
QUESTION_TYPES = [
('TEXT', 'Text'),
('MATH', 'Math'),
('CODE', 'Code'),
('SUBJECTIVE', 'Subjective'),
]
assignment = models.ForeignKey(Assignment, related_name='questions', on_delete=models.CASCADE)
question_text = models.TextField()
question_type = models.CharField(max_length=10, choices=QUESTION_TYPES)
# 标准答案,对于不同题型,格式不同。例如编程题是 JSON 格式的测试用例
standard_answer = models.TextField()
def __str__(self):
return self.question_text[:50]
class Submission(models.Model):
question = models.ForeignKey(Question, on_delete=models.CASCADE)
student_name = models.CharField(max_length=100) # 简化处理,不引入完整的学生模型
answer_text = models.TextField(blank=True, null=True) # 用于文本、代码、主观题
answer_image = models.ImageField(upload_to='answers/', blank=True, null=True) # 用于数学题
submitted_at = models.DateTimeField(auto_now_add=True)
class Result(models.Model):
submission = models.OneToOneField(Submission, on_delete=models.CASCADE)
score = models.FloatField()
feedback = models.TextField()
graded_at = models.DateTimeField(auto_now_add=True)
关键业务流程与 API 设计
-
批量上传作业 (
POST /api/assignments/upload/)- 流程:教师将一个班级的作业(例如,一个包含所有学生学号命名文件夹的 zip 包)上传。
- 后端处理:
- 接收并解压 zip 文件。
- 遍历文件夹,为每个学生的每道题创建一个
Submission对象。 - 异步触发一个批改任务(例如,使用 Celery)。
- 任务分发器根据
Question的question_type,调用services.py中对应的批改服务。 - 批改完成后,将结果存入
Result模型。
-
查看批改统计 (
GET /api/assignments/<int:assignment_id>/stats/)- 流程:教师点击一个作业,查看班级整体的批改情况。
- 后端处理:
- 查询与该
Assignment关联的所有Result。 - 计算平均分、最高分、最低分。
- 统计每道题的错误率,找出普遍性的难点。
- 返回结构化的 JSON 数据,供前端进行可视化展示(例如绘制分数分布直方图)。
- 查询与该
总结
恭喜你!从一个想法萌芽,到技术选型,再到逐一攻克文本、数学、编程题的批改,最后到引入大模型微调和搭建产品后台,你已经完整地走完了一个 AI 应用从 0 到 1 的全过程。
我们回顾一下这趟旅程的关键节点:
- 坚实的地基:我们用
venv搭建了独立的 Python 环境,并用Django创建了可扩展的 Web 项目骨架。 - 模块化的 AI 能力:我们将不同题型的批改逻辑封装在
services.py中,实现了高内聚、低耦合的设计。 - 组合创新的力量:我们没有重复造轮子,而是巧妙地组合了
Hugging Face的开源模型、Mathpix和Judge0的专业 API 以及SymPy这样的科学计算库,来解决复杂问题。 - 从“能用”到“好用”:我们不止步于实现功能,还通过微调
Qwen-7B模型,追求生成更人性化、更有教学价值的反馈,真正体现了 AI 的“智能”。 - 产品化闭环:我们设计了
Django后台的核心模型和业务流程,完成了从算法到产品的最后一公里。


200

被折叠的 条评论
为什么被折叠?



