首页
/ codegraph 跨调用 explore 去重 A/B 验证:在不触发 Agent 放弃的前提下削减上下文重复字节

codegraph 跨调用 explore 去重 A/B 验证:在不触发 Agent 放弃的前提下削减上下文重复字节

2026-09-05 14:51:37作者:咎岭娴Homer

本文基于 codegraph 仓库中 CG-20 的 A/B 基准报告(docs/benchmarks/explore-dedup-ab-cg20.md),完整还原一次"风险优先"的检索行为实验:如何在 MCP 会话内对 codegraph_explore 的重复源码做去重、用回指指针(back-reference)替代重发字节,同时通过四条验收门槛(Read 不增长、无放弃、充分性桶不偏移、残余占用下降)验证去重不会把 Agent 吓跑。读完本文,你将掌握去重机制的三条设计规则与关键阈值、A/B 实验的三件套测量方法与确定性回放技巧,以及"回收字节必须重新花掉而不是存起来"这一反直觉但可测量的设计决策背后的完整数据依据。

1. 问题定义:重复调用为什么是危险形状

codegraph 的核心工具 codegraph_explore 过去每一次调用都"当作第一次回答"——它不知道自己这个会话里已经发过什么源码。其直接后果是第 4 次调用会愉快地重发第 1 次调用已经交付过的代码主干(即 #1500 报告描述的现象:一个 2 次调用的 tier 预算里发生了 4 次 explore,且每次都重发同样的内容)。

CG-20 要验证的变更由两部分组成(均在 feature/CG-2 分支 @ 7a7ea30):

  • CG-17 会话状态层src/mcp/explore-session-state.ts 记录每个 MCP 会话、每个已解析项目根下,explore 已经返回过哪些文件、哪些行段、花了多少字节;
  • CG-18 跨调用去重src/mcp/explore-dedup.ts 把上述记录转化为本次渲染的决策——本次"将要输出"的行段中,哪些 Agent 已经持有,哪些是真正的新内容。

CG-20 是这一 epic(CG-2)的硬性门槛。报告开门见山地说明了为什么门槛设计为"风险第一、收益第二":在重复调用时返回更少内容,正是 CLAUDE.md 中记录的那个会驱动 Agent 回退到 Read、并在会话剩余时间里彻底放弃 codegraph 的失败形状。所以验证的重点不是"能省多少字节",而是"会不会让 Agent 回退到 Read / 放弃工具"。

最终结论先行:门槛 1–3 干净通过,门槛 4 未达成。两臂 24 次运行中 Read 恒为 0,没有任何一次放弃行为,两个失败桶一次都没有触发;残余上下文占用保持持平——而测量证明它不可能是别的值,因为 CG-18 的验收本身就要求回收的字节重新花掉在 Agent 没见过的文件上,而不是存起来。真正移动的是残余字节中的重复占比:Agent 运行上 −87%,确定性回放上 −86% / −94%。推荐:保留变更,并改写门槛 4 的度量口径

2. 实验方法:为什么必须多调用任务 + 三件套测量

2.1 任务与仓库选择

去重只存在于单个 MCP 会话内部,因此单调用任务根本无法触发它。两个目标仓库都用 CG-1/CG-22 A/B 中那个能可靠产生第二、第三次 explore 且符号袋与第一次重叠的"下钻型"问题:

仓库 语言 文件数 Tier 问题
kubernetes/client-go Go 2,454 medium(2 次调用 / 28K) "a shared informer 如何保持缓存同步并投递事件?"
excalidraw/excalidraw TS/React 672 medium(2 次调用 / 28K) "更新一个元素如何触发画布重新渲染?"

每个 prompt 都包裹为 Use codegraph to answer: <question>——所有臂完全相同(CG-22 的 wrapper)。报告特别强调这不是强制 Read=0:回退到 Read 依然自由,而这正是门槛 1 要测量的东西。

运行次数:每次调用 RUNS=3。client-go 跑一批(每臂 n=3);excalidraw 跑两批(每臂 n=6,合并计数),因为去重在那个仓库咬得最狠(基线序列重发了约 21% 的源码),也就是放弃风险最高的地方,需要更大的样本。

实验由 scripts/agent-eval/ab-new-vs-baseline.sh 驱动,两臂都挂 codegraph、都设置 CODEGRAPH_NO_PROMPT_HOOK=1(避免环境里 UserPromptSubmit 前置钩子经由第二个不受控通道注入上下文,混淆工具调用计数);模型为 --model sonnet --effort high(sonnet 是刻意的地板模型)。

2.2 三件套测量仪器

没有任何单一仪器能回答这个门槛,因此用了三个:

  1. CG-7 残余占用CG-8 充分性桶,来自 feature/CG-3 分支上的 parse-run.mjs 拷贝(三个反馈指标都活在那个分支;feature/CG-2 上的旧拷贝早于它们)。对应门槛 2–4。
  2. 重复残余度量(为本门槛专门编写):CG-7 的占用统计窗口中 codegraph 结果字符数,但分不清"Agent 已持有的字节"和"从未见过的字节"——而区分这两者恰恰是去重唯一在做的事。该度量读取一次运行中所有 explore 响应的渲染后 markdown(两臂同口径测量,因为 CG-4 诊断 sidecar 只存在于新构建上),从 <n>\t<text> 围栏重建每次调用放进窗口的 (文件, 源行) 对,把已被更早调用交付过的行计为重复字节。
  3. 刻意不提交为新的 scripts/agent-eval/*.mjs 文件:新文件会进入自查询评测 fixture 自身的语料并移动其数字(即 CG-15 观察者效应)。它的自然归属是 feature/CG-3 上的 parse-run.mjs,与其余三个指标并列。

3. 确定性核心:无 Agent 的 3 调用回放

同一索引、同一查询序列(从先前新臂 Agent 运行中逐字提取),在每个构建上用同一个 ToolHandler + 同一个 ExploreSessionState 回放,且在本次会话中对两个构建重新测量(而非引用旧值)。

3.1 client-go —— 3 次调用

基线 c65d56c 新构建 7a7ea30
响应字符 65,218 67,289(+3.2%)
源码字符 46,555 47,209
唯一源码 44,740 46,957(+5.0%)
重复源码 1,815(3.9%) 252(0.5%)—— −86%

3.2 excalidraw —— 3 次调用

基线 c65d56c 新构建 7a7ea30
响应字符 72,364 68,442(−5.4%)
源码字符 50,063 44,578
唯一源码 39,575 43,973(+11.1%)
重复源码 10,488(20.9%) 605(1.4%)—— −94%

两个仓库恰好夹住机制的两端:

  • 基线几乎不重复的地方(client-go,3.9%),几乎没有可回收空间,回收的字节加上指针文本反而让响应略变大
  • 基线重度重复的地方(excalidraw,20.9%),响应同时变得更小且更密——少 5.4% 的字节承载多 11.1% 的唯一源码。

这里藏着关于门槛 4 的全部论证:任何占用收益的天花板就是基线的重复占比。即便一个把每个回收字节都存起来(而不是重花)的设计,在这两个序列里也不可能移除超过 3.9% / 20.9% 的源码。

3.3 Explore 延迟:变更不是减速

每个构建 5 次 3 调用序列回放的中位数(CG-18 为每个交付片段增加了一个截断 SHA256,所以必须专门检查):

仓库 基线 新构建
client-go 1,230 ms 1,287 ms(+4.6%)
excalidraw 458 ms 445 ms(−2.8%)

三次调用之间的差值 ≤60 ms。这一数字排除了"去重导致服务端变慢"的假设——任何秒级的 Agent 侧时差都不能由它解释(见第 6 节反方证据)。

4. Agent A/B:四条门槛逐项验证

术语约定:explore = codegraph_explore 调用数;cgResidual = 运行结束时窗口中仍驻留的 codegraph 字符(CG-7);dup% = Agent 已被给予过的、本次交付源码的占比。

仓库 n explore Read Grep cgResidual(中位) 每次调用 时长(中位) dup%
client-go 3 2 / 3 / 2 0 / 0 / 0 0 19,446 9,641 31s 0.7%
client-go 基线 3 3 / 2 / 2 0 / 0 / 0 0 19,611 9,379 27s 7.9%
excalidraw 6 3,3,1,2,3,2 0 ×6 0 25,688 10,316 31s 0.7–0.8%
excalidraw 基线 6 2,2,2,1,3,2 0 ×6 0 20,123 10,158 23s 3.3–7.6%

门槛 1 —— Read 数不得增长 · 通过

两臂两仓库全部 24 次运行中 Read = 0、Grep = 0。 不是"没有增长",而是一次都没有触发——这是该门槛的最强形态。回指指针在 9 次多调用新臂运行中的 8 次真正到达了 Agent(共 28 个指针),且没有任何一次运行在跟了指针之后又去 Read。

门槛 2 —— 无放弃 · 通过

失败模式是静默的,所以用三种方式测量,24 次运行全部干净:

  • 两臂中 isError 响应为零(会话早期的一两次错误正是"教会"Agent 放弃的原因——一次都没有);
  • codegraph 是每一次运行中最后被调用的工具——最后一次 codegraph 调用之后,两臂都没有任何 Read/Grep/Glob/Bash 调用;
  • 新臂的调用次数没有坍塌:client-go 上 2–3 次、excalidraw 上 1–3 次,与基线同样的分布。

门槛 3 —— 充分性桶不得偏移 · 通过

本 epic 可能打破的两个桶是"Read 了我们已返回的文件"(裁错了东西)和"Read 了我们未返回的文件"。在 40 次已回答的 explore 调用上:

新构建 基线
Read 我们返回过的文件 0 0
Read 我们未返回的文件 0 0
Grep/Glob 0 0
再次 explore 12/21(57.1%) 10/19(52.6%)
移开 / 已回答 9 9

两臂的失败桶都是空的。"再次 explore"的差值在 n=21/19 下只有一次调用——是噪声。而且 CG-1 已经确立:这些是完整回答之后的自愿下钻,而非不充分重试(这也是 CG-19 被砍掉的原因)。

门槛 4 —— 残余占用必须实际下降 · 未达成

逐次运行的 cgResidual 被 Agent 选择的调用次数主导(两臂在 excalidraw 上都跨 1–3 次调用)。归一化掉这个因素后,每次 explore 调用的残余是持平的:client-go +2.8%(9,641 vs 9,379),excalidraw +1.6%(10,316 vs 10,158)。

这不是偶然,而是构造使然。CG-18 的验收几乎原话写着:"释放的预算——去重回收的字节应流向尚未展示的文件,而不是缩小响应。" src/mcp/tools.ts 中的 emitFileSection 精确实现了这一点:一个被完全持有的文件既把它的 sourceSpent 释放进 carry-forward 池,也释放它的 maxFiles 槽位。一个把每个回收字节都花掉的设计,不可能降低字节数。CG-18 的验收与 CG-20 的门槛 4 是互不满足的;这一矛盾(而非去重的缺陷)才是该门槛发现的东西。

epic 真正移动的东西,在同一批运行上测得:

新构建 基线
全部 Agent 运行的重复源码字符 329,222 中的 2,432(0.74%) 300,750 中的 19,295(6.4%)

重复字节 −87%,每次调用成本持平,且被更多唯一源码替换。

5. 源码纵深:去重机制的三条规则与关键实现

上面所有数字的根源在 src/mcp/explore-dedup.ts。该模块的头部注释把整个设计压缩为三条规则,全部来自同一处失败模式——"感觉不充分的响应会把 Agent 送去 Read,而会话早期的一两次会教会它彻底放弃 codegraph":

5.1 规则一:只给指针,绝不裸删

被移除的源码由一条回指指针替代,指明文件、符号和行段,措辞上让人无法误解:源码已在本次对话中交付且仍然有效。沉默会被读成"codegraph 没找到"。

formatBackReference 生成的指针长这样(src/mcp/explore-dedup.ts#L216-L232):

> **Already sent earlier in this conversation:** `internal/usecase/payroll/cycle.go`
> L42-76, L78-215 (Cycle, PayslipsForCycle, Service, +6 more) — unchanged on disk since,
> so that copy is still exact. Only the NEW lines are shown below; scroll back for the
> rest. Do NOT Read this file.

它承载让 Agent 回到自己上下文(而不是 Read)所需的全部三要素——路径、符号、行段——加上使该副本可用所需的两条事实:来自本次对话、且文件此后未变(被检查,而非被断言,见 5.3)。它从不说"omitted",从不把 Agent 引向 Read。

5.2 规则二:只凭证明去重(fingerprint 门)

一个行段只有在文件的字节与已交付内容逐字节相同时才被扣留——是内容指纹,不是 mtime,也不是索引的 drift 标志。两次调用之间发生过编辑,意味着 Agent 手里的副本是错的,源码必须完整重发。

fileFingerprint 的实现是 长度:sha1前缀16位src/mcp/explore-dedup.ts#L94-L96),长度前缀保证哈希前缀碰撞不能把两个不同大小的文件混为一谈。它回答的问题与索引 drift 标志不同:同一次 drift 窗口内的两次调用交付了相同的当前字节(去重正确);而两次调用之间被编辑且重新同步的文件永远不会"stale",Agent 的副本却已经错了(此时去重是主动有害的)。

servedRangesForFilesrc/mcp/explore-dedup.ts#L180-L195)只从交付了相同字节的调用中收集已服务行段;没有指纹的记录被忽略而非信任——无法证明的匹配正是应该重发的情形。

5.3 规则三:砍块,不砍碎片

只有被覆盖的连续行段达到 MIN_COVERED_LINES(8 行)才值得替换。更短的覆盖段要么是骨架渲染里的签名行,要么是 cluster 周围的 ±3 行上下文填充;指针句本身约 140 字符,比这更短的去重会让响应变大且读起来千疮百孔。所有未被扣留的内容都照常发出——代数拿不准时,就重发

核心阈值集中在 EXPLORE_DEDUP 常量中(src/mcp/explore-dedup.ts#L38-L69):

常量 作用
MIN_COVERED_LINES 8 可被回指指针替代的最短已服务连续段
MIN_DELTA_CHARS 160 新源码不足此数时,文件余量折入指针而非单独开围栏——这是本模块唯一扣留 Agent 未见过内容的地方,有界(约两行紧贴 Agent 已持有源码的位置),替代方案是一个包含 228\t 的"坏响应"式围栏
MAX_SPANS_IN_POINTER 4 单条指针具名的最大行段数,超出则以 +N more 收尾
MAX_SYMBOLS_IN_POINTER 5 单条指针具名的最大符号数

行段代数(mergeRanges / intersectRange / subtractRange / dedupeRange)是纯函数,dedupeRange 把一次调用的一个目标 span 拆成"现在要渲染的"与"回指的"两个集合;被覆盖段不足 8 行的会被故意留在 emit 集里,所以"几乎全持有"的 span 仍以完整形态返回,而不是围绕指针的断续碎片。

5.4 会话状态层与 daemon 安全管道

CG-17 的 ExploreSessionState 每个 MCP 会话一个实例,随 socket 消亡;按已解析项目根cg.getProjectRoot(),Windows/macOS 上大小写折叠)分桶。四个设计约束对应四条排除项:会话级不落盘(新 Agent 什么都没见过,跨会话去重会扣留它从未见过的源码);项目根为键(一个会话可按 projectPath 查多个项目);内存有界(会话可运行数小时);daemon 安全(一个 daemon 对所有连接客户端共享同一个 ToolHandler 和一个 worker 线程池,状态不能放 handler 上、worker 里或模块级单例)。

有界细节由 EXPLORE_SESSION_LIMITS 规定(src/mcp/explore-session-state.ts#L133-L144):4 个项目(LRU 淘汰)、每项目保留 8 条调用记录、每次调用 24 个文件、每文件 24 个行段、交给调用的 view 只含最近 4 次调用。所有界限只限细节callCountresponseBytes 越过淘汰继续累计,因为 CG-19 的衰减读调用次数,不能让自己的界限把自己重置。

跨 worker 的管道用两个普通对象属性实现(src/mcp/explore-session-state.ts#L50-L57):_cgExploreSession 把会话视图带进工具调用,_cgExploreEmission 把本次实际发出的记录带回主线程记录后再无条件删除——包括 CLI 这种不追踪的调用方——所以 Agent 可见的响应逐字节不变。设计方向的选择也很明确:宁可少报行段,不可多报——少报的代价是重发(浪费),多报的代价是扣留 Agent 从未见过的源码(一次 Read,比去重省下的每个字节都贵)。

5.5 回收预算的流向:花掉,不是存起来

在渲染循环(src/mcp/tools.ts#L4514-L4551dedupeSpansL4567-L4649emitFileSection)中,去重省下的预算走两个通道,都流向 Agent 没见过的文件

  1. sourceSpent——被去重的文件花费更少,CG-21 的 carry-forward 池把差额沿排名顺序传下去,后面每个文件的 headroom 都变大;
  2. maxFiles 槽位——完全回指的文件不占槽位,一个本来装不下的文件现在可以渲染。

此外还有一个全指针守卫suppressedFallbacksrc/mcp/tools.ts#L3318-L3322):如果去重压掉了所有东西且没有新源码顶上来,响应会变成纯指针——"codegraph 什么都没找到"的形状。渲染循环因此始终攥着第一个被完全压掉文件的真实 section,当循环以零新源码结束时把它缝回去。代价是在唯一一种会省下一切的情形上重发一个文件——安全的方向。

6. 反方证据:被保留在记录里、没有被磨平的东西

报告特意保留了三项不利于结论的证据:

  • excalidraw 新臂中位数更慢:31s vs 23s,中位调用数 2.5 vs 2。但这不是服务端——explore 自身延迟在那里是 −2.8%,且相同 3 查询序列的确定性响应在新构建上小 5.4%,所以额外的调用不是 Agent 在补偿更薄的回答(门槛 3 的失败桶也是空的)。但在 n=6、两臂都跨 1–3 次调用的样本下,这一差异只有一次调用,该测量无法把它归因给构建(两个方向都不能)。client-go 在相同的中位调用数上显示同样大小的时差(31s vs 27s)。处理:视为未决,只有更大的 n 能定案。
  • CG-4 诊断中的 dedup.savedChars 是裁剪前的数字,不能读作"被挡在窗口外的字节"。client-go 第 2 次调用上报 11,450 字符节省,而基线实际只重发了该文件 1,042 重复字符——被压掉的行段是相对未裁剪候选渲染测的,其中大部分预算分配器本来也会裁掉。同一次调用的 section 级核对:基线发出 shared_informer.go 的 232 行、与第 1 次调用重叠 22 行;新构建发 234 行、重叠 0,且大 544 字符任何基于 savedChars 去调 EXPLORE_DEDUP 阈值的人会把收益高估约 7 倍。
  • client-go 的基线重复量低于预期(确定性 3.9%,运行间 0–20.6%)。在那个查询上 tools/cache/** 本就主导图相关性,去重前的渲染自己就聚焦得很好——这也是 CG-22 在那里只找到很小 #1500 信号的同一原因。

7. 裁决:保留变更,改写门槛 4

门槛 结果
1. Read 不得增长 通过 —— 24/24 次运行 0 个 Read,两臂
2. 无放弃 通过 —— 0 个 isError,codegraph 在每次运行中最后被调用,无调用坍塌
3. 桶不得偏移到"Read 我们返回的文件" / "再次 explore" 通过 —— 两臂两个失败桶均为空
4. 残余占用实际下降 未达成 —— 每次调用持平,且在 CG-18 的再分配规则下不可达

epic 的验收文字说"门槛 4 不满足就回滚,因为回归风险不配一个边际收益"。CG-20 的回答是:那个前提的回归半部被测量了,且为零——24 次运行,无 Read、无放弃、无桶偏移、回指指针被证明到达了 Agent。而门槛 4 不是"因表现不足而未达成",是构造性不可达:它瞄准的字节天花板就是基线的重复占比(4–21%),而 CG-18 早已按"这些字节被花掉而非存起来"的规则被接受。

因此裁决是:保留变更,把 epic 的度量改写为"残余中的重复占比"(Agent 运行 −87%,确定性 −86%/−94%),且上下文成本持平——excalidraw 展示最佳情形:少 5.4% 的响应字节承载多 11.1% 的唯一源码。

这是一个对抗门槛 4 字面意义的判断,双向都很便宜:

  • 运行时CODEGRAPH_EXPLORE_DEDUP=0 无需重建即可关闭去重(exploreDedupEnabled 每次调用读取该环境变量,接受 0/false/off/no);
  • 源码git revert 7a7ea30 ab38d1f 4e94860 fc31b1e 完整移除 CG-17 + CG-18;
  • 第三选项,如果占用才是真目标:把回收的字节存起来而不是重花——这反转 CG-18 的释放预算规则,最多买到上面那个重复占比的字节数。它需要自己的门槛,因为它让每次调用返回严格更少的内容——正是本任务存在所害怕的形状。

8. 测试覆盖与可深入的路径

  • 去重本体:tests/explore-cross-call-dedup.test.ts——行段代数与阈值、fingerprint 门(文件被编辑后完整重发;无法证明的记录被忽略)、指针措辞(具名文件/行段/符号,从不说"omitted"、从不引向 Read)、以及真实索引上的第二次调用:绝不重发第一次发过的行、回收预算带来 >20 行新源码、"会话已持有一切"时仍总能返回真实源码、CODEGRAPH_EXPLORE_DEDUP=0 完全关闭、节省经 CG-4 诊断上报;
  • 会话状态:tests/explore-session-state.test.ts——容器层(键控、越过淘汰仍单调的索引、每个界限)、handler 缝(真实 explore 对真实索引记录真实行段;会话首次调用与未追踪调用逐字节一致;一个 handler 上两个状态互不干扰)、会话缝(一个引擎上两个 MCPSession 各得自己的状态);
  • 设计文档:docs/design/explore-session-dedup.md 是本文第 5 节的姊妹篇,覆盖了 daemon 约束、越界时的报告方向选择,以及"全指针守卫"在 CG-20 实战中从未触发的事实(21 次调用中最薄的一次仍带 12,011 字符新源码,newSourceChars === 0 的阈值在实地从未被触达);
  • 评测设施:scripts/agent-eval/ab-new-vs-baseline.sh(双臂 codegraph-on 的 A/B harness,含 daemon 预热、CLI 屏蔽与退出清理)、scripts/agent-eval/parse-run.mjsscripts/agent-eval/compare-arms.mjs(三个反馈指标与双臂并排表)。

原文报告的日志位于 /tmp/cg20/ab-client-go/tmp/cg20/ab-excalidraw/tmp/cg20/ab-excalidraw-b2(临时路径,需要可复现分发时应归档)。

适用前提与限制:本 A/B 结论绑定于 feature/CG-2 @ 7a7ea30 对基线 c65d56c 的差异,评测模型为 sonnet / high effort,目标仓库为 client-go 与 excalidraw 两个特定语料;门槛 4 的改写、excalidraw 中位时差的归因、以及 newSourceChars === 0 守卫的实地触发,均被报告本身标记为未决项,引用这些数据时应连同这些限制一起陈述。

登录后查看全文
热门项目推荐
相关项目推荐