开源项目将 RAG 引入教学场景,AI 仅基于教师个人课程笔记作答,无法胡编超出范围的答案,同时自动汇总全班共性困惑点。
用通用的 AI 聊天机器人问一道课程相关的问题,它会自信满满地给出答案,用它自己的符号体系、自己的惯例,偶尔还用它自己的事实。它不知道你对某个术语的定义和教材略有不同,不知道某个证明技巧还没讲到,也不知道它刚刚给出的那个"显然"的捷径是你的学生们在考试中不能用的。但它依然会回答你。这就是问题所在。
我教本科数学,每个学期都会重复同样的模式:少数学生在办公时间提出尖锐而具体的问题,而更多的学生则在深夜向 ChatGPT 提问——那时我不在场,无法在他们被 AI 礼貌地误导的那一刻及时介入。于是我构建了一个系统,它只从我自己课程的笔记中作答,在不知道答案时拒绝猜测,而且作为一个附带效果,它让我精确地看到全班学生真正在哪些地方卡住了。这个系统叫做 Classroom AI System,代码是公开的:github.com/Sohaib-Hasan/classroom_ai_system。
让它与众不同的技术名称叫检索增强生成(retrieval-augmented generation),简称 RAG。系统不是让语言模型从它训练过的所有内容中作答,而是首先从我自己用 LaTeX 编写的课程笔记中检索出与学生问题最相关的精确段落,然后指示模型严格依据那个段落作答,不参考其他任何内容。目前知识库覆盖微积分、离散数学和数论,按章节从我在课堂上分发的同一套笔记中切分而成。
这个系统由两个独立的应用共享同一个大脑:
一个学生助手,用 PIN 访问,学生输入问题并从真实笔记中得到有据可查的回答。
一个教师仪表盘,用密码访问,我可以在上面看到全班的提问情况:哪些话题反复出现、哪些问题已经被缓存处理了、同样的困惑在不同学生之间如何传播。
从".tex 笔记"到"可信的答案"整个过程分为两个阶段。第一个阶段——索引构建——在我的笔记发生变动时离线执行,一次完成:

python3 chunk_notes.py # .tex notes -> raw chunks (one per definition/example/theorem/proof)
python3 clean_chunks.py # strips decorative LaTeX, keeps the real math
python3 embed_chunks.py # embeds every cleaned chunk into knowledge_base.json
第二个阶段在每次学生提问时实时发生:问题被嵌入向量并与知识库做余弦相似度比较,最匹配的那些片段被提取出来,只有这些片段连同问题本身一起被发送给 Gemini 生成回答。
中间那一步 clean_chunks.py 听起来是整个流水线中最不起眼的部分。但它才是最重要的。
chunk_notes.py 从原始 .tex 源码中直接提取每个定义、例子或定理框,连同所有装饰性标记一起:颜色命令、表格、图表代码、间距宏命令。那些原始文本会成为模型每次看到问题时的提示词的一部分。如果跳过清理步骤,模型偶尔会把这些原始标记片段直接回显给学生,结果学生看到的不是干净清晰的解释,而是破碎的 LaTeX 代码。clean_chunks.py 剥离了所有装饰性内容,同时完整保留真正的 $...$ 数学表达式,所以模型看到的始终只是内容本身,而不是围绕在周围的格式噪音。这是一个小型且不起眼的脚本,但整个系统的可信度全仰赖它在每次嵌入之前正确运行。
整个系统完全运行在 Gemini 的免费层上,这意味着每个问题都要消耗配额,而配额才是真正的扩展上限。几个设计决策防止这成为一堵墙:
多 Key 轮换。Gemini 的速率限制是按 Google Cloud 项目计算的,而不是按 Key,因此来自不同账号的 Key 拥有独立的配额池。系统支持最多三个 Key,只有在调用失败时才无缝切换到下一个 Key,对提问的学生完全不可见。
缓存。SQLite 支持的缓存意味着已经有人问过的问题的改述版本可以完全跳过生成调用——这在课堂上非常重要,因为数十名学生往往会在少数几个令人困惑的知识点上反复提问。
本地嵌入。每个问题都需要一次嵌入调用,即便在缓存命中的情况下也是如此,这使得嵌入成为整个系统最大的配额消耗源。将嵌入提供商换成免费的本地模型可以彻底消除这笔开销,代价是知识库需要重建一次,因为 Gemini 和本地嵌入彼此不兼容。
按会话的速率限制。每个浏览器会话每滚动 60 秒最多 8 个问题,主要作为防止意外双击的安全网,而不是对正常使用施加真正的限制。
学生助手和教师仪表盘作为两个独立的 Streamlit Cloud 应用部署在两个不同的 URL 上,各自运行在独立的容器中。最后这一点成了整个项目中最棘手的基础设施问题:一个容器写入的本地 SQLite 文件对另一个容器来说根本不存在。

解决方案是 Turso,一个免费的、托管式的、兼容 SQLite 的数据库,两个应用都指向它而不是各自的本地文件。这套方案中有两个细节花费了大量调试时间,值得记录下来:连接字符串必须使用 https:// 而不是 libsql://,因为 libsql:// 依赖的 WebSocket 握手在 Streamlit Cloud 的沙盒环境中可能会失败;密钥必须恰好命名为 TURSO_AUTH_TOKEN。这两个地方出任何一点差错都不会导致崩溃、不会报错,只会让仪表盘静静地保持空白,没人知道为什么。
这是我作为教师最在乎的部分,也是通用 AI 聊天机器人永远给不了我的部分。办公时间只能让我看到那些愿意现身的学生当场提出的问题。仪表盘则向我展示全班学生实际存在的每一个问题:哪个话题不断卷土重来、哪种困惑在不同学生之间反复出现、缓存已经吸收了多少而又有多少是真正的新问题。这是从少量轶事到真实信号的差别。
业务逻辑位于 core.py 中,它刻意不依赖 Streamlit,因此可以完全独立于应用本身进行测试。目前有 91 个自动化测试支撑它,每一个测试的存在都是因为发现并修复了一个具体的 bug,而不是为了达到某种抽象的覆盖率指标。我特别偏爱的一个例子:系统的 verify_computation() 检查对函数的负值域、正值域和近零域都进行采样,而不只是正值域——原因专门在于,只检查正值会漏掉对 domain 敏感的误差,比如把 sqrt(x**2) 当作等于 x,而这只有在 x 非负时才成立。这类 bug 在快速演示中看起来没问题,却会在六周后悄悄地给一个学生输出错误的数学结果。
几个诚实的局限,因为一个为学生证明打分值的系统,理应对其构建者应用同样的标准:同类嵌套的 LaTeX 框(比如一个定义框嵌套在另一个定义框内)目前还没有被干净地解析,不过有一个安全检查会在发生的瞬间大声标记出来,而不会悄无声息地放行到学生面前。自动化清理处理了大部分装饰性 LaTeX,但不是全部:大约 3% 的片段(主要是位于真实数学内容内的颜色命令)仍然需要人工检查。还有一个可选的第三方备用提供商,当所有 Gemini Key 都耗尽时启用——这个功能我已经构建好了,但还没有在生产环境中做过压力测试,所以目前它是一张我不愿依赖的安全网。
在此基础上,我下一步要构建的是一个引导式答题模式:不再直接跳到完整解答,而是先给出一个提示,然后是一个引导性问题,只有在学生确实需要时才给出完整答案。目标从来不是更快地分发现成的证明,而是让一个学生从卡住的状态中解脱出来,而不是替他们思考。
这才是这个项目真正的出发点,也是我想在教学中进一步探索的方向:AI 之所以有用,恰恰是因为它受到了约束,而不是尽管受了约束却依然有用。
我是所在学院的数学系系主任,同时和我的 Manim 动画工作一起作为 PlotLab 构建这类工具。如果你也在教书,对这类工具是否适合自己的课程感到好奇,或者需要帮助来构建它,欢迎联系我。我会在 sohaib-hasan.github.io 写更多类似的东西,如果你想看我在这个项目之外构建的那种动画解释,可以在 Facebook(关注者 35,000+)和 Instagram(关注者 15,000+)上看到。期待你的想法,欢迎留言。