单元测试通过——这四个字,是代码 Agent 最好的奖励信号

前两篇讲了用代码作为推理接口(PoT/CoC),以及动态选择推理模式(CodeSteer)。但都有一个隐含假设:代码生成是一次性的——生成,执行,得到结果。

现实里,代码第一次往往是错的。

这不是悲观陈述,而是代码生成的基本特征。即使是熟练程序员写的代码,也需要调试迭代。那么问题变成:能不能让模型从代码的执行结果中学习,自动迭代修正?

这正是 CodeRL(2022)和随后一系列迭代调试工作的核心思路。这一篇从 CodeRL 的训练机制讲到 Agent 推理时的迭代修正,把"执行反馈"这个信号完整地展开。

代码生成为什么特别适合强化学习

先说为什么代码生成比其他 NLP 任务更适合用强化学习(RL)。

传统 NLP 的 RL 有一个大问题:奖励信号很难定义。"这个翻译好不好""这个摘要准不准",需要人工评分,成本高且不稳定。

代码不同。代码有一个近乎完美的自动评判者:单元测试

# 模型生成的代码
def find_max_subarray(arr):
    max_sum = arr[0]
    current_sum = arr[0]
    for i in range(1, len(arr)):
        current_sum = max(arr[i], current_sum + arr[i])
        max_sum = max(max_sum, current_sum)
    return max_sum

# 单元测试(不需要人参与)
assert find_max_subarray([-2, 1, -3, 4, -1, 2, 1, -5, 4]) == 6  # ✅
assert find_max_subarray([-1]) == -1                               # ✅
assert find_max_subarray([5, 4, -1, 7, 8]) == 23                  # ✅

测试通过 = 正奖励,测试失败 = 负奖励或零奖励。这个奖励信号:

  • 自动(不需要人工标注)
  • 精确(测试要么通过要么失败,不模糊)
  • 可扩展(每个编程任务都可以有测试集)
  • 可靠(测试执行结果是客观的)

这几个性质凑在一起,让代码生成成为 LLM 强化学习的绝佳训练场。

CodeRL:Actor-Critic 的代码版

CodeRL(Salesforce Research,2022)把代码生成建模成经典的 Actor-Critic 强化学习框架:

Actor:代码生成模型(基于 CodeT5),输入题目描述,输出代码。 Critic:代码质量预测器,输入(题目, 生成的代码),预测这段代码通过测试的概率。

训练流程:

1. Actor 生成一批候选代码
2. 执行单元测试,得到真实的通过/失败信号
3. 用测试结果训练 Critic(让它学会预测哪些代码会通过测试)
4. 用 Critic 的预测为 Actor 提供密集奖励信号(比稀疏的通过/失败信号更平滑)
5. 迭代:Actor 生成更好的代码 → Critic 学到更准确的预测 → Actor 得到更好的反馈

为什么需要 Critic 而不是直接用测试结果训练?

测试结果是稀疏的(通过或失败),信号很粗。Critic 提供的是一个 0~1 的连续预测值,能给出"这段代码有 0.7 概率通过测试"的密集信号,让 Actor 的梯度更稳定。

推理时的关键策略:Critical Sampling

CodeRL 的另一个贡献是推理时的采样策略。普通的做法是生成 N 个候选,选最高概率的。CodeRL 的做法:

def critical_sampling(problem: str, n_candidates: int = 10):
    # 生成多个候选
    candidates = actor.generate(problem, n=n_candidates)

    # 对每个候选,用 Critic 评分
    scores = [critic.score(problem, code) for code in candidates]

    # 不是选最高分,而是根据分数分布做加权采样
    # 这样能平衡探索(低分候选偶尔被选中)和利用(高分候选更常被选)
    weights = softmax(scores / temperature)
    selected = random.choices(candidates, weights=weights, k=1)[0]

    return selected

这个方法在 APPS benchmark(竞赛级编程题)上超越了当时所有基于 GPT 的系统,即使 CodeRL 用的基础模型参数量小得多。

实验结果:在 APPS 上,CodeRL 相比纯监督微调提升约 3~4%;在 MBPP(基础 Python 编程任务)上的零样本迁移也达到当时最优。

RLTF:用测试失败信息细化奖励

RLTF(Reinforcement Learning from Test Feedback,2023)进一步细化了测试反馈信号。

普通的"通过/失败"信号太粗:即使代码失败了,也有"错了一点"和"完全错了"的区别。RLTF 的思路是:从测试失败中提取更细粒度的信号

具体做法:

def compute_rltf_reward(code: str, test_cases: list) -> float:
    results = []
    for test in test_cases:
        try:
            output = execute(code, test.input)
            if output == test.expected:
                results.append(1.0)      # 完全正确
            else:
                # 部分奖励:输出类型对但值错,比完全类型错更好
                if type(output) == type(test.expected):
                    results.append(0.3)  # 类型对但值错
                else:
                    results.append(0.0)  # 完全错
        except Exception as e:
            if isinstance(e, (TypeError, ValueError)):
                results.append(0.1)     # 至少运行了,有语法
            else:
                results.append(0.0)     # 连运行都没运行

    return sum(results) / len(results)  # 平均部分奖励

这种细粒度奖励让模型能区分"几乎对了"和"完全错了",训练信号更丰富。

推理时的迭代自修正

上面讲的是训练阶段怎么用执行反馈。推理阶段可以更直接:看到执行失败后,让模型自己修正代码

这个思路在实践中有几个变体:

Self-Debugging(自我调试)

async def self_debugging_agent(problem: str, max_retries: int = 3):
    code = await generate_code(problem)

    for attempt in range(max_retries):
        result = execute_code(code)

        if result.success:
            return code

        # 把执行错误反馈给模型,让它修正
        error_context = f"""
代码:
```python
{code}

执行错误: {result.error_message}

{f'测试失败:期望 {result.expected},实际 {result.actual}' if result.test_failed else ''}

请修正代码:"""

    code = await generate_code(error_context)

return code  # 最后一次尝试的结果

**关键设计决策**:错误信息怎么格式化?实验发现,**把错误信息、失败的测试用例和原始代码一起放进上下文**,比只给错误信息效果好得多。模型需要同时看到"什么错了"(错误信息)、"在哪里错了"(失败测试)和"原来的代码是什么"(代码本身),才能做出有效修正。

**Rubber Duck Debugging(鸭子调试)**:

更进一步的变体——让模型先解释代码的意图和逻辑,再基于这个解释来修正:

```python
async def rubber_duck_debug(problem: str, buggy_code: str, error: str):
    # 第一步:让模型解释代码意图
    explanation = await llm.ask(f"""
解释以下代码的意图和每一步的逻辑(不要评判对错):
```python
{buggy_code}
```""")

    # 第二步:基于解释来修正
    fixed_code = await llm.ask(f"""
原始问题:{problem}

代码意图:{explanation}

代码执行报错:{error}

基于上面的意图描述,修正代码:""")

    return fixed_code

这个两步法的道理是:先让模型"理解"代码在做什么,再让它"修改"代码,避免直接修改时模型把已经正确的部分也改掉。

测试驱动的 Agent:TDD-as-Harness

更系统化的思路是直接采用测试驱动开发(TDD)的 Agent 框架:

async def tdd_agent(specification: str):
    """
    TDD 流程:先写测试,再写代码,让测试驱动代码的迭代
    """
    # 1. 根据规范生成测试
    tests = await generate_tests(specification)

    # 2. 尝试生成通过测试的代码
    code = await generate_code_from_tests(specification, tests)

    # 3. 迭代直到所有测试通过
    max_iterations = 5
    for i in range(max_iterations):
        results = run_tests(code, tests)
        passing = [r for r in results if r.passed]
        failing = [r for r in results if not r.passed]

        if not failing:
            return code  # 全部通过

        # 只把失败的测试反馈给模型
        feedback = format_failing_tests(failing)
        code = await fix_code(code, feedback, f"第 {i+1} 次修正")

    return code

TDD 框架的优点是:测试是在实现代码前就写好的,所以测试本身是正确的(不会因为代码写错了就改测试来通过)。这给迭代修正提供了一个稳固的"地面真相"。

一个被忽视的问题:测试本身的质量

用执行反馈训练或引导 Agent,一个根本前提是:测试是正确的

但测试可能覆盖不全:代码通过了所有测试,但在测试未覆盖的边界情况上还是错的。这叫"测试过拟合"——Agent 学会了让代码通过测试,而不是真正解决问题。

SWE-bench(第 21 篇)里提到了这个问题:有些 issue 的测试套件不完整,Agent 可以生成一个通过测试但破坏其他功能的 patch。

工程上的缓解:

  • 多样化测试:除了给定的测试,让模型额外生成边界测试用例
  • 反例测试:除了"这个应该通过"的测试,也要有"这个应该失败"的测试
  • 行为覆盖率:不只看测试通过率,看代码覆盖率指标(branches、statements)
async def generate_comprehensive_tests(spec: str, code: str):
    """让模型生成能发现边界情况的测试"""
    edge_tests = await llm.ask(f"""
给定以下函数规范和实现,生成 5 个边界测试用例:
- 空输入
- 极大/极小值
- 已通过的测试的反例(什么情况下应该返回不同结果)
- 最容易出错的边界情况

规范:{spec}
实现:{code}

返回 Python 测试列表(assert 语句):""")
    return edge_tests

从 CodeRL 到现代 Coding Agent

CodeRL 是 2022 年的工作。现在的 Coding Agent(SWE-agent、Claude Code 等)用的方法更复杂,但核心思路是一脉相承的:

执行反馈是代码 Agent 最好的学习信号。不管是训练时的 RL(CodeRL),还是推理时的迭代调试(Self-Debugging),都在利用"代码执行结果"这个天然存在的、免费的、精确的反馈信号。

这个信号的价值在于:它不需要人在循环里。代码能不能跑,单元测试过没过,这些判断不需要人来做,是客观的、自动的。这让代码 Agent 能在极短时间内迭代大量候选,形成快速的反馈循环。

你在 Claude Code 里看到的"检查结果 → 发现错误 → 修正代码 → 再试"这个循环,本质上就是 CodeRL 和 Self-Debugging 的运行时版本,只是集成到了更复杂的 Agent 框架里。

小结

代码执行结果是一个近乎完美的 Agent 反馈信号:自动、精确、可扩展、无需标注。CodeRL 把这个信号用到了训练时,RLTF 细化了奖励结构,Self-Debugging 把它用到了推理时迭代。

三条路线的共同点:把"这段代码执行失败了"从一个障碍变成了一个老师

第一模块(代码作为推理接口)到此完整。接下来进入第二模块——代码作为行动接口:当代码不只是推理工具,而是 Agent 在真实世界采取行动的方式时,会带来什么新的设计挑战。第一篇讲 Voyager:一个用代码技能库做终身学习的 Minecraft Agent,是"代码作为技能表示"最经典的案例。

相关阅读

版权声明: 如无特别声明,本文版权归 sshipanoo 所有,转载请注明本文链接。

(采用 CC BY-NC-SA 4.0 许可协议进行授权)

本文标题:03. 从执行反馈中学习:CodeRL 与迭代自修正

本文链接:https://www.sshipanoo.com/blog/ai/code-agent-harness/03-CodeRL/

本文最后一次更新为 天前,文章中的某些内容可能已过时!