大模型应用实战:教你从0到1打造 “AI 作业批改系统”(支持数学公式 + 编程题批改)

Llama Factory

Llama Factory

模型微调
LLama-Factory

LLaMA Factory 是一个简单易用且高效的大型语言模型(Large Language Model)训练与微调平台。通过 LLaMA Factory,可以在无需编写任何代码的前提下,在本地完成上百种预训练模型的微调

大模型应用实战:教你从0到1打造 “AI 作业批改系统”(支持数学公式 + 编程题批改)

传统的作业批改,无论对于教师还是学生,都是一个充满痛点的环节。教师需要投入大量时间和精力进行重复性劳动,评分标准难免带入主观色彩,反馈也往往不够及时;而学生则焦急地等待着结果,得到的可能只是一个冷冰冰的分数,却不清楚自己究竟错在哪里,对应的知识点又是什么。

这篇教程,正是要带你亲手终结这个痛点。我们将从零开始,一步步构建一个智能的、全能的“AI 作业批改系统”。它不仅仅是一个玩具项目,更是一个能处理文本题、数学公式、甚至编程题的实战级应用。你将像一位真正的 AI 工程师一样,思考需求的边界,选择合适的技术,并用代码将想法变为现实。

读完这篇教程,你将收获的不仅仅是一个能运行的项目,更是一套完整的 AI 应用开发“方法论”。你将掌握:

  • 大模型 API 调用与微调:从直接使用强大的预训练模型,到用自有数据“驯化”模型,让它更懂你的业务。
  • 多模态信息处理:学习如何让程序同时理解文本、图片(数学公式)和代码,这是构建高级 AI 应用的必备技能。
  • 后端服务开发:使用业界流行的 Django 框架,搭建一个稳定、可扩展的教师端后台,完成从算法到产品的闭环。
  • 工程化思维:学习如何拆解复杂问题,设计项目架构,并一步步完成功能模块的开发与集成。

这趟旅程不需要你具备深厚的算法背景,只需要你带着一颗好奇和敢于动手的心。现在,就让我们一起,为这个世界创造一个更智能、更高效的“AI 助教”吧!

万丈高楼平地起:环境准备与项目初始化

在正式敲下第一行代码之前,我们需要先搭建好开发环境,并规划好整个项目的“蓝图”。这个过程就像建造一栋大楼前,必须先打好地基、备好建材一样,至关重要。

技术栈概览

所谓“工欲善其事,必先利其器”。我们这个项目会用到一系列当前 AI 和 Web 开发领域最主流的技术。为了让你有一个宏观的认识,我用一张图来展示它们之间的关系:

第三方与底层技术
AI 核心批改服务
后端服务 (Django)
用户端 (Teacher)
BERT (Hugging Face)
Mathpix API
SymPy
Judge0 API
Qwen-7B (Hugging Face)
Python 3.9
PyTorch
文本题批改
数学公式批改
编程题批改
主观题微调模型
Django Web框架
用户认证 & 作业管理
数据统计与可视化
浏览器

这张图清晰地展示了项目的全貌:教师通过浏览器与 Django 后端交互,后端接收到批改请求后,会分发给不同的 AI 核心服务进行处理,而这些服务底层则依赖于像 BERT、Mathpix、Judge0 这样强大的模型和 API。

Python 环境配置

为什么选择 Python?

在 AI 领域,Python 是当之无愧的“王者语言”。这得益于它简洁的语法、强大的社区支持以及无与伦比的生态系统。几乎所有主流的深度学习框架(如 PyTorch、TensorFlow)和科学计算库(如 NumPy、SciPy)都提供了完善的 Python 接口。选择 Python,意味着我们站在了巨人的肩膀上,可以把精力更多地聚焦于业务逻辑,而非底层的繁琐实现。

创建虚拟环境

在开发项目时,一个最佳实践是为每个项目创建一个独立的 Python 环境。这样做可以避免不同项目之间的依赖冲突。我们将使用 Python 内置的 venv 工具来创建虚拟环境。

  1. 打开你的终端(Terminal)

  2. 创建一个项目目录并进入

    # 创建一个名为 AIGradingSystem 的文件夹
    mkdir AIGradingSystem
    
    # 进入这个文件夹
    cd AIGradingSystem
    
    • 为什么这么做?:将所有项目相关文件都放在一个独立的文件夹里,能让项目结构保持清晰、整洁。
  3. 创建虚拟环境

    # 使用 python3.9 创建一个名为 "venv" 的虚拟环境
    python3.9 -m venv venv
    
    • 命令解释
      • python3.9:明确指定使用 Python 3.9 版本。
      • -m venv-m 参数告诉 Python 以模块(module)的方式运行 venv 工具。
      • venv:这是我们给虚拟环境取的名字,是一个通用的习惯。执行后,你会在当前目录下看到一个名为 venv 的新文件夹。
  4. 激活虚拟环境

    • 在 macOS 或 Linux 上:
      source venv/bin/activate
      
    • 在 Windows 上:
      .\venv\Scripts\activate
      
    • 为什么需要激活?:激活虚拟环境后,你的终端提示符前面会出现 (venv) 的字样。这意味着,后续你所有安装库的命令(如 pip install)和执行 Python 脚本的命令,都将在这个独立的环境中进行,不会影响到系统的全局 Python 环境。

安装核心依赖

环境准备好后,我们来安装项目所需的核心库。我们会将所有依赖项记录在一个 requirements.txt 文件中,方便管理和分享。

  1. 创建 requirements.txt 文件

    touch requirements.txt
    
  2. 将以下内容写入 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 # 环境变量管理
    
    • 为什么使用 == 指定版本?:这可以确保协作开发者或者未来的你,在部署项目时使用的库版本是完全一致的,避免了因版本不兼容导致的问题。
  3. 使用 pip 安装所有依赖

    pip install -r requirements.txt
    
    • 命令解释-r 参数告诉 pip 从指定的文件中读取并安装所有列出的库。

项目结构创建

依赖安装完毕,我们开始用 Django 来搭建项目的骨架。

  1. 创建 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/
    
  2. 创建我们的核心应用(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/
    ...
    
  3. 注册应用

    创建了应用之后,我们必须告诉 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)则是这个思想的延伸,它将一整个句子映射成一个固定维度的向量,这个向量就代表了整个句子的“语义”。如果两个句子的意思相近,那么它们的句向量在空间中的距离也会非常接近。

我们如何利用它?

批改文本题的逻辑就变得异常简单了:

  1. 编码:分别将“标准答案”和“学生答案”输入到 BERT 模型中,得到它们各自的句向量。
  2. 计算:计算这两个向量之间的“余弦相似度”。
  3. 判断:设定一个相似度阈值(比如 0.8),如果计算出的相似度高于这个阈值,我们就认为学生回答正确。
大于阈值
小于等于阈值
学生答案文本
BERT 模型
标准答案文本
学生答案句向量
标准答案句向量
计算余弦相似度
相似度得分
与阈值比较
判定为正确
判定为错误

代码实战:构建文本批改服务

现在,让我们在 core 应用中创建一个新的文件 services.py,专门用来存放我们的 AI 批改逻辑。

  1. 创建 core/services.py 文件

  2. 编写文本相似度计算服务

    # 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 助教皇冠上的明珠,它同时考验了系统的“视觉识别”和“逻辑推理”能力。学生提交的数学作业往往是手写的图片,我们需要先将图片中的公式“翻译”成计算机能懂的语言,然后再去判断这个公式是否正确。

我们将这个过程拆解为两步:

  1. 公式识别:借助第三方的 OCR(光学字符识别)服务——Mathpix API,将公式图片转换为 LaTeX 字符串。
  2. 逻辑验证:使用 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,我们只需要将图片数据发送过去,就能收到识别结果。

准备工作

  1. 前往 Mathpix 官网 注册一个账号。

  2. 在你的账户后台,找到 API Keys,获取你的 App IDApp Key

  3. 为了安全地管理这些敏感信息,我们在项目根目录下创建一个 .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 / 2ax = \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 的核心函数之一。它会运用一系列代数规则,尝试将一个复杂的表达式化简到其“最简形式”。
  • 核心判断逻辑:我们利用了一个非常巧妙的数学原理:如果两个表达式 AB 是等价的,那么 simplify(A - B) 的结果必然是 0。这比直接比较 simplify(A) == simplify(B) 要更可靠,因为“最简形式”有时不是唯一的。对于方程式 LHS1 = RHS1LHS2 = 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

准备工作

  1. 你可以使用 Judge0 的免费 RapidAPI 接口进行快速测试,也可以按照其官方文档自行部署一个 Judge0 实例。

  2. 我们同样将 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

编程题的灵魂:测试用例设计规范

工具只是手段,真正决定评测系统效果的,是高质量的测试用例。在我们的教师端后台,应该为老师提供清晰的测试用例设计指导。

一个好的测试用例应遵循以下原则

  1. 准确性stdinexpected_output 必须完全匹配。尤其要注意 expected_output,它包含所有的换行符(\n)、空格等。程序输出必须与它一字不差。
  2. 覆盖度
    • 常规用例:覆盖题目最基本、最常见的情况。
    • 边界用例:测试数据规模的临界值,例如数组为空、数值为0或最大/最小值等。
    • 异常用例:测试一些可能的非法输入,考验程序的健壮性(虽然在竞赛型题目中较少)。
  3. 独立性:每个测试用例之间不应有依赖关系。

示例:A+B 问题的测试用例设计

stdinexpected_output考察点
1 23\n常规正整数
0 00\n边界值:0
-10 5-5\n负数输入
1000000000 20000000003000000000\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 设计

  1. 批量上传作业 (POST /api/assignments/upload/)

    • 流程:教师将一个班级的作业(例如,一个包含所有学生学号命名文件夹的 zip 包)上传。
    • 后端处理
      1. 接收并解压 zip 文件。
      2. 遍历文件夹,为每个学生的每道题创建一个 Submission 对象。
      3. 异步触发一个批改任务(例如,使用 Celery)。
      4. 任务分发器根据 Questionquestion_type,调用 services.py 中对应的批改服务。
      5. 批改完成后,将结果存入 Result 模型。
  2. 查看批改统计 (GET /api/assignments/<int:assignment_id>/stats/)

    • 流程:教师点击一个作业,查看班级整体的批改情况。
    • 后端处理
      1. 查询与该 Assignment 关联的所有 Result
      2. 计算平均分、最高分、最低分。
      3. 统计每道题的错误率,找出普遍性的难点。
      4. 返回结构化的 JSON 数据,供前端进行可视化展示(例如绘制分数分布直方图)。

总结

恭喜你!从一个想法萌芽,到技术选型,再到逐一攻克文本、数学、编程题的批改,最后到引入大模型微调和搭建产品后台,你已经完整地走完了一个 AI 应用从 0 到 1 的全过程。

我们回顾一下这趟旅程的关键节点

  • 坚实的地基:我们用 venv 搭建了独立的 Python 环境,并用 Django 创建了可扩展的 Web 项目骨架。
  • 模块化的 AI 能力:我们将不同题型的批改逻辑封装在 services.py 中,实现了高内聚、低耦合的设计。
  • 组合创新的力量:我们没有重复造轮子,而是巧妙地组合了 Hugging Face 的开源模型、MathpixJudge0 的专业 API 以及 SymPy 这样的科学计算库,来解决复杂问题。
  • 从“能用”到“好用”:我们不止步于实现功能,还通过微调 Qwen-7B 模型,追求生成更人性化、更有教学价值的反馈,真正体现了 AI 的“智能”。
  • 产品化闭环:我们设计了 Django 后台的核心模型和业务流程,完成了从算法到产品的最后一公里。

您可能感兴趣的与本文相关的镜像

Llama Factory

Llama Factory

模型微调
LLama-Factory

LLaMA Factory 是一个简单易用且高效的大型语言模型(Large Language Model)训练与微调平台。通过 LLaMA Factory,可以在无需编写任何代码的前提下,在本地完成上百种预训练模型的微调

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

THMAIL

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值