前面的系列已经覆盖了 API 调用、提示词、结构化输出、错误处理和评估。本篇在路线完成后做一个小型综合项目:读取一段技术笔记,请模型生成问答卡片,程序严格校验后保存为 JSON。它不负责替你判断知识是否正确,也不会把模型输出直接当成学习结论;重点是把一次生成请求放进可测试、可复用的程序流程。

先确定输入和输出

程序只处理一份纯文本笔记,输出若干张卡片。每张卡片包含 question、answer 和 difficulty 三个字段,其中难度只能是 easy、normal 或 hard。问题应能根据原文回答,答案不能凭空补充事实;无法从原文得到可靠答案时,模型应减少卡片,而不是猜测。

数据流分成五步:读取文件、请求模型、解析 JSON、校验字段、保存结果。模型只负责提出候选卡片,文件读写和格式边界由 Python 控制。这样,后续更换模型或提示词时,不会把不合格结果静默写入资料库。

准备环境和配置

在独立虚拟环境中安装 SDK:

1
2
3
python -m venv .venv
source .venv/bin/activate
python -m pip install openai python-dotenv

创建 .env,真实密钥只通过环境变量提供,下面的内容只是占位符:

1
2
3
OPENAI_API_KEY=替换为你的真实密钥
MODEL_NAME=替换为你可用的模型名称
OPENAI_BASE_URL=

不要把 .env 提交到 Git。若使用兼容服务,只有在服务商文档确认支持相同接口时,才配置 OPENAI_BASE_URL。

编写最小程序

新建 cards.py:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
import json
import os
import sys
from pathlib import Path

from dotenv import load_dotenv
from openai import OpenAI

load_dotenv()
MODEL = os.environ["MODEL_NAME"]
client = OpenAI(
api_key=os.environ.get("OPENAI_API_KEY"),
base_url=os.environ.get("OPENAI_BASE_URL") or None,
)

INSTRUCTIONS = """
你是技术学习卡片编辑。根据用户提供的笔记生成 3 到 8 张问答卡片。
只返回 JSON 数组,不要 Markdown 围栏或解释。每项只能有 question、answer、difficulty。
question 和 answer 必须是非空字符串,difficulty 只能是 easy、normal、hard。
只使用原文明确提供的信息;原文无法支持的问题不要生成。
""".strip()


def validate(raw: str) -> list[dict]:
try:
cards = json.loads(raw)
except json.JSONDecodeError as exc:
raise ValueError("模型返回的不是合法 JSON") from exc
if not isinstance(cards, list) or not 3 <= len(cards) <= 8:
raise ValueError("卡片数量必须在 3 到 8 张之间")

checked = []
for card in cards:
if not isinstance(card, dict):
raise ValueError("卡片必须是对象")
if set(card) != {"question", "answer", "difficulty"}:
raise ValueError("卡片字段不完整或包含未知字段")
if card["difficulty"] not in {"easy", "normal", "hard"}:
raise ValueError("difficulty 无效")
for key in ("question", "answer"):
if not isinstance(card[key], str) or not card[key].strip():
raise ValueError(f"{key} 必须是非空字符串")
checked.append(card)
return checked


def generate(note: str) -> list[dict]:
note = note.strip()
if not note:
raise ValueError("笔记不能为空")
if len(note) > 12000:
raise ValueError("示例只接受不超过 12000 个字符的笔记")
response = client.responses.create(
model=MODEL,
instructions=INSTRUCTIONS,
input=note,
)
return validate(response.output_text)


def main() -> None:
if len(sys.argv) != 2:
raise SystemExit("用法:python cards.py note.txt")
note = Path(sys.argv[1]).read_text(encoding="utf-8")
cards = generate(note)
Path("study-cards.json").write_text(
json.dumps(cards, ensure_ascii=False, indent=2), encoding="utf-8"
)
print(f"已生成 {len(cards)} 张卡片:study-cards.json")


if __name__ == "__main__":
main()

准备一份 note.txt 后运行:

1
python cards.py note.txt

responses.create 负责一次模型请求,instructions 放稳定规则,input 放本次笔记,output_text 取出文本结果。validate 不依赖网络,可以单独测试。只有校验通过,main 才会写文件;因此不能把某次模型的具体回复当成固定运行结果,真正稳定的是程序对输入和输出边界的处理。

增加离线测试

模型调用昂贵且结果不固定,先测试纯校验函数。创建 test_cards.py:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import json
import unittest

from cards import validate


class CardValidationTest(unittest.TestCase):
def test_valid_cards_are_accepted(self):
raw = json.dumps([
{"question": "什么是变量?", "answer": "用于保存值的名称。", "difficulty": "easy"},
{"question": "为什么校验输出?", "answer": "模型输出不是类型系统。", "difficulty": "normal"},
{"question": "怎样限制输入?", "answer": "在请求前检查长度。", "difficulty": "hard"},
])
self.assertEqual(len(validate(raw)), 3)

def test_unknown_field_is_rejected(self):
cards = [{"question": "q", "answer": "a", "difficulty": "easy", "source": "x"}] * 3
with self.assertRaises(ValueError):
validate(json.dumps(cards))


if __name__ == "__main__":
unittest.main()

运行 python -m unittest -v test_cards.py。这个测试不会调用模型,能稳定验证字段集合和合法数据路径。还可以补充非法 JSON、空答案、错误难度和超过数量上限的案例。测试的目标不是证明模型一定生成好卡片,而是保证坏结果不会直接落盘。

常见问题

为什么不让模型直接写 Markdown? Markdown 适合阅读,但字段结构不稳定。先保存 JSON,后续可以由确定性的 Python 代码渲染成 Markdown、网页或其他格式。

卡片内容错误怎么办? 程序只能校验类型和枚举值,不能证明答案符合事实。应保留原笔记、抽样人工复核,并把发现的错误样例加入评估集。

笔记很长怎么办? 不要无限增大单次请求。先按标题或段落分块,再分别生成卡片,并在保存记录中保留来源片段;否则既难控制上下文,也难定位错误。

为什么没有自动去重? 问题相似度需要额外规则或 embedding。初版先保持边界简单,后续增加去重时,应同时补充测试,避免把不同角度的问题误删。

小结

这个项目把“笔记转卡片”拆成读取、调用、解析、校验和保存五步。模型提供候选内容,Python 负责格式、长度和文件副作用,离线测试负责保护确定性逻辑。综合项目的价值不在于堆叠功能,而在于建立清晰的信任边界:生成结果必须经过程序检查和人工复核,才能成为真正可用的学习资料。