最近在一个自动化实验项目里,我尝试搭建了一个名叫Ducklab的开发助手框架(dev harness)。它的特别之处不在于用了多么复杂的前沿算法,而在于整个系统在开发过程中呈现出一种“自我构建”的迭代状态:由本地大模型驱动的代码生成、测试执行、报错反馈、再次修复形成闭环,最终在 416 次运行记录里完成了任务,整体调用成本约 $176,且全程依赖本地模型,没有大规模使用云端付费接口。
这篇文章不是只介绍一个“别人家的项目”,而是把 Ducklab 的核心设计思路拆开,带大家从零实现一个最小的本地模型版 dev harness。你可以把它理解成:用一个脚本把“写代码—跑测试—看报错—改代码”这件事自动化,让本地大模型充当开发助手,自己完成小模块的开发闭环。
1. Ducklab 是什么:一个会自我迭代的开发环境
1.1 为什么叫 “dev harness”
“Harness” 在工程领域的本意是“束线带、测试夹具”,在软件开发领域,它通常指一套用于驱动、控制和验证被测程序的脚手架。比如测试领域常说的 test harness,就是专门用来加载测试用例、执行被测代码、汇总结果的工具。
Ducklab 把它从测试层面扩展到开发层面:这个 harness 不再是开发完代码之后才用来验证代码的工具,而是在代码还没成型时,就负责“发布开发任务、收集反馈、驱动修改”的自动化平台。
换句话说,传统开发模式是“人来写代码,工具负责测试”;而 Ducklab 这类 dev harness 反过来,让“模型写代码,系统负责约束、验证和反馈”。人的角色从“逐行写代码”变成“定义任务边界和验收标准”。
1.2 “built itself” 意味着什么
“Ducklab built itself” 并不是说程序真的凭空产生了意识,而是指它通过生成式开发循环自己完成了大量代码片段的编写和修正。
整个循环大致是:
需求描述 -> 模型生成代码 -> 执行测试 -> 收集失败信息 ^ | | v +----------- 模型根据失败信息修改代码 <-------+一开始,模型可能写出一个非常幼稚的版本,通过 harness 跑测试后立刻暴露问题。系统把测试失败信息(包括异常堆栈、断言失败详情)原样返回给模型,模型在下一轮生成时参考这些信息进行修复。如此往复,直到测试全部通过。
Ducklab 在这 416 次运行中,完成了多个小工具的生成与调试。这类“自动写代码、自动修 bug”的循环,正是 Agent 类应用最常见的落地形态。
1.3 本地模型在其中的位置
Ducklab 最重要的一个决策是使用local models(本地模型),而不是直接把所有请求发送到云端大模型服务。
本地模型在这里承担了两个角色:
- 代码生成器:根据任务描述生成函数、脚本或补丁。
- 调试助手:根据测试失败信息分析原因并给出修复代码。
由于整个循环可能需要调用模型几十次甚至上百次,如果每次调用都走云端,延迟和成本都会成倍放大。本地模型虽然单次生成质量不一定比顶级云端模型高,但好在调用成本低、响应速度快,适合这种“多轮多跑”的自动化开发场景。
2. 为什么选择本地模型而不是云端 API
2.1 成本收益分析:416 次运行、$176
Ducklab 的 416 次运行如果全部使用主流云端大模型 API,按照每千 token 几美分到几十美元不等的计费方式,总成本大概率会明显高于 $176,尤其当任务中涉及上下文较长的代码修复时,成本会涨得很快。
而本地模型的核心优势是:
- 边际成本低:模型跑在本地,调用次数多了也不会产生每千 token 的增量费用。
- 延迟更可控:本地推理没有网络传输时间,也没有排队,尤其在串行多轮迭代场景下体验更好。
- 支持高频试错:测试失败、重新生成的过程天然需要多轮尝试,本地模型可以“放开了试”。
$176 这个数字换算一下,如果每一次完整运行对应多次本地推理,那么成本主要来自电费、硬件折旧,以及少数超出本地模型能力的场景下才需要调用的外部服务。这说明,模型能力不是越贵越好,关键是找到任务难度与成本之间的平衡点。
2.2 数据隐私与安全边界
在开发环境里,代码本身就是公司或个人的核心资产。如果大批量把业务代码片段发送到外部模型接口,即使有隐私协议,也会增加数据泄露风险。本地模型在这一层面的优势非常明显:代码不出机器,全部推理在本地完成。
这对企业内部项目尤其重要。涉及内部库、未公开的 API、敏感的配置文件时,本地部署模型可以减少很多合规审查压力。
2.3 可控性与可复现性
云端模型经常会悄悄更新版本,导致同一个 prompt 昨天和今天的输出风格不一样。而本地模型的权重一旦固定,输出相对稳定,更容易复现实验结果。
这对 dev harness 这种需要反复迭代的工具来说非常关键。Ducklab 在连续 416 次运行中能保持明确统计,一部分原因就是模型固定、prompt 模板稳定,不会因为模型版本漂移导致结果无法比较。
2.4 本地模型的接入方式
目前接入本地模型的常用方式有三种:
| 方式 | 说明 | 适合场景 |
|---|---|---|
| Ollama | 安装简单,一行命令启动本地模型服务,兼容 OpenAI 风格接口 | 个人开发、快速原型 |
| llama.cpp 服务 | 性能较好,支持 CPU/GPU 混合推理,部署更灵活 | 对吞吐或硬件要求更高的场景 |
| LM Studio | 图形化界面,适合先手动测试模型效果 | 非程序员调试模型、评估 prompt |
Ducklab 的思路是用一个统一的 HTTP 接口对接本地模型,后续即使更换模型也不影响上层代码逻辑。
3. 把它拆开:一个最小可用 dev harness 的设计
3.1 整体工作流
在写代码之前,我们先定义一下这个最小 dev harness 需要完成哪些事:
- 接收任务描述(例如“写一个把 CSV 数据按某一列分组的函数”)。
- 调用本地模型生成候选代码。
- 将代码写入临时文件。
- 动态执行测试用例。
- 收集通过/失败结果。
- 如果失败,把失败信息拼接到新一轮 prompt 中,回到第 2 步。
- 如果通过或达到最大轮数,结束循环并输出结果。
3.2 文件结构设计
ducklab-demo/ |-- main.py # 主循环逻辑 |-- model_client.py # 本地模型调用封装 |-- runner.py # 代码执行与测试运行 |-- prompts.py # prompt 模板 |-- tasks/ | |-- task_001.py # 测试用例文件 |-- generated/ | |-- solution_001.py # 模型生成代码保存位置 |-- logs/ | |-- runs.jsonl # 运行记录3.3 核心循环的抽象表达
用结构化伪代码来看:
function run(task_desc, tests): history = [] for round in range(max_rounds): prompt = build_prompt(task_desc, tests, history) code = model.generate(prompt) result = run_tests(code, tests) record(round, code, result) if result.passed: return success(code) history.append(result.feedback) return fail(history)这里最关键的一点是:模型并不直接得到“测试对不对”的判定,而是拿到具体的失败信息。它会看到“第 5 行抛出了 KeyError: 'name'”,或者“期望结果是 [1, 2],实际得到 [1]”。这些具体反馈比一句“你的代码有问题”有效得多。
4. 用 Python 实现一个可运行的本地模型 dev harness
这一节我们从零实现一个简化版 Ducklab。示例以 Ollama 作为本地模型后端,假设你已经安装并启动 Ollama,并且拉取了一个支持代码生成的模型(例如 qwen2.5-coder 系列或 llama3.1 系列)。如果你用的模型不同,代码逻辑基本不用改,只需要调整模型名称。
4.1 安装并启动 Ollama
在终端执行:
ollama pull qwen2.5-coder:7b ollama serveollama serve会默认监听http://localhost:11434。我们通过这个地址调用模型。
4.2 项目结构
ducklab-demo/ |-- main.py |-- model_client.py |-- runner.py |-- prompts.py4.3 model_client.py:封装模型调用
我们将模型调用封装成独立的模块,方便后续扩展。
# model_client.py import requests import json class LocalModelClient: """Ollama 本地模型客户端。""" def __init__(self, base_url="http://localhost:11434", model="qwen2.5-coder:7b"): self.base_url = base_url self.model = model def generate(self, messages, temperature=0.2, max_tokens=2048): """调用聊天接口,返回生成的文本内容。""" url = f"{self.base_url}/api/chat" payload = { "model": self.model, "messages": messages, "stream": False, "options": { "temperature": temperature, "num_predict": max_tokens, }, } resp = requests.post(url, json=payload, timeout=120) resp.raise_for_status() data = resp.json() return data["message"]["content"]这里需要注意:
temperature=0.2是偏保守的取值,尽量让代码生成更稳定,减少随机性。max_tokens=2048足够大多数小工具代码使用;如果你要生成较大文件,可以调大。stream=False便于直接拿到完整响应。
4.4 prompts.py:设计 prompt 模板
prompt 的质量直接决定生成代码的质量。我们把角色要求、任务说明、测试信息都写进模板。
# prompts.py SYSTEM_PROMPT = """你是一名 Python 开发工程师。请根据用户给出的任务和测试反馈, 生成可以直接运行的 Python 代码。要求: 1. 只输出代码,不要输出多余解释。 2. 代码必须放到 markdown 代码块 ```python ... ``` 中。 3. 如果任务有修改要求,请保留原有函数签名。 """ def build_generate_prompt(task_desc: str, tests_code: str) -> list: """首次生成代码时的 prompt。""" messages = [ {"role": "system", "content": SYSTEM_PROMPT}, { "role": "user", "content": ( f"任务:{task_desc}\n\n" f"以下是测试代码,你的实现需要通过这些测试:\n\n" f"```python\n{tests_code}\n```\n\n" f"请生成实现代码。" ), }, ] return messages def build_fix_prompt(task_desc: str, tests_code: str, failed_code: str, feedback: str) -> list: """代码失败后,将失败信息拼接成修复 prompt。""" messages = [ {"role": "system", "content": SYSTEM_PROMPT}, { "role": "user", "content": ( f"任务:{task_desc}\n\n" f"测试代码如下:\n\n" f"```python\n{tests_code}\n```\n\n" f"你上一轮生成的代码如下:\n\n" f"```python\n{failed_code}\n```\n\n" f"执行测试时出现了以下问题:\n\n{feedback}\n\n" f"请根据问题修复代码,生成完整的新代码。" ), }, ] return messages注意:这里要求模型只输出代码,但实际生成时模型偶尔还是会带“好的,我来写”这类废话,所以后续解析代码时要做兼容处理。
4.5 runner.py:执行代码并运行测试
测试执行是 harness 的核心环节。我们需要在隔离环境中导入模型生成的代码,然后运行测试用例并捕获异常、断言信息。
# runner.py import traceback import sys import io import contextlib def _extract_python_code(text: str) -> str: """从模型输出中提取第一个 python 代码块。""" if "```python" in text: start = text.find("```python") + len("```python") end = text.find("```", start) if end > start: return text[start:end].strip() # 如果没有代码块,直接把整段当作代码 return text.strip() def run_solution(code: str, tests_code: str, module_name="generated_solution"): """ 运行模型生成的代码和测试代码。 返回 (通过与否, 输出信息)。 """ solution_code = _extract_python_code(code) namespace = {} output_buf = io.StringIO() try: with contextlib.redirect_stdout(output_buf): exec(solution_code, namespace) exec(tests_code, namespace) return True, output_buf.getvalue() except Exception as e: error_msg = "".join( traceback.format_exception(type(e), e, e.__traceback__) ) return False, error_msg这里用了exec动态执行代码,在可控的实验环境里没有问题,但如果在生产环境中使用,必须搭配沙箱隔离,避免模型生成的代码执行危险操作。
4.6 main.py:主循环与运行记录
主循环做的事情是:
- 读取任务描述和测试代码。
- 调用模型生成代码。
- 执行测试。
- 如果失败,把失败信息拼回 prompt 再试。
- 每轮记录运行日志。
# main.py import json import time from model_client import LocalModelClient from prompts import build_generate_prompt, build_fix_prompt from runner import run_solution # 示例任务:实现一个函数,把列表中的所有偶数过滤出来 TASK_DESC = "实现函数 filter_even(numbers),返回输入列表中的所有偶数。" TESTS_CODE = """ from generated_solution import filter_even assert filter_even([1, 2, 3, 4, 5]) == [2, 4] assert filter_even([1, 3, 5]) == [] assert filter_even([]) == [] print("all tests passed") """ def main(): client = LocalModelClient() max_rounds = 8 history = [] for round_idx in range(max_rounds): if round_idx == 0: messages = build_generate_prompt(TASK_DESC, TESTS_CODE) else: last_code = history[-1]["code"] last_feedback = history[-1]["feedback"] messages = build_fix_prompt(TASK_DESC, TESTS_CODE, last_code, last_feedback) print(f"[round {round_idx + 1}] 调用本地模型生成代码...") raw_code = client.generate(messages) passed, output = run_solution(raw_code, TESTS_CODE) print(f"[round {round_idx + 1}] 测试结果: passed={passed}") if passed: print("成功!生成的代码:") print(raw_code) break else: history.append({"code": raw_code, "feedback": output}) print(f"[round {round_idx + 1}] 测试失败,失败信息长度 {len(output)} 字符") else: print("达到最大轮数,未能在限定次数内通过测试。") # 运行记录落盘 log_entry = { "task": TASK_DESC, "rounds": round_idx + 1, "passed": passed, "timestamp": time.time(), } with open("runs.jsonl", "a", encoding="utf-8") as f: f.write(json.dumps(log_entry, ensure_ascii=False) + "\n") if __name__ == "__main__": main()运行:
python main.py预期效果是模型首轮大概率生成正确代码,也可能在某些边界条件上失败,然后根据反馈进入修复循环。当你看到all tests passed时,说明这一个开发任务已经被 harness 自动完成。
4.7 效果说明
这个最小系统虽然只有一百多行代码,但它已经具备 Ducklab 的核心骨架:生成、验证、反馈、修复。你可以通过替换TASK_DESC和TESTS_CODE来扩展它处理不同任务。
实际测试时,我建议从一个更复杂的需求出发,比如“实现一个函数,把 CSV 字符串解析成字典列表,并处理引号转义”,这种任务首轮失败概率更高,能更好地观察多轮修复效果。
5. 记录运行与量化成本:从 416 次运行得到的教训
5.1 为什么要有运行记录
Ducklab 给我最大的启发不是“模型能写代码”,而是“模型写代码的过程可以被量化和审计”。
要判断一个 dev harness 是否有效,不能只盯着最终成功与否,还要关注:
- 平均几轮内可以通过测试?
- 哪些类型的测试最常失败?
- 失败原因集中在语法错误、逻辑错误还是接口不匹配?
- 本地模型调用总 token 量是多少?
这些信息都来自完整的运行记录。Ducklab 的 416 次运行本身就是一个可分析的数据集,它可以帮助我们判断模型的能力边界,以及 prompt 设计还有多少优化空间。
5.2 每次运行应记录什么
建议每条运行记录至少包含以下字段:
| 字段 | 说明 |
|---|---|
| task_id | 任务唯一标识 |
| round | 当前轮次 |
| prompt_version | prompt 模板版本 |
| model_name | 模型名称 |
| input_tokens | 输入 token 数 |
| output_tokens | 输出 token 数 |
| passed | 是否通过 |
| error_type | 异常类型(如果有) |
| latency_ms | 本次调用耗时 |
| timestamp | 时间戳 |
把 input_tokens 和 output_tokens 记录下来,后续即使切换云端模型,也可以精确估算成本。
5.3 成本统计逻辑
Ducklab 的成本统计思路很简单:本地模型用 token 总量和硬件功耗估算,云端服务按单价乘以用量计算。
# cost_estimate.py def estimate_cost(input_tokens, output_tokens, local=True, price_per_1k_input=0, price_per_1k_output=0): if local: # 本地模型:主要是电费,可按实际功率简单折算,这里先不做精细计算 return 0.0 input_cost = input_tokens / 1000 * price_per_1k_input output_cost = output_tokens / 1000 * price_per_1k_output return input_cost + output_cost在实战中,我发现很多团队愿意为一次代码生成花几美元,却在“反复返工”上浪费大量时间。dev harness 的价值恰恰在于把“返工”变成低成本的自动循环。
5.4 从运行记录里读什么
把 416 次运行按轮次聚合后,你会看到一条典型分布:大多数任务在前 3 轮通过,少数复杂任务需要 6 轮以上。如果你的日志显示平均轮次不断增长,可能是任务描述太模糊或者 prompt 模板需要优化,而不是模型能力的问题。
6. 常见问题与排查思路
在实现和运行 local model dev harness 时,最常见的问题集中在模型连接、输出解析、测试执行和循环收敛四类。
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 调用 Ollama 报连接错误 | Ollama 服务未启动或端口不对 | 检查ollama serve是否在运行,确认 11434 端口可访问 |
| 模型输出包含额外文字被当成代码执行 | prompt 没有强制约束输出格式 | 增强 prompt 要求,同时在解析时提取首个代码块 |
| 测试始终不通过,但模型一直在换写法 | 循环没有给模型足够具体的失败反馈 | 检查 feedback 是否包含完整异常堆栈和断言差异 |
| 代码执行超时 | 模型生成的代码有死循环 | 在 runner 中增加超时限制或使用独立进程执行 |
| 本地模型生成结果不稳定 | temperature 设置过高 | 调低 temperature,例如 0.2 或更低 |
| 模型回答“我无法访问外部资源” | 模型产生了无关回复 | 在 prompt 中限制只能输出代码,并增加重试判断 |
关于代码执行超时,简单实现可以用signal,但在跨平台项目中建议使用subprocess或multiprocessing控制执行时间。
# 简单的超时执行示例(仅 Unix/Linux 思路) import signal def timeout_handler(signum, frame): raise TimeoutError("代码执行超时") signal.signal(signal.SIGALRM, timeout_handler) signal.alarm(10) try: exec(solution_code, namespace) except TimeoutError: print("检测到执行超时") finally: signal.alarm(0)这只是一种简化方案。实际工程中更稳妥的方式是把测试执行放到沙箱容器或独立进程中,避免模型生成的代码影响主进程。
7. 工程化最佳实践
7.1 尽量让 prompt 输出结构化
模型输出越结构化,harness 越稳定。比如要求模型先输出THOUGHT:再输出CODE:,甚至直接输出 JSON:
{ "reasoning": "... ", "code": "def filter_even(...): ..." }这样解析代码时就稳定多了。
7.2 对生成代码执行环境做隔离
前面提到,exec直接执行模型生成的代码有很大风险。在本地实验可以接受,但生产环境必须考虑以下几点:
- 在临时目录或 Docker 容器中运行测试。
- 限制执行时间。
- 禁止文件写入、网络访问等危险操作。
- 截断输出长度,防止模型打印大量内容拖垮内存。
7.3 设置预算上限和轮数上限
dev harness 本质上是“自动花费计算资源”的循环,必须设置停止条件:
- 最大轮数,例如 10 轮。
- 最大 token 数,例如总输入不超过 100 万 token。
- 最大运行时间,例如单个任务不超过 30 分钟。
这些限制能让实验成本可控,Ducklab 能做到 $176 这个量级,不是因为它没有更多预算,而是因为它对每次任务都设置了收敛条件,不跑无效的长尾。
7.4 保留测试失败样本,形成回归集
每个失败过的任务和模型输出都值得保存。把它们收集起来,就形成了一个针对当前模型的“错题集”。下次模型版本升级或 prompt 调整后,可以重新跑这些任务,观察是否出现回归。
这也是 Ducklab 运行记录最重要的价值:它是一个可持续生长的评测基准。
7.5 根据任务难度分配模型
不是所有任务都需要最强模型。像“把变量名从 camelCase 改成 snake_case”这种机械改造任务,用轻量本地模型就够;而需要设计数据结构的复杂任务,可以调用参数更大的模型。
在 dev harness 中,可以根据失败轮次动态升级模型:
第 1 轮:使用小模型,快和便宜 第 3 轮仍未通过:切换到大模型,提高生成质量这种“先小后大”的策略能进一步压低成本。
8. 下一步扩展方向
如果你觉得上面的最小 dev harness 还不够过瘾,可以从以下几个方向继续做:
- 支持更多语言:目前只实现了 Python 测试执行,可以扩展到 Node.js、Go 或 Java。
- 引入代码静态检查:在运行测试前先用
ruff或pylint做一轮静态检查,把语法错误直接反馈给模型,节省掉执行测试的开销。 - 支持“需求变更”迭代:当前是固定需求,如果模型开发过程中发现需求本身有歧义,可以加入一个“需求澄清”环节,让模型先提问再编码。
- 记录 embedding 并做任务相似度判断:新任务到来时,先检索历史最相似的任务,把它的成功解决方案作为 few-shot 示例,能显著提升首轮通过率。
- 把生成代码提交到 Git:每轮都创建独立的 git commit,这样不仅能看到最终结果,还能审计每一次修改,方便回滚。
不过在使用这种自动化开发工具时,我们要特别注意:模型生成的代码必须经过人工审查和测试验证,不能直接合并到生产环境。自动化的价值在于“减少重复劳动、加速原型验证”,而不是取代代码评审和工程规范。
Ducklab 的实验也验证了一个观点:对于定义清晰、验收标准明确的小任务,本地模型加自动反馈循环已经完全具备实际产出能力;真正困难的不是让模型写出第一版代码,而是设计一个稳定、低成本、可持续迭代的反馈闭环。
如果你也准备动手试,建议从一个很小的函数任务开始,先跑通整个循环,再逐步增加复杂度。把这套 harness 跑起来之后,再去调 prompt、换模型、加沙箱,每一步的收益都会非常清晰。