01 · 环境搭建: uv 与项目骨架
一句话总结
这一章从一个空文件夹开始, 装好 Python 和包管理器 uv, 建出项目的目录骨架, 然后写下第一个真正有用的文件 —— 配置模块。做完之后项目还什么功能都没有, 但你会有一个能跑起来、能打印出自己配置的 Python 项目, 以及一份不会把 API Key 泄漏到 GitHub 上的 .gitignore。
本章你能学到什么
- 为什么 2026 年做 Python 项目应该用 uv 而不是 pip + venv
- 虚拟环境到底是什么, 以及"不用虚拟环境会怎样"
- 依赖版本为什么必须钉死, 不钉死会在什么时候咬你
- 配置为什么不能写在代码里, 以及一个只有 70 行的配置模块怎么支撑整个项目
.env和.gitignore: 唯一一件"做错了会上新闻"的事
一、装 Python 和 uv
1.1 先看看你有没有 Python
打开终端 (Mac 上是"终端"或 iTerm, Windows 上用 PowerShell), 输入:
python3 --version
如果显示 Python 3.11.x 或更高, 可以跳到 1.3。显示 3.10 或更低, 或者 command not found, 继续往下。
为什么要 3.11 以上: 这个项目用了两个新语法 —— X | None 这种类型写法 (3.10+) 和 Enum 的一些行为 (3.11 更稳)。用 3.9 跑会直接报语法错误。
1.2 什么是 uv, 为什么用它
是什么。 uv 是一个用 Rust 写的 Python 包管理器和项目管理器, 一个命令行工具同时干四件事: 装 Python 本身、建虚拟环境、装依赖包、跑脚本。
为什么不用传统的 pip。 传统流程是这样的:
python3 -m venv .venv # 建虚拟环境
source .venv/bin/activate # 激活
pip install fastapi # 装包
pip freeze > requirements.txt # 记录版本
四条命令, 三个坑: 忘记 activate 就装到全局去了; pip freeze 会把间接依赖也记进去, 文件变成 80 行没人看得懂; 装 torch 这种大包要等好几分钟。
uv 的对应流程:
uv init --python 3.12 # 建项目, 顺便装好对应版本的 Python
uv add fastapi # 装包, 自动建虚拟环境、自动写进 pyproject.toml、自动生成 lock 文件
uv run python xxx.py # 跑脚本, 不需要 activate
装依赖比 pip 快一个数量级, 因为它用了全局缓存和硬链接 —— 同一个包在十个项目里只在磁盘上存一份。
1.3 装 uv
Mac 和 Linux:
curl -LsSf https://astral.sh/uv/install.sh | sh
Windows (PowerShell):
powershell -c "irm https://astral.sh/uv/install.ps1 | iex"
装完重开一个终端 (让 PATH 生效), 然后:
uv --version
✅ Checkpoint 1: 看到版本号。这是本文档验证时的真实输出:
uv 0.11.32 (3010295ae 2026-07-23 aarch64-apple-darwin)
你的版本号会不一样, 只要能打印出来就行。aarch64-apple-darwin 表示 Apple 芯片的 Mac, Intel Mac 是 x86_64-apple-darwin, Linux 是 x86_64-unknown-linux-gnu —— 这个后缀不影响任何后续步骤。
二、虚拟环境: 为什么非要有这东西
是什么。 虚拟环境就是项目自己的一个私有 Python。它是一个目录 (通常叫 .venv), 里面有一份 Python 解释器的链接和这个项目自己的所有第三方包。
不用会怎样。 假设你有两个项目, A 项目用 pydantic 1.x, B 项目用 pydantic 2.x。两者的 API 完全不兼容。如果都装到全局, 装 B 的时候会把 A 的 pydantic 升级掉, 然后 A 就跑不了了。这不是假设 —— 这是每个 Python 开发者都遇到过的事, 而且报错通常长得完全不像"版本冲突"。
怎么用。 uv 会自动管理它, 你几乎不用手动碰。但要知道它在哪:
.venv/bin/python(Mac/Linux) 或.venv\Scripts\python.exe(Windows) 就是这个项目的 Python- 用
uv run xxx时, uv 自动用的就是这个 Python .venv目录绝对不要提交到 git, 它有几百兆而且换台机器就用不了
相关知识: uv run 和 activate 的区别。 老教程会让你先 source .venv/bin/activate 再跑命令, 这会修改当前终端的 PATH。uv run 不修改任何东西, 只是用正确的 Python 跑一次命令 —— 更干净, 也不会出现"我明明激活了怎么还是用错了 Python"。本文档全程用 uv run。
三、建项目骨架
3.1 初始化
mkdir android_interviewer && cd android_interviewer
uv init --python 3.12 --no-readme
uv init 会生成 pyproject.toml、.python-version 和一个示例 main.py (可以删掉)。
3.2 一次性建好所有目录
mkdir -p app/core app/rag app/agent \
corpus/lifecycle corpus/kotlin corpus/jetpack corpus/memory corpus/service \
eval finetune scripts tests static docker data
Python 要求包目录里有 __init__.py 才认它是包:
touch app/__init__.py app/core/__init__.py app/rag/__init__.py app/agent/__init__.py eval/__init__.py
为什么 eval/ 也要 __init__.py: 因为我们要用 python -m eval.eval_retrieval 这种方式跑评测脚本。-m 要求它是一个包。少了这个文件会报 No module named eval.eval_retrieval, 而报错信息完全不提"你缺一个 __init__.py"。
为什么 corpus/ 和 tests/ 不要 __init__.py: corpus/ 里全是 Markdown 不是代码; tests/ 用 pytest 跑, pytest 有自己的发现机制, 加了反而可能和 conftest.py 的路径处理打架。
3.3 目录职责一览
android_interviewer/
├── app/ 所有生产代码
│ ├── core/ 基础设施: 配置、LLM 客户端、数据库、鉴权
│ ├── rag/ 检索: 加载、切块、索引、检索
│ └── agent/ 面试逻辑: 数据契约、出题、判分、状态机、报告
├── corpus/ 语料 (Markdown), 按知识点分子目录
├── eval/ 评测集与评测脚本
├── finetune/ 微调数据导出
├── scripts/ 一次性脚本 (冒烟测试、造测试数据)
├── tests/ 自动化测试
├── static/ 前端 (就一个 index.html)
├── docker/ 部署相关
└── data/ 运行产物 (向量库、SQLite), 不进 git
分层的一条硬规则: core/ 和 rag/ 不许 import agent/, agent/ 不许 import api.py。 依赖方向永远是从外往里。第 7 章写完的状态机, 第 8 章接 Web 时一行都不用改, 靠的就是这条规则。
四、装依赖, 并把版本钉死
4.1 装
uv add "fastapi==0.141.1" "uvicorn[standard]==0.52.3" "pydantic==2.13.4" \
"python-dotenv==1.2.3" "pyyaml==6.0.3" "openai==3.1.0" \
"chromadb==1.4.1" "sentence-transformers==5.1.2" \
"rank-bm25==0.2.2" "numpy==2.5.2" "httpx==0.28.1"
uv add --dev "pytest==9.1.1" "pytest-cov==7.1.0" "ruff==0.16.3"
这一步会下载 torch (sentence-transformers 的依赖), 有 200 多兆, 慢是正常的。
4.2 为什么用 == 钉死版本
是什么。 fastapi==0.141.1 表示只装这一个版本。对比 fastapi>=0.115 表示"0.115 以上都行"。
为什么钉死。 因为"能跑"是有时效的。真实发生过的事:
chromadb在小版本升级里改过get_or_create_collection的参数pydantic从 1.x 到 2.x 几乎重写了整套 API, 而且 2.x 内部也调整过 validator 的行为
如果你写 >=, 那么: 今天构建的 Docker 镜像能跑, 三个月后同一份代码重新构建就可能起不来 —— 因为依赖悄悄升级了。这种问题最难查, 因为你的 git 记录里一行改动都没有。
代价。 需要定期手动升级 (uv add "fastapi==新版本"), 并且升级时要跑一遍测试。这个代价是值得的: 你控制升级的时机, 而不是让升级在某个周五晚上突然发生。
4.3 lock 文件必须提交
uv add 会生成 uv.lock, 里面记录了所有依赖以及依赖的依赖的精确版本和哈希。
pyproject.toml 里写的是"我要什么", uv.lock 记录的是"实际装了什么"。前者是意图, 后者是事实。两个都必须提交 git。不提交 lock 文件的话, 你钉死了 fastapi==0.141.1, 但 fastapi 依赖的 starlette 版本还是浮动的。
4.4 配置 ruff
ruff 是代码检查工具 (linter), 一秒钟扫完整个项目, 能找出未使用的 import、可能的 bug、不一致的代码风格。把它的规则写进 pyproject.toml:
[tool.ruff]
line-length = 110
target-version = "py312"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "SIM"]
ignore = [
"E501", # 行长交给 formatter, lint 不重复报
"B008", # FastAPI 的 Depends()/Header() 就是写在默认参数里的, 这是框架规定的写法
"UP042", # str+Enum 而不是 StrEnum: Pydantic 序列化行为更明确, 且兼容 3.10
]
[tool.ruff.lint.per-file-ignores]
# eval/ 和 finetune/ 的脚本要先 sys.path.insert 再 import 本项目模块, 顺序不能调
"eval/*.py" = ["E402"]
"finetune/*.py" = ["E402"]
"scripts/*.py" = ["E402"]
"tests/*.py" = ["E402"]
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-q"
六组规则的含义: E 代码风格、F 逻辑错误 (未定义变量、未使用 import)、I import 排序、UP 用新语法替代过时写法、B 常见 bug 模式、SIM 可以简化的写法。
关于 ignore 的重要原则: 每一条 ignore 后面必须写理由。没有理由的 ignore 半年后没人知道能不能删, 于是永远留着, 于是 lint 配置越来越松, 最后形同虚设。上面三条都能说清为什么。
为什么现在就配 lint: 项目只有一个空 app/ 目录时配置 lint 成本最低。等到 3000 行代码时再开 select = [...], 你会一次性收到 200 个错误然后放弃。
五、写第一个文件: 配置模块
5.1 为什么配置不能写在代码里
假设你把模型名字写在 grader.py 里:
resp = client.chat.completions.create(model="deepseek-chat", ...)
三个问题会依次出现:
- 想换模型试试效果, 得改代码 —— 而改代码就要提交、要 review、要重新部署
- 本地想用便宜模型、线上用贵模型, 做不到 (同一份代码不能有两种行为)
- API Key 只能写在代码里, 一提交就泄漏
所以配置必须来自环境变量。 代码只读环境变量, 不关心它是从哪儿来的 —— 本地从 .env 文件来, 线上从容器的环境变量来, CI 里从 workflow 的 env 来。
5.2 app/core/config.py
"""配置: 只从环境变量读, 一个 Settings 实例走全程。"""
from __future__ import annotations
import os
from dataclasses import dataclass, field
try:
from dotenv import load_dotenv
load_dotenv()
except ImportError:
pass
def _int(key: str, default: int) -> int:
try:
return int(os.getenv(key, str(default)))
except ValueError:
return default
def _float(key: str, default: float) -> float:
try:
return float(os.getenv(key, str(default)))
except ValueError:
return default
三个细节值得停一下:
load_dotenv()包在 try 里: 生产容器里不装python-dotenv也能跑 (环境变量由容器注入)。少了这个 try, 缺一个只在开发时用的包会让整个服务起不来。_int/_float吞掉 ValueError: 环境变量都是字符串。有人在.env里写TOP_K=五, 我们希望回退到默认值 5 继续跑, 而不是让服务因为一个配置错误彻底起不来。from __future__ import annotations: 让类型注解变成"字符串", 这样X | None这种写法在旧版本 Python 上也不报错, 而且能引用还没定义的类。这个项目每个文件都有这一行。
接着是配置本体:
@dataclass
class Settings:
# LLM
llm_api_key: str = field(default_factory=lambda: os.getenv("LLM_API_KEY", ""))
llm_base_url: str = field(
default_factory=lambda: os.getenv("LLM_BASE_URL", "https://api.deepseek.com/v1")
)
llm_model: str = field(default_factory=lambda: os.getenv("LLM_MODEL", "deepseek-chat"))
temperature: float = field(default_factory=lambda: _float("LLM_TEMPERATURE", 0.3))
# RAG
corpus_dir: str = field(default_factory=lambda: os.getenv("CORPUS_DIR", "corpus"))
chroma_dir: str = field(default_factory=lambda: os.getenv("CHROMA_DIR", "data/chroma"))
embed_model: str = field(
default_factory=lambda: os.getenv("EMBED_MODEL", "BAAI/bge-small-zh-v1.5")
)
rerank_model: str = field(
default_factory=lambda: os.getenv("RERANK_MODEL", "BAAI/bge-reranker-base")
)
use_rerank: bool = field(
default_factory=lambda: os.getenv("USE_RERANK", "false").lower() == "true"
)
top_k: int = field(default_factory=lambda: _int("TOP_K", 5))
fetch_k: int = field(default_factory=lambda: _int("FETCH_K", 30))
# 面试
db_path: str = field(default_factory=lambda: os.getenv("DB_PATH", "data/app.db"))
max_questions: int = field(default_factory=lambda: _int("MAX_QUESTIONS", 6))
max_followup_depth: int = field(default_factory=lambda: _int("MAX_FOLLOWUP_DEPTH", 2))
# 鉴权
api_keys: str = field(default_factory=lambda: os.getenv("API_KEYS", ""))
daily_limit: int = field(default_factory=lambda: _int("DAILY_LIMIT", 100))
# 离线模式: 规则判分 + Hash 向量, 不调任何外部服务。CI 和无网演示用。
offline: bool = field(
default_factory=lambda: os.getenv("OFFLINE", "false").lower() == "true"
)
@property
def allowed_keys(self) -> set[str]:
return {k.strip() for k in self.api_keys.split(",") if k.strip()}
settings = Settings()
为什么用 field(default_factory=lambda: ...) 而不是直接 = os.getenv(...): dataclass 的默认值在类定义时就求值了。直接写 = os.getenv("LLM_MODEL", "...") 的话, 这个值在 import 这个模块的瞬间被固定, 后面测试里改环境变量就没用了。用 default_factory 推迟到实例化时才读。
use_rerank 为什么要 .lower() == "true": 环境变量 USE_RERANK=false 是字符串 "false", 而 Python 里 bool("false") 是 True —— 非空字符串都是真。直接 bool(os.getenv(...)) 会让"关掉"变成"打开", 这是环境变量最经典的坑。
5.3 每个文件都自带一个自测
在文件末尾加上:
if __name__ == "__main__":
s = settings
print(f"llm_model = {s.llm_model}")
print(f"llm_base_url = {s.llm_base_url}")
print(f"llm_api_key = {'已配置(' + str(len(s.llm_api_key)) + '字符)' if s.llm_api_key else '未配置'}")
print(f"embed_model = {s.embed_model}")
print(f"use_rerank = {s.use_rerank}")
print(f"top_k/fetch_k = {s.top_k}/{s.fetch_k}")
print(f"api_keys = {len(s.allowed_keys)} 个")
这个模式贯穿整个项目: 每个模块末尾都有一个 if __name__ == "__main__":, 单独跑它就能验证这个模块是活的。好处是出问题时能一层层往下查, 而不是只知道"整个服务起不来"。
注意 API Key 只打印长度不打印内容。日志会被收集、被截图、被贴到群里 —— 打印 Key 明文是"迟早出事"的那类习惯。
✅ Checkpoint 2: 跑一下配置模块。
uv run python -m app.core.config
真实输出 (还没建 .env, 所以 Key 未配置):
llm_model = deepseek-chat
llm_base_url = https://api.deepseek.com/v1
llm_api_key = 未配置
embed_model = BAAI/bge-small-zh-v1.5
use_rerank = False
top_k/fetch_k = 5/30
api_keys = 0 个
如果报 No module named app.core.config, 检查两件事: 你在项目根目录 (ls 能看到 pyproject.toml), 以及 app/__init__.py 和 app/core/__init__.py 都存在。
六、.env 与 .gitignore
6.1 .env.example: 给人看的模板
.env 里有真 Key, 不能提交。但新同学 clone 下来得知道有哪些配置项 —— 所以提交一份不含真值的模板:
# 复制成 .env 再改: cp .env.example .env
# .env 已经在 .gitignore 里, 永远不要把真 Key 提交上去。
# ---------- LLM (判分和追问用) ----------
LLM_API_KEY=
LLM_BASE_URL=https://api.deepseek.com/v1
LLM_MODEL=deepseek-chat
# 判分要稳定复现, 温度必须低。不要用默认的 1.0 ——
# 同一份答案两次给出的分数差 3 分, 你就没法做任何对照实验了。
LLM_TEMPERATURE=0.3
# ---------- 离线模式 ----------
# true = 规则判分 + HashEmbedder, 不联网不花钱。CI 和无网演示用。
OFFLINE=false
# ---------- RAG ----------
CORPUS_DIR=corpus
CHROMA_DIR=data/chroma
EMBED_MODEL=BAAI/bge-small-zh-v1.5
RERANK_MODEL=BAAI/bge-reranker-base
# 重排默认关。开之前先跑 eval/eval_retrieval.py --rerank 看看是不是真的变好了。
USE_RERANK=false
TOP_K=5
# 粗召数量。必须显著大于 TOP_K, 否则精排没有挑选空间。
FETCH_K=30
# 向量召回的相似度阈值。低于这个分就当没检索到 —— 宁可交白卷也不要拿无关资料去编。
RETRIEVE_THRESHOLD=0.35
# ---------- 面试 ----------
DB_PATH=data/app.db
MAX_QUESTIONS=6
# 一道题最多追问几层。不封顶的话模型会一直往下钻, 面试变成拷问。
MAX_FOLLOWUP_DEPTH=2
# ---------- 鉴权与限流 ----------
# 逗号分隔的多个 Key。留空 = 接口对所有人开放, 只有本地开发才这样。
# 生成一个随机 Key: python -c "import secrets; print(secrets.token_urlsafe(32))"
API_KEYS=
DAILY_LIMIT=100
# ---------- HuggingFace ----------
# 国内直连 huggingface.co 基本下不动, 走镜像站。
HF_ENDPOINT=https://hf-mirror.com
然后建自己的:
cp .env.example .env
第 7 章之前 .env 里什么都不用填 —— 前面几章全程离线。
6.2 .gitignore: 唯一一件"做错了会上新闻"的事
# ---------- 绝对不能进仓库的 ----------
# 真实 API Key。这一行是这个文件里最重要的一行。
.env
.env.*
!.env.example
# ---------- 数据与模型 ----------
data/
runs/
*.db
*.sqlite3
finetune/data/
finetune/output/
*.gguf
*.safetensors
.cache/
hf_cache/
# ---------- Python ----------
__pycache__/
*.py[cod]
*.egg-info/
build/
dist/
.venv/
venv/
# ---------- 工具缓存 ----------
.pytest_cache/
.ruff_cache/
.mypy_cache/
.coverage
htmlcov/
coverage.xml
# ---------- 编辑器与系统 ----------
.idea/
.vscode/
*.swp
.DS_Store
三个细节:
.env.*后面跟!.env.example: 前一行忽略所有.env.开头的文件, 后一行用!把模板重新放回来。顺序不能反。data/必须忽略: 向量库几十兆, 而且它是运行产物 —— 从语料重新生成即可。提交它会让每次git pull都下载几十兆二进制。uv.lock不在忽略列表里, 这是故意的。它必须提交, 理由见 4.3。
6.3 如果已经把 Key 提交了怎么办
先撤销 Key, 再清历史。 顺序很重要 —— 很多人先花两小时用 git filter-repo 清历史, 期间 Key 还是有效的, 而爬 GitHub 新提交的机器人是分钟级的。
正确做法: 立刻去 API 提供商后台把这个 Key 删掉并生成新的 (30 秒), 然后再考虑历史。因为 Key 一旦推到 GitHub 就必须当作已泄漏, 清历史只是清理, 不是补救。
七、最后确认一遍
uv run ruff check .
✅ Checkpoint 3: 应该看到:
All checks passed!
现在的项目结构:
ls -a
应该有: .env、.env.example、.gitignore、.python-version、.venv、app/、corpus/、data/、docker/、eval/、finetune/、pyproject.toml、scripts/、static/、tests/、uv.lock。
踩坑与专家提示
- 不要用
sudo pip install。 它会把包装到系统 Python 里, 可能破坏操作系统自带的工具 (macOS 和 Ubuntu 都用 Python 写系统脚本)。所有包都应该装在虚拟环境里。 uv run而不是python。 直接敲python -m app.core.config用的是系统 Python, 它没有你装的包。报ModuleNotFoundError: No module named 'pydantic'十次里九次是这个原因。__init__.py忘了加, 报错完全不提它。 报的是No module named app.core, 让人以为是路径问题。建项目时一次性touch齐, 别边写边加。- 环境变量里的布尔值。
os.getenv("USE_RERANK")拿到的是字符串,bool("false") == True。必须显式.lower() == "true"。 .env不要用引号。 写LLM_MODEL="deepseek-chat"有些解析器会把引号也当成值的一部分, 于是你的模型名变成"deepseek-chat"(带引号), 请求直接 400。- 配置项加了就要在
.env.example里补一行。 否则半年后你自己都不知道有哪些开关。这个文件是配置项的唯一文档。 - 不要在
config.py里做任何验证之外的事。 见过有人在配置模块里初始化数据库连接, 结果 import 配置的每一个文件都被迫连库, 单元测试跑不起来。配置模块只做一件事: 读环境变量。
面试视角: 本章高频五问
Q1: uv 和 pip / poetry / conda 的区别?
pip 只管装包, 虚拟环境要自己建。poetry 管项目和依赖但不管 Python 版本本身, 而且解析依赖慢。conda 能管非 Python 依赖 (CUDA、编译器), 适合科学计算, 但环境很重。uv 用 Rust 写, 同时管 Python 版本、虚拟环境、依赖和运行, 速度比 pip 快一个数量级, 是目前新项目的默认选择。选型的实际考虑: 如果项目依赖 CUDA 生态, conda 仍有优势; 其他情况 uv。
Q2: 为什么要钉死依赖版本? 不怕安全补丁装不上吗?
钉死版本换来的是"可复现构建" —— 同一个 commit 在任何时间任何机器上装出完全一样的依赖。安全补丁的问题用另一套机制解决: 定期 (比如每月) 主动升级并跑测试, 或者用 Dependabot 自动提 PR。关键区别是升级发生在你选的时间, 而不是某次构建时突然发生。
Q3: .env 泄漏了怎么办?
先撤销 Key 再清历史, 不能反。Key 推到 GitHub 后必须当作已泄漏 —— 有机器人分钟级扫描新提交。更好的做法是从一开始就不让它有机会泄漏: .gitignore 里第一行就写 .env, 并且 CI 里加一条扫描 (比如 gitleaks) 拦住带密钥的提交。生产环境则完全不用 .env, 用云厂商的密钥管理服务注入。
Q4: 配置为什么要收在一个 Settings 类里, 到处 os.getenv 不行吗?
三个原因。一是可发现性: 想知道这个项目有哪些配置项, 看一个类就行, 不用全局搜 os.getenv。二是类型转换只写一次: TOP_K 是 int、USE_RERANK 是 bool, 转换逻辑集中在一处, 不会有的地方忘记转。三是可测试: 测试里 monkeypatch.setattr(settings, "daily_limit", 2) 一行就能改, 而散落的 os.getenv 要一个个 patch。
Q5: from __future__ import annotations 是干什么的?
让类型注解延迟求值 (存成字符串, 不在定义时执行)。三个好处: X | None 这种语法在 3.9 上也能写; 可以引用后面才定义的类而不用加引号; 减少一点 import 时的开销。Python 3.14 之后这会成为默认行为, 但现在还得手写。这个项目每个文件第一行都是它。
划重点
- uv 一个工具搞定 Python 版本、虚拟环境、依赖、运行。用
uv run不用activate。 - 虚拟环境的意义是隔离: 两个项目要不同版本的同一个包时不至于互相破坏。
- 依赖版本用
==钉死,uv.lock必须提交。"能跑"是有时效的, 钉死是为了让升级发生在你选的时间。 - 配置只从环境变量读, 收在一个
Settings类里。用default_factory推迟求值。 - 环境变量的布尔值必须
.lower() == "true", 因为bool("false")是True。 .gitignore第一行是.env。Key 泄漏了先撤销再清历史。- 每个模块末尾写一个
if __name__ == "__main__":自测。项目大了以后这是最省时间的习惯。 - 项目还是空的时候就配好 ruff。3000 行以后再配你会直接放弃。
下一章预告
有了骨架, 下一章开始填内容 —— 但填的不是代码, 是语料。我们会先定一份 Android 知识点大纲, 然后设计一套带元数据的 Markdown 规范 (知识点、难度、标签), 手写 7 篇语料, 最后写一个 60 行的加载器把它们读进来。这一章决定了整个系统的知识天花板: 语料里没有的东西, 后面再强的检索和模型都变不出来。