首页
/ codegraph CG-30:为 explore 的超大簇成员设定 1.5 倍上界 —— 基准数据、A/B 评测与源码实现

codegraph CG-30:为 explore 的超大簇成员设定 1.5 倍上界 —— 基准数据、A/B 评测与源码实现

2026-09-04 13:33:23作者:邵娇湘

CG-30 是 codegraph explore 检索管道的一次预算治理变更:当某个文件簇(cluster)里排名第一的成员(通常是单个超长函数)体积远超该文件被分配到的预算时,旧实现会把整个成员原样输出,挤爆响应信封;CG-30 为其引入 1.5 倍预算的封顶——超过上界后按整行开窗(windowed)而不是整体输出,从而把字节"让渡"给排名更低的文件。本文完整复现该变更的 A/B 基准报告:先看确定性测量中上界真实生效的位置,再看 agent 跑批的行为代价与充分性(bar),最后深入 src/mcp/tools.tsrenderCluster / 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. 1.5 这个数字与既有规则同源。 SPINE_CEILING 早在 CG-30 之前就已存在(为调用路径簇画的界),CG-30 把簇内单个超大成员的开窗上界复用了同一个 1.5 倍预留量,并保证 ceiling >= cap,所以已经装得下的簇永远不会被动到。
  2. 上界从共享信封中"抽取"。 注释(src/mcp/tools.ts)明确:1.5 倍是从共享信封里抽的,因此它恰好等于位移守卫需要资助的超额部分;超过 fundedHeadroom 之后,多出来的那半个预留量是别的文件的,不是空闲房间。这也是它读取 fundedHeadroom 而非 headroom 的原因(CG-31 的一半:后者包含每个未到达文件的全部预留量,花掉它们正是"一个簇化文件让五个被准入的同行归零"的机制)。
  3. 保留头部成员的"永不整体丢弃"原则被保留。 shrinkCluster 中(src/mcp/tools.ts)仍然"总是保留最重要的范围,即使它自己就是超大的"——空段落会把 agent 送去 Read,代价高得多;改变的是它可以超出的幅度被调用方的 ceiling 封顶,失控成员被开窗而非丢弃。

windowToCeiling:按整行开窗的裁剪器

真正的开窗逻辑在 src/mcp/tools.tswindowToCeiling。几个关键设计:

  • 成本按渲染行计算。 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.ts skippednull 且实际交付了字节;
  • 永不为空:所有簇化文件 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/

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

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.12 K
2.72 K
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
903
1.82 K
docsdocs
暂无描述
Markdown
888
5.78 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
854
1.34 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
527
590
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.51 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.33 K
1.45 K
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
540
384
flutter_flutterflutter_flutter
本仓库是 Flutter SDK 与 Flutter Engine 的 OpenHarmony 适配版本,由 CPF-Flutter 团队维护。开发者可使用熟悉的 Flutter 技术栈开发 OpenHarmony 应用,3.35.7 及以后的适配版本可基于本仓库源码构建支持 OpenHarmony 的 Flutter Engine。
Dart
1.17 K
341