Technical Deep Dive · 技术实现

一句话变成一份卷子,
中间隔着四层漏斗

还隔着一道把不合格的题挡在生产库外的门禁。
上游是把几千份历年真题 PDF 切成逐题可检索、可分类、可质检的结构化题库;下游是把一句自然语言收敛成一组可执行的检索约束。这一页讲这两件事怎么做的,以及踩过的坑。

api.teacheros.cn Express + Prisma + PostgreSQL 16 路由 · 38 model · 65 测试文件 ~70% 对话轮次零 token
编辑组卷页:每题带来源标注与难度标签
Architecture · 系统总览

单体,够用,可替换

MVP 阶段没有拆微服务。选型的判断标准只有一个:一个人能不能可靠地部署和回滚它。

运行时链路

微信小程序(原生,44 页 / 5 tab)
  ↓ HTTPS
Nginx :443 · Let's Encrypt 自动续期
  ↓ 127.0.0.1:3000
PM2 · teacher-os-api
  ↓
Express 4 + TypeScript 5
  ↓ Prisma 6
PostgreSQL 14+ · 每日备份

三层 Provider 抽象

为了让 MVP 能先跑通闭环、再逐个换成真实实现:

AImock / openai-compatible
组卷侧已接 DeepSeek;作业报告类仍 mock
Storagelocal / cos / wechat-cloud
后两者为占位,未接真实 SDK
RepositoryPrismaRepository / MemoryRepository
测试不依赖数据库
0后端业务路由模块
0Prisma model
0数据库迁移
0Vitest 测试文件
0小程序页面
Slot Filling · 解析漏斗

能用规则确定的事,不交给模型

自然语言组卷最容易的做法,是把整句话丢给大模型让它返回 JSON。我没这么做,因为那样每一轮对话都要花钱、都有延迟、都可能不稳定。实际做法是四级漏斗,前面能拦下的绝不往后放。

L1意图词表 —— 考试局 / 科目 / 动作意图的直接命中零 token
L2目录解析 —— 在按科隔离的目录快照上反查单元与知识点零 token
L3正则 —— 时长、题量、分值这类强格式槽位零 token
L4大模型兜底 —— 仅当前三级仍解析不出关键槽位调用 DeepSeek
效果 约 70% 的对话轮次停在 L1–L3,一个 token 都不花。这既是成本判断,也是稳定性判断——规则层的行为是确定的,模型层不是。 产品页上那个可以随便敲的解析器,跑的就是 L1–L3 的简化版。
章节组卷页:考试局、科目、单元三级筛选,单元后标注题量
保留下来的「章节组卷」,本质上是把 L1–L3 的三个槽位做成了手动界面:考试局、科目、单元。自然语言那条路只是把这三步压缩成一句话——底层走的是同一套目录快照。

目录反查为什么要"按科隔离"

知识点反查支持裸知识点——老师只说"积分"、不说单元,系统也要能找到。这就意味着检索必须能在没有 unit 上下文的情况下全局反查。

而一旦允许全局反查,下面这个 P0 就必然会发生。

P0 · 五层根因

一个物理老师,拿到了一份经济题

现象

偶发、无规律,而且测试用例还断言"通过"。前端也看不出异常——未解析项列表是空的,界面显示一切正常。

根因(五层叠加)

  1. 不同科目共用同一套裸 Unit 代码U1/U2/U4
  2. 参数组装时漏传了 subject
  3. 检索层的排序让某一科永远胜出(稳定错,不是随机错)
  4. 未解析项列表为空 → 前端无法察觉
  5. 测试断言只走到"追问澄清"就停了,没走到出题

修复

  • unit 身份按科隔离——限定域跟着标识一起传递
  • 目录快照按科过滤后再进检索
  • 0 题生成护栏:抽不到题就报错,不静默返回空卷
  • 测试补到真正出题那一步
留下的规则 凡是跨维度共用编码的地方,限定域必须跟着标识一起传递。只在检索层过滤是不够的——上游任何一处漏传,下游就会稳定地错。
Difficulty · 难度口径

把"偏难一点"变成一个可复现的数

早期做法:按题号推断

假设"题号越大越难"。问题有两个:这个假设从没被验证过;而且同一句需求两次生成,难度分布会漂。老师问"你凭什么说这题偏难",答不出来。

现在:unit 内确定性分位数

难度定义为该题在同一 unit 内的分位。同一句需求两次生成,难度分布一致;每一档都能解释成"这一档指的是 unit 内第几分位"。

编辑组卷页,题卡右上角显示「难度未标」
这张截图我没有修。每道题右上角写着「难度未标」——口径定好了,但标注是分批回填的,这批 P1 的题还没轮到。功能设计的完成度和数据覆盖的完成度是两件事,混着说就是虚报。

为什么分位数而不是绝对难度

A-Level 各单元的绝对难度本来就不可比——P1 的"难"和 FP3 的"难"不是一回事。老师说"P1 偏难一点",指的是在 P1 的范围内偏难,不是跨单元的绝对难度。分位数正好表达这个语义。

副作用:新灌进来的题必须等该 unit 的分布重算之后才有难度值。这是上面「难度未标」的直接原因,也是这个设计要付的代价。

Data Governance · 质检管线

上游更难的一半:每道题有没有被分对

组卷能不能用,取决于一件枯燥的事——每一道题有没有被正确分类到知识点。几千道题让老师逐题 review 不现实,全交给模型又不可信。

规则官方考纲作为基线约束划出合法的分类空间
教师视角知识点本体 + 教师方法标签标准由人定,模型不参与定义。物理本体已迭代到 v3,码表从 44 个节点收敛到 27 个
模型LLM 逐题分类标注规模化的脏活交给模型
模型双 Agent 独立复核:做题者 / 复核者分离避免同一个 Agent 自证
强模型更强模型抽样校准专门对冲同源偏置
门禁A / B / C 三档分流production · review_queue · quarantine
教师复核分歧 · 按老师标准重建分类最终判断权始终在人手里
发布分层发布:高一致先推生产,有分歧先进内测needs_review 不上生产
人定标准与拍板 模型做标注与复核 规则与门禁

A / B / C 门禁

A

production

高置信、双 Agent 一致 → 直接进生产库,可被检索到。

B

review_queue

有分歧或置信度中等 → 进复核队列,等教师拍板。不上生产。

C

quarantine

低置信或明显异常 → 隔离,连复核队列都不进,等重跑或人工介入。

为什么必须换一个更强的模型来抽验

我一开始用同一个模型既做题又复核,一致率很好看。后来意识到这是同源偏置:同一个模型对自己的判断天然认同,一致率高只说明它前后一致,不说明它对。

解决办法是引入不同代际的更强模型做抽样校准,并把分歧单独归档成文档。这件事有两组各自独立的数据可以说明它的必要性。

成片的错,逐题是看不出来的

物理本体重建后做全量复核:637 道已接受的题,独立复核 Agent 与管线的一致率 85.9%,90 处分歧里裁定管线错分 79 处。关键是这 79 处高度集中在 4 个知识边界上——驻波、emf 与电路、散射与加速器、矢量与 SUVAT。这类成片的系统性错误,逐题抽检是发现不了的,只有做全量对照才会浮出来。

同源偏置的实测代价

后来做化学 / 生物 / 经济 / 心理四科提质时,我把更强模型的校准固化进流程,专门对冲同源偏置。结果它拦下了 20 处复核 Agent 的"过纠"——心理学科的过纠率高达 25.8%,其中一个单元甚至被一边倒地判了 16/16。不换模型,这 20 处会被当成修正直接写进库里。最终 164 处确认纠正回写。

口径声明 · 请连这段一起读 我提到的"一致率"(如某轮 subtopic 重标 FP2 ≈ 98%、FP3 ≈ 81%)是内部指标:分母是该批次待标题目,比较对象是标注模型与独立复核 Agent 的判断不是人工 ground truth。它衡量的是"两个独立判断有多一致",不能直接读成准确率。 口径不清的百分比,在评测语境里等于没有信息——所以我把口径写在数字旁边。
Corpus · 题库

当前入库规模

Edexcel IAL 六科,基本 100% 带原始题图。CIE / AQA 已跑通分类 pilot,产品里明确标注「建设中」。

科目题量状态与备注
Mathematics 数学2,649含 FP1/FP2/FP3 的教师方法标签维度(与 chapter 解耦,仅供自然语言组卷使用)
Chemistry 化学1,056提质后残留污染 0.9%
Physics 物理556本体已迭代至 v3(44 → 27 节点)
Economics 经济353论述题需剥除点线答题格后再切图
Biology 生物317
Psychology 心理308
合计5,239另有选择题专卷与速练模块

为什么要建教师视角的本体

官方章节是考试局视角,按考纲归档。老师按"怎么讲、常用哪个方法"组织。所以除了章节维度,我额外引入了教师方法标签作为第二个组卷维度——与 chapter 解耦,只服务自然语言组卷。因为老师经常不是按章节出卷,而是按方法出卷("考他会不会用换元")。

本体粒度是可以太细的

物理本体 v3 做的主要动作是收敛:44 个节点砍到 27 个。太细的分类,老师同样不会用——他脑子里没有那么多格子。重跑后结构上仅一个单元的 review 率真降,其余持平,说明细分带来的不是精度而是噪声。

Rendering · 出卷链路

从题图到一份能打印的卷子

题库里存的是逐题切出来的图片片段(fragments)。要拼成一份能直接打印的卷子,中间这几步都得自己做。

逐题切图

从原卷 PDF 按题切出图片片段,跨页题需要跨页拼接。经济这类论述题还要先剥掉点线答题格,否则切出来一片格子。

重新编号

原题号(第 3 题 / 第 1 题 / 第 10 题)在新卷里重排为 1–4;选择题与大题分段独立编号;原题号保留在来源行里。

拉平水印

水印在渲染阶段合进页面而不是叠一层,不可被简单去除。

剥离噪声

原卷页脚、草稿区、"rough working" 条带在切图阶段就被滤掉,不带进新卷。

双文件产出

题目卷与答案卷分开渲染成两个 PDF。答案卷带官方评分点(B1 / M1 / A1)。

溯源页脚

U:e3e17e38 · WS:65efa3b3 · B:5c9d8b42 · UTC——使用者、卷子编号、构建号、生成时间。

题目卷 PDF 第一页

题目卷 · Question Paper

2 页 · 4 题 · 27 marks · WMA11/01, WMA11/01A
新窗口
答案卷 PDF 第一页与评分表

答案卷 · Mark Scheme

7 页 · 官方评分点 B1 / M1 / A1
新窗口
PDF 若浏览器不内嵌显示,请点「新窗口」
Engineering · 工程与安全

证明它是真上线的,不是原型

认证与权限

  • 微信 code2Session → JWT
  • 所有涉及用户数据的接口在后端做基于绑定关系的归属校验
  • 分级限流:登录 5/min · 通用 100/min · AI 10/hr · 文件 30/hr
  • Zod 输入校验 · Helmet · CORS · Morgan
  • 管理后台按 openid 门禁,含审计与角色模拟

交付纪律

  • 生产迁移只走 prisma migrate deploy禁用 db push
  • 改动流程固定:本地改 → rsync → 服务器 build → pm2 restart,不在服务器上改代码
  • 65 个 Vitest 文件覆盖 P0 主流程、越权与安全、限流、并发、接口契约、组卷与验收;当前有一批已知 flaky,归因未收敛(见下)
  • 每日数据库备份
上线前的一次安全整改 家长绑定从「持邀请码即绑」改为学生确认制;收紧课程写操作的归属校验;约课要求师生已绑定。 这类改动会让流程变慢,但绑定关系错了,后面所有的报告和学习档案都是错的——错误会一路沉淀下去。
War Stories · 三个真实的坑

现象 → 根因 → 处置 → 留下的规则

跨科串题那个已经在上面讲过了,这里是另外三个。第三个到现在还没修好,我照原样写在这儿。

「412 道重复题」其实一道都没重复

现象:判重脚本报出 412 道重复。
根因:判重用的 full_question_text页级字段——同一页上的多道小题拿到的是同一段文本,实际切图完全正确。差点为此重跑整条管线。

规则:字段的粒度就是它的语义。判重前先确认这个字段描述的是"页"还是"题"。

「请求失败」不是网络问题

现象:真机上一片"请求失败"。
根因:我用管理员的角色模拟功能在测,而模拟身份用的是一个没有实体的 ID,后端归属校验自然全部拒绝。

规则:真机回归必须用真实账号,模拟身份只能用于 UI 预览。表象错误和真实根因经常不在同一层。

测试间歇性变红(尚未收敛

现象:同一批测试有时全绿有时红,受害文件每次还不一样。
我原本的归因:supertest 每个请求起一个临时 server,端口复用产生竞态。我照这个思路做了「全局持久化一个 server」的修复,当时连跑全绿。
后来推翻了:vitest.config.ts 里明写着 fileParallelism: false——测试文件根本不并行,竞态说不通;而且那个修复没合进当前分支。独立复跑 7 轮,仍有 3 轮复现。目前更像是某个文件里 vi.doMock('../config/env.js') 的跨文件泄漏。

规则:一个说得通的根因不等于对的根因,没做证伪就不算定位。另外要把间歇失败和稳定失败分开记——本机没起 Postgres 时 dev-login 必然报错,那条每次都红,跟这个 flaky 是两回事,混在一起会让验收判断彻底失效。

Limits & Next · 边界与下一步

现在还不成立的部分

没有组卷结果的 golden set

题库分类质量有门禁、复核、校准和内部一致率;但"这份卷子是不是老师想要的那份"目前只靠约束满足和教师复核,没有正式评测集。下一步:建一个 20 份规模的教师打分集,把"效果评估"从意识变成实践。

多教师标注一致性没有量化

不同老师对同一道题的分类本来就会有分歧,目前走 review_queue 由教师拍板,但没有算过 IAA / Kappa。

难度标注未覆盖全库

分位数口径定好了,但回填是分批跑的。新灌进来的题必须等该 unit 的分布重算之后才有难度值。

基础设施还很单薄

无 CI/CD;数据库单点;文件存储仍是本地,COS / 微信云存储为占位;视频链路未部署;小程序尚未提交微信审核。