我用 AI Agent 造了一套面试学习系统:把「记住」变成可验证的工程问题

开源地址:https://github.com/lskeyi/interview-learning

一、缘起:我为什么不用 Anki,也不用「AI 帮我总结」

准备面试时,市面上的方案基本分两类,两类都有硬伤。

第一类是传统 SRS(Anki / 各种记忆卡片 App)。 它的调度算法是对的——间隔重复确实有效。但它的评分机制是自评:你看完答案,自己点「简单 / 一般 / 忘了」。问题在于,人类对自己是否真的掌握某个知识点,判断能力极差。这在认知科学里叫 流畅性错觉(illusion of fluency):你看到答案的那一瞬间觉得「哦对,我知道」,于是点了「简单」,但让你从零复述一遍,你根本讲不出来。自评分数一旦失真,后面再精妙的调度算法都是在错误的输入上做精确计算。

第二类是现在满地跑的「AI 学习助手」。 你丢一篇材料进去,它给你总结、出题、讲解,聊得非常热闹。但对话窗口一关,什么都不剩。它不知道你三天前哪道题答错了,不知道你对「MVCC」这个概念反复卡在同一个点上,也不知道今天该复习什么。LLM 有生成能力,但没有状态。

于是我想做的事情很明确:

  • 用 LLM 来做它擅长的:从材料里抽知识点、出题、按 rubric 判卷、追问;
  • 用一个普通的数据服务来做它不擅长的:记住你的每一次作答、算下一次复习时间、统计薄弱点。

听起来是「两个东西拼一下」,但真正花时间的不是拼,而是划清边界

二、核心设计:三层职责,一条单向链路

整个系统只有一条调用链,方向永不反转:

1
Hermes(对话层)→ Skill(智能层)→ 持久化 API(状态层)
负责什么 明确不负责什么
Hermes 接用户输入、追问、提醒、断点续接 不判卷、不算调度
Skill 调 LLM 生成卡片、按 rubric 评分、计算下次复习时间、调 API 不直接写数据库
持久化 API 数据校验、SQLite 存储、到期查询、统计、导出、幂等与并发保护 不碰 LLM

最后那一行是整套设计里我最坚持的一条。服务端的 README 里写死了一条禁令:

1
2
3
4
5
本目录中不得添加:
- LLM SDK 或模型供应商依赖
- LLM API Key
- 调用 LLM 的网络请求
- 卡片自动生成、答案自动评分或间隔计算

而且它有一个可被程序验证的哨兵——健康检查接口永远返回:

1
{"status": "ok", "llm": "disabled"}

为什么要这么极端地把 LLM 挡在服务端外面?

第一,换模型不用动数据。今天用这个模型判卷,明天换一个更强的,服务端一行不改,历史数据全部继续有效。判卷策略和存储结构完全解耦。

第二,密钥半径最小。服务端一个 LLM Key 都没有,泄露面天然收窄。

第三,测试能真跑。评分逻辑在 Skill 侧,服务端的所有行为都是确定性的,写单元测试不需要 mock 任何模型。

第四,也是最容易被忽略的一点:它是一条防止架构腐化的护栏。当你允许服务端「顺手调一下 LLM」,半年后那个服务里一定塞满了 prompt 模板、重试逻辑和模型降级策略,最终变成一个谁也不敢改的泥球。写死禁令、并用 llm: disabled 把它变成可断言的事实,比写在文档里祈祷后人自觉要靠得多。

三、把学习方法编码成机制

设计思想再漂亮,落不到机制上就是空话。这套系统里,每一条学习原则都对应一段代码或一条 API 约束。

1. 必须先答,才能看答案

这是对抗流畅性错觉最直接的手段。它在 API 层面被强制:

  • GET /api/v1/items/due 返回到期题目时,故意不返回 reference_answerrubric
  • 参考答案和评分标准在另一个接口 GET /api/v1/items/{id}/grading-context,只在用户答案已提交后才允许调用。

不是「建议先答」,是结构上拿不到答案。这两个字段分居两个接口,是整个数据模型里最有意图的一次拆分。

2. 评分交给 LLM,但必须给出证据

评分结果不是一个分数,而是一组结构化证据:

1
2
3
4
5
ai_grade: forgot | uncertain | mastered   # 三档判定
ai_score: 0-100 # 分数
ai_confidence: 0-1 # AI 自己的置信度
missing_points: [...] # 漏掉的要点
error_tags: [...] # 错误类型标签

注意 user_confidence(用户自评 1-5)依然被记录,但不参与调度。它被单独存下来做校准数据——用来回答一个更有价值的问题:「你觉得自己会」和「你真的会」之间差多少?这个差值本身就是重要的学习信号。传统 SRS 把自评当作输入,这里把自评当作观测对象。

3. 调度策略在 Skill 侧,可插拔替换

调度是一个纯函数,不到 60 行,输入状态和评分,输出下次到期时间:

1
2
3
4
5
6
7
8
9
10
11
12
13
def schedule_after_ai_grade(grade, state, now=None) -> NextSchedule:
if grade == "forgot":
interval, ease, repetitions = 1.0, max(1.3, state.ease_factor - 0.2), 0
elif grade == "uncertain":
interval = max(1.0, state.interval_days * 0.5)
ease, repetitions = max(1.3, state.ease_factor - 0.08), state.repetitions
elif grade == "mastered":
ease = min(3.0, state.ease_factor + 0.1)
if state.repetitions == 0: interval = 1.0
elif state.repetitions == 1: interval = 6.0
else: interval = max(1.0, state.interval_days * ease)
repetitions = state.repetitions + 1
return NextSchedule(due_at=now + timedelta(days=interval), ...)

三档语义很清楚:忘了就归零重来(interval 回到 1 天、ease 惩罚、repetitions 清零);不确定就腰斩(间隔减半但保留 repetitions,承认「学过但不牢」是一个独立状态,而不是简单等同于忘了);掌握了就按 ease 因子指数放大

这是一个 SM-2 变体。关键不在于它是不是最优算法,而在于它是一个可被整体替换的纯函数。想换成 FSRS?改这一个文件,服务端零迁移。因为服务端只负责原子地存下这个决定,从不自己判断答案、也从不自己算间隔。每条复习事件都带着 scheduler_version(默认 skill-sm2-v1),换算法后老数据依然可追溯是哪套策略产生的。

4. 区分知识类型,而不是一律做成填空题

知识不是同质的,所以 knowledge_type 分四类,对应不同的提问方式和 rubric:

类型 含义 该怎么考
fact 事实 直接回忆
concept 概念 要求解释、辨析近似概念
procedure 流程 要求复述步骤和顺序依赖
conditional_judgment 条件判断 给场景,问「什么情况下用哪个」

题型也相应分为 recall / compare / scenario / essay。把「Redis 持久化有哪两种」和「什么场景下该选 AOF 而不是 RDB」用同一套模板去考,是很多学习工具的通病——前者是 fact,后者是 conditional_judgment,需要的认知加工完全不同。

5. 生成与激活分离:解决「周末一时爽,周一崩盘」

这是我自己踩过的坑。周末有空,兴致一来导入五篇长文,生成 200 张卡。结果周一打开一看,200 张全部到期,直接放弃。

所以导入流程被拆成四步,生成不等于激活

1
2
3
4
POST /api/v1/sources                       # 记录来源
POST /api/v1/batches # 创建批次(draft)
POST /api/v1/batches/{id}/items:import # 导入生成内容 → prepared
POST /api/v1/batches/{id}:confirm # 按预算激活 → confirmed

导入后所有卡片状态是 queuedconfirm 时传 activate_count,只有这个数量的卡片按优先级被置为 active 参与到期计算,其余继续排队。允许周末密集生产,但每日新卡摄入量由预算控制。状态机 draft → queued → active → suspended → archived 让这件事变得显式。

6. 幂等 + 乐观锁:对话式系统的必需品

对话场景下,「用户答完题、AI 判完分、正在写库时网络抖了一下」是高频事件。如果处理不好,就是重复记一次复习、或者调度状态被覆盖。所以:

  • 幂等:每条复习事件带唯一 request_id(数据库 UNIQUE 约束)。网络超时后必须用同一个 request_id 重试,服务端返回已有结果并标记 idempotent_replay: true,绝不重复写入。
  • 乐观锁:提交时带 expected_state_version。如果卡片调度状态已被改动,服务端返回 409,Skill 必须重新拉取最新状态,不能拿旧版本硬写。

配合一条铁律:Hermes 只有在写库成功后才推进到下一题。AI 给了分不算复习完成,只有 API 落库成功才算。宁可重问一次,也不能悄悄丢一次记录。

7. 可恢复的会话

对话随时可能中断。learning_sessions 表存 Hermes 续接状态:

1
2
3
4
5
6
7
8
9
@dataclass(frozen=True)
class HermesContinuation:
session_id: int
phase: Literal["asking_question", "waiting_answer",
"evaluating", "saving_event", "completed"]
current_item_id: int | None
item_state_version: int | None
remaining_item_ids: tuple[int, ...]
metadata: dict[str, Any]

每个稳定阶段结束就落一次续接点,第二天说一句「继续」就能接着上次的题走。

四、使用方式:六个斜杠命令

系统以 Skill 形式挂在对话 Agent 上,日常使用就是六个命令。

/config

配置服务地址、用户 ID、可选 API Key、每日新卡预算、单次复习时间预算。配置存在 Skill/Hermes 层,不入库。配完自动打健康检查确认连通,且不回显密钥。

/learn — 导入材料

丢一篇材料进去,Skill 会:

  1. 切分章节、抽取知识组件,并为每个组件保留来源锚点(后面能追溯这个知识点出自原文哪一段);
  2. 生成结构化题目:知识类型、标签、提问、参考答案、rubric、优先级、题型;
  3. 给出预览:覆盖了哪些组件、候选多少题、优先级分布、今天计划激活几题
  4. 等你确认,才真正写库。

刻意不设固定的「每篇材料最多生成 N 题」——大材料按概念粒度分批就好,真正需要限制的是激活量,不是生成量。

/review — 每日复习主循环

1
2
3
4
5
6
7
8
9
1. 创建/恢复 review session
2. 拉取到期题目(不含答案)
3. 提问 → 落续接点 phase="waiting_answer"
4. 收集答案、耗时、是否用了提示、可选自评信心
5. 答案提交后才拉 grading-context
6. LLM 按 rubric 判卷,输出结构化证据
7. Skill 侧算下次调度
8. 用唯一 request_id 写入 review-events
9. 写库成功后才推进下一题

选题不是简单按到期时间排序,而是综合到期时间、优先级、话题多样性、你的可用时间,并刻意避免连续出现高度相似的题——连着五道 Redis 会产生虚假的熟练感。

/essay — 费曼式讲解

选一个 concept / procedure / conditional_judgment 类型的知识点,要求你完整讲一遍,讲完才给标准答案。评分维度是六个:正确性、完整性、因果或流程结构、边界条件、举例质量、追问抵抗力。

最后一项最狠:AI 会针对你讲得含糊的地方追问一两轮。能扛住追问,才算真的懂。

补救措施刻意做得很小——只生成一道针对性题目、一个反例、或一个迁移任务。不是丢给你一堆新卡片。补救的目的是补漏,不是加负担。

/simulate — 模拟面试

先拉到期题目和薄弱点统计,生成一份面试蓝图:话题覆盖、难度分布、回忆/概念/场景题配比、预设追问深度。然后连续提问,中途不给长反馈(除非下一题必须依赖一个纠正提示),全部结束后统一复盘。

因为真实面试里没有人会在每题之后告诉你「你刚才漏了一个点」。这个设计是为了保留压力和连续性。

/status — 学习状态

这个命令的设计原则是反虚荣指标

  • 优先展示:到期负担、活跃/排队数、评分分布、风险最高的知识组件;
  • 更看重:延迟独立回忆表现、迁移题表现、重复错误类型;
  • 刻意不强调:连续打卡天数、卡片总数这类让人自我感觉良好但无信息量的数字;
  • 复习历史不足时,直接说明不确定性,而不是给一个虚假的「掌握度 87%」。

薄弱点统计的 SQL 按 forgot_count DESC, average_score ASC 排序,也就是优先暴露反复忘的知识点,而不是展示你答得最好的部分。

/map — 概念地图

从已存的知识组件和来源锚点重建概念图。但不是画个图给你看——而是做重建任务:隐藏节点让你补、问两个概念之间的关系、要求区分近似概念。看图是被动的,重建是主动的。

五、技术栈与工程细节

刻意选了最轻的栈:

  • Python 3.13 + FastAPI + Uvicorn
  • SQLite(标准库 sqlite3,无 ORM)

核心表:sources / learning_batches / knowledge_components / items / schedule_states / learning_sessions / review_events

几个值得说的细节:

review_events 是追加式的(append-only),不做 UPDATE。每次复习是一个不可变事件,schedule_states 只是这些事件累积出的当前快照。想重算调度、审计历史、或者换算法后回溯,全部依赖这张事件表。这是 event sourcing 的轻量用法。

写操作全部 BEGIN IMMEDIATE 显式事务,异常统一 rollback。一次复习提交要同时插事件、更新调度状态,必须原子。

SQLite 是唯一权威状态源GET /api/v1/export/markdown 导出的 Markdown 只是可读视图。README 里明确写了:不要把 Markdown 的修改反写回状态表。单向导出,避免双写不一致。

测试覆盖真正在意的不变量,而不是刷覆盖率:

  • 健康检查确实是 LLM-free
  • 完整链路:来源 → 批次 → 导入 → 激活 → 到期查询
  • 到期题目不泄露参考答案与 rubric
  • 答案提交后能拿到评分上下文
  • 复习事件写入与幂等重放
  • 不存在的卡片提交复习被拒绝
  • 三种评分对应的调度结果

第三条是核心业务约束的回归测试。如果哪天有人「顺手」在 due 接口里加上 reference_answer 方便调试,测试会立刻挂。

六、几点反思

边界比功能重要。 这个项目代码量不大,但花在「什么不该做」上的时间超过一半。llm: disabled 这个健康检查字段没有任何业务功能,纯粹是一条可被断言的架构约束。我认为它是整个项目最有价值的三行代码。

LLM 应该是能力,不是架构中心。 很多 AI 项目的问题是把模型放在正中间,所有东西都围着它转,结果换模型等于重写系统。这里 LLM 只出现在 Skill 层的两个动作里——生成和评分。它是可替换的零件,不是地基。

认知科学的结论要变成结构约束才有用。 「先回忆再看答案」写在文档里,用不了三天就会为了方便而破功;把答案挪到另一个接口、并加一条测试守着,它才真正成立。同理,「不要用连续打卡糊弄自己」写成原则是鸡汤,写成 ORDER BY forgot_count DESC 才是机制。

给自己造工具的最大好处是诚实。 没有用户增长指标要交,所以可以放心地做一个会让人不舒服的工具——它不夸你、不给你连胜徽章、会反复把你最不想面对的知识点推到面前。这大概才是学习工具该有的样子。


代码已开源(MIT):https://github.com/lskeyi/interview-learning


我用 AI Agent 造了一套面试学习系统:把「记住」变成可验证的工程问题
http://bestcrr.com/2026/07/31/AI学习辅助系统设计/
作者
Newman liu
发布于
2026年7月31日
许可协议