在实际开发工具链里,dev harness 通常指驱动代码生成、测试和调试的自动夹具。Ducklab 这个项目的特殊之处在于,它把本地模型(local models)作为改写自身代码的执行引擎,在 416 次运行中让工具一步步构建出自己,并控制住了 176 美元的成本。这个组合对做本地模型实践的开发者很有参考价值:不止是“用模型生成代码”,而是让它在一个有测试、有预算、有日志的循环里自我迭代。
这篇文章会沿着 Ducklab 的核心逻辑,拆解如何设计一个最小可运行的自举式开发工具。重点不是复刻某个项目文件,而是把思路和可落地的工程细节讲清楚:包括自举循环怎么设计、本地模型客户端怎么写、测试反馈怎么转换成 prompt、运行次数和成本怎么记录、循环失控时怎么排查。
1. 先理解 Ducklab 这类“自举式 dev harness”的构造逻辑
1.1 自举并不是让 AI 自己写全部代码
“自举”在编译器领域有明确含义:一个编译器能编译自己的源码,就叫自举。Ducklab 把类似思想带到了 dev harness 上:harness 本身是一个会调用本地模型的工具,内部包含任务列表、测试用例和代码写入入口。每次运行,它先生成或修补一块代码,然后立即执行测试,把失败信息返回给模型,再根据失败信息继续修改。
与普通 AI 编程工具不同的是,这里的反馈来源不是人工 review,而是可自动判定的测试结果。所以 Ducklab 更像一条把“写代码、跑测试、看日志”压缩成循环的流水线。如果测试始终失败,循环就不会停止;如果测试通过,说明当前任务完成,可以进入下一个任务。这种方式不需要模型有很强的规划能力,只要它能在局部代码修改上达到一定准确率,就能在迭代中逼近可用状态。
1.2 为什么优先使用本地模型
使用本地模型有两个直接原因。一是数据不出本机,代码片段、测试输出和日志不需要发送到外部服务;二是在循环中反复生成代码时,本地模型的批处理成本更容易控制。模型下载到本地后,每次生成的边际成本主要来自 CPU/GPU 的功耗和耗电时长,这与按 token 计费的云端 API 不同。实际项目中,如果团队统一管理模型版本,还能避免云端模型升级后生成风格变化导致测试结果不稳定。
但本地模型也有代价:模型参数越大,内存和显存需求越高,生成速度越慢。7B 参数的量化模型可以在普通显卡上流畅运行,13B 或 70B 模型就需要更高显存。Ducklab 标题里的 416 次运行和 176 美元,正好说明这个问题:几百次迭代在本地模型上可以完成,但成本不能无限扩张,必须用预算参数来约束。
1.3 与测试驱动开发的关系
这个流程天然依赖测试驱动开发的思路:先确定“什么算完成”,再让模型去补实现。测试通过,代表一次迭代成功;测试失败,失败信息就是模型的下一段上下文。没有明确判定标准,自动循环很容易变成无意义的对话。
所以 Ducklab 的核心并不只是本地模型,而是“可验证的反馈回路”。写测试的难度决定了自举工具的上限。测试如果只检查函数返回值,模型很容易修正;如果测试依赖复杂的外部状态,失败信息会很长,模型可能无法准确定位问题。
注意:自举循环不是让模型无脑重写代码。每一次修改必须能对应到具体失败用例,否则模型会越改越乱,几百万次运行也得不到可用结果。
2. 搭建运行 Ducklab 前的最小环境
2.1 硬件和依赖
在复现类似项目之前,先把硬件和依赖理清。学习环境不要求高配置,但要能加载所选模型;如果要跑到数百次循环,内存和显存至少要支撑所选模型的持续推理。下面是一个常见组合。
| 组件 | 学习环境最低要求 | 长期运行建议 |
|---|---|---|
| 内存 | 16 GB | 32 GB 以上 |
| GPU 显存 | 6 GB(量化 7B 模型) | 12 GB 以上 |
| CPU | 4 核 | 8 核以上 |
| Python | 3.10+ | 3.11+ |
| 本地推理服务 | Ollama 或 llama.cpp | 固定版本,避免漂移 |
| 测试工具 | pytest | 保持版本一致 |
如果机器只有 CPU,也能跑量化的小模型,但生成速度会慢很多,成本也会更高。如果发现单次循环超过几十秒,建议先把模型换小,不要急于优化代码。
2.2 目录结构设计
可以设计一个仿 Ducklab 的最小项目结构。核心是让“任务定义、模型调用、测试执行、运行记录”四件事分离,避免所有逻辑堆在一个文件里。
ducklab/ ├── config.py ├── model_client.py ├── runner.py ├── tasks/ │ ├── hello_task.py │ └── date_task.py ├── tests/ │ ├── test_hello.py │ └── test_date.py ├── workdir/ │ └── generated/ └── logs/ └── run_meta.jsonltasks目录存放当前任务源码,tests目录存放已确定的测试用例,workdir/generated是模型生成代码的临时工作目录,logs用来记录每次运行的信息。这样的结构可以让自举循环只关注一个任务文件的修改,测试文件保持稳定。
2.3 本地模型客户端
以 Ollama 的 HTTP 接口为例。模型名称在不同环境可能不同,落地前先执行ollama list确认当前有哪些模型。示例客户端如下:
import requests class LocalModelClient: """本地模型客户端,负责把 prompt 发给本地推理服务,并返回文本。""" def __init__(self, model: str, base_url: str = "http://localhost:11434"): self.model = model self.base_url = base_url self.prompt_tokens = 0 self.completion_tokens = 0 def complete(self, prompt: str, max_tokens: int = 1024) -> str: payload = { "model": self.model, "prompt": prompt, "stream": False, "options": {"num_predict": max_tokens}, } resp = requests.post( f"{self.base_url}/api/generate", json=payload, timeout=180, ) resp.raise_for_status() data = resp.json() self.prompt_tokens += int(data.get("prompt_eval_count", 0)) self.completion_tokens += int(data.get("eval_count", 0)) return data.get("response", "").strip()这里统计 prompt 和 completion token 数量,是为了后面做成本估算。timeout 设置为 180 秒,是为了防止本地模型偶发卡死导致循环挂起。
2.4 验证本地模型链路
在写主循环之前,先跑一个最小请求,确认模型客户端能正常工作。
from model_client import LocalModelClient client = LocalModelClient(model="qwen2.5-coder:7b") print(client.complete("Return the string 'ok'"))如果模型返回空字符串或连接失败,先检查 Ollama 服务是否启动、模型是否已下载、端口是否被占用。这个步骤能省掉后面主循环排错时的大量干扰。
3. 实现一个最小可运行的自举循环
3.1 任务文件和测试文件
一个自举循环的第一步,是定义一个足够小的任务。比如让模型实现一个add(a, b)函数。测试文件如下:
# tests/test_hello.py from generated import hello def test_add(): assert hello.add(2, 3) == 5初始任务文件只包含函数签名和 docstring,故意不返回正确结果,好让循环有起点。
# tasks/hello_task.py def add(a, b): """Return the sum of a and b.""" return None自举循环会不断改写tasks/hello_task.py,而tests/test_hello.py保持不变。这样模型虽然能改自己的实现,但不能随意修改判定标准,保证循环不会作弊。
3.2 生成修复的 prompt 模板
模型需要看到测试失败信息、当前代码和任务说明。prompt 应该尽量让模型只输出可执行代码,不要输出解释。示例:
def build_fix_prompt(test_result: str, source: str) -> str: return ( "You are fixing failing code. " "Read the test failure below, then rewrite the function body only. " "Do not explain, do not add comments, output code only.\n\n" f"# Source code\n{source}\n\n" f"# Test failure\n{test_result}\n" )prompt 里明确“只输出代码”可以减少解析成本,但模型不一定遵守,所以后面还需要代码提取逻辑。
3.3 主循环 runner.py
runner 负责把测试、模型调用、代码写入串起来。最简版本如下:
import subprocess import time from pathlib import Path from model_client import LocalModelClient def load_source(path: Path) -> str: return path.read_text(encoding="utf-8") def save_source(path: Path, source: str) -> None: path.write_text(source, encoding="utf-8") def run_tests(work_dir: Path) -> subprocess.CompletedProcess: return subprocess.run( ["pytest", "-q"], cwd=work_dir, capture_output=True, text=True, timeout=60, ) def run_iterations( model_client: LocalModelClient, source_file: Path, work_dir: Path, max_runs: int, budget: float, ) -> bool: runs = 0 total_cost = 0.0 while runs < max_runs and total_cost < budget: runs += 1 test_result = run_tests(work_dir) if test_result.returncode == 0: print(f"[PASS] runs={runs}") return True source = load_source(source_file) prompt = build_fix_prompt(test_result.stdout + test_result.stderr, source) new_code = model_client.complete(prompt, max_tokens=512) save_source(source_file, extract_code(new_code)) total_cost = estimate_runs_cost(model_client, runs) append_log(runs, test_result.returncode, total_cost) return False这个循环的核心逻辑是:先跑测试,如果通过就结束;如果不通过,就用失败信息生成新代码并覆盖源文件,然后继续下一轮。
3.4 模型输出提取与语法校验
本地模型经常在代码周围输出解释文字,为了不让这些文字混进源码,需要做提取。示例:
import re def extract_code(text: str) -> str: """从模型输出中提取第一个代码块;没有代码块时,去掉常见解释性前缀。""" match = re.search(r"```(?:python)?\n(.*?)```", text, re.S) if match: return match.group(1).strip() lines = text.strip().splitlines() if len(lines) > 1 and lines[0].startswith(("def ", "class ", "import ")): return text.strip() return text.strip()更稳妥的做法是在写入前用ast.parse校验语法。如果语法不合法,可以要求模型重新生成一次,而不是直接覆盖源文件。
import ast def is_valid_python(source: str) -> bool: try: ast.parse(source) return True except SyntaxError: return False校验通过后再写入,能避免大量无意义的测试运行。
3.5 成本记录函数
为了让循环可审计,建议把每次运行的信息写入 JSON Lines 文件。示例:
import json from datetime import datetime def append_log(run_no, returncode, cost, prompt_tokens, completion_tokens): record = { "run_no": run_no, "time": datetime.utcnow().isoformat(), "returncode": returncode, "cost_estimate": cost, "prompt_tokens": prompt_tokens, "completion_tokens": completion_tokens, } with open("logs/run_meta.jsonl", "a", encoding="utf-8") as fp: fp.write(json.dumps(record, ensure_ascii=False) + "\n")如果项目已经用 git 管理,可以在写入代码后执行一次 commit,并把 commit SHA 记录到日志中。这样 416 次运行留下的不是一次乱摊子,而是一串可回溯、可回滚的历史。
4. 怎么记录 416 次运行并控制成本
4.1 日志结构和运行统计
标题里的 416 次运行和 176 美元,说明 Ducklab 把运行次数和成本做成了可统计的指标。如果我们要复现类似实验,至少要在日志中记录以下几类信息:
- 第几次运行。
- 本轮测试是否通过。
- 模型输入和输出的 token 数。
- 当前累计成本估算。
- 修改的文件或提交哈希。
有了这些字段,后续可以用脚本汇总。示例统计脚本:
import json total_runs = 0 pass_runs = 0 total_cost = 0.0 with open("logs/run_meta.jsonl", "r", encoding="utf-8") as fp: for line in fp: if not line.strip(): continue record = json.loads(line) total_runs += 1 total_cost = record["cost_estimate"] if record["returncode"] == 0: pass_runs += 1 print(f"total_runs={total_runs}") print(f"pass_runs={pass_runs}") print(f"latest_cost={total_cost:.2f}")统计脚本的输出可以作为运行结束后的验证结果。如果 total_runs 等于设定的 max_runs,说明循环是在没有耗尽预算的情况下因为达到次数上限而结束,需要检查是否陷入重复失败。
4.2 成本估算的三种口径
成本不能只写一个总数,需要说明口径。不同项目适用的成本计算方法不同。
| 口径 | 计算方法 | 适用场景 |
|---|---|---|
| 硬件功耗 | 平均功耗 × 运行时长 × 电价 | 本地模型长期运行最实用 |
| token 成本 | 模型单价 × 输入输出 token 量 | 云端 API,本地模型仅供参考 |
| 人工时间成本 | 调试和审查耗时 × 单位时间工资 | 对比人工开发时使用 |
由于标题中的 176 美元是特定环境的结果,换成不同硬件和电价后数字会变化。更合理的做法是把budget参数传入循环,让程序在耗尽预算时自动停止。
def estimate_power_cost(watts: float, hours: float, price_per_kwh: float) -> float: return watts * hours / 1000 * price_per_kwh这里的watts可以取整机平均功耗,而不是只看 GPU 型号功耗。循环运行时,记录开始时间和结束时间,用运行时长乘以平均功耗再乘以电价,得到本轮成本。
4.3 用 git 形成可回滚的自举历史
自举循环很容易出现“越改越糟”的状态。每次失败后如果都直接覆盖源文件,一旦连续失败几次,就很难回到可运行的中间态。推荐做法是每次写入代码前先执行git add和git commit;如果测试失败超过阈值,可以从上次 PASS 的 commit 恢复。
在 runner 中接入 git 的逻辑可以很简单:
import subprocess def commit_if_git_available(message: str) -> str: try: subprocess.run( ["git", "commit", "-am", message], check=True, capture_output=True, text=True, cwd=".", ) sha = subprocess.check_output( ["git", "rev-parse", "HEAD"], text=True, cwd="." ).strip() return sha except subprocess.CalledProcessError: return ""commit 失败不影响循环,但记录了 SHA 后,回滚就变得容易:
git reset --hard <commit_sha>当模型连续修复同一个失败超过 3 次时,可以自动执行回滚,再从新的 prompt 分支尝试。这样 416 次运行就变成了有结构的实验,而不是随机猜测。
5. 常见问题与排查链路
5.1 模型生成代码不能通过语法解析
现象:写入生成代码后,pytest还没开始执行,Python 解释器就报SyntaxError。原因是模型输出了不完整代码,或者提取逻辑把说明文字混入了源码。
检查顺序:
- 查看日志中的模型原始输出。
- 确认
extract_code是否只提取了代码块。 - 使用
ast.parse在写入前校验语法。
处理方式是在save_source前加一次语法校验,校验失败则跳过本轮写入,并记录一条syntax_error日志。否则循环会把无效代码写进文件,后续测试结果全部失真。
5.2 循环陷入重复修复
现象:模型反复生成相似代码,测试失败原因不变,运行次数持续增加。原因是 prompt 只包含了当前源码和最新失败信息,没有包含之前尝试过的历史,模型在局部搜索里找不到出路。
处理方式:
- 限制同一失败信息连续出现次数,比如 3 次。
- 将历史修复尝试摘要加入 prompt。
- 如果连续 N 次失败,从最近一次 PASS 的 git commit 恢复,并降低生成温度。
在 prompt 中加入历史摘要时,不要把全部上下文都塞进去,否则长对话会导致模型丢失重点。可以只保留最近 3 次失败时的函数签名和主要异常类型。
5.3 本地模型内存持续增长
现象:循环跑到几十次后,机器变卡,推理速度明显下降。原因可能是本地推理服务为每个请求保存了上下文,或者客户端没有释放请求连接。
排查步骤:
- 查看模型服务日志和系统监控。
- 确认客户端使用会话连接池而不是每次创建新连接。
- 在长时间运行的循环中,考虑定期重启模型服务,或改用短上下文模型。
下面是常见问题速查表:
| 问题现象 | 常见原因 | 检查方式 | 处理建议 |
|---|---|---|---|
| 语法错误 | Markdown 代码块提取失败 | 查看模型原始输出 | 用 ast.parse 增加校验 |
| 重复修复 | prompt 缺少历史信息 | 对比多次 prompt | 加入历史摘要和恢复策略 |
| 成本超预算 | 没有设置 budget | 查看运行日志 | 在循环开头检查总成本 |
| 推理越来越慢 | 上下文累积或内存不足 | 查看系统监控 | 定期重启模型服务或换小模型 |
| 测试超时 | 用例执行时间过长 | 查看 pytest 输出 | 缩短测试用例、调整 timeout |
5.4 成本估算偏离实际
现象:日志中的成本没有超过预算,但实际电费或资源消耗比预期高。核心是估算口径模糊。解决方式是记录运行时长、模型推理时长、功耗和单价,而不是只记录一个累加数。
把power_watts和price_per_kwh做成配置项,让成本模块可以根据实际环境调整。记录时至少保留原始运行时长,不能只存一个最终成本,否则后期换电价或换硬件后无法重新计算。
6. 从多次自举运行中沉淀的最佳实践
6.1 把任务切成可独立验证的小块
自举循环的每一个任务都应该能在一个文件内验证。任务过大时,测试失败信息携带大量上下文,模型难以定位问题。建议按函数或模块维度拆任务,每个任务只改一个文件。
如果任务依赖多个模块,在任务定义中把依赖关系写成固定 import,不要让模型自己规划项目结构。模型的强项是局部修改,不是大型架构设计。
6.2 将模型参数和循环参数拆成配置
把模型名称、temperature、max_tokens、max_runs、budget、timeout 全部放到配置文件中。这样换模型、换预算时不需要改主循环代码。
# config.py DEFAULT_CONFIG = { "model_name": "qwen2.5-coder:7b", "temperature": 0.2, "max_tokens": 512, "max_runs": 416, "budget": 176.0, "power_watts": 250, "price_per_kwh": 0.15, "test_timeout_sec": 60, }标题里的 416 和 176 在这里就变成了两个普通参数。实验时可以先设max_runs=10、budget=1,验证流程,再逐步恢复完整参数。
6.3 学习环境与生产环境的差异
学习环境主要验证循环链路,生产环境则需要更多保障。差异可以用这张表概括:
| 环节 | 学习环境 | 生产环境 |
|---|---|---|
| 代码生成 | 直接覆盖源文件 | 生成到临时分支,人工审查后合并 |
| 测试执行 | 本地 pytest | 容器或沙箱环境 |
| 日志 | 打印关键信息 | 结构化日志 + 监控告警 |
| 模型版本 | 可随时更换 | 固定版本,禁止随意升级 |
| 预算控制 | 手动调整 | 自动熔断,超限立即停止 |
| 回滚策略 | git reset | 全量发布 + 蓝绿发布 |
6.4 安全边界不能省略
让模型生成代码并自动执行,意味着系统必须在沙箱边界内运行。不要在没有隔离的服务器上直接执行生成代码,尤其不要让生成代码访问不相关的环境变量和网络资源。
学习环境中,推荐把测试目录限定在一个独立文件夹;生产环境中,使用容器或虚拟机隔离。模型生成的代码可能在exec或 import 时触发副作用,因此至少要做依赖白名单和网络权限控制。
注意:自举循环适合作为实验性开发工具,不适合直接对接生产环境的发布流程。生成代码合并前,至少要做一次代码审查和人工测试。
6.5 下一步可以扩展的方向
如果已经跑通最小自举循环,可以继续加入:
- 多任务队列:按依赖顺序依次生成代码。
- 语义化日志:记录每个任务的成功率和平均修改次数。
- 自动 commit:每次测试通过后自动打标签,形成可恢复版本。
- 人机协同:模型连续失败时,不再重试,而是输出待人工决策的问题摘要。
- 模型对比:用同一组任务比较不同本地模型在修复成功率、耗时和 token 消耗上的差异。
这些扩展方向会让自举式 dev harness 从“一个会改自己代码的脚本”变成团队内部可复用的开发自动化底座。
回到 Ducklab 的 416 次运行和 176 美元,真正的价值不只是这两个数字,而是它验证了一个可复现的开发流程:本地模型负责生成和修改,测试负责判定,预算负责兜底。只要把这三个角色拆开,任何团队都能搭出类似的自举式 dev harness。初学阶段建议先别追求大模型和长任务,用一个小任务把循环跑通,再逐步增加复杂度。第 416 次运行和第 1 次运行,应该分布在同一个可追踪、可回滚、可复算的实验框架里。