用本地模型构建自举式Dev Harness:416次迭代与成本控制实战
在实际开发工具链里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 GB32 GB 以上GPU 显存6 GB量化 7B 模型12 GB 以上CPU4 核8 核以上Python3.103.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, jsonpayload, timeout180, ) 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(modelqwen2.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.pyrunner 负责把测试、模型调用、代码写入串起来。最简版本如下import subprocess import time from pathlib import Path from model_client import LocalModelClient def load_source(path: Path) - str: return path.read_text(encodingutf-8) def save_source(path: Path, source: str) - None: path.write_text(source, encodingutf-8) def run_tests(work_dir: Path) - subprocess.CompletedProcess: return subprocess.run( [pytest, -q], cwdwork_dir, capture_outputTrue, textTrue, timeout60, ) 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_tokens512) 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, encodingutf-8) as fp: fp.write(json.dumps(record, ensure_asciiFalse) \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, encodingutf-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(ftotal_runs{total_runs}) print(fpass_runs{pass_runs}) print(flatest_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], checkTrue, capture_outputTrue, textTrue, cwd., ) sha subprocess.check_output( [git, rev-parse, HEAD], textTrue, 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 输出缩短测试用例、调整 timeout5.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_runs10、budget1验证流程再逐步恢复完整参数。6.3 学习环境与生产环境的差异学习环境主要验证循环链路生产环境则需要更多保障。差异可以用这张表概括环节学习环境生产环境代码生成直接覆盖源文件生成到临时分支人工审查后合并测试执行本地 pytest容器或沙箱环境日志打印关键信息结构化日志 监控告警模型版本可随时更换固定版本禁止随意升级预算控制手动调整自动熔断超限立即停止回滚策略git reset全量发布 蓝绿发布6.4 安全边界不能省略让模型生成代码并自动执行意味着系统必须在沙箱边界内运行。不要在没有隔离的服务器上直接执行生成代码尤其不要让生成代码访问不相关的环境变量和网络资源。学习环境中推荐把测试目录限定在一个独立文件夹生产环境中使用容器或虚拟机隔离。模型生成的代码可能在exec或 import 时触发副作用因此至少要做依赖白名单和网络权限控制。注意自举循环适合作为实验性开发工具不适合直接对接生产环境的发布流程。生成代码合并前至少要做一次代码审查和人工测试。6.5 下一步可以扩展的方向如果已经跑通最小自举循环可以继续加入多任务队列按依赖顺序依次生成代码。语义化日志记录每个任务的成功率和平均修改次数。自动 commit每次测试通过后自动打标签形成可恢复版本。人机协同模型连续失败时不再重试而是输出待人工决策的问题摘要。模型对比用同一组任务比较不同本地模型在修复成功率、耗时和 token 消耗上的差异。这些扩展方向会让自举式 dev harness 从“一个会改自己代码的脚本”变成团队内部可复用的开发自动化底座。回到 Ducklab 的 416 次运行和 176 美元真正的价值不只是这两个数字而是它验证了一个可复现的开发流程本地模型负责生成和修改测试负责判定预算负责兜底。只要把这三个角色拆开任何团队都能搭出类似的自举式 dev harness。初学阶段建议先别追求大模型和长任务用一个小任务把循环跑通再逐步增加复杂度。第 416 次运行和第 1 次运行应该分布在同一个可追踪、可回滚、可复算的实验框架里。