Skip to content

Repository files navigation

coding-agent-from-scratch

中文 · English

从零手写一个命令行 coding agent,不用任何 agent 框架。每一行都是自己敲的,每一个 设计决定都要能说出理由。

核心代码约 1,300 行、实验与评测约 800 行,七个工具(read_file / list_files / grep / edit_file / bash / delegate / run_tests),52 个测试。

模块 0–9 已完成。下面写的每个数字都来自 results/ 里的原始数据,可以自己重跑 核对。没有测过的东西不会写成结论——包括三个「做了但结论是负面」的模块。

跑起来

mkdir -p ~/.config/agent-from-scratch
echo "DEEPSEEK_API_KEY=sk-..." > ~/.config/agent-from-scratch/env
chmod 600 ~/.config/agent-from-scratch/env

python3 -m unittest test_tools -v     # 不调模型,不花钱
python3 agent.py                       # 跑一次任务
python3 experiment.py 10               # 跑对照实验

已经测出来的东西

工具描述是 prompt 的一部分,不是注释

同一个任务、同一个模型(DeepSeek v4-flash),只改 grep 工具的 description, 每组 10 次,两组交替且每轮随机先后(避免时间成为混杂变量)。

描述写"搜索" 描述写清用途
第一步就用 grep 4/10 10/10
平均输入 token 7,966 ± 3,132 5,918 ± 1,845
read_file 调用总数 24 13

但平均值会骗人。 按步数拆开看:

3 步的运行 4 步的运行
描述写"搜索" 6 次,平均 5,606 4 次,平均 11,505
描述写清用途 9 次,平均 5,344 1 次,11,078

同样步数下两组成本几乎一样。真正的区别是走到第 4 步的概率:40% vs 10%。 第 4 步大约多花 6,100 token,用 (0.4 − 0.1) × 6,100 ≈ 1,830 反推,和实测的 2,048 差异对得上。

结论:好的工具描述不让每一步更便宜,而是让昂贵的失败路径更少发生。效应在 分布的尾部,不在中心。描述本身每次调用多花约 90 token,远小于收益。

选指标这件事我错了三次

尝试 指标 结果
1 token 总量,n=3 组内标准差 468,组间差 212——噪声淹没信号
2 「第一步是否用 grep」二元 测错了东西;而且两次独立实验给出 8/10 和 4/10
3 保存原始轨迹后重新分析 找到真正的驱动因素:read_file 次数

正确的指标是从原始数据里发现的,不是设计出来的。 所以 results-*.json 提交 进仓库——下一个想问的问题,永远不是设计实验时预想的那个。

SEARCH/REPLACE:四级匹配策略,只有两级被用到

模型复述代码时会有偏差——丢缩进、吃掉行尾空格、把长行截断。逐字符精确匹配因此 经常失配,而每次失配都要模型重来一轮。所以做成一条从严到宽的策略链, 每一级都独立校验匹配唯一性,因为放宽条件本身会制造歧义。

策略 做什么 能否静默改错地方
exact 逐字符相等 否
normalized 去行尾空白后仍要求相等(字符级) 否
indent 去公共缩进后仍要求相等 否
fuzzy 相似度 ≥ 0.85 且明显优于次佳 是

实测 42 次真实编辑(DeepSeek v4-flash,每次运行前把目标文件复原):

文件状态 编辑数 exact normalized indent fuzzy
干净文件 12 100% 0 0 0
仅方法体带行尾空白 13 100% 0 0 0
全文件带行尾空白 30 70% 27% 0 0

三条结论:

归一化层的价值完全取决于文件脏不脏。 干净文件上一次都不触发;行尾空白普遍 存在时约四分之一的编辑靠它救回来。问题不是「有没有用」,而是「什么条件下有用」。

模型对不可见空白的处理是全有或全无的。 同一次编辑的 old_str 里,行尾空格 要么全部保留、要么全部丢失,从无混合。说明它在「逐字符复制」和「重新生成规范化 代码」两种模式间切换,不是随机漏字符——这让归一化层只需处理一种情况。

indent 和 fuzzy 在 42 次编辑里从未触发,但不该一并删掉。 前两级是 归一化(化到同一种写法再比相等),不可能匹配上本质不同的代码;fuzzy 是 猜测,是唯一能静默改错地方的路径。三者未触发的含义因此完全不同:indent 零风险、成本常数,留着当保险;fuzzy 代码量最大又带误改风险,是最该被质疑的 一个。容错机制要按失败代价分类评估,不能一视同仁。

阈值该定在哪:两类错误的代价不对称

模糊匹配有两道闸——绝对阈值 0.85,以及「最佳必须比次佳高 0.05」。第二道更重要: 文件里有两个相似函数时,0.93 和 0.91 都过了绝对阈值,而你不知道模型想改哪个。

犯哪种错 后果 代价
误拒(拒绝正确匹配) 模型带更多上下文重试一次 一轮往返,可恢复
误受(改错地方) 文件被静默改坏,返回「已修改」,模型信了继续走 不可恢复,没人会发现

代价差着数量级,所以阈值明显偏向保守。实测中确实误拒过一次本来会改对的编辑 (0.96 vs 0.92,差 0.04 < 0.05)——这是刻意付出的代价。

Repo Map:用图排序决定把哪些代码放进上下文

agent 曾经为了回答一个问题把项目里每个文件都读了一遍,烧掉 48,411 token。6 个文件 尚且如此,真实仓库直接破产。所以需要回答:给定任务,该把哪一小部分代码塞进上下文?

做法分四步:tree-sitter 解析 AST 抽出「谁定义了什么、谁引用了什么」→ 按引用关系把 文件连成有向图 → 个性化 PageRank 排序 → 渲染成地图注入上下文。

① 导入约束把假边清掉了。 只靠符号名匹配时,本仓库 19 条边里只有 6 条是真的 (set().add() 撞上 Cart.add、unittest.main() 撞上实验脚本的 main()、 cart.py 和 cart_dirty.py 互相连边——两个文件根本互不认识)。Python 里不 import 就用不了,把这条硬约束加上:

边数 真边 精确率
纯符号名匹配 19 6 32%
加导入约束 6 6 100%

真边一条没丢。代价是失去多语言通用性——导入语义每种语言都不同,aider 正因为要支持 几十种语言才放弃了这条路,改用权重稀释噪音。这是精度和通用性的取舍,不是谁更对。

② 无个性化的 PageRank 对 repo map 没用。 依赖箭头天然指向底层,所以 在 mini-swe-agent(112 文件)上跑出来的 top 是 exceptions.py / serialize.py 这类 被所有人 import 的工具,而核心的 agents/default.py 排到第 9。把边反向,top 又全变成 测试文件和入口脚本。「被依赖最多」和「最值得读」是两回事——个性化不是优化项, 是这套方法能用的唯一理由。

③ 瓶颈在查询理解,不在排序算法。 第一版种子提取只做标识符完全匹配,于是 「swebench 批量运行时 docker 镜像名怎么拼」提取出零个种子——因为符号叫 get_swebench_docker_image_name、文件叫 swebench.py,而人不会这么说话。 更糟的是种子为空时 PageRank 静默退回均匀分布,输出一份看起来人模人样、实际与任务 无关的排名(又一次 fail-open)。

第二版按「匹配上的符号个数」累加,结果变成了文件大小排序:问 LitellmModel, 排第一的是 portkey_response_model.py——它有十来个符号名含 model,每个加一分。

第三版用 IDF 修好:每个任务词对一个文件最多贡献一次,再按该词的稀有度加权。

任务词 出现于 IDF
litellmmodel 1 个文件 4.04
api 21 个文件 1.81

litellm_model.py 由此从被压制变成领先次名 5.5 倍。检索系统的上限通常由查询理解 决定,不由排序算法决定。

④ 效果:在 112 个文件的仓库上,每项指标都更好。 3 个只读问答任务 × 3 轮, 开关交替:

关地图 开地图
步数 6.8 5.4 −21%
总输入 token 64,397 43,394 −33%
非缓存输入(全价) 13,709 10,327 −25%
输出 token 1,517 1,284 −15%
read_file 次数 4.1 3.6 −12%

地图本身约 600 token,且每步重发,5.4 步就是约 3,200 的固定开销——却换回了 21,000 的净节省。省钱的主要机制不是「少读文件」,而是步数下降:历史累积, 第 7 步要重发前 6 步的全部内容,砍掉尾部那 1.4 步省下的最多。

和模块 2 是同一个结构:效应在分布的尾部,不在每一步的平均成本上。

事后校准:模块 5 用 A/A 测试(两组配置完全相同)测出同样实验设计下,输入 token 的噪声底噪约 26%。上表那个 −33% 只比底噪高一点,严格说需要更多重复才算站得住。写这段时我没有底噪可参照,把结论说硬了。

先用 n=1 跑时,输入 token 还 +6%,看起来地图不划算;n=3 之后变成 −33%。 同一个实验,样本量差三倍,结论反号。

已知边界:只测了一个仓库、3 个只读问答任务,没测编辑任务;只支持 Python; 任务侧分词不拆驼峰,用户写「default agent」而非 DefaultAgent 时会退化成泛词匹配。

A/A 测试:先知道噪声有多大,才知道效应算不算数

一次写错的实验开关(定义了 RANGE_READS 却忘了在函数里读它)让两个 arm 跑了 完全相同的配置。本来是个 bug,结果成了最有价值的一次测量——A/A 测试: 两组一样,差出来的全是噪声。

9 次 × 2 组,同样的代码、同样的任务:

指标 组 1 组 2 纯噪声造成的差异
输入 token 32,731 44,197 26%
步数 6.7 8.1 17%
上下文字符 24,625 29,000 15%
非缓存输入 5,866 6,835 14%

此后任何 n=9 的对照实验,输入 token 差异不足 26% 的都和噪声无法区分。

这个数字反过来审视了之前所有结论(见模块 4 那节的事后校准)。 A/A 测试应该是对照实验的第一步,而不是第十步。

另一个观察:上下文字符的底噪(15%)明显低于总 token(26%),因为它是对最终 消息列表的确定性测量,中间少一层模型的随机决策。越贴近机制的指标噪声越小。

最便宜的压缩是不产生

先测 token 花在哪,再决定怎么省。把消息列表按来源分桶(tool 结果按产生它的 工具名分桶,否则只知道「工具占 82%」没用):

来源 占比
tool结果/read_file 82%
tool结果/grep 8%
任务 + 仓库地图 4%
其余 6%

三次 read_file 产出 44,436 字符,因为它读的是整个文件。于是有两条路:

思路 对缓存的影响
压缩历史(摘要 / 丢弃旧消息) 改写前缀 → 缓存全废
不产生:read_file 支持行区间 完全不碰历史 → 缓存全保留

选了第二条。grep 的返回本来就带行号,信息链是完整的,只是没给模型这个能力。

对照实验(3 任务 × 3 轮,开关交替),每个效应都和上面的底噪对比:

指标 关 开 效应 底噪 判定
上下文字符 36,438 25,013 −31% 15% 成立
非缓存输入 7,986 5,840 −27% 14% 成立
总输入 token 38,364 26,818 −30% 26% 勉强
步数 6.0 5.9 −2% 17% 无效应
答对 7/9 8/9 +1 — 无法区分

底噪杀掉了我自己的一个说法。 步数这件事我前后讲了两遍、方向还相反(先说 「代价是多走 3 步」,后说「反而少走 1.4 步」)——两次都是拿未配对的单次运行 在读噪声。真相是区间读根本不改变步数。

同时它也验证了正确率没有退化:省下的 token 不是用答案质量换的。

(判定正确与否用的是关键词匹配,粗糙但客观可重复。这个指标本身还没被验证—— 「答错」的那几次究竟是真答错还是判定太严,要看原始数据里的完整回答。)

一个负面结论:输出封顶之后,历史压缩就没有必要了

模块 5.2 解决了「别把垃圾塞进上下文」,剩下的问题是「已经进去的要不要压缩」。 标准答案是滑动窗口或摘要。但先确认瓶颈存在。

造了一个需要遍历 7 个文件、928 行代码的调研任务,预期会跑出 15 步以上的长历史。 实测只有 4 步,10 次工具调用:

步  输入      新增
1   1,392    +1,392
2   1,701    +309
3   6,381    +4,680     ← 一轮批量读了好几个文件
4   12,630   +6,249     ← 又一批

模型在并行调用工具——一轮同时发出多个 read_file,而不是读一个、问一次。 于是上下文长到 54,611 字符,历史却只被重发 4 次。

并行工具调用把「步数」和「上下文体积」解耦了。 我预期的 O(N²) 没有发生。

算一下压缩的收支:read_file 内容约 12,000 token,主要在第 3、4 步进入,最多被 重发一次,所以省略旧结果最多节省约 4,500 token。而第 4 步有 6,528 token 是缓存 命中的——改写历史会让前缀失配,这些全部变成全价。

省约 4,500,赔约 6,500 从便宜变贵。方向就是负的,不是效果不明显。

真正会产生长历史的是步骤之间有依赖、无法并行的工作负载:改代码 → 跑测试 → 读报错 → 再改。而本项目的 agent 当时只有读 / 搜 / 改四个工具,没有执行命令的 能力,构造不出这种串行循环。

所以 5.3 当时测不了——依赖关系是反的,要先有命令执行(模块 6),才有值得压缩 的历史。这个顺序是数据定的,不是计划定的。

模块 6 之后补测。 造了一个含 5 个独立 bug 的夹具,agent 必须「跑测试 → 看失败 → 定位 → 修 → 再跑」,每步依赖上一步,无法并行。实测 13 步、13 次工具调用,全部 修复且独立复验 7/7 通过——确实是串行轨迹。但:

步    输入     缓存    未缓存   新增
 1   1,055     640     415   +1,055
 4   2,724   2,560     164     +194
 8   3,215   3,072     143     +141
13   4,226   3,840     386     +399

增量恒定在 +100~300,13 步跑完上下文才 11,013 字符,缓存命中率 89%。

两种工作负载因为相反的原因都不需要压缩:

调研任务 串行修 bug
步数 4 13
最终上下文 54,611 字符 11,013 字符
原因 上下文大但重发次数少 步数多但没什么可重发的

要让压缩有价值,需要步数多且每步新增大。而每个工具的输出上限 (MAX_READ_LINES=400、MAX_BASH_OUTPUT=8000、MAX_HITS=200)已经让后一个条件不成立。

边界估算:最坏情况每步塞满 8,000 字符 bash 输出(≈2,200 token),填满 128k 窗口需 约 58 步;按实测速率(167 token/步)则需 700 多步。

「不产生」让「压缩」变得不必要——这是在两种工作负载上都测过的结论,不是推测。

意外发现:agent 自己的工具调用参数占上下文 29%(edit_file 的 old_str + new_str), 和读进来的文件内容(24%)相当。这部分封不了顶,因为它是模型的输出而非工具的输出。 这事后验证了模块 3 的选择:若当时选「全文件重写」,这个桶会膨胀成压倒性的大头—— 每次编辑都要把整个文件作为 new_str 吐一遍。当时是凭推理选的,现在有数据了。

沙箱:先建约束,再开能力

模块 6 要给 agent 加执行任意命令的能力。动机很实在:此前它为了跑测试,自己执行了 pip install --user pytest,装到了我的用户环境里。 那次装的是无害的包,但同一条路径 可以写任何文件、连任何网络。

审批闸挡不住这个。 一条长命令里藏了什么,人扫一眼看不出来;连按十次 yes 之后, 第十一次不会细看。审批回答的是「我同意你做这件事」,沙箱回答的是**「即使我同意了 你也跑不出去」**——两者是不同层级的防御。

所以这个模块先把沙箱建好并通过逃逸测试,再接上 bash 工具。危险能力一天都没裸奔。

策略设计(macOS Seatbelt / SBPL):用 (allow default) 再逐项 deny,而不是反过来。 后者更安全,但会把 python 启动所需的几十个系统调用全挡掉。代价是**「忘记 deny 的就是 放行的」——所以必须用逃逸测试验证实际行为,不能只读策略文本。**

工作区路径是拼进策略文本的,所以 _sbpl_string 对含引号/反斜杠的路径直接拒绝而 不是转义:带一个引号进来就能提前闭合字符串、注入 (allow file-write*),沙箱当场失效。 这和 SQL 注入是同一个形状的问题,而路径含引号极罕见——拒绝比写转义规则更不容易出错。

实测(9 条逃逸测试):工作区内可写、工作区外 Operation not permitted、工作区外可读 (必须,否则 python 加载不了库)、网络阻断、python 正常。pip install --user 被两层 独立拦住:网络断了下载不到,就算下载到了 ~/Library/Python 也在工作区外写不进去。

两次「假测试」:断言「坏事没发生」是不够的

第一次。 test_network_is_blocked 写成「连不上就算通过」,9 条全绿。做了个对照—— 把沙箱去掉跑同一个探针:

沙箱内   BLOCKED  (0.05s)
无沙箱   BLOCKED  (5.05s)   ← 没有沙箱也通过

探测的 1.1.1.1:443 在该网络下本来就连不上,5 秒超时,结果同样是 BLOCKED。 这条测试在任何环境下恒绿,等于不存在。

修法是断言拒绝的证据而不是成功的缺席:沙箱拒绝给出 PermissionError(EPERM), 网络不通给出 timeout 或 gaierror。EPERM 只可能来自沙箱。并加一条测试的测试—— 同一探针在沙箱外绝不能返回 EPERM,否则上一条已经失去意义。

成功的缺席有很多原因(断网、防火墙、DNS 挂了),EPERM 只有一种。

第二次,就在一个回合之后。 bash 工具因为漏了 import sandbox 完全无法运行, 而新写的两条测试照样绿:「沙箱外文件未被创建」(命令压根没执行)、「交互命令没卡住」 (瞬间报错返回)。刚总结完的原则,下一次写测试立刻又踩了一遍。

通用修法:每个「坏事没发生」的断言,都要配一个「操作确实执行了」的断言—— [退出码 这个标记只有 subprocess.run 真的返回后才会出现。

fail-closed:同一条原则的第四次出现

场景 fail-open 会怎样 实际做法
WORKSPACE 配置退化成空串 边界检查形同虚设 启动断言
429 分不清限流与欠费 白白退避 6 次 按文案区分,欠费立即失败
沙箱不可用 命令裸奔 REQUIRE_SANDBOX 拒绝执行
拿不到终端 等于自动批准一切写操作 拒绝

最后一条是审批闸从 input() 改读 /dev/tty 时发现的——stdin 经常被重定向 (heredoc、管道、CI),那时 input() 直接 EOF。「问不到人就当作同意」恰好在无人值守 时失效,而那正是最需要它的时候。

串行工作负载终于出现了

接上 bash 之后跑了第一个真实的「改代码 → 加测试 → 跑测试验证」循环。agent 独立 加上了价格校验和三条测试,其中两条是我没要求的:验证拒绝时不留下半截状态, 以及钉死边界(0 合法,仅负数拒绝)。独立复跑 9/9 通过。

这类任务步骤之间有依赖、无法并行——正是模块 5.3 缺的那种工作负载。历史压缩可以 在这个基础上重新测了。

评测体系:让「效应是否超过噪声」成为默认输出

此前每个实验各写各的任务集、指标和存盘格式,结论之间无法比较;而且只有一次意外的 A/A 测过底噪,模块 2 和模块 4 的结论都是在不知道噪声多大的情况下下的。

evaluate.py 把这些收敛成一套体系,核心设计是:跑批时自动把基线复制一份作为 A/A 对照组,报告直接把每个 arm 的效应和底噪并排输出,标出哪些结论能信。

底噪以前是撞出来的,现在是默认产物。

三个配套设计:

  • 配置用完立刻还原。agent 的开关是模块级全局(模块 2 就埋下的坏味道),不还原会让 arm A 的配置泄漏到 arm B——这种污染不报错,只会悄悄让结果变错。
  • 修复类任务用独立命令复验,看真实测试的退出码,而不是信 agent 自称「跑通了」。
  • 夹具每次运行前复原,保证每条 arm 从相同初始状态出发。

第一次运行就改掉了我自己的一个错误。 报告原本用 |arm − baseline| / baseline, 而 baseline 只是 A/A 两条里的一条。那次 A/A 两条同配置却差了 41% 的 prompt—— 换哪一条当基线,整文件的效应会在 22% 和 107% 之间跳。改成合并两条作为基线估计、 用两者之差作为底噪之后:

指标 基线 底噪 整文件的效应 判定
非缓存输入 5,618 22% 60% ✓
上下文字符 24,739 24% 56% ✓
总输入 token 57,202 52% 54% ✗
步数 11 22% 1% ✗
正确率 67% 22% 11% ✗

存活的两个恰好是模块 5.2 识别出的那两个,裕度都超过底噪的 2 倍——独立复现成立。

而正确率那一行是新加的。 原本报告没给正确率算底噪,表面看「整文件 7/9 比区间读 5/9 更准」像个发现;加上底噪才看清——相同配置的两条就差了 22%,那是噪声。

底噪本身也是有噪声的

测量 同样 n=9 时的 prompt 底噪
模块 5.2 15%
模块 7 41%

底噪是从两个样本均值之差估出来的,它自己也是随机量。所以 ✓/✗ 在边界上并不稳。 严格做法是多跑几组 A/A 取分布,但成本太高。实用折中:只相信效应远超底噪的结论, 对勉强过线的保持怀疑——上表两个 ✓ 都在 2 倍以上,而 prompt 的 54% vs 52% 就不采信。

Sub-agent:隔离有效,但总账是亏的

给 agent 加一个 delegate 工具:派一个只读子 agent 去调查,它有独立的消息列表, 只有结论回到主上下文,翻过的文件内容用完即弃。

最容易作弊的地方先堵上。 如果只统计主循环的 token,隔离必然看起来是净赚—— 探索开销被挪到了一个一次性上下文里,从主循环的账上消失了。但钱还是花了。 所以代码先把子 agent 的用量记上账,报告里 total_prompt 才是唯一诚实的成本指标。

模型从不主动使用它。 四次运行、三轮诱导,delegate 调用次数都是 0:

尝试 结果
初始描述(只讲机制:「子 agent 有独立上下文」) 0 次
描述改成讲收益(「你读的文件会一直占上下文,它的不会」)+ system prompt 明示 0 次,但改用了区间读,token −14%
换成任务自带分解的三个独立子问题 0 次,改用并行工具调用,4 步 21,670 token 全答对
主 agent 读取上限压到 40 行 0 次,改成分块硬啃;第 12 步用 bash 里的 cat 绕开了限制

两个发现:

① 成本提示只能改变「单步内可执行」的行为。 它接受了区间读(一步之内的选择), 但不接受委派——委派要求先把任务拆成自包含的子问题,那是一个规划动作,而 ReAct 每一步只决定「下一步做什么」,这个粒度下没有什么可委派的。委派预设了分解。

② 对一个工具的限制,在另一个工具提供同样能力时等于不存在。 我压低了 read_file 的上限,它自己找到了 bash 里的 cat。这和模块 6 把约束下沉到 OS 层是同一个教训的 另一面:能力约束要在能力层面施加,不在工具层面。

把 bash 也从主 agent 拿掉之后,委派终于发生了。于是做 A/B——两个 arm 受限程度完全 相同(40 行上限、无 bash),唯一差别是有没有 delegate。3 个广度任务 × 3 轮:

指标 基线(可委派) 无委派 效应 底噪 判定
总输入 token 184,130 107,466 +42% 19% ✓ 委派更贵
主上下文字符 51,460 65,759 −28% 10% ✓ 隔离有效
主循环 token 85,631 107,466 −25% 4% ✓
完成率 56% 56% 0% 22% ✗ 无差别
正确率 56% 56% 0% 22% ✗ 无差别

净结论:多花 42% 的 token,换来主上下文少 22%,完成率和正确率都没动——在这个规模上是亏的。

单次观察又骗了我一回。 强制委派的第一次运行是三种配置里唯一 finished 的,我据此 写下「它把失败变成了成功」。重复 9 次后:67% vs 56%,而相同配置的 A/A 两条是 67% vs 44%,底噪 22%。那次「唯一做完」是运气。这是本项目第四次被未经重复的单次 观察误导。

这份数据还暴露了我自己的两个测量缺陷:

  • uncached 一列只统计了主循环,子 agent 的未缓存输入一分没算,于是委派看起来 「全价输入低 27%」。total_prompt 防住了这个陷阱,这一列没防住。已改为全口径, 但本表中的该列仍是主循环口径,不可用于成本判断——缺陷写下来,而不是悄悄删掉。
  • finished 是比率却按整数格式化,0.55 被显示成 1,读表的人会以为完成率是 100%。

和模块 5.3 是同一个结构:模块 2 的 grep 引导、模块 5.2 的区间读、模块 6 的输出封顶, 已经把探索阶段的上下文成本压得很低——sub-agent 要解决的问题,早期的优化已经消化掉了。 两个教科书级的 agent 模式(历史压缩、子 agent 隔离),在这套架构下都被前面的工作提前 取消了需求。

Reflexion 未能测量,以及为什么这本身是个结论

Reflexion(失败后自我批评再重试)要测,前提是基线得失败得足够频繁——通过率 100% 的话任何改进都无从显现。所以先造夹具,目标是把基线通过率压到 40–60%。

三轮都没压下去:

夹具 设计 基线通过率
shop 5 个直白 bug(减号写成加号、用错常量) 6/6
shop-hard 5 个隐蔽 bug(可变默认参数、is 比较字符串、优惠券与税的顺序、浮点未格式化、缺下限钳制) 6/6
shop-hidden 同上,但测试源码对 agent 不可见,只能运行并从断言信息反推规格 6/6,且只用 4 步

造 shop-hard 时我自己先栽了一次:设计的两个「bug」根本不是 bug。 「先加税再打折」 在乘法下可交换(x·1.08·0.9 ≡ x·0.9·1.08);is 比较字符串时,"apple" + " " + "pie" 会被编译期常量折叠成字面量并驻留,is 反而成立。第三次栽在「任务集必须先验证」上—— 后来改成了扣固定金额的优惠券(顺序才有意义)和 " ".join([...]) 运行期拼接。

shop-hidden 用上了模块 9 的教训:光让 read_file 拒绝没用,bash 里一个 cat 就绕过去了。所以测试放到工作区之外(边界检查天然拒绝 read_file),再给沙箱加一条 定点 (deny file-read* ...) 堵住 cat,另给 agent 一个 run_tests 工具——在沙箱外 运行测试、过滤掉 traceback 里的源码行,只回传测试名、结果和断言消息。

结论:

Reflexion 在本项目中未能测量——不是因为它无效,而是因为造不出让基线失败得足够 频繁的任务集。 要达到那个难度需要真实仓库里的真实 issue,而造那种题比实现 agent 本身还贵——这正是 SWE-bench 作为一个独立研究项目存在的理由。

附带确立了一个 Reflexion 实验必备的设计:对照组必须是「重试但不反思」,而不是 「只试一次」。否则测到的只是「多给一次预算」的效果——任何靠压低步数上限来制造失败的 实验,都会掉进这个陷阱。

第五类 fail-closed:量不出来就别给数

隐藏测试那轮基线报出 0/3 答对,看起来像「隐藏测试把它难住了」。实际是我们的验证 命令用了相对路径、根本没跑起来(ImportError: Start directory is not importable)—— agent 其实四步就全修好了,独立复验 6/6 通过。

坏掉的验证器和失败的 agent,在报表上长得一模一样。

唯一的破绽是「4.3 步」这个数字太反常。修法是让验证器自己出错时直接抛异常,而不是 安静地返回「答错」:

    if "Ran " not in out:
        raise RuntimeError(f"验证命令没有真正跑起来,这次结果不可信:\n{out[-400:]}")

这是 fail-closed 在本项目的第五次出现,也是新的一类——前四次是「拿不到授权就拒绝」, 这次是**「量不出来就别给数」**。

踩过并修掉的问题

问题 根因
401 Unauthorized "Bearer" + KEY 少一个空格;HTTP 认证头的空格是语法的一部分
agent 读到了 .env 并把 key 发给了模型 read_file 没有路径约束。修法:把密钥移出工作目录(缩小爆炸半径)+ 加边界检查
/etc/passwd 绕过了边界检查 os.path.dirname(".") 返回空串,检查退化成「是不是绝对路径」——fail-open。修法:改 realpath + 启动时断言
模型调 grep 一直失败,但 agent 没报错 schema 写了、registry 忘了。容错机制让 bug 静默;修法:启动时断言两者一致,且工具错误打印 ⚠
模型用「半行」当插入锚点,四级策略全挂 策略 1 是字符级的,策略 2–4 却都按整行比对。这个假设没写进任何接口,所以被模型违反。修法:把归一化层也改成字符级(归一化 + 下标映射),一次同时解决行尾空白和半行片段
端到端实验 10/10 通过,而代码里躺着 NameError 重构时删掉了策略 2 的中间变量,策略 3 还在引用它。该分支一次都没被执行到,所以集成测试看起来完全健康——由单元测试抓出。端到端只能覆盖走过的路径
实验跑了 12 次才发现任务描述的 bug 不存在 让 agent 去修一个「percent==100 被误拒」的问题,而代码里本来就是对的。agent 正确地拒绝编造改动,但一次烧了 48k token 在反复找。任务集必须先验证再使用——现已用 test_cart.py 把该契约钉死

设计上的几条约定

  • 工具永远返回字符串,永远不抛异常。 抛异常等于剥夺模型自我修正的机会。
  • 每个工具的输出都有上限(grep 截断在 200 条)。一次失控的搜索能塞爆上下文窗口。
  • 路径检查用 realpath 不用 abspath,否则工作目录里一个软链就能穿出去。
  • 安全相关的配置在启动时断言。静默放行比没有检查更危险。
  • 两处必须手动保持一致的东西,要么合并,要么加自动检查。 靠记性不行。
  • 容错机制按失败代价分类。 归一化(化到同一写法再比相等)可以放心加; 猜测(接受「不相等但够像」)必须先证明自己值得那份风险。

路线图

  • 模块 0 — 手打 curl,看清 agent 就是 JSON 往返
  • 模块 1 — 最小循环 + 工具调用协议
  • 模块 2 — 工具层、错误契约、路径安全、第一个对照实验
  • 模块 3 — 代码编辑与匹配策略链
  • 模块 4 — repo map:tree-sitter + 图排序
  • 模块 5 — 上下文预算与压缩(含 5.3 在串行工作负载上的补测)
  • 模块 6 — 执行沙箱(macOS Seatbelt)与命令执行
  • 模块 7 — 统一评测体系(自动 A/A 基线)
  • 模块 8 — Reflexion:三轮夹具均无法把基线压到 100% 以下,未能测量(结论见上)
  • 模块 9 — Sub-agent:隔离有效但总账为负;模型从不主动使用

About

从零手写的终端 coding agent(无框架):ReAct 循环、SEARCH/REPLACE 匹配策略链、tree-sitter + PageRank 仓库检索、Seatbelt 沙箱。每个设计决策都由带 A/A 噪声校准的对照实验验证。

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages