0027 · 实验三:让助手推荐并验证

实验三:让助手推荐并验证

本节目标:做出题目文本进、候选算法出、自动验证收尾的端到端脚本,并能说清每个环节的判据。

这次要做出什么

前面 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": "要让最小长度最大化"}

提示词里有四样东西,缺一样后面就会出麻烦:

  1. 输出格式(格式固定为:)——后面要按这个键名取值。
  2. 不许有别的文字、不许用围栏(只输出一个 JSON 对象、不要用代码块围栏)——减少解析要处理的形态。
  3. 取值范围(可选的算法族:…)——不限定的话,模型可能给出「动态规划优化版」这种你在项目里根本没有对应实现的名字。
  4. 转义的约定(字符串里的引号必须转义)——这是最难察觉的一条,下一节会看到为什么。

返回文本的两层解析

即使提示词写了「不要围栏」,真实返回的文本仍然可能带围栏、带前后解释。这不是模型不听话,是文本生成本身就有浮动。所以解析器要分两层:

第一层剥围栏,第二层取第一个 { 到最后一个 } 之间那段。

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 解析器在 题目说 之后就认为字符串结束了,后面接的 最小化最大值 就成了非法内容。

这一层任何「找大括号」的招数都救不了。 括号是配平的,位置也对,坏的是内容本身。

所以修复要往前挪一层,挪到提示词:

解析器的能力有边界。 「多剥一层」能处理格式浮动(围栏、前后解释),但处理不了内容本身不合法。碰到后面这一类,只能回到提示词和接口参数去修。分不清这两类问题,就会一直在解析器里打补丁而修不好。

把题面映射到算法族

这一段可以完全不靠模型:拿题面里的关键词,给每个算法族打分。它不是主力,但它是交叉检查——模型给了一个结论,关键词表给不出任何支持,就值得警惕。

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 个:['最短'])

前六道题的判断都说得过去,而且「岛屿数量」那一行的并列本身就是有用信息——网格连通块用图的搜索或并查集都能做,两个候选都交下去试就是了。这也说明为什么这一段要返回候选列表而不是一个答案。

关键词会误报

看「合并果子」那一行:关键词表把背包也列成了并列候选,理由只有一个词——题面里有「重量」。

而这道题的正确答案是堆(或者叫优先队列),跟背包毫无关系。「重量」在这里指的是石子堆的重量,不是背包的容量约束。

这就是关键词匹配的假阳性:一个词在不同题目里指的不是同一件事。

所以这一段的定位要说准:

真正理解题意的那一步必须靠模型,关键词表补不上。这也是为什么这一课的主体是提示词,而不是规则表。

验证验的是实现不是名字

前面三段给出的是候选。最后这一段决定要不要信它。

拿「区间调度」当例子:推荐给出「贪心」,然后写两个实现——都叫贪心,只是排序关键字不同。

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/,四个任务拼成一条完整的链:

任务文件做出什么
1task1_prompt.py造提示词,四样约束一个不少
2task2_parse.py两层解析,六种形态里过五种
3task3_classify.py关键词打分,返回排好序的候选
4task4_pipeline.py端到端:题面进,候选实现 + 验证结论出

算过的标准是判分脚本全绿,具体在跑之前先看 ../lab/README.md 的说明。要件是这几条:

  1. 提示词里四样约束齐全(格式、不许别的话、取值范围、转义约定)。
  2. 解析器在六种形态样本里至少过五种,且失败的那一种要返回一个明确的失败标记,不能抛异常、不能返回半个字典。
  3. 关键词打分对给定的题面返回排序的候选列表;「合并果子」那一行要能出现背包的并列(这是如实反映关键词的假阳性,不许藏)。
  4. 端到端脚本对同一个算法族下的两个实现给出不同结论:一个通过、一个被否掉并附反例。
  5. 离线可跑:测试版不许联网,模型调用必须走那道缝。

怎么跑

cd lab/0027-lab-03-recommend-and-verify
python run_tests.py

参考解在 ../lab/solutions/0027-lab-03-recommend-and-verify/,可以直接进那个目录跑一遍对照。

要接真模型时,把 call_model 换成真实请求即可——但先让离线版全绿。链路上任何一个环节没接好,联网跑只是把错误藏进网络噪声里。

上手做 四段拼成一条链
  1. 先写 task1_prompt.py:把四样约束写进提示词,并打印出来自己读一遍。缺哪一样,后面就会在哪一段出问题。
  2. 再写 task2_parse.py:朴素版与加固版都写,用六种形态样本对拍,确认朴素过 1 种、加固过 5 种。
  3. 然后 task3_classify.py:关键词表打分,对六道样例题面跑出候选。看到假阳性不要改数据,那是要如实报告的现象。
  4. 最后 task4_pipeline.py:把三段串起来,末端接上实验二那套对拍验证,对两个实现给出不同结论。

卡住了先读 ../lab/README.md 的「卡壳顺序」。

自查清单

最可能卡在哪

一、模型给的算法名与自己的实现接不上。 这是本课最典型的卡点。推荐说「二分答案」,但你的项目里没有对应的可执行代码——只有名词,没有方案。解法:候选实现必须是你自己写好的、能跑的代码,推荐只在「选哪一个候选」这一层起作用。

二、提示词没约束格式,返回内容没法解析。 少了「不要用代码块围栏」,六种形态里立刻有四种解析不了。解法:把四样约束写全,并且用形态样本测一遍,别等真实调用才发现。

三、只信推荐,不跑验证就当成答案。 这是最危险的一条,因为它不会报错——脚本跑完了,输出了一个看着很合理的结论,你把它当答案交上去。解法:让结论里必须带验证结果;没有验证结果的推荐,在输出里标成「未验证」,不许混进「可用」里。

四、把不可控的部分撒得到处都是。 网络调用如果在代码里直接写死,测试就只能联网跑,一断网全废。解法:所有不可控的东西(网络、时间、随机)都收在一处,做成可替换的函数。

卡壳顺序

  1. 读错误信息,先分清楚是解析失败还是验证失败——这两类的修法完全不同。
  2. 单独跑那一段,别在整条链里猜。提示词问题就只跑提示词那段,把输出打出来看。
  3. 看失败标记。解析器返回的失败标记里带上原文和异常类型,比 None 有用得多。
  4. 对拍。验证段的结论跟暴力版不一致,先用小数据手算一遍。
  5. 上面四步都过了还是不通,再来问,把你打印出来的东西一起带上。

做完之后

用一句话回答:这个助手给出的结论,凭什么值得相信?

如果回答是「因为它分析得挺有道理」,那这一课还没做完——道理不是判据,跑出来的对拍结果才是。

到这里项目的主干就长出来了。后面第 31 课的实验四会给它加上间隔重复的刷题计划,第 33 课的综合实战把它用在一整套题上。

没看懂?随时问 把看不懂的段落(或报错)原样复制回会话,说一句哪里不懂就行,我只解释这一段,不打断你的进度。