首页
/ CodeGraph explore 的端到端预留不变式:CG-26 如何修复渲染预算的三个记账漏洞(附 A/B 基准与源码证据)

CodeGraph explore 的端到端预留不变式:CG-26 如何修复渲染预算的三个记账漏洞(附 A/B 基准与源码证据)

2026-09-05 14:48:36作者:齐冠琰

本文解析 CodeGraph 项目 codegraph_explore 工具渲染层的核心预算机制——"预留不变式"(reservation invariant):每个被分配器接纳的文件,在任何文件动用结余余量之前,必须至少拿到它被预留的字节数。读完本文,你将理解 CG-26 任务修复的三个记账漏洞(整文件路径缺移位移守卫、节开销按 200 字符平摊、owedPayableBelow 全有全无)、epilogue 从"整段丢弃"到"预算制"的重构方案,并能用仓库自带的探针脚本与回归测试复现验证全部结论。

一、预留不变式与实验设定

CG-26 不是孤立变更,它收口的是 explore 预算分配 epic(CG-10 相关性打分 → CG-12 按权重预留 → CG-21 预留结转 → CG-30 超大簇成员上界 → CG-31 簇路径移位移守卫)遗留下来的三个洞。不变式的完整表述是:

every admitted file receives at least its reservation before any file draws on carry-forward slack —— 每个被接纳的文件在任何文件动用"结转余量"之前,先拿到至少它的预留额。

实验设定决定了表中数字的可信度(引自原文档头部):

  • 日期/版本:2026-08-06;新分支 bugfix/CG-26 @ 7cbde95,基线为 bugfix/CG-31 尖头 c54e008(刻意不用 main,使所有数字只隔离 CG-26 一个变量);
  • 测试框架scripts/agent-eval/ab-new-vs-baseline.sh--model sonnet --effort high
  • 控制条件:两臂均开启 codegraph、CLI 被屏蔽(全程 0 污染)、CODEGRAPH_NO_PROMPT_HOOK=1
  • 索引状态:所有被测索引均为完全重建,从未增量同步(CG-33 的要求)。

按文档要求,三篇应顺序阅读:CG-30 超大成员 A/BCG-31 移位移守卫 A/B → 本文。

结论先行:三个仓库无行为回归,且响应第一次对自己的预算保持诚实。 没有仓库被截断,没有仓库丢失文件——okhttp 反而多交付一个文件。两个仓库在最后一名次的文件上损失几百字符源码,换来的是"指针列表"(明确点名本次响应没能覆盖哪些文件)——这些字节在 CG-31 尖头上之所以存在,是因为旧代码超填了一个自己测错的天花板,然后连 epilogue 整段丢弃。

二、三个记账漏洞

漏洞 1:整文件路径没有移位移守卫

旧实现中,BUY 臂(整文件购买规则)的适装测试读的是 totalChars + fileContent.length + FILE_OVERHEAD <= renderCeiling——衡量的是"天花板之前还有多少空间",而那段空间属于循环尚未到达的所有文件;它自己的源码空间姊妹变量(owedBelow)恰恰拒绝了这笔交易。GRACE 臂(整文件宽限规则)则完全没有做适装测试。

实测后果(okhttp 仓库):CallServerInterceptor.kt8,499 字符发出,而它的资金上限(funded ceiling)只有 5,964,它下方第 6 名次的文件什么都没拿到。

CG-26 的修复:两个整文件臂都改为对"它们实际产生的渲染"与 fundedHeadroom 做适装测试;整文件渲染装不下时落入簇路径而不是跳过该文件——用一节簇化的内容换取没有内容,正是资金池设计所要拒绝的交易。

源码印证:src/mcp/tools.ts#L4884-L4927 中,BUY 臂的适装条件为 fileContent.length <= fundedHeadroom,整节渲染需同时满足 wholeSection.length <= fundedHeadroom && totalChars + wholeCost <= renderCeiling,并附带注释说明 GRACE/BUY 两臂现在都"测试它们实际产生的渲染";src/mcp/tools.ts#L4721-L4726 的簇路径同样被约束到 fundedHeadroombodyCap = Math.min(allowance, fundedHeadroom))。

漏洞 2:节开销按固定 200 字符记账

真实的节头——文件路径加上至多 maxSymbolsInFileHeader 个符号名——实际运行在 300–500 字符。而下游一切量纲(headroomfundedHeadroom、所有适装测试)都建立在这些单位之上,所以这个少计不是舍入误差,而是用不存在的字节资金化承诺

实测后果(okhttp):循环按 26,601 字符做分配,天花板只有 24,400,最终截断把一整个已完整渲染的节扔掉了。

CG-26 的修复分三步:

  1. 节按其真实成本收费src/mcp/tools.ts#L4370-L4382sectionOverhead() 从该文件自己的候选符号出发,估算"文件头(路径 + 符号名列表)+ 代码围栏 + 空行"的真实长度,并用 overheadCache 缓存。源码注释直接点破了旧行为的性质:"使用分配器的 FILE_OVERHEAD(固定 200)是每待处理文件约 250 字符的范畴错误(category error)";
  2. owedPayableBelow 扣留每个待处理文件的预留额 加上 按其自身符号估算的单文件开销src/mcp/tools.ts#L4402-L4426need = r + overhead);
  3. 边际超支时修剪最弱的簇——或把最后一个簇开窗到剩余空间里——而不是因为约 300 字符的记账差异就跳过整个文件。

注意区分两个常量:EXPLORE_ALLOCATION.FILE_OVERHEAD: 200src/mcp/tools.ts#L561)仍是分配器拆分信封时的平摊常量;CG-26 只修正了渲染循环一侧的记账。

漏洞 3:owedPayableBelow 全有全无

CG-31 的判断"天花板够不到的承诺不是对本文件字节的索取"是对的,但它丢掉了部分情形:当最后一个被接纳文件的 FULL 预留不再装得下时,旧代码为它保留的是

实测后果(precise-query 测试夹具):第 5 名文件在 2,948 的预留下拿走了 4,134 字符,而第 6 名——已被接纳、预留 2,539——只剩 4 个字符被跳过。

CG-26 的修复是保留剩余部分,只要该剩余仍值一个节(不低于 MIN_CHARS)。源码即 src/mcp/tools.ts#L4410-L4421:当 held + need > budgetLeft 时计算 partial = budgetLeft - held,若 partial >= MIN_CHARS + overheadheld += partial 后 break。注释解释了为何卡在 MIN_CHARSEXPLORE_ALLOCATION.MIN_CHARS = 700src/mcp/tools.ts#L548):低于它的切片装不下一个完整方法,碎片反而逼出本工具要阻止的 Read 回退。

三、Epilogue:从"整段丢弃"到"分层预算制"

CG-31 移交的问题:渲染循环为 epilogue 预留了固定 600 字符,而它实测为 1,064(gin)、1,788(django)、2,231(excalidraw);六个套件仓库中四个靠整段丢弃 epilogue 存活——响应里既没有指针列表也没有任何提醒。

团队跑过一遍 margin 扫描,但刻意没有采纳:对照单个套件调一个常量,正是 CG-30 记录里警告过的陷阱。

修复不是更大的常量,而是把 epilogue 拆成两件事:

  1. 地板(floor):渲染循环必须预留的部分,尺寸取自真实字符串——一句话声明"存在未覆盖区域、另一次 explore(而非 Read)能到达它"(即 src/mcp/tools.ts#L876EPILOGUE_LOST_NOTE),加上每个字节被刻意 WITHHELD 的文件的指针行。被悬崖文件(cliffed file)的字节是在"agent 仍能在后续调用中命名它"(CG-12 的承诺)上被交换走的;如果天花板连这个名字都吃掉了,这笔交易就是一次静默丢弃;
  2. 弹性尾部(elastic tail):指针列表的剩余部分与提醒条目,在循环结束后按优先级、逐条适配到实际剩余的空间里。

于是饱和响应现在能"付得起多少 epilogue 就带多少",而不是零。实现上 renderCeiling = hardCeiling − floor 取代了 hardCeiling − 600,其中 src/mcp/tools.ts#L4273-L4285

const cliffPointerFloor = [...cliffedFiles]
  .slice(0, POINTER_MAX_FILES)          // 指针列表仍封顶 10 个文件
  .reduce((n, fp) => {
    const g = fileGroups.get(fp);
    return g ? n + pointerLineFor(fp, g.nodes).length + 1 : n;
  }, cliffedFiles.size > 0 ? POINTER_HEADER.length + 2 : 0);
const epilogueFloor = EPILOGUE_LOST_NOTE.length + 2 + cliffPointerFloor;
const renderCeiling = hardCeiling - epilogueFloor;

hardCeiling 本身定义为 Math.min(Math.round(budget.maxOutputChars * 1.5), 25000)src/mcp/tools.ts#L4257)——它必须压在宿主(如 VSCode)约 25K 的内联工具结果限制之下,超过则结果会被外部化成文件让 agent 再 Read 回来(n=4 A/B 中一次 35K 的 vscode explore 就这么发生过)。

四、确定性测量:主要证据

同一份干净重建索引、同一查询、两个构建,每仓库一次 codegraph_explore。用 node scripts/agent-eval/probe-suite-envelope.mjs 复现(该脚本由 CG-26 任务新增,见 scripts/agent-eval/probe-suite-envelope.mjs)。它跑的就是 CG-30/CG-31 表格所用的同六个查询(django / tokio / okhttp / excalidraw / gin / alamofire),数字来自 CG-4 诊断(CODEGRAPH_EXPLORE_DEBUG 旁路 jsonl),因此度量的是发货的分配器本身,而不是从 markdown 反推份额:

仓库 基线源码字符 新构建源码字符 Δ 基线文件数 新文件数 天花板行为
django 20,791 20,878 +87 6 6 原本丢弃 epilogue
tokio 21,521 21,607 +86 5 5 原本丢弃 epilogue
okhttp 19,034 18,870 −164 5 6 +1 文件交付;保住指针列表
excalidraw 20,204 19,652 −552 8 8 保住指针列表
gin 10,776 10,776 0 4 4 逐字节一致
alamofire 11,662 11,662 0 2 2 逐字节一致

两个负值要诚实地读:它们不是饥饿(starvation),恰恰相反——在 CG-31 尖头上,两个响应都是超填(over-filled)的:循环少计了自己的节开销,花过了渲染天花板,硬天花板切割随后把 epilogue 拿走抵账;okhttp 还有一个文件被渲染后又丢弃。现在记账精确了,循环停在它声明会停的位置,省下的约 500 字符给了"点名响应没能覆盖哪些文件"的指针列表(excalidraw 上是 2 个,都是 max-files 跳过)。两者都没有任何被接纳的文件挨饿。

gin 与 alamofire 在两构建间逐字节一致——它们都不饱和,所以守卫和 epilogue 适配都没有触发。这正是对照组应当展示的样子。

五、回归夹具:不变式如何在 CI 里被钉住

主夹具 tests/explore-reservation-invariant.test.ts(14 个测试,在 CG-31 尖头上有 3 个失败)基于共享的 displacement-ts 测试夹具(四个流水线阶段争抢一个信封,其一为约 20K 字符的单函数,外加 520 个填充文件把响应压到 24K 档位,使预留真正饱和天花板)。它用三种查询形状从双向钉住不变式:

  • spread:四个阶段全点名,巨型文件排 #1 且向下超支;
  • tail:巨型文件下方阶段被点名,小而排 #1 的文件与下方的巨型文件竞争——这是 CG-31 夹具够不到的方向;
  • precise:单符号查询,守卫不能压平的集中度情形。

核心闸门(测试文件 L147-L219):

  1. CG-26 GATE:任意渲染路径(簇 / 整文件宽限 / 整文件购买)上,没有文件发出超过"当时仍空闲"的字节(emittedChars <= funded + 1,+1 容忍开窗切割的舍入);
  2. CG-26 GATE:每个被接纳文件(allowance > 0)都被交付,skipped 为 null 且响应中实际存在其字节;
  3. 方向性:#1 文件即使下方文件超支也拿到其预留额(或整文件,若整文件更小),并断言该闸门非空洞——tail 形状下确实存在超支者;
  4. 天花板不得再做两件事:分配越过自身(allocatedChars <= hardCeilingtruncated === false);渲染后丢弃(render === 'dropped' 必须为空)。

CG-31 的 tests/explore-displacement-guard.test.ts(11 个测试)继续原样通过。

分配夹具 scripts/agent-eval/allocation-fixtures.json两个夹具全部 PASS。self-query 闸门换了形态,原因记录在 afterCG26 块中:按信封计价的 answerShareAtLeast 在此读 47.5%,而 CG-31 尖头是 51.0%——但 tools.ts 的源码字节在两臂间逐字节一致。真正移动的是分母:新响应交付了第五个被接纳文件(memory-budget.ts,名次 4,付满了其 3,123 字符的完整预留;CG-31 尖头渲染了它却让天花板丢弃了整个节),并保住了过去会被丢弃的 epilogue 散文。两者都是本 epic 存在的目的。闸门现改为以交付源码计价,答案组读 55.5%,两臂均通过。

六、Agent A/B 运行

提示词 = 确定性查询 + "Trace the flow end to end.",每臂 2 次运行(引自原文档表格):

django new django base excalidraw new excalidraw base okhttp new okhttp base
运行数 2 2 2 2 2 2
时长 (s) 42 43 [36–50] 53 [45–62] 45 [37–54] 49 [39–59] 42 [32–52]
工具调用 4 [3–4] 4 [3–5] 3 [2–4] 5 [4–5] 4 4 [3–5]
Read 0 0 0 0 0 0
Grep/Glob 0 0 0 0 0 0
codegraph 调用 3 [2–3] 3 [2–3] 3 [2–3] 4 [3–4] 3 3 [2–4]
上下文占用份额 36.6% 37.6% 38.2% 47.2% 44.8% 40.8%
分配效率 99.2% 94.7% 81.9% 82.5% 75.8% 87.6%

全部 12 次运行、两臂之中 Read 均为 0。 按调用汇总的充分性检查:0 次"Read 我们已返回的文件",所有仓库两臂均 0 次召回缺失——交付少几百字符的响应并没有把 agent 逼回文件。

新臂看似更差之处,及为何不读作回归

  • okhttp 分配效率 75.8% vs 87.6%,占用 44.8% vs 40.8%:新臂信封是 99,411 字符对 89,297——它多返回一个文件、总体源码更多,而该指标度量的是"返回字节中答案引用的份额"。同一笔交易在 CG-31 记录中已在该仓库注明;该指标自身文档说明它是相对量,不得读作浪费;
  • excalidraw 与 okhttp 的时长:n=2 且区间完全重叠(45–62 vs 37–54;39–59 vs 32–52),测量机同时还在跑另一臂的构建。excalidraw 新臂用 3 次工具调用完成同样的工作(对照 5 次),并少占用 9 个点的上下文

七、残留与刻意未改之处

本任务无残留。渲染循环预算现在端到端精确:

  • totalChars 计入 flow.text(真实逐节成本)与 epilogue 地板;
  • 每个套件仓库上 allocatedChars ≤ hardCeiling
  • 最终的节边界截断在正常操作中已不可达(保留为 backstop——源码见 src/mcp/tools.ts#L5897-L5910 的最终切割路径,仅在 output.length > hardCeiling 时触发)。

一项刻意未改:指针列表仍封顶 10 个文件POINTER_MAX_FILESsrc/mcp/tools.ts#L853)。修剪从名次序列底部进行,且 "+N more files" 尾部被重写为逐条承认每个被丢弃的条目,所以数量永远不会静默出错。

八、小结:这套不变式值得复用的工程判断

CG-26 的价值不在某一行修复,而在它确立的记账纪律,全部有源码与测试锚点可查:

  1. 任何适装测试必须对"本文件有权花的"(fundedHeadroom)而非"天花板前的公共空间"做——否则单个文件的超额会隐性地吃掉低名次文件的预留(漏洞 1,src/mcp/tools.ts#L4486-L4490);
  2. 记账单位必须与被计量的真实渲染一致——固定 200 字符的开销假设让循环"相信自己拥有不存在的字节"(漏洞 2,src/mcp/tools.ts#L4370-L4382);
  3. 部分承诺优于零承诺,但有下限——MIN_CHARS 以下的碎片比指针更糟(漏洞 3);
  4. 不要对着单一套件调常量——margin 扫描被刻意放弃,地板改为从真实字符串量出(epilogue 重构);
  5. 对照组要真的没动——gin/alamofire 逐字节一致是"守卫未误伤未饱和响应"的直接证据。

复现入口汇总:确定性六仓库包络扫描用 scripts/agent-eval/probe-suite-envelope.mjs(需先 npm run build 且索引为全量重建);不变式回归闸门跑 tests/explore-reservation-invariant.test.ts;分配份额夹具用 node scripts/agent-eval/probe-allocation.mjs 消费 scripts/agent-eval/allocation-fixtures.json;Agent A/B 框架为 scripts/agent-eval/ab-new-vs-baseline.sh

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