codegraph CG-30:为 explore 的超大簇成员设定 1.5 倍上界 —— 基准数据、A/B 评测与源码实现
CG-30 是 codegraph explore 检索管道的一次预算治理变更:当某个文件簇(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/。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0622
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00