codegraph CG-30:为 explore 的超大簇成员设定 1.5 倍上界 —— 基准数据、A/B 评测与源码实现
【免费下载链接】codegraphPre-indexed code knowledge graph, auto syncs on code changes, for Claude Code, Codex, Gemini, Cursor, OpenCode, AntiGravity, Kiro, CoPilot, and Hermes Agent — fewer tokens, fewer tool calls, 100% local项目地址: https://gitcode.com/GitHub_Trending/co0degr/codegraph
CG-30 是 codegraphexplore检索管道的一次预算治理变更:当某个文件簇(cluster)里排名第一的成员(通常是单个超长函数)体积远超该文件被分配到的预算时,旧实现会把整个成员原样输出,挤爆响应信封;CG-30 为其引入1.5 倍预算的封顶——超过上界后按整行开窗(windowed)而不是整体输出,从而把字节"让渡"给排名更低的文件。本文完整复现该变更的 A/B 基准报告:先看确定性测量中上界真实生效的位置,再看 agent 跑批的行为代价与充分性(bar),最后深入 src/mcp/tools.ts 中renderCluster/windowToCeiling的实现,并说明回归测试与夹具如何把这次修复永久钉住。
变更背景与评测条件
变更日期:2026-08-06 ·分支:bugfix/CG-30·基线:main@d6d1728评测脚本:scripts/agent-eval/ab-new-vs-baseline.sh,--model sonnet --effort high,两臂均为 codegraph 开启状态,CLI 被阻塞(所有运行中 0 污染),并设置CODEGRAPH_NO_PROMPT_HOOK=1。
问题本身一句话:一个簇的头部成员可以在多大程度上超出其文件可花费的预算?在 CG-30 之前,答案是"没有上限"。shrinkCluster有意保留超大簇中重要性最高的成员整体输出——因为空的文件段落会把 agent 逼去 Read,这正是explore要防止的结局;但"永不为空"不等于"任意大小"。CLAUDE.md 点名的风险正在于此:一个不再充分的章节会把 agent 引向 Read,而一两次这样的经历就会教会它彻底不再调用 codegraph。
A/B 脚本 ab-new-vs-baseline.sh 的设计值得注意:两臂都挂载 codegraph,因此它隔离的是"检索变更"本身,而不是 codegraph 的采用与否;CLI 被 no-cli-shim.sh 阻塞的原因也是归因性的——通过 Bash 发出的 explore 会被记到 Bash 名下,永远不会进入充分性与分配效率的解析。脚本会为每臂预热一个持久化 codegraph daemon,并以CODEGRAPH_WASM_RELAUNCHED=1跳过启动重执行,避免 agent 在 codegraph 完成约 2–3 秒启动前就扑进 Read/grep。
评测脚本踩坑记录(原文报告专门保留,因为它代价是一次重跑):
ab-new-vs-baseline.sh在基线臂运行期间会把引擎 checkout 到 BASELINE ref,退出时再恢复。运行期间不要 commit——中途的 commit 会捕获到基线源码。django/gin 的第一批数据正是因此作废(它们的changed:行只列出explore-diagnostics.ts,即两臂跑的是同一套检索代码),随后被重跑。相信这份评测工具的任何 A/B 结论之前,先检查那一行。
确定性测量:上界究竟在哪里生效
同一份索引、同一个查询、两个构建。这是主要证据;下面的 agent 跑批只是给风险定价。
django:签名缺陷的复现与关闭
查询:codegraph explore "How does a QuerySet turn into SQL and fetch rows from the database?"
| 文件 | baseline | new |
|---|---|---|
django/db/models/query.py | 3,669 预算上输出 7,784 字符 ——2.12x | 5,464 ——1.49x,已开窗 |
django/contrib/admin/filters.py | 3,633(继承 2,271 可花费) | 8,057(继承 9,160) |
| 交付源码 | 17,929 字符,5 个文件 | 20,033字符,5 个文件 |
这就是 CG-30 的签名缺陷,在公开仓库上复现后被关闭:排名第 1 的文件花了预算的 2.12 倍,其下方的文件继承了这份亏空(下文的 CG-31 会处理继承的另一半)。封顶之后,这些字节直接沿排名顺序让渡——响应携带同样的 5 个文件,并且多出2,104 字符的真实源码。
gin:对照仓库,字节级一致的证明
两个构建对路由分发查询产生字节级一致的 explore 输出(13,457 字符)。gin 中没有任何成员大到能让上界生效(观察到的最大值仅占可花费预算的 0.94x),这正是对照仓库应该呈现的样子——它同时意味着 agent 表中所有 gin 数字都是逐次运行的方差,而不是变更本身的效果。
夹具:同一个缺陷的两个侧面
夹具tests/fixtures/oversize-member-ts/ 构造了三个报表构建器争抢同一个信封的场景,每个文件都是单个超长函数(如 monthly.ts 约 509 行、核心是约 490 行的buildMonthlyReport,quarterly.ts约 235 行、核心约 200 行):
| 文件 | baseline | new |
|---|---|---|
monthly.ts(24.5K,单个 ~490 行函数) | 3,334 预算上输出 12,391 字符 ——3.7x | 4,941 ——1.48x,已开窗 |
quarterly.ts(11.4K,单个 ~200 行函数) | 被丢弃——budget-clusters,没有剩余空间 | 交付 4,004 |
| 响应 | 19,223 字符,3 个文件 | 15,852 字符,4 个文件 |
两行是同一个缺陷的正反两面:成员大于文件份额会吃满信封;成员大于整个响应上限则会直接让文件消失。这个行为由tests/explore-oversize-member.test.ts 钉住(9 个测试;在main上有 4 个失败)。
Agent 跑批行为数据
| django new | django base | gin new | gin base | excalidraw new | excalidraw base | |
|---|---|---|---|---|---|---|
| runs | 5 | 5 | 3 | 3 | 2 | 2 |
| duration (s) | 39 [36–71] | 35 [35–60] | 39 [37–51] | 34 [28–46] | 52 [43–60] | 41 [40–42] |
| tool calls | 3 [3–10] | 4 [3–23] | 4 [3–4] | 3 | 4 [3–5] | 4 [3–4] |
| codegraph calls | 2 [2–3] | 2 [0–3] | 2 [2–3] | 2 | 3 [2–4] | 3 [2–3] |
| Read | 0 [0–5] | 0 [0–13] | 0 [0–1] | 0 | 0 | 0 |
| Grep/Glob | 0 | 0 | 0 | 0 | 0 | 0 |
| occupancy share | 33.1% [30.7%–49.5%] | 34.4% [29.3%–47.3%] | 28.9% [26.4%–37.3%] | 30.1% [28.6%–31.9%] | 43.0% | 39.3% |
| allocation efficiency | 100.0% | 98.6% | 88.5% | 98.3% | 85.8% | 90.5% |
django 数据跨两个批次合并(n=2 + n=3)。查询分别是:django "How does a QuerySet turn into SQL and fetch rows from the database? Trace the flow end to end.";gin "How does a registered route handler get invoked for an incoming HTTP request?…";excalidraw "How does updating an element re-render the canvas on screen?…"。
充分性——按调用池化,这才是真正要看的 bar。django:new 臂 10 次应答调用中出现 1 次"Read 了我们返回过的文件"(开窗会首先触发的分配未命中信号),而 baseline 是 10 次中 1 次"Read 了我们未返回的文件" + 1 次 Grep——这是未命中类型的转移,不是次数的增加。gin:7 次中 1 次分配未命中,对 6 次中 0 次;而两个构建在 gin 上输出的是相同字节,所以按构造这就是方差。excalidraw:两臂 0 未命中。
新臂看起来更差的地方,以及为什么不构成回归:
- django 时延中位数慢约 10%。n=5 下区间重叠(36–71 对 35–60),且 baseline 臂有一次运行完全丢失了 codegraph 挂载(0 次 codegraph 调用、13 次 Read、23 次工具调用),这同时向两个方向扭曲了该臂的分布。
- gin 分配效率 88.5% 对 98.3%。两个构建在 gin 上字节一致。这是该指标文档记载的相对性——归因基于引用,而 agent 的后续查询逐次不同——不是变更的效果。
- excalidraw 的占用率/时延。调用次数噪声:new 臂两次运行中有一次做了第 4 次 explore 调用(baseline 是 2–3 次),时延、信封和占用率都跟着它走。按调用计的信封是平的(20,015 对 19,446 字符/次),Read/Grep 保持 0,工具调用数一致。CLAUDE.md 自带的 worked example 在这个 prompt 上记录的也是 3–10 次 codegraph 调用。
源码实现:上界从哪里来、窗口如何切
1.5 倍上界与SPINE_CEILING
在 src/mcp/tools.ts 的簇选择循环里,每文件的预算是该文件的预留量(reservation)被硬上限前的剩余值封顶:
const fileBudget = Math.min(allowance, fundedHeadroom); // 调用路径簇可以超出预留量,但有界 —— 1.5 倍预留且绝不超过 ceiling const SPINE_CEILING = Math.min(Math.round(allowance * 1.5), fundedHeadroom); ... const cap = rc.c.hasSpine ? SPINE_CEILING : fileBudget; // CG-30: shrinking keeps the top member whole however big it is, so bound // how far that member may overshoot — the same 1.5x-of-reservation bound const ceiling = Math.max(cap, SPINE_CEILING);注意几个从源码可确认的事实:
- 1.5 这个数字与既有规则同源。
SPINE_CEILING早在 CG-30 之前就已存在(为调用路径簇画的界),CG-30 把簇内单个超大成员的开窗上界复用了同一个 1.5 倍预留量,并保证ceiling >= cap,所以已经装得下的簇永远不会被动到。 - 上界从共享信封中"抽取"。注释(src/mcp/tools.ts)明确:1.5 倍是从共享信封里抽的,因此它恰好等于位移守卫需要资助的超额部分;超过
fundedHeadroom之后,多出来的那半个预留量是别的文件的,不是空闲房间。这也是它读取fundedHeadroom而非headroom的原因(CG-31 的一半:后者包含每个未到达文件的全部预留量,花掉它们正是"一个簇化文件让五个被准入的同行归零"的机制)。 - 保留头部成员的"永不整体丢弃"原则被保留。
shrinkCluster中(src/mcp/tools.ts)仍然"总是保留最重要的范围,即使它自己就是超大的"——空段落会把 agent 送去 Read,代价高得多;改变的是它可以超出的幅度被调用方的 ceiling 封顶,失控成员被开窗而非丢弃。
windowToCeiling:按整行开窗的裁剪器
真正的开窗逻辑在 src/mcp/tools.ts 的windowToCeiling。几个关键设计:
- 成本按渲染行计算。
lineCost把行号本身的宽度也算进预算,所以"装得下"的判定是精确的,不是近似。 - 永远不为空。第一个超界的段会被截成头部窗口(head window),并且允许突破
room的下限是MIN_WINDOW_LINES(12 行)——注释给出的理由很直接:短于 12 行的碎片不值得输出,反而有害,因为会话记录会声称持有 4 行的碎块,而下一次调用的去重要么把整块切碎、要么重发它。低于地板时该段直接丢弃——除非什么都还没输出,那里地板优先于上限,因为"空段落比超大段落更差"是唯一更糟的结局。 - 焦点行保护。
focusLines(来自focusLinesOf,src/mcp/tools.ts)是裁剪不能丢失的行:调用路径的下一跳调用点,以及查询点名的成员定义(importance >= 9,最多 6 个)。若头部填充够不到焦点行,先用 60% 的额度填头,再把剩余均分给未覆盖的焦点行(按源顺序贪婪分配正是这个守卫防的 bug 本身:提问点名的定义在文件尾部时,总是第一个被超限渲染切掉的跨度)。
renderCluster(src/mcp/tools.ts)把这一切串起来:先按cap收缩(丢弃低重要性成员),若仍超过ceiling,交给windowToCeiling开窗;返回的shrunk标记会参与后续的文件级anyClusterShrunk记账。而 tools.ts 的主注释把 CG-30 的语义写成了文件级不变量:
一个超大的单个成员(一个长而整体化的函数)在装得下有界超额时保持完整——半个方法没用,agent 只会 Read 剩下的,而这正是 explore 存在的 fallback 要防止的;超过该界之后它按整行开窗而不是被丢弃(CG-30),因此 god-method 既不能被静默丢失,也不能花掉整个响应的信封。
回归测试:如何证明"有界且交付"
tests/explore-oversize-member.test.ts 通过CODEGRAPH_EXPLORE_DEBUG侧车拿到每文件预算,再逐条断言:
- GATE:巨型文件
monthly.ts的输出 ≤round(spendable × 1.5) + 1(修复前是 3.7x); - 所有
render === 'clusters'的文件都不超过 1.5 倍可花费量; - CG-31 联动:曾被饿死的
quarterly.tsskipped为null且实际交付了字节; - 永不为空:所有簇化文件
emittedChars > 0,且开窗文件仍以查询点名的符号(export function buildMonthlyReport)领头; - 整行切断:响应中为该文件编号的每一行都逐字符等于源文件整行(验证超过 20 行);
- 报告
clipped: true——被切了就说被切了,而不是把窗口冒充整个文件; - 响应总长不超过
report.budget.hardCeiling。
夹具形状测试(单个符号 > 180 行、文件 > 220 行从而走clusters渲染路径)被放在最前面——测试注释直言"如果这些坏了,下面的 gate 就没有意义"。
结论与遗留事项
判定:无回归,且确定性收益无歧义。行为 bar 成立(Read/Grep ~0、无弃用、在上界真实生效的仓库上分配效率 100%),代价是 django 在区间重叠下中位时延增加约 10%,新臂产生的一次分配未命中调用与 baseline 的两次召回未命中调用相互对消。
随变更带入的 caveat:scripts/agent-eval/allocation-fixtures.json 中的self-query探针夹具在本变更下翻转为 FAIL。两臂的分配不变、tools.ts 交付相同的 8,282 字符——变化在于一个超额预留的附带文件现在被交付而不是被硬上限截断,而它此前的 PASS 恰恰依赖那次截断。该现象已记录为该夹具的afterCG30块。超额预留本身是 epic CG-24 的主题;正确的修法不是放松这个上界,而是按 docs/benchmarks/explore-noise-epic-cg24.md 的方向处理。
对想复核这套数据的读者,入口是:A/B 评测脚本与 compare-arms.mjs 做逐次运行对比,tests/explore-oversize-member.test.ts 可以在任意构建上直接运行(在修复前的构建上会有 4 个用例失败),夹具源码在tests/fixtures/oversize-member-ts/。
【免费下载链接】codegraphPre-indexed code knowledge graph, auto syncs on code changes, for Claude Code, Codex, Gemini, Cursor, OpenCode, AntiGravity, Kiro, CoPilot, and Hermes Agent — fewer tokens, fewer tool calls, 100% local项目地址: https://gitcode.com/GitHub_Trending/co0degr/codegraph
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考