用 Qwen2.5-Coder + Ollama function calling 为 Foundry 智能合约自动生成和迭代 fuzz 测试,通过三个工具(读文件、运行测试、写测试)完成端到端循环。本地运行,无需外部 API。
第一次我让本地模型无监督地驱动 Foundry 时,它花了十一个回合试图通过重命名测试函数来修复一个模糊测试。不是改变逻辑。只是重命名。testFuzz_withdraw,然后 test_fuzz_withdraw,然后 testWithdrawFuzz,每次都运行测试套件,并以崭新的乐观主义重新读取同一个编译器错误。
这个实验最终还是演变成了我工作流中最有用的工具之一,只要我接受了小型本地模型在 agent 循环中能做和不能做的事。思路很简单:给 qwen2.5-coder 三个工具(运行 forge 测试、读取合约文件、写入模糊测试),指向一份目标合约。它读取代码,写属性测试,运行它们,读取失败,然后迭代。当它有效时,它会浮现出那些我最终会发现的破损不变量,但它在我喝咖啡的时候就能找到,而且所有东西都留在我的机器上。
Ollama 通过其 chat API 支持函数调用。你用 JSON schema 描述工具,模型返回 tool_calls,你执行并把结果反馈回去。保持工具集最小,每一个额外的工具都是让 7b 模型困惑的另一种方式。
const tools = [
{
type: "function",
function: {
name: "read_file",
description: "Read a Solidity source file from the project.",
parameters: {
type: "object",
properties: {
path: { type: "string", description: "Path relative to project root" },
},
required: ["path"],
},
},
},
{
type: "function",
function: {
name: "write_fuzz_test",
description:
"Write the complete contents of the fuzz test file test/Fuzz.t.sol. " +
"Always write the FULL file, never a fragment.",
parameters: {
type: "object",
properties: {
content: { type: "string", description: "Full Solidity file content" },
},
required: ["content"],
},
},
},
{
type: "function",
function: {
name: "run_forge_test",
description: "Compile and run the test suite. Returns compiler errors or test results.",
parameters: { type: "object", properties: {}, required: [] },
},
},
];
这里面隐藏了两个设计决策。首先,write_fuzz_test 不接受路径参数:模型恰好写一个文件,test/Fuzz.t.sol,总是完整写入。让小型模型选择文件路径或输出 diff 就等于在要求 contracts/test/../test/Fuzz.sol 和针对根本不存在的行号的补丁。其次,run_forge_test 完全不接受参数,所以模型无法造出标志。
执行器是薄包装器。唯一有趣的是测试运行器,它会激进地截断:
import { execFileSync } from "node:child_process";
function runForgeTest(): string {
try {
const out = execFileSync("forge", ["test", "--fuzz-runs", "256"], {
cwd: PROJECT_ROOT,
timeout: 120_000,
encoding: "utf8",
});
return tail(out, 3000);
} catch (e: unknown) {
const err = e as { stdout?: string; stderr?: string };
return "FAILED\n" + tail((err.stdout ?? "") + (err.stderr ?? ""), 3000);
}
}
截断很重要,因为 forge 在失败的模糊测试上的追踪可能非常庞大,把 40k token 的追踪转储到小模型的上下文中并不会让它更清楚,反而会让它变蠢。我保留末尾,那是 Foundry 放反例和摘要的地方。
const messages: Message[] = [
{ role: "system", content: SYSTEM_PROMPT },
{ role: "user", content: `Target contract: src/${target}. Read it, identify invariants, write fuzz tests, run them, iterate until they compile and either pass or reveal a real counterexample.` },
];
for (let turn = 0; turn < MAX_TURNS; turn++) {
const res = await ollama.chat({ model: "qwen2.5-coder:7b", messages, tools });
messages.push(res.message);
if (!res.message.tool_calls?.length) break; // model thinks it's done
for (const call of res.message.tool_calls) {
const result = dispatch(call.function.name, call.function.arguments);
messages.push({ role: "tool", content: result });
}
}
系统提示是大部分迭代的地方。那些赚到了自己位置的部分:
You write Foundry fuzz tests for one target contract.
Rules:
- ALWAYS read the target contract before writing any test.
- Test properties and invariants, not specific values. Good properties:
balance accounting sums correctly, access control cannot be bypassed,
state transitions preserve solvency, no operation mints value from nothing.
- Use vm.assume or bound() to constrain inputs, never require statements.
- After every write, run the tests. Read the ACTUAL error before editing.
- If the same error appears twice in a row, change your approach entirely.
- A failing fuzz test with a counterexample is a SUCCESS. Report it and stop.
最后一行很重要也很反直觉。模型的本能是通过削弱断言直到通过来"修复"失败的测试,这恰好是安全工具不应该做的。明确地告诉它一个合法的反例是胜利条件在大多数情况下(不总是)能抑制那种反射。我在构建 spectr-ai 时也遇到过同样的失败模式:小模型被训练为消除错误,而在安全工作中,错误本身往往就是产品。
小模型在长 agent 循环中会迷失方向。在八到十二个回合左右,qwen2.5-coder:7b 开始忘记系统提示中的约束,重新读取已经读过的文件,或在同一测试的两个破损版本之间循环。1.5b 模型更糟,它很少能在第四个回合后活下来而不发出参数格式错误的工具调用。这不是我能通过提示解决的 bug,这就是有限的上下文处理和小参数量在实践中的样子。
所以我停止与之对抗,转而约束循环:
硬性回合上限。 MAX_TURNS = 12。超过那个,test/Fuzz.t.sol 中的任何东西就是输出,我接手。
循环检测。 我对每个 write_fuzz_test 的 payload 进行哈希。相同的哈希出现两次意味着模型在循环,工具库会注入一条用户消息说明这一点,仅一次。第二次重复会杀死这次运行。
编译优先门控。 最常见的死亡螺旋是追逐编译器错误(错误的导入路径、错误的 pragma)。工具库用已知良好的骨架预先填充文件(正确的导入、目标在 setUp 中的已部署实例),所以模型从能编译的东西开始,只需要填入属性。这一个改变大约让运行以有用方式结束的频率翻了一番。
一个目标,一个文件。 我不会要求它模糊整个协议。我要求它模糊一份合约,有时是一个函数。小范围是专注的属性测试和第十一个回合函数重命名之间的区别。
有了这些边界,一次运行在我的机器上(WSL2、Ollama、7b 模型)需要几分钟,通常以有用方式结束:要么编译我会编辑并保留的属性测试,要么一个真正的反例。它在我自己的一个保险库实验中发现了一个未检查的舍入情况,我手写的测试遗漏了,主要是因为模型对代码"应该"做什么一无所知,它测试了我跳过的无聊不变量。
这是诚实的宣传。它不是一个自主审计员,它是一个不知疲倦、略显呆板的实习生,拥有 Foundry 执照,以及边界正确的话,真的值得一用。
你试过给本地模型工具吗,如果试过,在哪一个回合你的开始自我重复?