Code Agent 里的文件提及迷思

免责声明:这篇文章是我写的,但我用 GLM 5.2 做了结构调整和文字润色。背后的一部分调查工作也是和 GLM 5.2 一起完成的。

事情的起因

Claude Code 刚出来的时候,我从官方博客文章[[1]](#[1])里捡到了两个习惯,当时立刻就觉得是种升级:用 ! 内联执行 shell 命令,以及用 @ 来 mention 一个文件。

两个都很顺手。! 让我可以自己跑那些我用惯了的命令,把输出喂进 Claude 的上下文;或者干脆手动接管后续的活儿,同时 Claude 仍然完整保留着发生过什么的上下文。@ 感觉一样好:它让我可以准确地告诉 Claude 一个文件在哪里。如果我只给出文件名,它就得生成内容去调用工具、去找这个文件,位置可能还不准确,而且可能要好几轮操作才能找对。

有一阵子,我就是这么工作的。

「浪费 token」的说法

几个月后,我开始在 X 上看到有人反对 @。这个论点很简单,而且在当时看来显然是对的:@ 不是什么魔法。它就是一段确定性的客户端代码,从磁盘上读取文件,把内容展开进用户的 prompt,然后把整个东西发给 LLM。[[2]](#[2]) 所以如果你 @ 了一个非常大的文件,你就在浪费海量的 token。

这一下就说通了。mention file 当然只能是这么实现的:就像 ! 调用 shell 一样,它本质上是个确定性的程序,所以这类问题很难避免。

vibe coding 带来的转变

后来 vibe coding 火了,模型明显变强,我发现自己大部分时候已经不用 @ 了。每次看模型干活,它都会在需要的时候去看目录结构;我让它读某个文件时,它通常已经知道文件在哪儿了。上下文窗口也普遍变大,让模型多花几次工具调用和几个来回,成本并不高;换来的是我可以含糊地描述文件、而不用敲精确路径的效率。(是的:我发现瓶颈现在是我,以及我的打字速度。)

之后很长一段时间,我没再想过 @ 是怎么实现的。

自己动手写 agent

直到我开始写 paimon------我自己的 code agent------情况才变了。轮到实现 mention file 的时候,那个老问题又回来了。我仔细想了一遍,突然觉得旧的结论未必是对的。

  • 如果 @ 只是用户输入时的一个本地便利功能(一个文件路径的自动补全),程序压根就不把任何内容读进 prompt 呢?
  • 或者再往下想:mention file 确实是一种确定性的行为。如果 code agent 已经真的读过一次文件,它能不能在发送完整内容的同时附带更多信息,比如内容的 sha256?那么下次再读这个文件时,如果发现之前发送过、而且 sha256 没变,是不是可以只发元数据,让模型知道它已经读过这个文件、并且哈希没变?
  • 如果是这样,当内容确实需要展开时,compaction 又该怎么处理?这是不是需要某种复杂的机制来管理?

想了一圈之后,我做出了一个基于传递元数据的设计。每个被 mention 的文件都会包在一个 XML 信封里,告诉模型文件路径、内容的 sha256、里面包的是完整文件还是只有一部分、包含了哪些行、以及文件实际有多少行。大致是这样:

xml 复制代码
<file
  path="src/parser.py"
  sha256="3f6a..."
  content="partial"
  lines="1-200"
  total-lines="1240"
>
...
</file>

如果完整内容在当前会话里已经发送过、而且哈希没变,就只发出引用性的元数据,让模型知道它已经读过这个文件、什么都没变;超过体积阈值的文件只发开头,真实长度放在元数据里。由于没有任何模型见过这套约定,规则会在 system prompt 里写清楚。底层上,agent 会在内存里维护一张表,记录到目前为止发送过的每个文件(哈希,以及具体传输了什么),并在 compaction 之后重置它------那时「已经完整发送过」不再成立。加起来是一张相当复杂的状态转移图。

听起来有点麻烦,但在 ChatGPT 5.6 sol 的帮助下,状态和流程很快就理清楚了。它甚至贴心地在此之上又加了一层设计:当用户用 @filename:10-20 这种语法钉住行号时,完整传输过的区间也会被记录下来;当之后另一个区间的 mention 与之前发送过的部分重叠、并且被完全覆盖时,区间就可以合并。计算这些重叠(哪些区间已经被覆盖、哪些部分相交、哪些应该合并成一个)是整个设计里真正繁琐的部分。

等等,Cursor 不是试过这个吗?

就在那一刻我忽然反应过来:这个机制是不是变得太复杂了?

这让我想起 Cursor 刚发布的时候,大家都在分析和猜测它底层到底是怎么工作的。说法是这样的:LLM 的上下文窗口很小,所以代码是用 RAG 检索的,只发送相关的片段[[3]](#[3]);有些分析走得更远,推测在一次工具调用完成对文件或命令的操作之后,围绕这次调用的上下文会被整个丢掉,只把结果缝回去。不管后半部分是不是真的,大家所相信的那个设计看上去极其聪明。但在我当时自己的使用中,在比较长的 agent 式任务上,效果并不理想。然后 Claude Code 出来了,做的几乎是相反的事:它就用 rg 之类朴素的命令粗暴地找代码,从不回滚上下文,只是一直把所有东西往前推,直到窗口填满、compaction 启动。对我来说,这个效果好得多。[[4]](#[4])

我不敢说自己完全理解 LLM 是怎么工作的,但看到 Claude Code 的做法让我意识到,那个传闻中的设计本质上在做什么:把上下文窗口当成一种数据库,假定任何发给 LLM 的东西都会被可靠地记住。但今天流行的基于 attention 的 LLM 并不提供这种保证。已经出现在上下文里的内容并不会被可靠地使用,尤其是当它埋在一段很长的历史深处时[[5]](#[5]),这也正是模型会想再读一遍的原因。

而现在我正在设计的,恰恰就是那种聪明的机制。区间合并、基于哈希的去重、「哈希匹配就只发元数据」的小把戏。所有这些都是在把上下文窗口当作一个权威记录的数据库,也许我正在犯同样的错误。

去核对事实

好在开源的 code agent 现在遍地都是,其中不少还相当有名。所以我决定不再空想,而是真的去调查一下我去年得出的那些结论是不是成立。我用 opencode 配 GLM 5.2,过了一遍五个知名的开源 agent:piopencodegemini-cligrok-buildcodex[[6]](#[6])

光是第一个问题------@ 到底会不会发送文件内容------就把这几家分成了两派。五个里有四个会读取文件并把它内联进用户消息:pi 把它包在 <file name="..."> 里(不过只对通过 CLI 启动参数传入的文件;在它的交互式 TUI 里,@ 只是路径自动补全,发送出去的是字面的路径文本),grok-build 用带行号的 <file_contents path="...">,gemini-cli 夹在 --- Content from referenced files --- 标记之间,而 opencode 则伪造了一次工具调用------模型看到的是 Called the Read tool with the following input: {...} 后面跟着标准的 Read 输出,就好像是它自己调用的工具一样。然后是 codex,它根本不读文件:@ 是一个模糊文件名搜索,选中一个结果只是把路径作为纯文本插入。模型在需要的时候应该自己去 cat 或者 rg。这恰好就是我第一条设想里「也许 @ 只是自动补全」的那种可能。

Agent @ 发送什么 路径之外的元数据 行区间语法 是否追踪已发送内容
pi 完整内容,不截断(仅限 CLI 参数)
opencode 通过自家 Read 工具取得的内容(上限 2000 行 / 50 KB),伪装成一次工具调用 截断提示:「Use offset=N to continue」 @file#12-18
gemini-cli 完整内容(2000 行上限,20 MB 硬性拒绝) 一条指向 read_file 的截断警告
grok-build 完整内容,至多约 5,000 估算 token,超出后只发一个纯元数据的 stub 超大文件上的 skipped="true" 加原因 @foo.rs:10-20
codex 什么都不发,只有作为纯文本的路径 ---

然后我把 paimon 的设计逐条对照这些实现。

没有人发送哈希,文件的真实大小只在截断提示里露面。 信封里带的是路径和内容,基本上就这些了(opencode 的 Read 风格输出结尾确实会带一个总行数)。最接近我那套元数据设计的是 grok-build 对超大文件的处理:超过约 5,000 个估算 token 之后,它会把正文整个丢掉,发送一个像 <file_contents path="..." skipped="true" reason="file too large (~5800 estimated tokens, limit 5000). Use read_file tool to read specific sections."/> 这样的 stub。会截断的 agent 都做了某个版本的这件事:告诉模型内容被切断了,并把它指回自己的读取工具。元数据是一个「你自己去读」的提示,从来不是一个去重用的键。

没有人追踪已经发送过什么。 没有哈希,没有 mtime,没有内存表,也没有「你已经有这个文件了」的分支。每次 mention 都重新读磁盘、重新完整发送;唯一存在的去重是一个 Set,用来折叠同一条消息内重复的 mention。我在 mention 相关代码里找到的唯一一个 sha256(grok-build 的)是用来给磁盘上的溢出文件命名的,从不会到达模型。唯一一个真正的反例埋在 gemini-cli 里:一个 ContextCompressionService,它对文件内容做哈希,并让一个小模型把每个旧文件路由到 FULL / PARTIAL / SUMMARY / EXCLUDED,和我设计里的一大块惊人地接近。只不过它藏在一个默认关闭的实验性 flag 后面,在我读的那个 commit 上,运行时里没有任何地方真正实例化它;而且就算它跑起来了,它也只处理读取工具的响应,而不是 @ mention 的内联内容。有人想到了同样的主意,但它没有上线。

行区间语法存在;重叠记账不存在。 opencode 支持 @file#12-18(被翻译成一次带 offset 和 limit 的 Read 调用),grok-build 支持 @foo.rs:10-20。两者都不记录发送过哪些区间,也都不合并重叠的区间;每次 mention 都是一次独立的读取。我和 ChatGPT 勾画的那套重叠合并机制,哪儿都不存在。

没有人在 system prompt 里解释这套约定。 五份 system prompt 对 mention 长什么样都只字不提。pi 和 grok-build 依赖 XML 本身是自解释的;grok-build 源码里的一条注释把它的格式称为「我们一直在用的训练格式」。opencode 那次伪造的 Read 调用是最聪明的规避:什么都不需要写文档,因为模型本来就知道 Read 的输出长什么样。而 codex 没有任何东西要解释,因为它除了一个路径什么都不发。

compaction 对 mention 没有任何特殊处理。 五个里面,当历史被摘要时,内联的文件内容都被当作普通的用户文本,整个喂给摘要器:没有哈希路由,没有占位符替换。我关于「状态怎么熬过 compaction」的担忧彻底消解了:根本没有状态需要熬过去。

没有人关心内容过期。 如果一个被 mention 的文件之后在磁盘上发生了变化,没有任何机制会把上下文里的旧副本标记为过期。模型要等到下次碰巧再读这个文件时才会发现。

而最能说明问题的发现是:codex 曾经是另一种做法。 在它的 TypeScript CLI 时代,codex 的 @ 和其他几家完全一样:用引入它的那个 pull request 的话说,是「文件内容在发送给 LLM 之前自动展开成 XML 块」,并且把 @path[50:80] 这样的行选择列为下一步。Rust 重写把这一切都换成了只有路径的模糊搜索,commit 历史里对于为什么放弃内容展开只字未提,而那份 TypeScript 实现后来被直接删掉了。唯一一个可以证明走过「展开并加料」这条路的 agent,掉头一路走回了尽可能最简的设计。

一点收尾的想法

所以我旧的理解几乎在每一点上都是错的。一个方向上,今天的 code agent 比我想象的聪明:它们中的大多数不会天真地把一整个文件一股脑铲给 LLM;超过一定大小就会截断、拒绝,或者换成一个 stub,告诉模型自己去读这个文件。另一个方向上,它们谁也不需要我曾经如此得意的那套精巧系统。不管那张状态图看上去多么精确,LLM 一点都不想要。模型不会因为我们重复发送了一个文件而失败;它失败是因为找不到文件,或者因为我们发送的比它需要的更少。那些复杂度是为了满足我自己,不是为了满足模型。

至于这件事会走向哪里:模型在不断变聪明,而 codex 的设计就是对此的一次直接下注。除了文件名什么都不发,让模型自己去取它关心的东西。考虑到 agentic search 已经工作得相当好,我怀疑这才是更好的路子,而且如果看到更多 agent 朝这个方向漂移,我不会感到意外。

而在这个小困难背后还藏着一个更大的困难,这是我自己动手写 agent 之后才体会到的。像「展开文件还是只发路径」这样的问题,没法靠读代码或者凭品味来定夺:你必须拿真实任务去评测,而评测烧 token 的规模,是我以前那些业余项目从来没有过的。一个 CLI 工具可以在本地几秒钟内免费验证;一个 agent 的设计决策,每个数据点都要花真金白银。

更糟的是,答案未必能在模型之间迁移。强化学习在让模型变好这件事上做了很多工作,它同时也给每个模型打上了各自的工作风格烙印,所以每家厂商的 CLI 自然会发布那种它自己的模型被训练朝向、并且在自己的 evals 上得分最高的机制。grok-build 的源码把这一点直说了出来:它的 XML 格式之所以存在,是因为那是「我们一直在用的训练格式」。对于任何在别人的模型之上构建通用 agent 的人来说,这是一个安静而永久的麻烦来源:最好的机制不是普适的,而你又负担不起把所有东西都测一遍。


  1. 《Claude Code: Best practices for agentic coding》,最初发表在 Anthropic 的工程博客上,现在作为官方文档的一部分维护。 ↩︎

  2. 官方文档至今仍然这样描述 @:「用 @ 引用文件,而不是描述代码在哪里。Claude 会在回应之前读取该文件。」 ↩︎

  3. 这部分猜测大致是对的,除了「本地」这一点:Cursor 在本地对文件分块,但在自己的服务器上计算 embedding 并存进远端的向量数据库,而代码本身留在你的机器上。参见 How Cursor Indexes Codebases Fast。至于「丢掉工具调用上下文」那部分,据我所知从未被证实;早期的 Cursor 甚至还没有 agent 式的工具调用。 ↩︎

  4. 一个我在给这篇文章做事实核查时才知道的细节:早期的 Claude Code 也曾尝试过 RAG 加本地向量数据库,后来放弃它、转向了朴素的 agentic search。它的作者 Boris Cherny:「早期版本的 Claude Code 用过 RAG + 本地向量数据库,但我们很快发现 agentic search 通常效果更好。」同一个帖子里另一位 Anthropic 工程师:「在我们的测试中,我们发现 agentic search 的表现远远胜出,这挺让人意外的。」 ↩︎

  5. 这是一个被测量过的现象,不只是感觉:模型对长上下文中间部分信息的利用,明显差于开头和结尾的信息。参见 Lost in the Middle: How Language Models Use Long Contexts↩︎

  6. 五个都是在 2026 年 7 月中旬检查的,对应 commit:pi 87ad8243、opencode efb6cc2d4、gemini-cli 3ff5ba2、grok-build 98c3b24、codex 315195492c。这些都是移动的靶子;等你读到这篇文章时,下文的细节可能已经变了。 ↩︎