免费模型生成补丁的几何级错误空间使其输出不可靠;改为要求模型输出pytest测试用例,客观可验证,再用mutation filter筛选,可靠性与昂贵模型持平。
对于免费编程模型,最有用的任务不是生成补丁,而是生成候选测试。用提示词让小模型生成补丁,返回的是表面可信实则暗藏错误的代码,你会花大量时间去验证。而用提示词让模型生成 pytest 测试函数,返回的结果自带客观判定:测试通过、失败或抛出异常。当你再用变异过滤器跑一遍这些测试,就能快速发现哪些值得永久保留在测试套件里。对于零预算的模型和昂贵的模型,信任门槛是相同的:一个被保留下来的测试,就是一个在真实植入 bug 时会失败、而在原始代码上保持通过的测试。
本文从头构建这个工作流。当你在面对一个陌生的函数、又不想让模型替你决定什么是正确行为时,可以使用这个方法。模型决定如何搜索,变异过滤器决定每个搜索结果是否值得保留。
补丁生成具有几何级数的错误空间。模型必须选对正确的行、正确的表达式、正确的调用签名,以及正确的周围控制流。免费模型往往局部语法正确但全局语义错误,这让输出看起来诱人却难以审查。测试的契约更小。生成的测试断言一个具体的输入输出对,或一个已记录的异常。如果断言错误,测试会在原始函数上大声失败。如果断言正确,它仍然不会告诉你它在 mutation 下会如何——除非你跑一次。
第二个优势是冗余。你可以廉价地请求大量候选测试,去重后只保留那些证明了强度的。一个单一的有用补丁需要完全正确。一批测试只需要几个幸存者。这种不对称性使得更弱、更便宜的模型也能在这个角色中发挥作用。
整个流程分为四步。第一步,将函数签名和简短描述发送给一个免费模型,请求 pytest 候选测试。第二步,在未修改的函数上执行每个候选测试,剔除所有在此失败的。第三步,对函数施加小的机械变异,再用存活的候选测试在每个变异副本上重新运行。第四步,只保留那些在捕捉到至少一个变异、同时在未改动的实现上保持通过的候选测试。
为了让这个例子可运行,我使用了一个刻意简小的目标函数。它解析一个冒号分隔的正整数区间。
# interval.py
def parse_span(s: str) -> tuple[int, int]:
start, end = s.split(":")
return int(start), int(end)
简单的代码让评估过程可读。同样的过程可以用于更大的函数,但大函数会让你更难理解某个测试为何存活或死亡。
生成步骤就是一个 OpenAI 兼容的聊天调用。我一次请求十个候选,并使用约束严格的系统提示词。
# generate_candidates.py
import os
from openai import OpenAI
client = OpenAI(
base_url=os.getenv("LLM_BASE_URL", ""),
api_key=os.getenv("LLM_API_KEY", ""),
)
SYSTEM_PROMPT = """You are a test-generation engine.
You receive a function signature and a short description.
Generate pytest functions that challenge edge cases.
Return ONLY the pytest code. No explanation.
"""
def generate_candidates(prompt: str, n: int = 10) -> list[str]:
resp = client.chat.completions.create(
model=os.getenv("LLM_MODEL", "default"),
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": prompt},
],
temperature=0.9,
n=n,
)
return [choice.message.content for choice in resp.choices]
这个循环故意用蛮力方式。Temperature 采样让候选测试分散开,而不是收敛到一条平淡的 happy path。当端点是免费层级时,这个循环的成本接近于零,这正是 MonkeyCode 的免费模型访问和免费服务器选项的用武之地。两者都通过 OpenAI 兼容接口暴露,所以上面的脚本无需更换 SDK 或重写提示词就能工作。
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
提示词要小。冗长的上下文会把免费模型推入复现例子的模式,而不是发明新的边界情况。我的工作提示词只有签名、描述,以及两行说明什么是有效测试。
过滤器应用若干源代码级变异,然后重新运行每个候选。对于这个函数,我使用了三种变异:起点偏差一、终点偏差一,以及分隔符从冒号改为短横线。
# mutation_filter.py
import subprocess
import sys
MUTATIONS = {
"int(start) + 1": "int(start)",
"int(end) - 1": "int(end)",
"s.split('-')[0]": "s.split(':')[0]",
}
def run_single_test(test_code: str, module_code: str, name: str) -> bool:
with open(f"mutation_{name}.py", "w") as f:
f.write("from interval import parse_span\n")
f.write(test_code)
result = subprocess.run(
[sys.executable, "-m", "pytest", f"mutation_{name}.py", "-q", "--no-header"],
capture_output=True,
text=True,
)
return result.returncode == 0
def evaluate(test_code: str, original: str) -> tuple[bool, list[str]]:
base_pass = run_single_test(test_code, original, "base")
caught = []
for label, mutated in MUTATIONS.items():
mutated_code = original.replace("int(start)", label, 1)
if run_single_test(test_code, mutated_code, label):
caught.append(label)
return base_pass, caught
字符串替换是刻意粗糙的。它记录了方法,而不是假装是一个完整的 AST 工具。对于生产级设置,可以用一个解析 Python 并变异特定节点的库来替代;过滤逻辑保持不变。
假设一个生成的候选测试是 assert parse_span("2:5") == (2, 5)。它在原始函数上通过。面对起点偏差变异时,它失败了,因为变异后的函数返回 3。面对终点偏差变异时,它失败了,因为返回 4。面对分隔符变异时,它失败了,因为调用抛出了 ValueError。这个候选测试赢得了一席之地。
在这个玩具例子里,每个候选测试都能捕捉到每一种变异,因为变异粒度很粗。这是小函数的症状。在真实的模块上,大多数候选测试会捕捉到零个变异,过滤器会显得很残酷。残酷正是重点。一个捕捉不到任何变异的候选测试没有任何可观测的价值,不管它看起来多巧妙。
这个方法不会取代人类设计的属性测试。免费模型倾向于重复相同的输入类别,通常是零、一、二,以及一个空字符串,而遗漏整数溢出或不常见分隔符处的边界。变异过滤器只对模型已经猜测的内容打分;它无法发明一个缺失的类别。如果你的代码库已经有强大的属性测试套件,添加这一层基本上只会产生噪音。
过滤器还假设变异是忠实的。引入语法错误的变异会让每个候选测试都失败,使保留决策变得无用。从精心策划的语义变异列表开始,并在运行完整批次前验证每个变异都能编译。以及,除非你已经确认了其留存策略,否则永远不要将专有或受监管的代码传入托管的免费端点。这个方法与提供商无关,但信任边界仍然由你划定。
一个存活下来的测试并不能证明模型理解了这个函数。它只能证明模型生成了一个假设,而这个假设在一次认真的破解尝试中存活了下来。这个区别很重要。你并不是在将一个免费模型纳入你的设计流程。你是在用它作为大量小型、可证伪声明的生成器,然后用你对待同事建议的同等标准来运行这些声明。
差异在审查时变得明显:一个补丁往往会冻结讨论,而经过过滤的测试列表往往会打开讨论。每个存活的测试都编码了一个假设,审查者可以不同意这个假设而不阻止整个合并。
免费层级改变了这个循环的经济性。无限的试探性测试生成变成了后台任务,而不是预算决策。即使你的下一个模型是付费的前沿模型,这个工作流仍然有用,因为过滤器不在乎是哪个端点产生了猜测。它只在乎哪些猜测足够强大值得保留。