Lecture 11 · 代码生成 · Code Generation

Prompt Engineering · Lecture 11。个人学习讲义与配套实验,按主题整理并持续修订。

课程源码 ZIP ↓

个人整理的学习笔记,非官方讲义;课程来源与整理说明见课程介绍。配套示例未逐一运行验证。

本篇目录

对应 Maps to:CMU 17-630 Prompt Engineering(Prof. Travis Breaux),第 7 周 · Code Generation(原课作业 3”Prompt Augmentation”在这一周截止)。原课 Schedule 对这一讲的说明(意译):软件开发辅助工具的研究现状——代码模型的演变、评估生成代码的指标、自我调试,以及软件开发智能体的最新进展。 依据说明:原课的 slides 和阅读清单不公开。Schedule 点名的四项——代码模型的演变(§1)、评估指标(§2、§4)、自我调试(§5)、软件开发智能体(§6)——各占一节;§3(沙箱:怎样安全地运行模型写的代码)是为没有系统背景的读者补充的,§5.4(CodeT)是讲义补充的。材料来自 Chen et al.(2021,Codex / HumanEval)、Austin et al.(2021,MBPP)、Li et al.(2022,AlphaCode)、Liu et al.(2023,EvalPlus)、Ren et al.(2020,CodeBLEU)、Chen et al.(2023,CodeT)、Chen et al.(2024,Self-Debugging)、Jimenez et al.(2024,SWE-bench)、Yang et al.(2024,SWE-agent)、Xia et al.(2024,Agentless)等;引用的数字都核对过原文。全部实验是本讲义自己设计的,不代表课堂上讲了什么。 配套代码 Code:code/lecture11/(只用 Python 标准库;3 个模块 + 7 个脚本;不需要 API key;第一次运行要在沙箱里执行所有可能的程序,每个脚本 15 秒以内,之后读缓存) 预计阅读时间:3.5 小时(含运行代码)


0. 本讲导航 · How to read this lecture

0.1 学习目标 Learning goals

学完这一讲,你应该能够:

  1. 说出代码为什么是提示工程里一个特殊的领域:代码可以被执行,执行结果是一个客观、便宜、外部的反馈信号。
  2. 解释 pass@k 的含义,写出它的无偏估计,并说明为什么朴素的 1−(1−p^)k1-(1-\hat p)^k 会偏低;知道温度怎样影响 pass@1 和 pass@100。
  3. 说明为什么 BLEU、精确匹配不适合评估代码,以及”测试不够”怎样让 pass@k 虚高。
  4. 描述一个最小的代码沙箱(子进程、超时、资源上限、临时目录),并知道它能防住什么、防不住什么。
  5. 设计自我调试的反馈(失败的测试、报错信息、解释),知道它的上限由”模型看得见的测试”决定;在没有测试时用 CodeT / 多数投票挑程序。
  6. 解释 SWE-bench 在测什么,以及”智能体–计算机接口”(ACI)为什么会大幅影响智能体的表现。

0.2 先修与本讲范围 Prerequisites & scope

默认你已经会 Assumed本讲只给直觉,后面再展开 Previewed only
第 4 讲:self-consistency(多数投票)提示注入通过工具和代码造成的危害(第 12 讲)
第 8 讲:工具使用、错误信息就是提示、CRITIC智能体的规划与记忆(第 13–14 讲)
第 10 讲:外部反馈 vs 内在的自我纠正;开发集虚高评估设计与 benchmark 污染(第 17 讲)
前置讲 P2:进程、HTTP、重试、缓存

0.3 本讲地图 Map

§1  代码模型的演变:为什么代码特殊
§2  HumanEval 与 pass@k:用执行来评估                                ← 代码 02
§3  沙箱:怎样安全地运行模型写的代码                                  ← 代码 01
§4  指标:为什么不用 BLEU;测试够不够;数据污染                         ← 代码 02、03
§5  自我调试与执行反馈:Self-Debugging、CodeT                         ← 代码 04、05
§6  软件工程智能体:SWE-bench、SWE-agent 与 ACI                       ← 代码 06
§7  在真实模型上                                                     ← 代码 07(可选)
§8  小结   §9 常见误区   §10 练习   §11 延伸阅读   §12 下一讲预告

0.4 记号与术语 Notation & terms

术语本讲义里的意思
功能正确性(functional correctness)程序通过测试,而不是”长得像参考答案”
pass@k每道题让模型写 k 次,至少一次通过全部测试的题目比例
基础测试 / 加强测试像 HumanEval 那样每题几个测试 / 像 EvalPlus 那样补上边界情况
给定的测试(visible tests)题目里给模型看的测试;评估用的测试可以更多,模型看不见
沙箱(sandbox)一个隔离的执行环境:被测代码出任何问题都不影响评测程序
自我调试(self-debugging)把执行结果(失败的测试、报错)交给模型,让它修改自己的程序
ACI(agent-computer interface)给智能体用的命令和它们的输出格式,类似给人用的 IDE

0.5 本讲的实验装置 · The setup

一个小型的 “HumanEval”(code_lab.py):20 道题,每道题是一个函数签名加文档字符串:

def max_subarray(nums):
    """Return the largest sum of a non-empty contiguous subarray of nums."""

每道题有 1–3 个基础测试(像 HumanEval:只覆盖常见情况)和 3–5 个加强测试(像 EvalPlus:空输入、负数、重复值、大小写……)。

模拟的代码模型 SimCoder 不是一个神经网络,而是这样写代码的:每道题有 1–2 种写法(模板),模板里有几个”容易写错的地方”(槽位),每个槽位有参考写法、等价的正确写法和常见的错误写法:

def max_subarray(nums):
    best = cur = <<INIT>>          # nums[0](对)/ 0(常见错误:全是负数时错)
    for x in nums<<REST>>:         # [1:](对)/ 空(和 nums[0] 搭配时会把第一个数算两次)
        cur = <<UPD>>              # max(x, cur + x)(对)/ cur + x(错)
        best = max(best, cur)
    return best

模型按每个选项的 logit 和温度采样;“能力”越强,参考写法的 logit 越高;题目越难,越容易写错。small / medium / large 三种规模只差一个能力参数。程序是真的 Python 代码,执行和测试是真的——只有”模型会写出哪个程序”是模拟的。所以它能演示评估方法本身的性质(估计量的偏差、测试不足、BLEU 的问题、反馈的作用),但不能说明真实模型的数字;每次用它,讲义都给出论文里真实模型的数字作对照。

所有程序都在 sandbox_runner.py 的子进程里运行(§3),结果缓存在 cache/ 里。§6 另有一个迷你仓库 swe_lab.py。


1. 代码模型的演变:为什么代码特殊 · Why code is special

1.1 代码是”可以被执行的文本”

对语言模型来说,代码只是另一种文本:GitHub 上有海量的代码,模型在上面预训练,就学会了续写代码。但和普通文本相比,代码有一个根本的不同:它可以被执行。

这一点改变了提示工程的很多东西:

  • 评估:一篇摘要好不好需要人或判官(第 10 讲)来判断;一个函数对不对,跑一下测试就知道。
  • 反馈:第 10 讲反复看到,自我纠正要有可靠的外部反馈。代码天然有:报错信息、失败的测试、运行结果。
  • 挑选:模型写 100 个版本,跑测试就能挑出对的那个——生成很多个、用执行筛选,这是代码生成里最有效的技巧之一。

但执行也带来了新的问题:模型写的代码是不可信的,必须在隔离的环境里运行(§3);测试本身可能不够(§4.2);智能体可能学会”让测试通过”而不是”把问题修好”(§5.3)。

1.2 一段很短的历史

  • Codex(Chen et al. 2021):在 GitHub 的 Python 代码上微调 GPT-3。论文同时发布了 HumanEval(164 道手写的题,平均每题 7.7 个单元测试)和 pass@k 指标。Codex-12B 的 pass@1 是 28.8%,GPT-3 是 0%,GPT-J 是 11.4%;每题采样 100 次时 Codex 解决 70.2%。它是 GitHub Copilot 最初的模型。
  • MBPP(Austin et al. 2021):974 道入门级的 Python 题,每题 3 个测试——另一个常用的 benchmark。
  • AlphaCode(Li et al. 2022):竞赛编程。核心是大规模采样 + 按程序行为过滤:每道题生成大量程序,用题目给的样例过滤,再按”在新输入上的行为”聚类挑出少量提交。在模拟的 Codeforces 比赛中平均排在前 54.3%(参赛者超过 5000 人的比赛)。
  • 开源代码模型:InCoder、CodeGen、StarCoder(BigCode 社区,许可证宽松的代码数据集 The Stack)、Code Llama、DeepSeek-Coder、Qwen-Coder 等。几个反复出现的技术主题:
    • 中间填充(fill-in-the-middle,Bavarian et al. 2022):训练时把文档切成”前缀 / 中间 / 后缀”,让模型学会根据前后文填中间——IDE 里补全光标处的代码正需要这个;
    • 更长的上下文:理解一个仓库需要看很多文件;
    • 代码的指令微调(第 3 讲)和用执行结果做强化学习(测试通过就是奖励)。
  • 今天:最强的通用模型也是最强的代码模型;推理模型(第 13 讲)在竞赛题上大幅进步;评估的重心从”写一个函数”转向”在真实仓库里修一个 issue”(§6)。

这一讲不追逐最新的排行榜,而是讲这条演变中不变的方法:用执行评估、用执行反馈、用执行挑选,以及给智能体设计好的接口。


2. HumanEval 与 pass@k · Evaluating by execution

2.1 pass@k 是什么

HumanEval 的每道题给模型一个函数签名和文档字符串,模型续写函数体,然后跑隐藏的单元测试。pass@k:每道题让模型写 k 次,只要有一次通过全部测试就算解决;pass@k 是解决的题目比例。

为什么要 k?因为”第一次就写对”和”能写对”是两回事。用户在 IDE 里会看好几个补全建议;AlphaCode 式的系统会生成成百上千个再筛选。pass@1 衡量前者,pass@100 衡量模型”有没有能力”写出正确程序。

02_pass_at_k.py 演示 1:20 道题,T = 0.8,每题 200 个样本(括号里是模拟器精确算出的真实值):

模型贪心 pass@1pass@1pass@10pass@100
small30%27.3%(27.5%)78.0%(78.8%)97.4%(96.7%)
medium45%45.3%(45.0%)92.4%(93.2%)99.9%(99.9%)
large80%62.1%(62.9%)99.1%(98.9%)100.0%(100.0%)

small 模型贪心只解决 30% 的题,采样 100 次时 97%。

2.2 怎样估计:一个容易犯的错误

最直接的做法:每题采样 k 次,看有没有对的。但这样方差很大,而且要为每个 k 重新采样。Chen et al.(2021)的做法是:每题采样 n≥kn \ge k 次(他们用 n = 200),数出正确的个数 c,然后估计

pass@k=E题目[1−(n−ck)(nk)]\text{pass@}k = \mathbb{E}_{\text{题目}}\left[1 - \frac{\binom{n-c}{k}}{\binom{n}{k}}\right]

意思是:从这 n 个样本里不放回地抽 k 个,至少有一个正确的概率。(n−ck)/(nk)\binom{n-c}{k}/\binom{n}{k} 是”抽到的 k 个全是错的”的概率。

一个看起来等价、其实有偏的做法是:先估单次成功率 p^=c/n\hat p = c/n,再算 1−(1−p^)k1-(1-\hat p)^k。演示 2:每题只采样 n = 20 次,重复 200 次取平均(medium,T = 0.8):

k真实值朴素估计偏差无偏估计偏差
144.99%45.08%+0.0945.08%+0.09
584.43%82.32%−2.1184.42%−0.02
1093.17%90.80%−2.3793.11%−0.05
2097.15%94.82%−2.3297.00%−0.15

为什么朴素估计偏低?f(p)=1−(1−p)kf(p) = 1-(1-p)^k 是 pp 的凹函数。p^\hat p 是 pp 的无偏估计,但凹函数的期望小于期望的函数值(詹森不等式):E[f(p^)]≤f(E[p^])=f(p)\mathbb{E}[f(\hat p)] \le f(\mathbb{E}[\hat p]) = f(p)。每道题都偏低,平均之后也偏低。无偏估计没有这个问题,因为它直接回答”不放回地抽 k 个”的问题,不需要先把 pp 估出来再代进非线性函数。

数值上的小技巧:(n−ck)/(nk)=∏i=n−c+1n(1−ki)\binom{n-c}{k}/\binom{n}{k} = \prod_{i=n-c+1}^{n}\left(1 - \frac{k}{i}\right),不用算巨大的组合数(code_lab.pass_at_k,和原论文的代码一样)。n−c<kn - c < k 时直接返回 1。

2.3 温度:pass@1 和 pass@100 要的东西不一样

演示 3(真实值):

模型温度pass@1pass@10pass@100
large贪心80.0%80.0%80.0%
large0.273.0%93.5%99.9%
large1.059.4%98.9%100.0%
medium贪心45.0%45.0%45.0%
medium0.244.4%65.3%81.1%
medium0.446.1%82.4%97.6%
medium1.043.9%94.5%100.0%
  • pass@1 要”最有把握的那个”:large 模型贪心最好,温度越高越差。
  • pass@100 要多样性:贪心时模型每次都写同一个程序,pass@100 = pass@1;温度越高,100 次里越可能有一次写对。medium 在 T = 0.2 时 pass@100 只有 81%,T = 1.0 时 100%。

Chen et al.(2021)在一个 6.79 亿参数的模型上报告:pass@1 最好的温度约 0.2,pass@100 约 0.8。按 k 选温度,并在报告里写明用的是哪个温度。

medium 那几行有一个值得多想的现象:它的 pass@1 几乎不随温度变化,贪心并不比采样好。原因是在好几道题上,正确的写法有好几种(.lower() 和 .casefold()、abs(n) 和 -n if n < 0 else n),概率被它们分掉了,而某一个错误写法单独的概率反而最大。贪心解码挑的是”最可能的那一种写法”,不是”最可能正确的行为”。§5.4 的 CodeT 正是利用这一点。


3. 沙箱:怎样安全地运行模型写的代码 · Sandboxing

3.1 为什么不能直接 exec

评测程序要运行模型写的代码。最省事的写法是在评测程序自己的进程里 exec(code)。问题是,模型写的代码是不可信的——不是因为模型”有恶意”,而是因为它会写错:

  • 死循环:评测程序永远卡住;
  • 递归太深、吃光内存:评测程序崩溃;
  • 加载时就调用 sys.exit():评测程序直接退出,后面的题都不评了,你甚至可能以为”正常结束了”;
  • 写文件、删文件、改全局变量、改掉内置函数:评测程序的状态被破坏;
  • 读环境变量:你的 API key 可能被它读到、甚至打印出来。

01_sandbox.py 演示 2 在本进程里 exec 一个”一加载就退出”的程序:它抛出 SystemExit,我们用 try 接住了;没有接住的话,评测程序就在那一行结束。try 也接不住死循环(不会抛异常)和 os._exit()(不走异常机制,直接结束进程)。

3.2 一个最小的沙箱:子进程

给没有系统背景的读者补两个概念:

  • 进程(process):操作系统里一个正在运行的程序,有自己独立的内存。一个进程崩溃、卡死,不影响别的进程。
  • 子进程:一个程序启动的另一个程序。父进程可以给它输入、读它的输出、给它设时限,必要时把它杀掉。

所以最基本的隔离办法是:每段被测代码都在一个新的子进程里运行。code_lab.run_in_sandbox 这样做:

subprocess.run([sys.executable, "-I", "sandbox_runner.py"],   # 新的 Python 进程;-I:隔离模式
               input=任务的JSON, capture_output=True, text=True,
               cwd=临时目录,                                   # 它写的文件落在这里,运行完整个删掉
               env={"PATH": ..., "PYTHONHASHSEED": "0"},       # 只给最少的环境变量
               timeout=总时限)                                 # 超时就把整个子进程杀掉

子进程里(sandbox_runner.py)再加几层:

  • 每个测试一个计时器(signal.setitimer),超时就打断死循环;
  • CPU 时间和内存的上限(resource.setrlimit);
  • 把被测代码的 print 重定向到内存里,免得混进结果;
  • 结果只通过标准输出的一行 JSON 交回父进程。

演示 1 的结果:

代码加载状态沙箱报告的信息
正确的程序okpass返回了 6
答案错okfail返回了 5
抛异常okerrorIndexError: list index out of range
死循环oktimeout超过了每个测试的时间上限(约 1 秒)
递归太深okerrorRecursionError
一加载就退出失败errorSystemExit: 0
狂打印okpass(1 万行输出被截住)
写文件okpass(文件写进了临时目录,项目里没有)
读环境变量okfail返回了 ‘not visible’(读不到 API key)
语法错误失败errorSyntaxError

每一种问题都变成了一个结论,评测程序自己一直好好的。

代价:启动一个 Python 子进程要几十毫秒,比运行这些小函数本身慢得多。评测几千个样本时这是主要开销,所以要缓存:同一段代码 + 同一组测试只运行一次(模型的采样里重复的程序非常多)。缓存的前提是运行结果是确定的——我们固定了 PYTHONHASHSEED,让字符串的哈希和 set 的遍历顺序每次一致。

3.3 这不是一个真正的安全沙箱

子进程 + 超时 + 资源上限 + 临时目录,挡得住写错了的代码,挡不住故意的恶意代码:它仍然能读你能读的文件、能联网、能启动别的进程。真正的隔离要靠操作系统层面的机制:

  • 容器(Docker 等):独立的文件系统视图、网络和资源限制;
  • 更强的隔离:gVisor(在用户态拦截系统调用)、Firecracker 这样的轻量虚拟机;
  • 网络策略:默认不能联网,只开放需要的地址。

Codex 论文发布 HumanEval 的评测代码时,默认把执行模型代码的那一行注释掉了,并提醒使用者在沙箱里运行。到了第 12 讲你会看到,当模型能执行代码、调用工具时,“不可信”不只是”会写错”,还包括”被提示注入操纵”——那时最小权限和隔离就是主要的防线。


4. 指标:为什么不用 BLEU;测试够不够 · Metrics

4.1 字面相似 ≠ 功能正确

机器翻译用 BLEU:数候选和参考答案之间重合的 n 元组。早期的代码生成工作也用 BLEU 或精确匹配。03_bleu_vs_execution.py 把 20 道题所有可能的程序(114 个)和参考解比 BLEU:

  • 正确的程序平均 BLEU 0.80,错误的 0.73,AUROC 0.68——看起来 BLEU 有一点区分能力。
  • 但这主要来自 20 个和参考解一字不差的程序(BLEU = 1)。去掉它们,剩下 25 个写法不同但正确的程序平均 BLEU 只有 0.65,比错误程序还低,AUROC 降到 0.42:BLEU 更偏爱长得像参考解的错误程序。

两个极端:

# BLEU = 0.97,但是错的(fizzbuzz)——和参考解只差一个词:
    for i in range(1, n + 0):          # 参考解是 range(1, n + 1)

# BLEU = 0.14,但完全正确(second_largest)——参考解是 sorted(set(nums))[-2]:
    first = second = float("-inf")
    for x in nums:
        if x > first:
            first, second = x, first
        elif first > x > second:
            second = x
    return second

更严重的是排名会反过来(演示 3):

模型平均 BLEUpass@1(执行测试)
A:small,偏爱参考解的写法0.8130%
B:large,偏爱另一种写法0.6875%

按 BLEU,A 比 B 好;按执行,B 远好于 A。参考解只是很多种正确写法中的一种;用 BLEU 选模型、调提示,会把你推向”模仿参考解的风格”,而不是”写出正确的程序”。这就是 Codex 论文改用功能正确性的理由。CodeBLEU(Ren et al. 2020)在 BLEU 之外加入了语法树和数据流的匹配,比 BLEU 好,但它衡量的仍然是”像不像”,不是”对不对”。

(这和第 10 讲 LLM 判官的长度偏差是同一类问题:指标奖励了一个和目标相关、但不是目标本身的特征。)

4.2 测试够不够:pass@k 也会虚高

执行测试比 BLEU 好,但它只能发现测试覆盖到的错误。HumanEval 平均每题 7.7 个测试,很多边界情况没有覆盖。02_pass_at_k.py 演示 4(贪心解码):

模型只用基础测试基础 + 加强测试差
small75.0%30.0%−45
medium90.0%45.0%−45
large95.0%80.0%−15

medium 模型”通过了基础测试、没通过加强测试”的程序,错在什么地方?

count_vowels('HELLO')                 期望 2       实际 0          ← 忘了 .lower()
max_subarray([-3, -1, -2])            期望 -1      实际 0          ← 初始值写成 0
second_largest([4, 4, 3])             期望 3       实际 4          ← 没去重
binary_search([1, 3], 1)              期望 0       实际 -1         ← while lo < hi
unique_in_order([1, 2, 1])            期望 [1,2,1] 实际 [1, 2]     ← 理解成了"全局去重"
title_case("don't stop")              期望 "Don't Stop"  实际 "Don'T Stop"   ← 用了 str.title()
digit_sum(-45)                        期望 9       实际 ValueError  ← 没处理负号

全是典型的边界情况。Liu et al.(2023)的 EvalPlus 把 HumanEval 的测试扩充了 80 倍(HumanEval+),各个模型的 pass@k 最多下降 19.3%–28.9%——原来的测试漏掉了大量错误的代码。

带走的经验:测试不够,pass@k 就会虚高——和第 10 讲”开发集太小会虚高”是同一个道理。给自己的项目做代码生成评估时,测试要专门覆盖边界情况,最好用属性测试(property-based testing)或参考实现在随机输入上对拍。

4.3 数据污染

HumanEval 是 2021 年公开的,之后训练的模型很可能在预训练数据里见过这些题和答案(污染,contamination)。一个模型在 HumanEval 上 95%,可能是会写代码,也可能是背过答案。对策:用模型训练截止日期之后才出现的新题(例如 LiveCodeBench 持续收集新的竞赛题),或者用私有的、从不公开的测试集。第 17 讲会系统地讲 benchmark 污染。


5. 自我调试与执行反馈 · Self-debugging

5.1 Self-Debugging:小黄鸭调试法

程序员有一种调试方法叫”小黄鸭调试法”(rubber duck debugging):对着一只玩具鸭子逐行解释自己的代码,解释着解释着就发现了错误。Chen et al.(2024)的 Self-Debugging 用少样本示范教模型做同样的事:写出程序 → 执行 → 把结果作为反馈 → 修改。他们比较了几种反馈:

  • 简单反馈:只说”上面的代码是错的,请修改”;
  • 单元测试反馈:失败的测试、期望输出、实际输出或报错信息;
  • 代码解释:让模型逐行解释自己的代码,再判断哪里错了(不需要测试);
  • 执行轨迹:逐行的变量值。

他们报告:在有单元测试的 TransCoder(C++ 到 Python 的翻译)和 MBPP 上,Self-Debugging 最多提升 12 个百分点;在没有单元测试的 Spider(文本到 SQL)上,只靠代码解释也能提升 2–3 个百分点,最难的题上提升 9 个百分点;而且利用失败的尝试和反馈,它能追上甚至超过采样 10 倍以上候选程序的基线。

一条单元测试反馈长这样(来自 04 号脚本):

The code failed the test: run_length('aaabcc') should return 'a3b1c2', but it returned 'a3b1'.

它几乎直接指出了错误:最后一组字母没有输出——循环结束后忘了把最后一组写进结果。

5.2 在模拟器上:反馈越具体越好,但有天花板

04_self_debug.py 演示 1:T = 0.8 写初稿,跑题目给定的 2 个测试,没通过就按反馈修改,最多 3 轮(20 道题 × 10 个随机种子)。模拟器里模型怎样”修”是一个旋钮:有具体的失败测试时,它以 0.7 的概率定位到出错的地方并改对;定位不到、或者只被告诉”错了”时,它只能整个重写——而它对这道题的误解还在,重写出来的常常还是同样的错。

模型反馈通过给定的测试真正正确(全部测试)调用次数
medium不改(初稿)80.0%45.0%1.0
medium只说”错了”97.0%58.0%1.4
medium失败的测试99.0%60.0%1.3
medium失败的测试 + 逐行解释99.5%61.0%1.3
small不改(初稿)70.0%25.0%1.0
small只说”错了”94.0%40.0%1.5
small失败的测试96.0%44.0%1.4

两件事:

  1. 反馈越具体,模型越容易定位错误:失败的测试 > 只说”错了”。(“逐行解释”在模拟里只是把定位概率从 0.7 提到 0.85,和”只给测试”的差别在噪声以内。)
  2. “通过给定的测试”和”真正正确”之间始终有一大段差距:medium 用单元测试反馈后,99% 通过了给定的测试,只有 60% 真正正确。

第二点是自我调试的天花板。演示 3 改变”给模型看多少测试”(medium,贪心初稿):

模型看得见的测试真正正确
给定 2 个测试55.0%
全部基础测试55.0%
基础 + 加强测试95.0%

自我调试的上限由它看得见的测试决定:测试没有覆盖的边界情况,它既发现不了,也不会去修。(“给它看全部测试”在评估时是作弊——你会用同样的测试判它对不对。正确的做法是给模型一部分测试,用另一部分或一份隐藏的测试集评估,和第 10 讲的开发 / 测试划分一样。)

演示 2 再做一个公平的对照:同样最多 4 次调用,“采样 4 个、挑第一个通过给定测试的”得到 63.0%,和带单元测试反馈的自我调试(60.0%)差不多。原因很清楚:两者用的是同一个信号(给定的 2 个测试),最后都卡在同一个天花板上。没有具体反馈的自我调试,本来就等于”重新采样、再用测试挑”。这个模拟里每道题的程序很短、可能的写法很少,重新采样很容易碰对,“定位”的优势显不出来;程序越长、写法越多,从头再来越难碰对,把”在哪种输入上错了”变成”改哪一行”的价值就越大——这是论文里真实模型上的结论。

5.3 两个风险:针对测试写代码,以及改测试

模型(尤其是被要求”让测试通过”的智能体)有时会走捷径:

  • 针对测试写代码:if nums == [-3, -1, -2]: return -1——通过了看得见的测试,在别的输入上仍然是错的。
  • 改测试:智能体能修改仓库里的文件时,它可能把失败的测试删掉、改掉,或者让测试永远通过。

这是第 10 讲古德哈特定律在代码上的样子:“测试通过”是目标的代理,一旦被直接优化,就可能和目标脱节。对策:评估用模型看不见的测试;智能体没有权限修改测试文件;检查补丁有没有动测试;人工审查一部分补丁。

5.4 没有测试时:CodeT 与多数投票

很多真实场景没有现成的测试。Chen et al.(2023)的 CodeT 让模型自己写测试:采样很多个程序和很多个测试,然后用”双重执行一致性”挑程序——把通过同一组生成测试的程序归为一组,组的得分 = 组里的程序数 × 它们通过的测试数,选得分最高的那一组。一个更简单的变体是按输出多数投票(MBR-exec):在同样的输入上输出完全相同的程序归为一组,选最大的一组。两者的直觉相同:正确的程序彼此一致,错误的程序各错各的——和第 4 讲的 self-consistency 是同一个想法,只是”投票”的单位从答案变成了程序的行为。

05_codet.py:每题 20 个样本(T = 0.8)、20 个生成的测试(输入随机,期望输出是模型”心里认为”的答案——用它另外采样的一个程序的输出来代表)。

模型随便挑一个贪心输出多数投票CodeT上限(20 个里有对的)
small26.7%30%26%31%89%
medium46.3%45%63%66%99%
large62.8%80%83%82%100%
  • medium:CodeT 比随便挑和贪心都高约 20 个百分点,完全没有用到人写的测试。Chen et al.(2023)报告 CodeT 把 code-davinci-002 在 HumanEval 上的 pass@1 提到 65.8%,绝对提升 18.8 个百分点。
  • 贪心不一定比随便挑好(medium 45% vs 46.3%):贪心挑”最可能的写法”,CodeT 挑”最常见的行为”——正确写法有很多种时,后者才对得上(§2.3)。
  • 离上限还很远,而且 small 上几种方法都差不多:在难题上,模型最常见的行为本身就是错的,一致性救不了它。

模型自己写的测试有多少是对的?small 70.5%、medium 81.8%、large 87.8%。模型写测试时对答案的”信念”和它写代码时同源,会把自己的误解原样写进期望输出里。CodeT 不要求每个测试都对:一个错的测试只会被”犯同样错误的程序”通过;但如果模型在一道题上大多数时候都犯同一个错,错误的程序和错误的测试会互相支持,CodeT 会自信地挑错——和第 4 讲 self-consistency 修不了系统性误解是同一件事。


6. 软件工程智能体 · Software engineering agents

6.1 SWE-bench:从”写一个函数”到”修一个 issue”

HumanEval 的题是独立的小函数,真实的软件工程是另一回事:在一个几十万行的仓库里,读懂一个 issue,找到相关的代码,改对,不弄坏别的东西。Jimenez et al.(2024)的 SWE-bench 从 12 个流行的 Python 仓库里收集了 2294 个真实的 GitHub issue 和对应的修复:给模型仓库和 issue 描述,它生成一个补丁;补丁要让原来失败的测试通过,并且不让原来通过的测试失败。论文发布时,最好的模型(Claude 2)只解决了 1.96%。之后出现了更小的子集 SWE-bench Lite,以及 500 道经过人工确认的 SWE-bench Verified。

这个任务暴露了很多 HumanEval 测不到的东西:在大仓库里定位(应该改哪个文件的哪个函数),读长代码,做精确的编辑,运行测试并根据结果调整——这些都是智能体的能力(第 13–14 讲)。

6.2 SWE-agent:接口设计决定表现

Yang et al.(2024)的 SWE-agent 的核心观察是:语言模型是一种新的”终端用户”,就像人需要 IDE,智能体也需要为它设计的接口——他们叫”智能体–计算机接口”(ACI)。他们给智能体设计的命令包括:

  • search_file / search_dir / find_file:在仓库里搜索,返回简洁的结果;
  • open / scroll_up / scroll_down:文件查看器,一次只显示 100 行,带行号;
  • edit 起始行:结束行:只替换指定的几行;编辑之后自动做语法检查,有错误就拒绝这次编辑、告诉智能体哪里错了;
  • 简洁、格式固定的反馈(命令成功但没有输出时,也明确说一声)。

用这套接口,SWE-agent 在 SWE-bench 上得到 12.5%(pass@1),在 HumanEvalFix 上 87.7%。更有启发的是他们的消融实验(SWE-bench Lite,GPT-4 Turbo):

接口的变化解决率
完整的 ACI18.0%
编辑后不做语法检查15.0%
没有 edit 命令10.3%
文件查看器显示 30 行14.3%
文件查看器显示整个文件12.7%

6.3 在迷你仓库上

06_swe_agent.py 做了一个能在几秒内跑完的版本:两个文件、14 个函数的小仓库,每次注入一个 bug(26 个 issue),issue 只描述现象(“Calling count_vowels(‘Python’) returns 2, but 1 is expected.”)。模拟智能体的旋钮:看的代码越多越难认出出错的那一行;窗口比函数短时只看到一部分;按行编辑以 15% 的概率写错缩进;重写整个文件时每 10 行有 3% 的概率出错、10% 的概率顺手改坏别的函数。文件编辑、语法检查(Python 的 compile)、测试运行都是真的。最多 8 步,26 个 issue × 5 个种子:

接口解决平均步数平均 token
完整的 ACI(search + 12 行窗口 + edit + 语法检查)73.1%6.3166
去掉语法检查68.5%6.8194
窗口太小(4 行)56.9%6.8107
open 显示整个文件66.2%6.6739
没有 edit(只能重写整个文件)54.6%6.7819
没有 search63.1%6.8265
只有 shell(cat、重写整个文件、没有 search 和语法检查)52.3%7.01955

数字由旋钮决定,但每一项的方向都和 SWE-agent 的消融一致,而且每一项背后的机制都很直观:

  • 语法检查把错误挡在发生的那一刻:没有它,写错的缩进进了文件,要等跑测试才发现,智能体还可能没意识到是语法问题、接着去”修逻辑”。这是第 8 讲”错误信息就是提示”的又一个例子:好的接口让错误早、清楚地暴露。
  • 窗口大小有一个最佳点:看得太多,难以在一大段代码里认出那一行(第 7 讲的”迷失在中间”);看得太少,连整个函数都看不全。
  • 按行编辑比重写整个文件安全得多:改的越少,出错的机会越少,也不会顺手改坏别的东西。
  • token:只有 shell 命令时,智能体读进上下文的文字是完整 ACI 的 11.8 倍——更贵、更慢,也更容易迷失。

6.4 一定要用智能体吗?

Xia et al.(2024)的 Agentless 提出了一个反问:不让模型自己决定下一步做什么,只用一个固定的三阶段流水线——定位(逐级缩小到文件、函数、行)、修复(生成多个候选补丁)、验证(用测试挑补丁)——在 SWE-bench Lite 上得到 32.00%(96 个),成本约 0.70 美元,在当时的开源方案里既是最好的、也是最便宜的。这正是第 9 讲”任务分解”和第 8 讲”先规划 vs 边做边看”的取舍:流程是固定的、可预测的时候,固定的流水线往往比让模型自由探索更便宜、更稳;任务需要根据中间结果改变计划时,智能体才更有优势。第 13 讲会接着讨论这个问题。


7. 在真实模型上 · On a real model

07_real_llm.py(可选,需要 API key,约 20–40 次调用,预算上限 1.00 美元)让真实的 Claude 写这 20 个函数:

  • 只给函数签名和文档字符串,看基础测试和全部测试各通过多少——它在哪些边界情况上出错?
  • 给定的 2 个测试没通过的题,把失败的测试作为反馈交给它,最多改 2 轮;修好的程序在加强测试上也对吗?
  • 它写的代码和参考解的 BLEU,和是否正确有关系吗?

模型写的代码都在沙箱的子进程里运行。强的模型可能一题都不差——那说明这 20 道题对它太简单了,可以换成你自己项目里的函数和测试。--toy 模式用 SimCoderLLM 跑同一段代码(编写讲义的环境里没有运行真实模型)。


8. 小结 · Summary

  1. 代码特殊在可以被执行:执行给出客观、便宜、外部的信号,可以用来评估、反馈和挑选。
  2. pass@k:k 次里至少一次通过全部测试。用 n ≥ k 个样本和无偏估计 1−(n−ck)/(nk)1-\binom{n-c}{k}/\binom{n}{k};朴素的 1−(1−c/n)k1-(1-c/n)^k 因为凹函数而偏低(n = 20、k = 10 时偏低 2.4 个百分点)。pass@1 要低温,pass@100 要高温。
  3. 沙箱:每段代码在子进程里运行,加超时、资源上限、临时目录、最少的环境变量;它挡得住写错的代码,挡不住故意的恶意代码——生产环境要用容器或虚拟机级别的隔离。
  4. BLEU 不适合代码:去掉和参考解一字不差的程序后,BLEU 偏爱长得像参考解的错误程序(AUROC 0.42),甚至会把两个模型的排名颠倒。测试不够时 pass@k 也会虚高:加上边界情况的测试,medium 从 90% 掉到 45%。
  5. 自我调试:具体的反馈(失败的测试)比”错了”好;但它的上限由模型看得见的测试决定。和同样调用次数的”采样 + 用测试挑”比较,才知道”改”本身的价值。小心针对测试写代码和改测试。
  6. CodeT / 多数投票:没有测试时,用程序之间行为的一致性挑——正确的程序彼此一致,错误的各错各的;它修不了模型一贯的误解。
  7. SWE-bench 与 ACI:真实的软件工程需要定位、精确编辑、根据测试调整;同一个模型,接口设计(搜索、窗口、按行编辑、编辑后检查)能让解决率差一大截。固定的流水线(Agentless)有时比自由的智能体更便宜、更稳。

9. 常见误区 · Common pitfalls

误区实际情况
“pass@10 就是每题采样 10 次看有没有对的”方差大、浪费;采样 n ≥ k 次,用无偏估计
“先算 p̂ = c/n,再算 1−(1−p̂)^k”有偏(偏低),因为 1−(1−p)^k 是凹函数
“温度设成 0 最好”对 pass@1 通常是;对 pass@100 要高温,需要多样性
“在评测脚本里 exec 一下模型的代码就行”死循环、sys.exit、写文件、读环境变量都会作用在评测程序身上;用子进程,生产环境用容器
“子进程加超时就是安全的沙箱”它只挡得住写错的代码,挡不住故意的恶意代码
“BLEU 高说明代码写得好”BLEU 奖励的是像参考解,差一个字符的错误程序 BLEU 可以接近 1
“通过了测试就是对的”只说明测试覆盖到的情况对;测试不够时 pass@k 会大幅虚高
“自我调试能修好所有错误”它只能修看得见的测试暴露的错误;还可能针对测试写特判
“模型自己写的测试没用,因为它会写错”CodeT 不要求每个测试都对,只要正确的行为比任何一种错误行为更常见
“智能体越自由越好”接口设计决定错误多容易发生、多容易被发现;流程固定时,固定的流水线常常更便宜、更稳

10. 练习 · Exercises

练习 1(计算) 某道题采样 n = 10 次,c = 3 次正确。(a)用无偏估计算 pass@1、pass@3、pass@8。(b)用朴素估计算 pass@3,和(a)比较。(c)n − c < k 时为什么直接返回 1?

参考答案:(a)pass@1 = 1 − C(7,1)/C(10,1) = 1 − 7/10 = 0.3;pass@3 = 1 − C(7,3)/C(10,3) = 1 − 35/120 ≈ 0.708;pass@8 = 1 − C(7,8)/C(10,8),C(7,8) = 0(7 个错的里抽不出 8 个),所以 pass@8 = 1。(b)朴素:1 − 0.7³ = 0.657,比无偏估计低约 5 个百分点。(c)错的样本只有 n − c 个,少于 k 时,不放回地抽 k 个必然抽到至少一个对的。

练习 2(设计) 你要为公司内部的一个 Python 工具库评估”让模型写新函数”的能力。设计评估方案:题目从哪里来、测试怎样写、用什么指标、怎样防止污染。

参考答案(一种可能):题目:从库的 issue 和近期的功能需求里挑 50 个,写成”签名 + 文档字符串 + 用法示例”;从不公开。测试:每题由两个人分别写——常见情况、边界情况(空、负数、Unicode、超大输入)、错误输入的行为;再用参考实现在随机输入上对拍(属性测试)。给模型看的测试只用其中 2–3 个,其余作为隐藏测试。指标:贪心 pass@1(日常使用)、T = 0.8 时 n = 20 的 pass@5(“给几个建议”的场景)、加上运行时间和是否使用了禁止的依赖。防污染:题目是新写的、不公开;定期轮换。所有代码在容器里运行,不能联网。

练习 3(解释) §2.3 里,large 模型贪心的 pass@1(80%)明显高于 T = 0.2 采样(73%),而 medium 模型两者差不多。用”正确的写法有好几种”解释这个差别,并说明为什么 CodeT 在 medium 上收益最大。

参考答案:large 模型在大多数题上,参考写法本身的概率就最高,贪心直接选中它;采样只会引入随机错误。medium 模型在一些题上,正确的行为分散在两三种写法上(每种的概率都比某一个错误写法低),贪心选中了那个错误写法;但把所有正确写法加起来,正确的行为仍然是最常见的行为。采样之后按行为分组(CodeT、多数投票),就能把分散的正确写法合在一起、胜过那个单独概率最大的错误写法——所以 medium 上收益最大(45% → 66%)。large 上贪心已经选对,收益小;small 上正确的行为在难题上连”最常见”都不是,一致性也救不了。

练习 4(动手) 在 code_lab.py 里给 count_vowels 的基础测试加上 ("HELLO",),重新运行 02 号脚本的演示 4 和 04 号脚本。哪些数字变了?为什么?

参考答案:count_vowels 的”忘了 .lower()“这个错误会被基础测试抓住:演示 4 里”只用基础测试”的通过率下降(medium 少一道),和”基础 + 加强”的差距变小;04 号脚本里,如果 ("HELLO",) 在给定的前两个测试里(要把它放在前面),自我调试就能看到这个失败、修好它,“真正正确”上升。这说明测试的选择直接决定了 pass@k 的高低和自我调试的上限。(注意:改了测试,缓存的键也会变,第一次运行会重新执行相关的程序。)

练习 5(批判) 一篇博客说:“我们的编程智能体在内部 benchmark 上的通过率从 40% 提升到了 85%,方法是允许它在提交前运行测试并反复修改。“列出至少三个你想问的问题。

参考答案:(1)它能运行的测试和评估用的测试是同一套吗?如果是,85% 衡量的是”让这些测试通过”,不是”代码正确”——要用它看不见的测试评估。(2)它有没有修改测试文件的权限?补丁里有没有动测试、加特判?(3)调用次数和成本增加了多少?和”同样预算下多采样几次、用测试挑”比过吗?(4)测试本身覆盖得怎样?有没有像 EvalPlus 那样补充边界情况再测一次?(5)benchmark 的题目是否可能出现在模型的训练数据里?

练习 6(设计接口) 你要给一个”修改 SQL 查询”的智能体设计 ACI。参照 §6.2 的原则,列出你会给它的 4–5 个命令,以及每个命令的输出格式。

参考答案(一种可能):(1)list_tables → 表名和行数,一行一个;(2)describe 表名 → 列名、类型、是否可为空、外键,最多 50 行;(3)run_query SQL → 只读事务里执行,返回前 20 行和总行数,超时 5 秒,报错时给出数据库的原始错误信息和出错位置;(4)edit_query 起始行:结束行 新内容 → 修改草稿查询,修改后自动做语法检查(例如用数据库的 EXPLAIN),有错就拒绝并给出错误;(5)submit → 提交草稿查询。原则:只读权限(最小权限,第 12 讲)、输出简短固定、错误早暴露、限制每一步读进上下文的量。


11. 延伸阅读 · Further reading

代码模型与 benchmark

  • Chen et al. (2021). Evaluating Large Language Models Trained on Code(Codex、HumanEval、pass@k)。
  • Austin et al. (2021). Program Synthesis with Large Language Models(MBPP)。
  • Li et al. (2022). Competition-Level Code Generation with AlphaCode. Science.
  • Bavarian et al. (2022). Efficient Training of Language Models to Fill in the Middle.
  • Li et al. (2023). StarCoder: May the Source Be with You!;Rozière et al. (2023). Code Llama: Open Foundation Models for Code.
  • Jain et al. (2024). LiveCodeBench: Holistic and Contamination Free Evaluation of Large Language Models for Code.

指标

  • Ren et al. (2020). CodeBLEU: A Method for Automatic Evaluation of Code Synthesis.
  • Liu et al. (2023). Is Your Code Generated by ChatGPT Really Correct? Rigorous Evaluation of Large Language Models for Code Generation(EvalPlus). NeurIPS.

执行反馈与挑选

  • Chen et al. (2024). Teaching Large Language Models to Self-Debug. ICLR.
  • Chen et al. (2023). CodeT: Code Generation with Generated Tests. ICLR.
  • Shi et al. (2022). Natural Language to Code Translation with Execution(MBR-exec)。

软件工程智能体

  • Jimenez et al. (2024). SWE-bench: Can Language Models Resolve Real-World GitHub Issues? ICLR.
  • Yang et al. (2024). SWE-agent: Agent-Computer Interfaces Enable Automated Software Engineering. NeurIPS.
  • Xia et al. (2024). Agentless: Demystifying LLM-based Software Engineering Agents.

12. 下一讲预告 · Next lecture

这一讲的沙箱防的是”写错了的代码”。当模型能执行代码、调用工具、读网页和邮件时,还有一类更危险的输入:有人故意写给模型看的指令。第 12 讲进入原课第 8 周:对抗性提示与护栏(以防御为主线)。

  • 威胁模型:谁是攻击者,能控制什么,想得到什么;
  • 提示注入:直接注入,以及藏在网页、文档、工具结果里的间接注入(Greshake et al. 2023)——为什么”数据”和”指令”在一个提示里很难分开;
  • 越狱为什么能成功的两种解释(目标冲突与泛化不匹配,Wei et al. 2023)——只讲机理与分类;
  • 数据投毒与后门的风险面;
  • 护栏:输入 / 输出分类器、结构化分隔(spotlighting)、指令层级、最小权限和人工确认;
  • 红队评估与”自适应的攻击者”:为什么一个在固定攻击集上 0% 成功率的防御,不等于安全。
← 返回课程目录