实验三:让助手推荐并验证
本节目标:做出题目文本进、候选算法出、自动验证收尾的端到端脚本,并能说清每个环节的判据。
这次要做出什么
前面 26 课学的是你自己会做。这次要做的是让程序替你判断——这是配套项目 AI 刷题助手的主干能力:
输入一段题目文本 → 判断它像哪一类算法 → 给出候选实现 → 自动跑验证 → 输出结论。
判断「这题像哪一类」的依据,正是第 16 到 22 课学过的东西:哈希、树、堆、并查集、图与搜索、二分答案、贪心。而验证模块就是实验二那套对拍脚本。
做完这一课,这个项目第一次真的能用:题目进,候选算法与验证结论一起出。
三段式与那道缝
整条链有三段,每段都有明确的输入输出:
| 段 | 输入 | 输出 |
|---|---|---|
| 推荐 | 题目文本 | 一段文本(希望是 JSON) |
| 解析 | 那段文本 | 一个字典 {family, confidence, why} |
| 验证 | 候选实现 + 数据生成器 | 通过 / 被否掉(附反例) |
中间那道缝值得单独说:调用模型是要联网的,而测试不能依赖网络。所以要把「发请求」这件事做成一个可以换掉的函数——真正跑的时候它是 call_model,离线测试时换成一个假的。
这就是接缝:把不可控的部分(网络、时间、随机)收在一个函数里,其余部分就都成了纯逻辑,可以反复跑、可以对拍。
提示词要约束格式
提示词不是「说清楚要什么」就完了。既然下一步要用程序解析返回文本,就必须在提示词里把格式钉死。
def build_prompt(problem):
"""把题面裹成一条有格式约束的提示词。"""
return (
'你是一个算法助教。读下面的题目,判断它最像哪一类算法。\n'
'\n'
'只输出一个 JSON 对象,不要输出任何别的文字,不要用代码块围栏。\n'
'格式固定为:\n'
'{"family": "<算法族名>", "confidence": "高|中|低", "why": "<一句话理由>"}\n'
'字符串里的引号必须转义。\n'
'\n'
'可选的算法族:二分答案、并查集、堆、图的搜索、背包、贪心、前缀和、双指针。\n'
'\n'
'题目:\n' + problem.strip()
)
def call_model(prompt):
"""真正跑的时候这里发一次 API 请求;离线测试时换成假的。"""
raise NotImplementedError('离线环境不联网,测试时请换掉这个函数')
def fake_model(prompt):
"""假的模型:只认题目里有没有「木头」这个词,用来离线跑通链路。"""
if '木头' in prompt:
return '{"family": "二分答案", "confidence": "高", "why": "要让最小长度最大化"}'
return '{"family": "贪心", "confidence": "中", "why": "没认出来,先猜一个"}'
def recommend(problem, model):
"""题目进、候选算法出。model 就是那道缝。"""
prompt = build_prompt(problem)
raw = model(prompt)
return raw
P = '有 n 根木头,要切成 k 段等长的小段。求每小段最长的长度,即让最小长度最大化。'
print(' —— 提示词长这样 ——')
for line in build_prompt(P).splitlines():
print(f' | {line}')
print()
print(f' 假模型返回: {recommend(P, fake_model)}')
—— 提示词长这样 ——
| 你是一个算法助教。读下面的题目,判断它最像哪一类算法。
|
| 只输出一个 JSON 对象,不要输出任何别的文字,不要用代码块围栏。
| 格式固定为:
| {"family": "<算法族名>", "confidence": "高|中|低", "why": "<一句话理由>"}
| 字符串里的引号必须转义。
|
| 可选的算法族:二分答案、并查集、堆、图的搜索、背包、贪心、前缀和、双指针。
|
| 题目:
| 有 n 根木头,要切成 k 段等长的小段。求每小段最长的长度,即让最小长度最大化。
假模型返回: {"family": "二分答案", "confidence": "高", "why": "要让最小长度最大化"}
提示词里有四样东西,缺一样后面就会出麻烦:
- 输出格式(
格式固定为:)——后面要按这个键名取值。 - 不许有别的文字、不许用围栏(
只输出一个 JSON 对象、不要用代码块围栏)——减少解析要处理的形态。 - 取值范围(
可选的算法族:…)——不限定的话,模型可能给出「动态规划优化版」这种你在项目里根本没有对应实现的名字。 - 转义的约定(
字符串里的引号必须转义)——这是最难察觉的一条,下一节会看到为什么。
返回文本的两层解析
即使提示词写了「不要围栏」,真实返回的文本仍然可能带围栏、带前后解释。这不是模型不听话,是文本生成本身就有浮动。所以解析器要分两层:
第一层剥围栏,第二层取第一个 { 到最后一个 } 之间那段。
import json
import re
FENCE = '`' * 3
SAMPLES = {
'干净 JSON': '{"family": "二分答案", "confidence": "高"}',
'套了 json 围栏': FENCE + 'json\n{"family": "二分答案", "confidence": "高"}\n' + FENCE,
'围栏 + 前面一段话': '根据题意,答案具有单调性。\n\n' + FENCE + 'json\n'
'{"family": "二分答案", "confidence": "高"}\n' + FENCE,
'围栏 + 后面一段话': FENCE + 'json\n{"family": "二分答案", "confidence": "高"}\n'
+ FENCE + '\n\n希望有帮助,有疑问可以继续追问。',
'没有围栏,但有前后解释': '这道题我建议用二分答案。\n'
'{"family": "二分答案", "confidence": "高"}\n'
'理由如上。',
'内容本身不是合法 JSON': '{"family": "二分答案", "why": "题目说"最小化最大值""}',
}
def parse_naive(raw):
"""直接 json.loads——最直觉的写法。"""
try:
return json.loads(raw)
except Exception as e:
return f'<{type(e).__name__}>'
def parse_robust(raw):
"""先剥围栏,再取第一个 { 到最后一个 } 之间那段。"""
text = raw.strip()
fence = re.search(r'`{3}(?:json)?\s*(.+?)`{3}', text, re.S)
if fence:
text = fence.group(1).strip()
lo, hi = text.find('{'), text.rfind('}')
if lo == -1 or hi == -1 or hi < lo:
return '<没有找到 JSON 对象>'
try:
return json.loads(text[lo:hi + 1])
except Exception as e:
return f'<{type(e).__name__}>'
ok_naive = ok_robust = 0
for name, raw in SAMPLES.items():
a, b = parse_naive(raw), parse_robust(raw)
ok_a, ok_b = not isinstance(a, str), not isinstance(b, str)
ok_naive += ok_a
ok_robust += ok_b
print(f' {name}')
print(f' 朴素: {a["family"] if ok_a else a}')
print(f' 加固: {b["family"] if ok_b else b}')
print(f' 六种里成功解析:朴素 {ok_naive} 种,加固 {ok_robust} 种')
干净 JSON
朴素: 二分答案
加固: 二分答案
套了 json 围栏
朴素: <JSONDecodeError>
加固: 二分答案
围栏 + 前面一段话
朴素: <JSONDecodeError>
加固: 二分答案
围栏 + 后面一段话
朴素: <JSONDecodeError>
加固: 二分答案
没有围栏,但有前后解释
朴素: <JSONDecodeError>
加固: 二分答案
内容本身不是合法 JSON
朴素: <JSONDecodeError>
加固: <JSONDecodeError>
六种里成功解析:朴素 1 种,加固 5 种
上表这六种是构造出来的形态样本(不是某次真实调用的记录),用来比较两个解析器的覆盖面。朴素写法六种里只过一种;加两层之后过五种。
re.search(r'{3}(?:json)?\s(.+?){3}', text, re.S) 里的 re.S 是必需的——不加它,. 不匹配换行,而 JSON 一定是多行的,围栏里的内容就抓不到。这里用 ` {3} `` 而不是直接写三个反引号,是因为三个反引号会把 Markdown 的代码围栏提前截断——这个坑我在写这一课时踩过一次,页面上整块代码都错位了*。
解析器救不了的那种
第六种两种写法都失败了。再看一眼它到底坏在哪:
BAD = SAMPLES['内容本身不是合法 JSON']
print(f' 原文: {BAD}')
print(f' 朴素: {parse_naive(BAD)}')
print(f' 加固: {parse_robust(BAD)}')
print(f' 大括号配平吗: {BAD.count("{") == BAD.count("}")}')
print(' 括号是配平的,坏的是字符串内容本身。')
原文: {"family": "二分答案", "why": "题目说"最小化最大值""}
朴素: <JSONDecodeError>
加固: <JSONDecodeError>
大括号配平吗: True
括号是配平的,坏的是字符串内容本身。
"题目说"最小化最大值"" —— 里面那对引号没有转义,于是 JSON 解析器在 题目说 之后就认为字符串结束了,后面接的 最小化最大值 就成了非法内容。
这一层任何「找大括号」的招数都救不了。 括号是配平的,位置也对,坏的是内容本身。
所以修复要往前挪一层,挪到提示词:
- 提示词里那句
字符串里的引号必须转义; - 更保险的是直接用结构化输出:DeepSeek 的
response_format={"type": "json_object"}。官方文档对它的说明是「该模式保证模型生成的消息是有效的 JSON」。
解析器的能力有边界。 「多剥一层」能处理格式浮动(围栏、前后解释),但处理不了内容本身不合法。碰到后面这一类,只能回到提示词和接口参数去修。分不清这两类问题,就会一直在解析器里打补丁而修不好。
把题面映射到算法族
这一段可以完全不靠模型:拿题面里的关键词,给每个算法族打分。它不是主力,但它是交叉检查——模型给了一个结论,关键词表给不出任何支持,就值得警惕。
FAMILIES = [
('二分答案', ['最大化', '最小化', '最小值的最大', '最大值的最小', '单调',
'猜一个', '判定一个值行不行', '最长', '最短']),
('并查集', ['连通块', '属于同一组', '合并集合', '是否连通', '朋友的朋友']),
('堆', ['前 k 大', '前 k 小', '第 k 大', '中位数', '每次取最小', '每次取最大']),
('图的搜索', ['最短步数', '最少几步', '网格', '迷宫', '走法', '扩散', '可达']),
('背包', ['容量', '重量', '价值', '装下', '每件最多选一次', '选与不选']),
('贪心', ['区间', '不相交', '最多能选几个活动', '按结束时间']),
('前缀和', ['区间和', '连续子数组的和', '多次询问', '子矩阵']),
('双指针', ['有序数组', '两数之和', '左右两个指针', '原地去重']),
]
def classify(text):
"""按关键词命中数给算法族打分,返回排好序的候选。"""
scores = []
for family, keys in FAMILIES:
hit = [k for k in keys if k in text]
if hit:
scores.append((len(hit), family, hit))
scores.sort(key=lambda t: (-t[0], t[1]))
return scores
PROBLEMS = {
'木材加工': '有 n 根木头,要切成 k 段等长的小段。求每小段最长的长度,即让最小长度最大化。',
'岛屿数量': '一个由 0 和 1 组成的网格,1 表示陆地。求有多少个连通块。',
'合并果子': '每次把两堆合并,代价是重量之和。求最小总代价,每次取最小的两堆。',
'区间调度': '给出若干个区间,选出最多几个互不相交的区间,按结束时间排。',
'第 k 大': '在一个数据流里随时回答第 k 大是多少。',
'跳石头': '一条河里有若干块石头,最多移走 M 块,求最短跳跃距离的最大值。',
}
for title, text in PROBLEMS.items():
got = classify(text)
if not got:
print(f' 题面「{title}」:一个都没命中')
continue
print(f' 题面「{title}」首选 {got[0][1]}(命中 {got[0][0]} 个:{got[0][2]})')
if len(got) > 1:
mark = '并列' if got[1][0] == got[0][0] else '次选'
print(f' {mark} {got[1][1]}(命中 {got[1][0]} 个:{got[1][2]})')
题面「木材加工」首选 二分答案(命中 2 个:['最大化', '最长'])
题面「岛屿数量」首选 图的搜索(命中 1 个:['网格'])
并列 并查集(命中 1 个:['连通块'])
题面「合并果子」首选 堆(命中 1 个:['每次取最小'])
并列 背包(命中 1 个:['重量'])
题面「区间调度」首选 贪心(命中 3 个:['区间', '不相交', '按结束时间'])
题面「第 k 大」首选 堆(命中 1 个:['第 k 大'])
题面「跳石头」首选 二分答案(命中 1 个:['最短'])
前六道题的判断都说得过去,而且「岛屿数量」那一行的并列本身就是有用信息——网格连通块用图的搜索或并查集都能做,两个候选都交下去试就是了。这也说明为什么这一段要返回候选列表而不是一个答案。
关键词会误报
看「合并果子」那一行:关键词表把背包也列成了并列候选,理由只有一个词——题面里有「重量」。
而这道题的正确答案是堆(或者叫优先队列),跟背包毫无关系。「重量」在这里指的是石子堆的重量,不是背包的容量约束。
这就是关键词匹配的假阳性:一个词在不同题目里指的不是同一件事。
所以这一段的定位要说准:
- 它是提示,不是判决;
- 它给出的是排序的候选,不是唯一答案;
- 它最可靠的用法是当交叉检查:模型选了 A 而关键词表一条都不支持 A,就要多看一眼。
真正理解题意的那一步必须靠模型,关键词表补不上。这也是为什么这一课的主体是提示词,而不是规则表。
验证验的是实现不是名字
前面三段给出的是候选。最后这一段决定要不要信它。
拿「区间调度」当例子:推荐给出「贪心」,然后写两个实现——都叫贪心,只是排序关键字不同。
def greedy_by_start(intervals):
"""按开始时间排的贪心——很自然,但错。"""
picked = []
last_end = -1
for s, e in sorted(intervals):
if s >= last_end:
picked.append((s, e))
last_end = e
return picked
def greedy_by_end(intervals):
"""按结束时间排的贪心——正确。"""
picked = []
last_end = -1
for s, e in sorted(intervals, key=lambda p: p[1]):
if s >= last_end:
picked.append((s, e))
last_end = e
return picked
def brute_intervals(intervals):
"""枚举所有子集,找两两不相交的最大个数。"""
best = 0
n = len(intervals)
for mask in range(1 << n):
chosen = sorted(intervals[i] for i in range(n) if mask >> i & 1)
if all(chosen[i][1] <= chosen[i + 1][0] for i in range(len(chosen) - 1)):
best = max(best, len(chosen))
return best
CASE = [(0, 100)] + [(2 * i + 1, 2 * i + 2) for i in range(6)]
A, B, WANT = greedy_by_start(CASE), greedy_by_end(CASE), brute_intervals(CASE)
print(f' 推荐给出的算法族: 贪心')
print(f' 实现 A(按开始时间排)选出 {len(A)} 个: {A}')
print(f' 实现 B(按结束时间排)选出 {len(B)} 个: {B}')
print(f' 暴力验证给出的最优: {WANT} 个')
print(f' A 通过吗: {len(A) == WANT} B 通过吗: {len(B) == WANT}')
推荐给出的算法族: 贪心
实现 A(按开始时间排)选出 1 个: [(0, 100)]
实现 B(按结束时间排)选出 6 个: [(1, 2), (3, 4), (5, 6), (7, 8), (9, 10), (11, 12)]
暴力验证给出的最优: 6 个
A 通过吗: False B 通过吗: True
推荐给出的名字是对的,两个实现却一个好一个坏。
这就是这一课最要紧的一句话:验证环节要验的是具体实现,不是算法名。 只报出一个名字的推荐落不了地——名字下面可以挂无数种写法,其中大部分是错的。
所以整条链的结论不该是「推荐用贪心」,而该是这样一个结构(这是格式示意,不是某次运行的输出):
推荐 贪心(置信度 中)
候选实现 A 按开始时间排 → 被否掉:选出 1 个,最优 6 个,反例 [(0,100)]
候选实现 B 按结束时间排 → 通过
有反例的否掉,比没有反例的通过更有价值——前者告诉你这条路走不通,而且给了你一个能复现的证据。
怎么算过
这一课的练习在 lab/0027-lab-03-recommend-and-verify/,四个任务拼成一条完整的链:
| 任务 | 文件 | 做出什么 |
|---|---|---|
| 1 | task1_prompt.py | 造提示词,四样约束一个不少 |
| 2 | task2_parse.py | 两层解析,六种形态里过五种 |
| 3 | task3_classify.py | 关键词打分,返回排好序的候选 |
| 4 | task4_pipeline.py | 端到端:题面进,候选实现 + 验证结论出 |
算过的标准是判分脚本全绿,具体在跑之前先看 ../lab/README.md 的说明。要件是这几条:
- 提示词里四样约束齐全(格式、不许别的话、取值范围、转义约定)。
- 解析器在六种形态样本里至少过五种,且失败的那一种要返回一个明确的失败标记,不能抛异常、不能返回半个字典。
- 关键词打分对给定的题面返回排序的候选列表;「合并果子」那一行要能出现背包的并列(这是如实反映关键词的假阳性,不许藏)。
- 端到端脚本对同一个算法族下的两个实现给出不同结论:一个通过、一个被否掉并附反例。
- 离线可跑:测试版不许联网,模型调用必须走那道缝。
怎么跑
cd lab/0027-lab-03-recommend-and-verify
python run_tests.py
参考解在 ../lab/solutions/0027-lab-03-recommend-and-verify/,可以直接进那个目录跑一遍对照。
要接真模型时,把 call_model 换成真实请求即可——但先让离线版全绿。链路上任何一个环节没接好,联网跑只是把错误藏进网络噪声里。
- 先写
task1_prompt.py:把四样约束写进提示词,并打印出来自己读一遍。缺哪一样,后面就会在哪一段出问题。 - 再写
task2_parse.py:朴素版与加固版都写,用六种形态样本对拍,确认朴素过 1 种、加固过 5 种。 - 然后
task3_classify.py:关键词表打分,对六道样例题面跑出候选。看到假阳性不要改数据,那是要如实报告的现象。 - 最后
task4_pipeline.py:把三段串起来,末端接上实验二那套对拍验证,对两个实现给出不同结论。
卡住了先读 ../lab/README.md 的「卡壳顺序」。
自查清单
- 提示词里四样约束都在吗?(格式固定、不要别的话、取值范围、引号转义)
- 解析器对六种形态都试过了吗?失败那一种返回的是明确的失败标记吗?
- 解析失败时,你的代码是抛异常中断还是返回失败标记?整条链要能带着「这一段失败了」继续往下走。
- 关键词表对「合并果子」给出背包并列了吗?如果没有,是你把关键词改窄了——那是把现象藏起来了。
- 端到端脚本对两个实现的结论不同吗?如果两个都通过,验证段大概是假的。
- 被否掉的那个实现,反例打印出来了吗?
call_model是不是唯一的联网点?测试时换成假的还能跑通吗?- 有没有哪一处把算法名当成了答案,而不是把具体实现的验证结果当答案?
最可能卡在哪
一、模型给的算法名与自己的实现接不上。 这是本课最典型的卡点。推荐说「二分答案」,但你的项目里没有对应的可执行代码——只有名词,没有方案。解法:候选实现必须是你自己写好的、能跑的代码,推荐只在「选哪一个候选」这一层起作用。
二、提示词没约束格式,返回内容没法解析。 少了「不要用代码块围栏」,六种形态里立刻有四种解析不了。解法:把四样约束写全,并且用形态样本测一遍,别等真实调用才发现。
三、只信推荐,不跑验证就当成答案。 这是最危险的一条,因为它不会报错——脚本跑完了,输出了一个看着很合理的结论,你把它当答案交上去。解法:让结论里必须带验证结果;没有验证结果的推荐,在输出里标成「未验证」,不许混进「可用」里。
四、把不可控的部分撒得到处都是。 网络调用如果在代码里直接写死,测试就只能联网跑,一断网全废。解法:所有不可控的东西(网络、时间、随机)都收在一处,做成可替换的函数。
卡壳顺序
- 读错误信息,先分清楚是解析失败还是验证失败——这两类的修法完全不同。
- 单独跑那一段,别在整条链里猜。提示词问题就只跑提示词那段,把输出打出来看。
- 看失败标记。解析器返回的失败标记里带上原文和异常类型,比
None有用得多。 - 对拍。验证段的结论跟暴力版不一致,先用小数据手算一遍。
- 上面四步都过了还是不通,再来问,把你打印出来的东西一起带上。
做完之后
用一句话回答:这个助手给出的结论,凭什么值得相信?
如果回答是「因为它分析得挺有道理」,那这一课还没做完——道理不是判据,跑出来的对拍结果才是。
到这里项目的主干就长出来了。后面第 31 课的实验四会给它加上间隔重复的刷题计划,第 33 课的综合实战把它用在一整套题上。