Modem 详解编码智能体如何读代码:如何为智能体写可检索的代码
How coding agents read your code (and how to write for them)
Modem 发布《Writing Code for Agents》系列第一篇,指出编码智能体主要靠 grep/ripgrep 做文本搜索来导航代码库,因此变量、函数、文件名本身就是搜索词。
Modem 用自家 68 万行 LLM 生成代码库的实测数据,说明命名与类型如何影响智能体的检索开销与准确率。
Writing Code for Agents 第 1 部分:智能体如何通过字符串搜索在你的代码库中导航,以及如何编写它们真正能找到、解析和信任的代码。
当我们在 2025 年初创立 Modem 时,我们相信 AI 编程是真实的,它将迅速加速软件开发,而传统的产品流程将成为新的执行瓶颈。
为了投身于这个未来,我们从第一天起就用 AI 代码生成工具构建 Modem——那时 Sonnet 3.7 还是领先的编程模型。一年后,Modem 已成为拥有付费客户的真实产品:360,000 行 TypeScript 应用代码,另有 320,000 行测试代码,其中 99.9% 由 LLM 生成。
让 Modem 达到可用的代码库并不容易。它需要实验、反复试错,以及持续投入,才能与智能体式代码生成高效协作。在这个博客系列 Writing Code for Agents 中,我们将分享一路走来学到的一些技巧。
第 1 部分——也就是本文——讲的是如何编写代码,让智能体更有效地导航、解析和阅读它:你选择的名称、你定义的类型,以及你把解释放在哪里。结果并非假设;在我们的测试中,遵循本指南中的技巧使得用于代码检索的 token 更少、智能体轮次更少,缺陷检测率更高,并且自信给出错误答案的情况更少。
但要理解这何以可能,我们需要先理解:智能体究竟是如何搜索代码的?
智能体如何搜索代码(剧透:就是文本搜索)
你的编程智能体会频繁地在你的代码库中搜索代码。例如,想重命名一个函数?Claude Code、Codex、OpenCode 等都会搜索你的代码,找出所有可能需要更新的该函数引用。
在底层,大多数编程智能体只是在运行 grep——或者更准确地说,是它更快的表亲 ripgrep,也就是 rg——来快速扫过你的代码并找到匹配的文本引用。智能体读取返回的内容,如果上下文不够,就重复扫描你的代码,直到获得所需信息。
$ rg -l 'formatDuration' --type ts
packages/common/src/duration.ts
packages/common/src/duration.test.ts
apps/dashboard/src/lib/utils/dates.ts
apps/slack/src/lib/rate-limit.ts
...formatDuration 的引用。
在文件中查找文本字符串并不是智能体搜索的唯一方式:它们也会按文件名搜索。例如,如果你问编程智能体“session broker 是如何工作的?”,智能体很可能会在代码库中扫描相似名称:
$ rg --files | rg -i 'session-broker|sessionBroker|session_broker'
apps/api/src/session-broker/index.ts
apps/api/src/session-broker/handler.ts
apps/api/src/session-broker/handler.test.ts所以路径也是搜索词。一个名为 session-broker/ 的目录在智能体读取任何一行代码之前就已经命中了。
注意,这一切都只是字符串。没有编译器向智能体提供依赖图,也没有语言服务器解析符号(通常如此,下文会进一步说明)。Claude Code 团队早期尝试过嵌入和向量数据库,但把它们抛弃了,因为纯文本搜索效果更好。学术界的智能体也得出了同样的结论;SWE-agent 通过给模型专门构建的关键词搜索工具而取得成果,没有更花哨的东西。
task'修复时长格式化 bug'
search$ rg formatDuration
读取最佳命中周围的一段窗口
decide上下文足够吗?[y/n]
y
edit> 开始编辑
n · 更好的查询
即便是前沿模型,只要配备了得力的工具,通常也会用本质上就是 grep 的方式在你的代码中导航。而且请注意,这个循环中有一个由代码作者掌控的最重要输入:你代码中的用词和文件上的命名是否构成好的搜索词。
并非所有代码都同样易于发现
让我们设想 3 个假想的函数。它们做的事情完全相同:创建一个 API 客户端对象,向外部服务(例如 Stripe)发起 HTTP 请求。
唯一的区别在于它们的命名:
export function create(apiKey: string) {
// ...
}
export function createClient(apiKey: string) {
// ...
}
export function createStripeClient(apiKey: string) {
// ...
}以下是这些函数在代码库其他地方可能被调用的方式:
import { create } from '../api.ts';
const client = create(...);
client.getUsers(...);
// const client = createClient();
// const client = createStripeClient();假设你正和智能体一起处理这段代码。你想修改这个 API 客户端。比如,添加一个新方法,调用你新引入的一个 HTTP API 端点。智能体会自然而然地尝试找出这个对象被使用的所有位置。正如我们之前了解到的,它会使用 grep/ripgrep。
以下是在我们的 monorepo——约 2,900 个 TypeScript 文件——中,grep 对这些字符串会返回多少条匹配,以及在一台 M5 级 MacBook 上使用 ripgrep(rg)每次搜索耗时多久。
| 搜索 | 匹配行数 | 文件数 | 耗时 |
|---|---|---|---|
create | 1,585 | 459 | ~50ms |
createClient | 466 | 23 | ~47ms |
createStripeClient | 43 | 19 | ~47ms |
create 的 1,585 条命中横跨数百个不相关的东西:测试夹具、数据库插入、工作流注册。createStripeClient 的 43 条命中则是定义、调用点和测试——全都关于同一个客户端。
你大概也注意到了,ripgrep 很快。搜索 create(50ms)或 createStripeClient(47ms)在耗时上没有明显差别。但问题不在这里。问题在于这些命中如何影响智能体的上下文。
糟糕的代码可发现性会吞噬上下文
Grep 返回的是匹配行,而不是答案。像 const client = create(config) 这样的一条命中,并不能告诉智能体这是你关心的那个客户端,还是数百个其他名为 create 的东西之一。
为了弄清楚,智能体会读取命中位置附近的一段窗口,而且往往会扩展或打开更多片段。这可能累计从几十行到成百上千行代码不等。
$ rg -c 'create' --type ts | wc -l # 459 files carry a match
459
# the agent opens the plausible ones and reads each in full:
# apps/ingest/src/jobs/register.ts (~220 lines) → a workflow registration, not it
# packages/database/src/seed.ts (~110 lines) → a database insert, not it
# apps/dashboard/src/hooks/use-form.ts (~70 lines) → a form factory, not it
# ...more files, before the real one turns up按每行大约十个 token 计算,每次读取文件都要花掉数百到数千个 token 来排除一个错误的候选。处理十来个之后,你已经在区分信号与噪声上烧掉了数万个 token,而实际任务还一行都没写。
而搜索那个具体的名字则省去了筛选:
$ rg -l 'createStripeClient' --type ts
apps/payments/src/stripe-client.ts
apps/payments/src/checkout.ts
apps/payments/src/stripe-client.test.ts
# Just 3 files to read instead of 459三个文件,全都关于同一个客户端。而且这种节省会不断累积。上下文是有限的,所以每花一个 token 去排除 create 夹具,智能体就少一个 token 用于你的改动。更糟的是,当它的上下文被这些似是而非的命中填满时,模型在使用其中真正有用的内容时会明显变得不那么可靠。一个 grep 一步就能解析的名字,能让智能体的注意力和预算都留在任务上。
模块救不了一个泛泛的名字
到目前为止我们讨论的都是纯粹的符号查找。但导入和模块呢?
让我们回到之前的 client TypeScript 示例:
import { create } from '../api.ts';
const client = create(...);
client.getUsers(...);这个文件顶部有一个相当清晰的线索,指明了 client 函数定义的位置。既然我们可以直接在这里查到文件,为什么还要在整个代码库里 grep 呢?
如果目标严格来说只是找到定义,那你说得对。导入是一个前向指针:给定一个调用点,它告诉你定义在哪里。
但开启这篇文章的任务是一次重命名。那是反方向——给定一个定义,找出所有调用方——而纯文本搜索不提供反向索引。api.ts 中没有任何内容记录谁导入了它。(有些语言在这方面做得更好;详见下文。)
反向符号查找
假设 create 有三个调用点,每个都使用普通的导入风格:
// relative path
import { create } from '../../../packages/common/src/api';
// package specifier, via a barrel
import { create } from '@modem/common';
// renamed import
import { create as createApiClient } from '@modem/common';现在试着用文本找到它们全部:
$ rg -l "from '.*api'" # 1 of 3 — package specifiers don't contain the path
$ rg -n '\bcreate\(' # 2 of 3 — misses the rename, plus every unrelated create()上面两个查询都无法干净地找到全部三个——除非名字足够独特,grep 它只会返回真实用法。导入图存在于你的代码中,但它无法被反向 grep。对于以 grep 为先的 agent 来说,一个独特的符号名是最可移植的反向查找方式。
Barrel 文件与包说明符
即使在正方向——从调用点到定义——在 monorepo 中也会退化。我们文件顶部的导入说 create 来自 @modem/common。但 @modem/common 不是路径;它是一个包说明符。要顺着它走,agent 首先得找出那个包在哪里,这意味着打开 package.json 或 grep 包名。
这就把它带到了 barrel 文件:
// packages/common/src/index.ts
export * from './api';
export * from './auth';
export * from './duration';
export * from './validation';哪个 re-export 有 create?barrel 没有说明。export * 抹掉了它转发的名字,所以 agent 又回到了在包内 grep 以找到实际定义。同样的搜索它本可以一开始就做,现在却晚了 3 跳。
对象上的方法
方法从来就没有导入。看看我们一直在处理的这个调用:
const client = create(...);
client.getUsers(...);没有任何 import 语句提到 getUsers;导入处理的是模块及其顶层导出,而方法两者都不是。以 grep 为先的 agent 找到 getUsers 定义在哪里、或谁调用它的唯一方式,就是搜索这个名字。而且与上面两个失败不同,这不是 TypeScript 的错:client.getUsers() 在 Python、Go、Ruby 和 Java 中留下了同样不存在的痕迹。没有语义工具,方法名通常是 agent 拥有的最佳文本抓手。
模块指向一个方向,在 barrel 和包说明符后面变得模糊,并且对你的对象上的方法只字不提。名字仍然在承担重任。
类型强制更好的可发现性
名字帮助 agent 找到你的代码;类型告诉它如何使用那些代码。而且与注释或 README 不同,类型是被强制执行的。
换句话说:agent 可能会跳过你的文档。但当它运行编译器时,类型错误会提供快速、具体的反馈。一个无效调用会产生一个错误,agent 可以用它来纠正自己的做法——通常在一轮之内。
不过,在编译器捕获任何东西之前,类型就已经在为自己买单了,因为一个精确的签名往往能回答 agent 最初的问题,而不需要它阅读实现。考虑这个:
function enrichUser(user: User): EnrichedUseragent 在 grep 结果中看到这个,然后继续前进。输入什么(User),输出什么(EnrichedUser)。取决于所问的问题,可能没有理由打开函数体。现在看无类型的版本:
function enrichUser(data) {
// or
function enrichUser(data: any) {要了解 data 是什么,agent 很可能必须阅读实现。如果实现把 data 传给别的东西,它也会读那个,可能还有一两个调用方。一个缺失的注解可以把一行阅读变成好几个文件的阅读。
注意 TypeScript 的 any 在看起来像类型的同时造成同样的损害;每一个 any 都增加了 agent 需要阅读实现的可能性。
类型质量很重要
所以仅仅类型的存在就能减少 agent 的阅读。但这些类型的描述性也很重要。
考虑这个函数:
function transferOwnership(userId: string, orgId: string, projectId: string) {
// ...
}
// accidental swapped args
transferOwnership(orgId, projectId, userId);由于每个参数的类型都是 string,编译器无法捕捉到意外的参数顺序颠倒。但如果这些 ID 各自拥有不同的类型(TypeScript 中的 branded types、Rust 中的 newtypes)呢?
function transferOwnership(userId: UserId, orgId: OrgId, projectId: ProjectId) {
// ...
}现在这种颠倒就变成了构建错误:
Argument of type `OrgId` is not assignable to parameter of type `UserId`.智能体会遇到这个错误,修复它,然后继续推进,通常只需一轮。研究者已经观察到这个循环确实有效:一项关于编译器反馈的研究发现,模型经过几轮错误与重试后,成功率大约提升到原来的三倍。
这里就与上文的一切联系起来了。那个错误指出了一个类型——OrgId——而 OrgId 几乎肯定定义在另一个文件里。要针对这个错误采取行动,智能体必须去读那个定义,而它到达那里的唯一方式就是:搜索这个名字。
类型名和函数名一样,就是一个搜索词,遵循同样的规则。OrgId 或 UserEnrichmentResult 能 grep 到唯一一个定义;而一个 Result、一个 Data、一个 Config 则会把智能体丢回前面那种多命中的干草堆里。编译器告诉它该往哪里看;名字决定看多久。
这就是反对 any 的完整理由:编译器无话可说,智能体也无从搜索。一个糟糕的名字至少还留下了一条线索。
同一思路的较小应用
到这里模式已经很清楚了:让你代码里的词成为好的搜索词。剩下的大部分内容,都是同一思路在更小场景中的应用。
把注释写在定义上。智能体通过搜索一个名字来找到你的代码,而搜索会定位到该名字被定义的地方——所以定义就是你能指望它一定会读到的那一处。这使得紧贴在定义上方的一行注释成为你能写的最具性价比的文档:它恰好位于搜索落点。写一句话,说说代码本身无法表达的东西。
每个概念只选一种拼写。尽量给不同的概念起独一无二的名字。例如,不要在一处叫它 organizations,在另一处又叫 customers——哪怕用的是本地导入别名。代码库里的同义词也可能导致混淆(例如 orgId 与 organizationId),但智能体似乎能更成功地应对这些。
测试文件按其所覆盖的源码命名。我们有意让测试文件按其所覆盖的代码来命名(例如用 stripe.test.ts 来测试 stripe.ts)。我们观察到,如果没有显而易见的名字,智能体要多花几轮才能找到匹配。
用 @deprecated 标记遗留路径。如果你出于某种原因保留了遗留代码,一定要把它标记为已弃用(例如使用 TypeScript 的 @deprecated 标签)。否则智能体会发现并使用你不希望它使用的代码。更好的做法是,努力把这段代码彻底删除。
编写约定文件。你很可能已经有了一个 AGENTS.md 或 CLAUDE.md。务必记录下任何不显而易见的内容,比如命名约定、源码位置等。这是确保智能体有效浏览你代码的最高杠杆习惯。
这些全是感觉吗?让我们看看录像
到目前为止,我们都是在理论上论证。但它在实践中真的有区别吗?
为了尝试回答这个问题,我们让 Fable 5 把这篇博文提炼成一个 write-discoverable-code 技能文件——一页纸的规则集,在智能体编写代码时加载进它的上下文。随后,在一组隔离实验中,我们比较了其他智能体在有无这些规则的情况下,对代码的检索与推理方式。你可以从 agent skills for your codebase 安装这个技能。
在我们进入结果之前,有一个注意事项:这件事出奇地难以干净地测试。智能体在代码库中导航的难易程度取决于代码本身、任务、模型和运行框架,以及许多其他因素。此外,本文中的建议(记录在技能文件中)会同时改变好几样东西:名称、文件、类型、结构等等。变量太多了。
这些并非旨在成为权威基准。它们是更小、范围更明确的实验,问一个更简单的问题:当任务和周围的仓库大致保持不变时,为可发现性而编写的代码是否更容易让搜索驱动的智能体导航?
实验 1:生成的库(TypeScript)
这个实验的目标是看看使用我们技能文件的全新智能体生成代码,是否比不使用它带来更好的智能体检索结果。
两个作者智能体各自编写这个库两次——一次无辅助,一次使用技能文件——产生四个代码变体。每个变体都被埋进同一个 500 文件的仓库中。然后十四个独立的读者配置搜索并阅读这些仓库;读者从不接收技能文件。
作者智能体编写
2 个作者
Haiku 4.5 Claude Code
GPT-5.6 Sol Codex CLI
每个编写两次
无辅助
+ 技能文件
测试夹具
4 个版本
+ 同一个 500 文件仓库
读者智能体阅读
14 个读者配置
全新会话
搜索 + 阅读
无技能文件
重试延迟在哪里计算?
为此,我们从一个用于小型通知管道库的中立规范开始,该库具有重试、速率限制、负载签名等功能。然后我们把它交给两个不同的作者智能体:Claude Code 中的 Haiku 4.5 和 Codex CLI 中的 GPT-5.6 Sol。
每个作者生成这个库两次:一次无辅助,一次加载了 write-discoverable-code.md 技能。这给了我们同一个库的四个版本:
- Haiku 4.5(Claude Code)- 无辅助
- Haiku 4.5(Claude Code)- 加载了技能文件
- GPT-5.6 Sol(Codex CLI)- 无辅助
- GPT-5.6 Sol(Codex CLI)- 加载了技能文件
我们有意选择 Haiku 4.5 作为一个小型作者模型,其无辅助版本产生的正是本文所警告的那种代码。加载技能后,差异立刻体现在名称上:
| Haiku 4.5 - 无辅助 | Haiku 4.5 - 技能辅助 |
|---|---|
signer.ts | hmac-payload-signer.ts |
queue.ts | channel-router.ts |
validateConfig() | validateNotificationDeliveryConfig() |
另一方面,GPT-5.6 Sol 的无辅助代码已经为可发现性而塑造:calculateBackoffDelay 在 backoff.ts 中,TokenBucket 在 token-bucket.ts 中。在这次运行中,Sol 在没有任何额外指导的情况下产生了具体、可发现的名称。
加载技能后,Sol 更进一步——例如 backoff.ts 变成了 notification-retry-backoff.ts——但其两个版本之间的差距明显比 Haiku 的更窄。(就个人品味而言,我觉得这段代码可以说是过度指定了。)
接下来,我们测试了其他智能体如何阅读这四个库版本。我们把每个版本埋进来自我们 monorepo 的同一批 500 个真实代码文件中(以观察在我们现有代码中的假设影响)。然后我们启动全新的智能体 CLI 会话,限制为搜索和阅读工具,并询问诸如以下问题:
Where is the wait time before the next retry attempt computed?
Where is the computed HMAC signature attached to the outgoing payload before delivery?
Where can I look up all past delivery attempts for a given notification id?
...十四个模型/工具组合针对每个版本各回答 10 个问题三次:总计 1,680 次运行。每次运行,我们记录了 token 消耗以及答案是否正确。
作者 1:Haiku 4.5(Claude Code)
| 模型 | 每问题 Token 数无辅助 → 使用技能 | 变化 | 错误答案 |
|---|---|---|---|
| DeepSeek v4 pro · OpenCode | 70,336→24,142 | −65.7% | 4→0 |
| GLM 5.2 · OpenCode | 42,657→17,526 | −58.9% | 1→0 |
| Composer 2.5 · Cursor CLI | 164,952→89,125 | −46.0% | 0→0 |
| GPT-5.6 Sol · pi | 49,684→28,218 | −43.2% | 0→0 |
| Grok 4.5 · OpenCode | 33,008→19,431 | −41.1% | 0→0 |
| Haiku 4.5 · Claude Code | 153,299→102,215 | −33.3% | 0→0 |
| GPT-5.6 Sol · Codex CLI | 78,346→52,816 | −32.6% | 0→0 |
| GPT-5.6 Luna · Codex CLI | 82,429→57,051 | −30.8% | 1→0 |
| GPT-5.6 Luna · pi | 44,718→35,656 | −20.3% | 0→0 |
| Sonnet 5 · Claude Code | 92,052→76,365 | −17.0% | 1→0 |
| GPT-5.6 Terra · pi | 11,692→9,725 | −16.8% | 0→0 |
| Fable 5 · Claude Code | 113,941→97,779 | −14.2% | 0→0 |
| GPT-5.6 Terra · Codex CLI | 55,939→51,275 | −8.3% | 0→0 |
| Opus 4.8 · Claude Code | 98,216→92,121 | −6.2% | 1→0 |
作者 2:GPT-5.6 Sol(Codex CLI)
| 模型 | 每问题 Token 数无辅助 → 使用技能 | 变化 | 错误答案 |
|---|---|---|---|
| Composer 2.5 · Cursor CLI | 166,449→90,555 | −45.6% | 0→0 |
| GPT-5.6 Sol · pi | 45,398→28,052 | −38.2% | 0→0 |
| Haiku 4.5 · Claude Code | 148,395→111,215 | −25.1% | 0→0 |
| GLM 5.2 · OpenCode | 20,055→16,455 | −18.0% | 0→0 |
| Grok 4.5 · OpenCode | 23,966→19,825 | −17.3% | 0→0 |
| Sonnet 5 · Claude Code | 138,981→118,797 | −14.5% | 0→0 |
| GPT-5.6 Sol · Codex CLI | 61,276→52,996 | −13.5% | 0→0 |
| GPT-5.6 Terra · Codex CLI | 58,975→52,932 | −10.2% | 0→0 |
| GPT-5.6 Luna · Codex CLI | 60,901→55,637 | −8.6% | 0→0 |
| Fable 5 · Claude Code | 110,109→100,636 | −8.6% | 0→0 |
| GPT-5.6 Terra · pi | 11,694→10,767 | −7.9% | 0→0 |
| Opus 4.8 · Claude Code | 103,125→99,426 | −3.6% | 0→0 |
| DeepSeek v4 pro · OpenCode | 23,638→24,225 | +2.5% | 0→0 |
| GPT-5.6 Luna · pi | 37,263→40,379 | +8.4% | 0→0 |
合起来看,这些表格显示,在无辅助作者生成更通用代码的地方效果更大。在这些运行中,Haiku 编写的代码中位数减少 32%,Sol 编写的代码减少 12%。十四个 Sol 读取器配置中有十二个得到改善;两个没有。在这些运行中,当作者留下更多改进空间时,该技能起到了下限的作用,尽管该实验并未将命名与技能引入的其他变化区分开来。
读取器行为也很重要。有一行值得单独用一句话说明:Sol 读取自己无辅助编写的代码时,如果该代码是在技能下编写的,仍然便宜了 13% 到 38%(取决于驱动它的工具)。编写代码的模型并没有关于东西在哪里的特权记忆。它运行和其他所有人一样的搜索。
与此同时,错误答案只属于一张表。在 Haiku 的通用代码上,无辅助会话返回了自信的错误答案——当被问及通知库的投递历史时,一个 agent 指向了仓库中别处的 Slack 投递账本;当被问及 HMAC 签名附加在哪里时,两个指向了我们媒体代理的 URL 签名器。技能命名的代码产生了零个错误,因为接近但不准确的代码无法拥有像 hmac-payload-signer.ts 这样的名字。在 Sol 的代码上,这种失败模式在两个版本中基本消失(840 次运行中 834 次正确,错误是一次性的且分布在两个分支中)。在这些运行中,自信的错误答案集中在通用命名的 Haiku 版本中。
实验 2:PewDiePie 的 Odysseus 仓库(Python)
合成代码库只能展示这么多,所以我们还针对一个棘手的真实案例测试了这个想法:Odysseus异常庞大的电子邮件子系统。Odysseus 是自托管的 AI 工作空间,第一周就获得了数万个 GitHub 星标——对于一位自学成才的开发者来说,这是件了不起的事——而 routes/email_routes.py(在 GitHub 上查看)有 4,943 行,其中约 3,900 行是一个单一函数,以嵌套闭包的形式容纳了全部 81 个路由处理器。一个函数,就是整个电子邮件客户端。

我们只重构了那一个文件(email_routes.py),使用 Claude Code + Sonnet 5 并遵循该技能——相同的行为,相同的路由,经过机械验证——重新组织为按概念命名的模块:
之前
email_routes.py 4,943 lines 81 nested handlers
之后
email/ smtp_settings.py message_rendering.py folder_fallbacks.py delivery_history.py
然后向一组混合的智能体/测试框架询问关于两个版本的同样六个问题:
"Where does the code check that SMTP settings are complete enough to send outgoing mail?"
"Where is markdown converted to HTML when composing an outgoing email?"
"When the user's preferred mail folder does not exist on the IMAP server, where does the code pick a fallback folder instead?"
...以下是每个模型/智能体测试框架的表现。(这次模型/测试框架组合更少,因为我们并非拥有无限的额度。)
| 模型 | 每问题 Token 数单体 → 重构后 | 变化 |
|---|---|---|
| Haiku 4.5 · Claude Code | 290,324→190,811 | −34.3% |
| Grok 4.5 · OpenCode | 25,600→17,535 | −31.5% |
| GPT-5.6 Sol · Codex CLI | 72,072→67,843 | −5.9% |
| Sonnet 5 · Claude Code | 198,291→190,569 | −3.9% |
生成库表格中的模式再次出现:在单体中迷失的模型节省最多。Haiku 此前一直在消耗 40 万 Token 的会话,翻遍一整面代码墙;重构后,它停止了。Grok 节省了三分之一。Sol 和 Sonnet 从未真正迷失,大致持平。
轨迹表明文件名是主要因素,尽管重构也改变了文件大小和模块边界。当智能体在寻找电子邮件文件夹逻辑,而它位于一个命名合理的文件如 email_folders.py 中时,智能体无需打开其他任何东西就能找到它。
但重构更大的影响在于最坏情况。几乎每一次灾难性运行——例如一次 40 万 Token 的搜索最终没有答案——都发生在原始单体中。之所以说几乎,是因为 Haiku 在重构版本中也产生了一次,而位置很说明问题。我们的实验只重构了一个文件,而 Haiku 迷失在一个相关但未改动的 1,800 行 email_helpers.py 杂烩文件中。重构提高了下限,而新的下限成了剩下的最差文件。
实验 2.5:Odysseus 缺陷搜寻
最后一个测试:可读性是否改变了智能体做对的事情,而不仅仅是它们的花费?我们在该电子邮件代码的两个版本中植入了四个细微缺陷——字节完全相同的编辑,因此每个版本包含完全相同的缺陷——并要求四个模型在固定的八轮审查预算下验证相关需求。每个模型对每个缺陷审查两次。
Haiku 4.5单体
████▓░░░
4/8
重构后
████████
8/8
Sonnet 5单体
███░░░░░
3/8
重构后
████████
8/8
Grok 4.5单体
████████
8/8
重构后
████████
8/8
GPT-5.6 Sol单体
████████
8/8
重构后
████████
8/8
我们测试的最新、最先进的前沿模型——Grok 4.5 和 GPT-5.6 Sol——在两个版本中都找出了每一个 bug。对于训练有素的搜索者来说,八轮足以通过 grep 找到这些缺陷中的任何一个,即使是在一个 4,943 行的文件里。
但 Haiku 和 Sonnet 无法完成同样的壮举:它们分别找出了 8 个中的 4 个和 8 个中的 3 个,而且失败的原因很少是答案错误——而是那些审查耗尽了全部预算,在文件中翻来翻去,却始终没有得出结论。在重组后的代码中,两者都取得了完美的 8/8。
Sonnet 那一行也值得注意:在早先的 Odysseus 检索测试中,它几乎完全没有从重构中受益。而在审查预算下,它的准确率仍然从 8 个中的 3 个提升到了 8 个中的 8 个。在单体代码中能够负担得起查找,与能够在其中得出结论,并不是一回事。
在这些实验中,更轻松的检索让智能体有更多预算去做实际工作。这体现为更便宜的查找、更少的错误答案,以及在轮次限制之前找出更多 bug。
总结:是的,代码很重要
回顾一下:智能体通过字符串搜索来浏览你的代码库,它们相信你的命名所告诉它们的内容,编译器是它们唯一无法忽视的审查者,而它们最先读到什么,很大程度上取决于搜索落在哪里。当我们在系列实验中对此进行检验时,它站得住脚。
在我们使用较弱代码作者(Haiku 4.5)的合成生成测试中,全部十四个模型/框架组合读取技能编写的代码时消耗的 token 都更少——少了 6% 到 66%——而且自信的错误答案消失了。在较强作者的代码(GPT-5.6 Sol)上,十四个模型/框架组合中有十二个表现更好,其余两个没有改善。
在我们涉及一个现有 vibe 生成仓库(Odysseus)的实验中,同样的重构为那些迷失方向的模型削减了约三分之一的 token 消耗——而对那些没有迷失方向的模型则收支平衡。在 bug 搜寻中,审查从单体代码中的 32 次正确 23 次,提升到重构后的 32 次正确 32 次,两个较弱的模型从 7/16 提升到完美的 16/16,因为它们把预算花在了判断上,而不是查找上。
我们不想过度吹嘘这些数据。我们的仓库测量是观察性的,而受控实验只是一个规格和几千次运行。框架也会持续演进——Cursor 已经公布了可观的数字,表明嵌入在 grep 之上也有帮助,所以纯文本搜索可能并非终局。但 grep、仓库地图和嵌入都依赖同一个输入:你源代码中的文字。更好的命名能改善所有这些。在这行里,这差不多是最稳妥的赌注了。
预构建索引会带来它们自身的一种失败模式。为什么 Devin 在代码库超过 15-20k 行后就举步维艰探讨了当仓库地图不再覆盖仓库时会发生什么。
有趣的是,这些几乎都不是新建议。描述性的命名、精确的类型、解释原因的注释——几十年来我们一直在互相告诫要做这些事,而且大多在自己没做到时原谅自己,因为人总是可以问队友或翻查 git blame。智能体不能。它只有你的文本、你的类型和一个搜索框。为那个读者而写,代码就会对每个人都更好。
敬请期待《为智能体编写代码》第二部分:确定性的力量。
来源:Modem Blog · modem.ai