中文 · 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 # 跑对照实验同一个任务、同一个模型(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 提交
进仓库——下一个想问的问题,永远不是设计实验时预想的那个。
模型复述代码时会有偏差——丢缩进、吃掉行尾空格、把长行截断。逐字符精确匹配因此 经常失配,而每次失配都要模型重来一轮。所以做成一条从严到宽的策略链, 每一级都独立校验匹配唯一性,因为放宽条件本身会制造歧义。
| 策略 | 做什么 | 能否静默改错地方 |
|---|---|---|
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)——这是刻意付出的代价。
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 时会退化成泛词匹配。
一次写错的实验开关(定义了 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-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% 就不采信。
给 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(失败后自我批评再重试)要测,前提是基线得失败得足够频繁——通过率 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 实验必备的设计:对照组必须是「重试但不反思」,而不是 「只试一次」。否则测到的只是「多给一次预算」的效果——任何靠压低步数上限来制造失败的 实验,都会掉进这个陷阱。
隐藏测试那轮基线报出 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:隔离有效但总账为负;模型从不主动使用