首页
/ codegraph explore 输出预算分配机制详解:诊断仪表、按分比例分配与"预留必花"渲染设计(CG-4 到 CG-21)

codegraph explore 输出预算分配机制详解:诊断仪表、按分比例分配与"预留必花"渲染设计(CG-4 到 CG-21)

2026-09-06 12:16:59作者:何举烈Damon

本文聚焦 codegraph 仓库中 codegraph_explore 工具的输出预算分配体系:固定字节信封如何在多个候选文件间切分、切分决策为何长期不可观测、以及从 CG-4 诊断、CG-10 相关性打分、CG-12 按分比例分配,到 CG-15/CG-21"预留必须被花掉"的完整演进过程。读完你将掌握 EXPLORE_ALLOCATION 各常量的含义与校准依据、allocateExploreBudget 的分配算法、渲染循环中 buy 规则与 carry-forward 的实现细节,以及 CODEGRAPH_EXPLORE_DEBUG 诊断与回归探针的实际用法。

固定信封:问题的起点

codegraph_explore 的输出被一个固定的字节信封约束:总字符上限 getExploreOutputBudget().maxOutputChars,并且被硬顶在 25K,以保证宿主 Agent 永远不会把结果外置成文件再 Read 回来。至于这个信封在文件之间如何切分,是由 handleExplore 中一条长长的门控、分层与上限链条决定的——在 CG-4 落地之前,这条链条完全不可观测:你只能读一个 explore 响应然后靠猜,而无法说出"这个文件占了 16%,那个占了 20%"。

分层预算本身由索引文件数决定,当前实现见 getExploreOutputBudget

文件数区间 maxOutputChars defaultMaxFiles maxCharsPerFile
< 150 13,000 4 3,800
< 500 18,000 5 3,800
< 5,000 24,000 8 6,500
< 15,000 24,000 8 7,000
≥ 15,000 24,000 8 7,000

大仓库不再放大单次响应,而是通过 getExploreBudget 增加允许的调用次数(1~5 次)。18,000 与 24,000 这两个档位的上限都刻意压在宿主内联工具结果上限(约 25K)之下——超过就会触发"写文件 + Read 回读",正是这个工具要防止的失败模式。

CG-4:让分配可观测的诊断仪表

设置 CODEGRAPH_EXPLORE_DEBUG 后,每一次 codegraph_explore 调用(MCP 工具或 codegraph explore CLI)都会输出一份报告,取值决定输出位置:

取值 输出目标
1 / true / on / yes / stderr stderr 上的人类可读表格
json stderr 上的一份 pretty-printed JSON 报告
其他任意值 视为路径——每行一份 JSON(JSONL)追加
未设置 / 0 / false / off / no / 空 关闭

该模块是 src/mcp/explore-diagnostics.ts。每个文件报告:相关性分数、图(RWR)质量、去重后的查询词命中数、排序标志(named / entry / central / spine / low-value / generated)、渲染模式、被分配的源码字节数实际交付的字节数、两者占比,以及是否被裁剪;对于最终没有渲染的文件,报告原因(max-filescliffbudget-whole-filebudget-clustersunreadableno-ranges)。总量部分覆盖信封全貌(delivered vs allocated vs maxOutputChars vs 硬顶)、源码/元文本拆分、每个筛选阶段的文件漏斗,以及所应用的阈值(分数下限、相关性门)。

它默认关闭,且关闭时输出逐字节一致——它随产品二进制一起发布,任何让响应偏移一个字节的诊断都会使开启它时所做的一切 A/B 测量失效。ExploreDiagnostics.start() 在环境变量未设置时返回 null,因此所有调用点都是 diag?. 空操作,由 tests/explore-diagnostics.test.ts 钉住这一约束。

两个信封数字被刻意分开:

  • allocated(分配)——渲染循环在最终硬顶截断之前选择发出的量,是分配器自己的决策,预算工作围绕的正是它;
  • delivered(交付)——Agent 实际收到的量。

两者恰好在硬顶截断时出现分叉。把二者混为一谈,尾文件被丢弃就悄无声息了。

基线(2026-08-03,本仓库 main

codegraph explore "how does explore allocate its output budget across files" --path .

469 个文件被索引 → small 档位(maxOutputChars 18,000,maxCharsPerFile 3,800,defaultMaxFiles 5)。信封:18,000 预算下交付 23,196 / 分配 23,193——超支 29%,之所以没出事,只是因为有 25K 硬顶在它之上兜底。

# share bytes score graph hits flags render file
4 21.2% 4,928 10 0.125 1 entry whole scripts/agent-eval/offload-eval-hook.mjs
5 20.1% 4,665 10 0.125 1 entry whole scripts/agent-eval/offload-eval-metrics.mjs
3 19.8% 4,585 22 0.125 1 entry central whole scripts/agent-eval/parse-session.mjs
1 15.8% 3,659 54 0.322 4 entry central clusters* src/mcp/tools.ts
2 15.0% 3,479 34 0.082 2 entry clusters* src/index.ts

* 被裁剪。排名进入但从未渲染:scripts/agent-eval/offload-eval-cost.mjs(#6)与 src/resolution/lru-cache.ts(#7),均被 maxFiles 切掉。

文件漏斗:17 个分组 → 10 个过分数下限(≥3)→ 10 个过低价值过滤 → 7 个过相关性门(graph ≥ 0.0193,为最大值 0.3215 的 6%)→ 5 个进入输出。

基线揭示的两件事

相关性分数不驱动分配。 真正回答该查询的 src/mcp/tools.ts 拥有 5.4 倍的相关性分数、2.6 倍的图质量与 4 倍的词命中,得到的份额却小于每一个 .mjs 脚本。三个 agent-eval 脚本合计吃掉信封的 61%;答案文件只拿 16%。

机制在于两条分配路径都由文件大小而非相关性决定:小文件能过 WHOLE_FILE_MAX_LINES/WHOLE_FILE_MAX_CHARS 从而整文件发出,大文件落入簇选择并在 maxCharsPerFile 处被裁剪。于是弱相关的 130 行脚本拿到自己 100% 的内容,强相关的 5,000 行文件只拿到 3,800 字符。排序本身是对的(tools.ts 排 #1)但毫无收益——排名对文件拿到多少字节没有任何影响。

信封被超订。 18,000 的预算下分配出 23,193,说明每文件上限无法组合成总量上限;总量只能靠 25K 硬顶静默丢弃整个尾部段落来执行。换一个略不同的索引状态(多一个候选文件),同样的查询分配到 27,518 字符,硬顶丢掉了 7,678 字符的段落——响应中最大的一次分配——唯一的痕迹是结尾的截断提示。

这就是该 epic 其余部分要补的缺口:相关性比例分配加相对悬崖(CG-12),建立在不奖励偶然名字碰撞的打分之上(CG-10,已落地,见下节)。

CG-10:相关性打分——决定"什么进入响应"

CG-10 改变的是什么进入响应,先于字节怎么切。四个杠杆全部是乘法性的,可以无顺序意外地叠加。

1. 符号类型加权(Kind weighting)

一个符号到达所属文件的方式分层(named seed +50、查询命中 +10、与命中相邻 +3、外围 +1)说明的是它怎么来的RELEVANCE_KIND_WEIGHT 回答的是这个匹配是不是证据。函数/类型类权重 1.0,成员约 0.5,constant/variable/parameter 仅 0.15–0.35——一个局部变量叫 explore 在被其他证据佐证之前只是名字碰撞。

孤立检查(Isolation):对弱类型符号的前两档,"有没有人在用它?"就是佐证——图中不存在任何使用边(contains 被排除——词法嵌套不是使用)时降到 0.08。成本有界:只有权重能撑起整个文件的档位的弱类型符号才付出探针代价,且子图自身边在多数情况下免费回答。实测无延迟变化(210 vs 211 ms/call,n=12 交错测量)。

外围上限(Peripheral cap):距任何命中 ≥2 跳的节点现在累积进一个上限为 5 的独立桶。没有限制时每个这样的节点都加一个平白 +1,文件越大反而越"相关"——parse-session.mjs 靠一个偶然常量和十两个无关符号拿到了 22 分。体积不是证据。

2. 相对分数下限(Relative score floor)

score >= 3 的绝对下限在任何头部文件得分 50+ 的仓库里都会放进噪声。新下限是 clamp(topScore × 0.2, 1, 10),当前实现中 SCORE_FLOOR_MAX = 10src/mcp/tools.ts),计算见 src/mcp/tools.ts

  • 相对——弥散型问题没有文件主导,所有候选都贴近顶部,整个梯度存活;精确型问题则切掉尾部;
  • 上限 10——对一个函数符号的一次直接查询命中。单次满强度命中从不偶然,因此别处的任何集中度都不应排除它。没有这个上限时,一个 named-seed 密集文件曾把下限推到 21,丢掉了一个 Agent 按名点名的文件(类以 +10 进入而非 +50——named seed 是函数符号);
  • 回填(backfill)——存活文件少于 3 个时,被下限切掉的最好者回到池中,但只从有真实证据(≥绝对下限)的文件中回补;如果什么都没剩,回填放弃该要求:gather 明明找到了候选却回答"未找到相关代码",只会把 Agent 直接送回去 grep。

3. 生成文件状态进分数,而非平局裁决

rankPenalty(file) 对生成文件同时把相关性分数和图质量乘以 0.3(低价值文件乘 0.5),当前实现见 src/mcp/tools.ts。只作用于分数的话修不了 #1500:生成 CRUD 的图质量比手写用例更大,而比较器里图质量先于分数。该惩罚自归一化——全生成代码库中所有文件一起缩放,相对排名不受影响——且永不硬排除:按名字问生成 API 时,named-seed 档仍会把它排到第一。

4. excludeLowValueFiles——已死的配置

任务要求重新考虑的那个按档位标志是死配置:声明在 ExploreOutputBudget 上并按档位设置,却没有任何地方读取。后续变更早已让 test/spec/icon/i18n 排除在所有档位无条件生效,该标志被删除(源码注释见 src/mcp/tools.ts)。

实质性缺口在检测器而非门控:isLowValue 原来匹配 /\/(tests?|__tests?__|spec)\//,锚定在前导斜杠上,因此仓库根目录test/ 目录——express、cobra 以及 npm 和 Go 的大部分项目——永远匹配不上。Express 的"how does express route a request to a handler?"把信封的 59% 花在三个测试文件上,而 lib/application.js 被裁剪。现在检测器同时锚定 ^/src/mcp/tools.ts),同一查询返回 lib/application.js + lib/response.js,没有任何测试文件。

两个关联变更:过滤现在运行在分数下限之前,并且用整个 gather 而非下限之后的集合来判断"还有没有其他候选"(在下限之后判断,正是下限的最低保留量把测试文件作为"梯度"拉回来的途径);以及穿过过滤 ≥2 个非测试候选逃生门存活下来的低价值文件,通过 rankPenalty 降权而非保留满强度。

实测效果

同一索引、确定性测量(CODEGRAPH_EXPLORE_DEBUG 诊断,两臂同一构建体系,基线 bd86ad2):

仓库 · 查询 之前 之后
本仓库 · self-query 夹具 72% 给 eval 脚本,tools.ts 18.5% 脚本 0%tools.ts #1
本仓库 · handleExplore buildFlowFromNamedSymbols … 82% 给 eval 脚本 tools.ts 48% + index.ts 32%
本仓库 · "how is error handling done" 58% 给 eval 脚本,tools.ts 交付 0 transport/tools/cobol/api
本仓库 · "what languages does codegraph support" 63% 给 scripts/add-lang/* grammars/index/cli
本仓库 · "main components of the indexing pipeline" 逐字节一致
payroll-go 夹具 生成文件 57.4%,答案 25.6% 答案 61.5%,生成 23.5%
express · route a request 59% 给 test/* application.js + response.js
cobra · 3 个查询 逐字节一致

两个逐字节一致的行是对照组:答案本已集中的地方,新下限只是更早、更便宜地剪掉同一条尾部,得到同样的响应。

已知的薄输出案例。 Express 的"how does the app object get created and what does it expose"从 4 个文件(头部是一个 examples/ 文件,占 38%)缩到单独的 lib/express.js,2.6 KB 对着 13 KB 预算。lib/application.js 只靠一个未使用的文件级 var app 匹配上——在符号层面与 eval 脚本里未使用的 const explore 无法区分;express 把 API 面建模为赋给该对象的属性,图中没有这些边。这是抽取覆盖面问题,不是排序问题。回填曾被尝试并拒绝:节点数平局让名额给了 examples/route-middleware,占信封 48%。薄而精确胜过噪声填充——错误文件并不能替 Agent 省掉它本来要在其上填充的那次后续调用。

CG-12:按分比例分配——决定"字节怎么切"

CG-10 修好了什么进入响应。CG-12 修的是进入的字节如何切分——在此之前这根本没有被真正决定过。每个准入文件都被同一个平的 maxCharsPerFile 封顶,整文件规则又给任何小于 maxCharsPerFile × 3 的文件全部内容。所以信封跟着文件大小走:

  • self-query:memory-budget.ts(分数 18)以 5,672 字符整文件发出,占 51.2%tools.ts(分数 41,4 倍图质量,3 倍词命中——它字面上就放着分配器)在 3,800 处被裁剪,只拿 32.9%
  • payroll-go:两个生成 CRUD 文件各以约 4.5 KB 整文件发出,消耗了档位 4 个文件名额中的 2 个,于是 BuildPayslip——问题中手写的"计算"一半——排 #6 从未渲染。

模型

allocateExploreBudgetsrc/mcp/tools.ts)在排序之后、任何渲染之前运行一次。它为每个文件预留信封的一份;渲染循环随后花的是预留,而不是与排在前面的文件争夺剩余。

  1. 权重 = score × worth × (spine ? 2 : 1)worthrankPenalty第二次应用:排序回答"这个文件是不是关于查询的",分配回答"这些字节能否教会 Agent 任何东西"。生成 CRUD 可以合法地获得排名——它在每个领域词上名字碰撞,且体量大、自引用密集,在比较器优先的结构键上得分——但它的字节仍是机械样板。第二次惩罚才是真正把它沉下去的东西。
  2. 相对悬崖在头部权重的 15% 处(CLIFF_FRACTION),自身上限为 SCORE_FLOOR_MAX(10)。低于它的文件拿到零源码——只有路径、符号和行号,约 100 字符而非约 4,500,并且不占 maxFiles 名额,名额让给挣得字节数的文件。正是这次名额移交让 BuildPayslip 进入了响应。上限和比例同样重要:一个 500 分的神文件否则会把悬崖推到 75,让分数下限刚放进来的所有同级文件噤声。
  3. 先保底再切分。 每个准入文件先拿 MIN_CHARS(700——足以容纳一个完整方法);剩余部分按权重切分。保底让弥散型调查问题仍返回梯度;剩余部分让精确型问题集中。
  4. 安全阀而非每文件上限:任何文件不超过信封的 70%(MAX_SHARE)。平的每文件上限退位——比例切分本身已按权重份额限制了文件。

当前常量全集见 EXPLORE_ALLOCATION

常量 作用
CLIFF_FRACTION 0.15 低于头权重 15% 的文件零源码
CLIFF_MAX SCORE_FLOOR_MAX(10) 悬崖阈值上限,保护单次满强度命中
MIN_CHARS 700 每文件保底预留
MAX_SHARE 0.7 单文件安全阀
FILE_OVERHEAD 200 每文件的 markdown 开销(头 + 围栏),切分前从池里扣除
SPINE_WEIGHT_BOOST 2 流程脊柱文件权重加倍且豁免悬崖
WHOLE_FILE_GRACE_FRACTION / MAX 0.15 / 800 整文件规则的"薄片"宽限
WHOLE_FILE_BUY_FRACTION 0.6 预留已买下文件大部分时整文件发出(CG-21)
WHOLE_FILE_BUY_OVERSHOOT_FRACTION 0.15 buy 规则的共享超支池

预留随后治理所有渲染路径——整文件、簇、聚焦/骨架——在此之前整文件分支比簇分支慷慨 3 倍,正是这 3 倍摆动按文件大小决定了切分。

让预留真正"咬合"需要两个支撑性变更:

  • 超大簇按成员收缩。 簇是若干完整符号区间的合并,在符号密集的文件里所有符号会合并成一个横跨整个文件的 blob(cycle.go 的 209 行 Service)。旧规则无论如何大都整块拿走排名第一的簇,于是单簇文件直接无视预算——比分配多拿约 40%,排在它下面的文件随后因空间不足被丢。新收缩按重要度丢弃完整成员,函数体永远不会被切半。
  • 到达顺序的停止点被移除。 budget-90pct!fileNecessary && totalChars > maxOutputChars 检查按到达顺序丢文件:排名靠前的文件花光信封,之后的一切都在一个自己无权参与制定的上限处被切掉。现在只剩绝对硬顶停止。

实测效果(CG-12)

仓库 · 查询 之前(CG-10 后) 之后
本仓库 · self-query 夹具 tools.ts 32.9%,memory-budget.ts 51.2% tools.ts 60.6%,memory-budget 17.2%
payroll-go 夹具 答案 61.5%,生成 23.5%,BuildPayslip 缺席 答案 78.7%,生成 0%BuildPayslip 被交付
express · route a request 2 文件,头部 43.1% 1 文件,头部 82.3%response.js 触悬崖——0 词命中)
express · app registers middleware 1 文件,71.6% 逐字节一致
cobra · parse flags and execute 2 文件,command.go 40.1% 2 文件,command.go 77.2%
cobra · 弥散 "main components" 3 文件,头部 48.1% 3 文件,头部 50.0%——梯度保持
gin · request reaches a handler 3 文件,头部 ginS/gins.go 48.8% 3 文件,头部 routergroup.go 53.8%
gin · 弥散 "what it provides" 3 文件,头部 recovery.go 43.0% 4 文件,头部 context.go 33.3%

两个弥散行是矫枉过正的对照组:文件数保持(3→3,3→4),调查型问题仍然拿到梯度。两个 gin 行还把头部文件换成了更贴切的一个——routergroup.go 胜过于薄壳的 ginS 单例包装,context.go 胜过 recovery.go——因为集中由权重而非"哪个文件碰巧小"决定。

"此前未裁剪的文件不会变得被裁剪"的例外。 memory-budget.ts 此前以 5,672 未裁剪整文件发出,现在在 3.1 KB 预留内走簇路径。这是该 epic 对自身 bug 的诊断而非回归:它 18 分对 tools.ts 的 58 分,拿更大切片纯粹因为小到能整文件发出。保证在它本应成立的地方成立——没有文件因为更紧的上限丢失字节;丢字节的是比例切分认定被超供的文件。

CG-6:回归夹具——把失败模式钉死

两个夹具把这个失败模式钉死,让它无法静默复发。它们是以失败为目的写成的——这正是它们的用途。CG-10 关闭了两者的排序半边,CG-12 关闭了字节切分半边;现在两者都通过,是活的回归。下表引用的数字是CG-10 之前的基线;现状见上面两张"实测效果"表。

夹具声明在 scripts/agent-eval/allocation-fixtures.json,由 scripts/agent-eval/probe-allocation.mjs 驱动——它通过 JSONL 旁路驱动 CG-4 诊断(因此测量的是产品分配器而非重新推导),把渲染文件分组为 answerincidental,并检查声明的份额阈值。需要一次当前 npm run build;任何断言失败时退出码 1。

node scripts/agent-eval/probe-allocation.mjs                # 两个夹具
node scripts/agent-eval/probe-allocation.mjs payroll-go     # 单个
node scripts/agent-eval/probe-allocation.mjs --json         # 机器可读

1. payroll-go——报告者的形状

tests/fixtures/payroll-go/ 是一个合成 Go 服务:生成 FKIT CRUD 旁立手写 payroll 用例,从 HTTP 路由进入。完整描述见该目录 README。要点:

  • 生成文件用普通名字并携带 // Code generated ... DO NOT EDIT.——对纯路径检测不可见,这正是它成为 #1500 夹具而非 .pb.go 夹具的原因——旁边还有 payrollpb/*.pb.go 覆盖可路径检测的通道。
  • 刻意碰撞:BuildPayslipUpsertStore 各存在两次(生成与手写),生成层在每个查询词上名字碰撞。
  • cycle.go(227 行)位于整文件窗口之上从而被裁剪;生成文件在窗口之下从而整文件发出。

查询——一个架构问题,没有点名任何回答符号:"how does payroll cycle create and calculate payslips?"

allocated delivered
手写 48.4% 25.6%(全部是领域类型)
生成 CRUD 39.9% 57.4%

cycle.go 拿到最大的单份切片(7,052 字符,30.6%)却交付零——19,500 硬顶丢掉它的整个段落。payslip_builder.go(排名 #8)从未渲染。于是 runPayrollCycleAll、手写的 BuildPayslip 和真正的 Upsert 从未到达 Agent,而到达的每个字节描述的都是 CRUD 或类型。

该夹具是无交互的(hermetic):探针把树复制到临时目录并每次重新索引,同一构建下两次运行逐字节一致(已验证)。tests/explore-allocation-1500.test.ts 在 vitest 中运行同样的断言。

CG-10 之后生成文件排 #3/#4 而非 #1/#2,cycle.go 交付 38.9%(此前交付为零),runPayrollCycleAll + 真正的 s.store.Upsert(ctx, slip) 到达 Agent。这些断言现在是活的回归。仍保持 it.fails 的是 payslip_builder.go:它排 #6,档位 maxFiles 是 4,渲染循环仍按文件大小花钱——那是 CG-12 的工作。

刻意留下的未修发现: runPayrollCycleAll*payslipstore.Store 上调用 s.store.Upsert,但图把这条边解析到生成的 internal/gen/fkit/payroll/store.goStore.Upsert。两个都定义 Store.Upsert 的包之间同名方法解析选错了接收者。它在分配上游——错误的边把生成 store 拉进子图。CG-10 缓解了症状(生成 store 在分数和图质量上都被罚,不再挤掉真正的),但解析 bug 本身属于同名方法解析工作(见 samename-method-resolution-1079)。

2. self-query——没有生成代码的同款 bug

上述基线提升为夹具:本仓库,"how does explore allocate its output budget across files"。scripts/agent-eval/*.mjs 偶然提到 exploreBUDGET——它们是 eval 脚手架而非分配器——且小到能整文件发出,而 src/mcp/tools.ts 大到会被裁剪。

493 个索引文件(small 档)下:脚本语料占交付信封 71.8%(分配 79.4%),对照 tools.ts18.5%——尽管 tools.ts 分数 46 对 10、2.3 倍图质量、3 倍去重词命中。

该夹具读取本仓库的索引,因此与 payroll-go 不同,其精确数字随仓库变化而漂移。断言因此是相对的(答案组对偶然组、最大交付文件),从不用固定百分比。两件事需要知道:

  • <500 文件档边界很近。 本仓库索引 493 个文件(含新夹具);跨过 500 会把 maxOutputChars 从 18,000 翻到 24,000、maxFiles 5→8、maxCharsPerFile 3,800→6,500,上表所有数字都会移动。跨过之后应重新定基线,而不是把漂移当回归。
  • 添加 payroll-go 夹具本身把计数从 472 推到 493。它的 Go 文件不匹配该查询的任何词,因此只改变档位算术。

复现

查询探索的是本仓库,所以src/mcp/tools.ts 的未提交编辑会改变结果——索引会拾取它们、分数会移动(同一查询在 CG-4 工作树上依同步状态报告 tools.ts 13–19%)。在干净树上测量:从 main 恢复 src/mcp/tools.ts、删除 src/mcp/explore-diagnostics.tscodegraph sync,然后运行构建出的 dist/ 二进制(其中仍带着仪表)。测完恢复。

CG-14:把分配锁死——覆盖设计

分配变更是一个函数加三个渲染循环边界,而它回归的每一种方式都是静默的:没有异常,响应只是变得没用一点,Agent 回退到 Read。因此覆盖围绕"如果移除这个杠杆,某个测试会变红吗?"来构建,而不是围绕行覆盖率。

覆盖分布

文件 负责
tests/explore-proportional-allocation.test.ts allocateExploreBudget 隔离测试——切分、悬崖、档位不变量、信封安全、脊柱加权、退化输入
tests/explore-allocation-e2e.test.ts 同样行为穿过真实渲染循环与真实索引项目——self-query 夹具形状、退化结果集、弥散查询对照
tests/explore-allocation-1500.test.ts 报告者的 Go 形状(CG-6 夹具 1)加硬顶压力用例
scripts/agent-eval/probe-allocation.mjs 对构建出的 dist/ 跑两个 CG-6 夹具,含读取本仓库自身索引的 self-query 臂

最后两者的分法是刻意的:self-query 夹具读移动目标(本仓库),精确数字随树漂移,它应该出带到带外——在那里漂移是一个重新定基线的数字,而不是一个变红的套件。npm test 拥有它的合成镜像:同样的三个角色、同样的大小不对称,固定不变。

两个边界不是同一个边界

值得说一次,因为混淆二者的测试看起来对、并为错误的原因通过:

  • maxOutputChars 约束的是预留。 sum(allowances) <= pool <= maxOutputChars,在每个档位、每种候选形状下精确成立。
  • 硬顶——min(maxOutputChars × 1.5, 25000)src/mcp/tools.ts)——约束的是响应。 渲染循环被允许有界超支(整文件宽限、超大首簇),所以响应合法地超过信封。payroll-go 正是这样:13,000 信封、19,500 硬顶下交付 19.3K。

只有 25K 是绝对的。超过它宿主会把结果写文件让 Agent Read 回来——正是这个工具存在要防止的失败。

变异测试,而不只是绿灯

每个杠杆曾被逐一从 src/mcp/tools.ts 移除并重新跑套件。每个杠杆都至少被一个失败测试覆盖——没有红色测试的杠杆是可以被意外删除的杠杆:

变异 变红的测试
渲染循环回退到 CG-12 之前(fileBudget = maxCharsPerFile,整文件边界 maxCharsPerFile * 3 5 e2e + 2 payroll
比例切分替换为均分 4 unit
禁用悬崖(cliffAt = 0 7 unit + 3 payroll
移除脊柱加权与悬崖豁免 3 unit
移除 MIN_CHARS 保底 2 unit
移除 MAX_SHARE 上限 4 unit

第一行最关键:它在合成夹具上逐字复现 #1500,一个相关性只有一半的文件纯靠体积拿更大份额。

file score CG-12 前 CG-12
src/mcp/allocator.ts 77.5 4,843(39.7%) 9,335(80.1%)
src/util/budget-math.ts 36.0 6,079(49.8%) 1,037(8.9%)

覆盖发现的两个缺陷

两者都是靠写不变量而非读代码找到的:

  1. 取整后的份额可以超过池。 对每个文件的比例切片做 Math.round 让预留总和最多超过 pool 每文件半个字符——小,但把"预留装进信封"从精确变成了假。现在两项都向下取整(src/mcp/tools.ts)。
  2. 非有限分数产生 NaN 预留。 Infinity 权重让每个份额变成 Infinity / Infinity。管线中分数是有限和,实际不可达,但失败形态是把 NaN 交给渲染循环。weightOf 现在失败安全到 0(src/mcp/tools.ts)。

校准 vs 不变量

EXPLORE_ALLOCATION 被导出以便测试读取。不变量测试(信封安全、档位单调性、保底)引用常量,在任何取值下成立;一个测试钉住字面量,因此重新调整常量是一个可见的决策——说"重跑探针",而不是对夹具静默重新校准。

CG-15:Agent A/B 的发现——预留可以花不掉

Agent A/B(完整记录见 docs/benchmarks/explore-allocation-ab-1500.md)在两个中型仓库上通过——client-go 和 excalidraw 每次运行 Read 均为 0,excalidraw 34s → 24s 中位且少一次 explore 调用,占基线信封 10.5% 的生成 clientsets 从每个新运行中消失——但在小型对照组 express 上 3 次运行中 1 次失败:对 lib/utils.js 的 4 次 Read,而基线只对另一个文件做了 1 次 Read。

这不是 Agent 方差。确定性回放该运行自己的查询:

lib/utils.js(5,293 B,272 行) 基线 CG-12
交付 6,380(46.1%)整文件 583(7.7%)簇桩
源码信封(预算 13,000) 13,849 9,241

诊断显示分配器是对的,渲染循环不是:

allocation 12,398 reserved of 12,400 pool · nothing cliffed
 #  deliv%   bytes  reserved  score   flags                render    file
 1    5.7%     583     3,870   56.0   named entry central  clusters  lib/utils.js

utils.js排名第一的文件,预留 3,870 字符——只花了 583。整文件边界(现见 src/mcp/tools.ts)是 allowance + min(GRACE_MAX, allowance * GRACE_FRACTION) = 3,870 + 580 = 4,450,低于文件 5,293 字节,于是整文件渲染被拒;回退簇渲染只有三个匹配符号可用,发出 583 字符。预留的其余 3,287 字符没有被再分配——丢失了,这就是响应在预算不变的情况下缩水三分之一的全部原因。

这正是 CG-12 自己的验收标准("此前未裁剪的文件不会变得被裁剪")的失败。CG-14 记录过一个实例作为文档化例外(memory-budget.ts);这是同一缺陷的野外版本,代价是一次 Agent 往返。它不是系统性的:两个中型仓库上渲染循环饱和([over budget] [TRUNCATED],23,600 池预留了 23,599),没有可丢失的东西。它需要这样一个文件:预留落在自身尺寸之下、匹配符号集又薄——最可能出现在小仓库,那里每文件预留最小。

两个候选修复

放宽信封明确不在其中——那是 iter2 已证明无效的旋钮,且这里的字节本来就已为该文件预留。

  1. 让足够大的预留买下整文件。allowance >= k * fileSize(某个 k < 1,utils.js 处在 0.73)时整文件渲染,让有界超支被硬顶容差吸收。改动小,匹配观察到的形状。
  2. 再分配缺口。 渲染循环知道文件实际尺寸后,把花不掉的字节交给下一排名文件。更强的不变量——池被花完——并直接修复缩小的信封症状。

两者可组合;单独 (1) 就足以覆盖此案例。无论哪个落地,都需要 express 形状作为无交互夹具——匹配符号少、尺寸刚好高于预留的中型头名文件——因为现有套件中没有这个形状,这正是它被放行的原因。

CG-21:花掉预留——两个修复都落地

两个修复都落地了,因为它们覆盖同一失败的不同半边,且单独都不充分。统一规则:预留是渲染循环必须兑现的承诺,而不是它可以悄悄少用的上限。

1. 已买下文件大部分的预留,买下剩余部分

WHOLE_FILE_BUY_FRACTION(0.6,src/mcp/tools.ts)。既有宽限按薄片校准——只救基本上放得下的文件。在"放得下"与"几倍于预留"之间有个洞,lib/utils.js(预留/尺寸 = 0.73)正落在洞里。所以测试不是文件是否放得进预留,而是预留是否已经买下了文件的大部分:超过 0.6 时循环至多额外支付三分之二的预留,而不是失去整件事——花在一个已经挣得这些字节的文件上。

资金是容易做错的部分。 资格测试是比例,因此凡是有多个文件贴近它的地方,它们全部合格——N 个独立超支会把响应膨胀到渲染顶丢掉最后者。按各文件自身余量出资被尝试并在 payroll 夹具上测量:三个文件整文件发出,payslip_builder.go——问题问的、计算工资单的那个文件——被完全丢弃,好让三个更高排名的文件各自发出最后的薄片。被丢的段落严格差于被簇化的段落。

所以 buy 规则是两个独立测试,分开正是全部设计:

读取 回答
资格(merit) 文件自己的 reserved 这个文件的相关性是否挣得了它的大部分自身?
资金(funding) 一个共享超支池(WHOLE_FILE_BUY_OVERSHOOT_FRACTION,信封的 15%) 字节是否存在,且它下面每个预留仍可支付

资格读 reserved 而非带进位后的 allowance,因此借来的宽裕永远无法把弱文件提升到整文件。资金对照分配器承诺的测量而非 renderCeiling——顶在信封之上 50%,谁欠谁什么它一无所知,从它出资 buy 只是把缺口移到循环最后到达的文件头上。owedBelow 项让它成为位移防护而非尺寸上限,且自限:每次 buy 增大 sourceSpent,池不会被花两次。当前实现(src/mcp/tools.ts):

const buysWhole = fileContent.length <= graceBound
  || (reserved >= fileContent.length * EXPLORE_ALLOCATION.WHOLE_FILE_BUY_FRACTION
      && sourceSpent + fileContent.length + owedBelow <= sourceCeiling
      && fileContent.length <= fundedHeadroom);

2. 文件花不掉的,交给下面的文件

低于 buy 比例时缺口是真实的——文件几倍于预留,簇渲染才是对的——但字节仍不能蒸发。渲染循环维护两个运行总量(src/mcp/tools.ts):迄今所有承诺的(reservedSoFar)与迄今所有发出的(sourceSpent),其差值是下一个文件可以加到自己预留上的宽裕(src/mcp/tools.ts)。

用两个总量而非一个穿过循环十几个 continuespent 变量,因此任何出口路径都漏不掉记账——不可读、磁盘漂移、因顶跳过、薄匹配集。它是对称的:超支的 buy 让 sourceSpent 跑过 reservedSoFar,抑制宽裕直到之后的少支覆盖债务,于是池双向守恒,没有文件被切到低于承诺值。宽裕按排名顺序流动(单遍循环中唯一还能支付的文件),并被 MAX_SHARE 夹住,以免少支的头名把整个响应交给弱尾部文件。

实测效果(CG-21)

Express 复现场,同一索引、同一 13,000 预算:

lib/utils.js(5,293 B,272 行) 基线 CG-12 CG-21
交付 6,380(46.1%)整文件 583(7.7%)桩 6,268(39.3%)整文件
源码信封 13,849 9,241 14,505

以及 self-query——epic 自身验收标准尚未满足的地方:

文件 CG-12 前 CG-12 CG-21
src/mcp/tools.ts(分数 58) 32.9% 60.6% 52.6%(10,945,簇)
src/resolution/memory-budget.ts(分数 18) 51.2% 整文件 17.2% 簇 27.3% 整文件(5,672)

memory-budget.ts 例外被解决而非重新辩护。 CG-14 把它记录为"此前未裁剪的文件不会变得被裁剪"的文档化例外;它的预留/尺寸比是 0.73——与 express utils.js 同一个窗口——因此 buy 规则覆盖它。答案文件仍然赢下信封,并且没有以前整文件发出的东西被裁剪——这是 CG-12 验收标准两半首次同时成立。

tests/explore-allocation-e2e.test.ts 中的合成镜像刻意不动:其辅助处在比例约 0.17,远低于 buy 比例,因此仍走簇——正确地。这种分歧正是比例的意义:memory-budget.ts薄片超供,合成辅助被倍数超供。

覆盖

两个新的无交互夹具,每个杠杆一个,因为现有套件两者都看不到:payroll 与 self-query 夹具都饱和[over budget] [TRUNCATED],23,600 池预留 23,599),而饱和的响应没有可丢失的未花预留。

夹具 形状 防护
"低于文件尺寸的预留仍买下文件" 中型头名文件、薄匹配集、尺寸刚好高于预留且宽限边界之外 buy 规则
"无法花掉的预留流向下一文件" 头名文件大到买不起(比例 0.24)少支;稠密的第二名吸收 carry-forward

每个夹具带一个 fixture shape 块断言它依赖的窗口(0.6 × size <= reserved < size、宽限边界之外、220 行整文件上限之内)。它们是承重的而非脚手架:一旦目标漂移小到宽限可覆盖,每个门控都会空真通过——正是这个缺陷躲藏的方式。

变异测试,方法与 CG-14 相同:

变异 变红的测试
移除 buy 分支(buysWhole = fileContent.length <= graceBound 3(新 buy 夹具)
移除 carry-forward(allowance = reserved 2(新 carry-forward 夹具)
移除资金守卫(… && true 4——含 payroll 的 payslip_builder.go 被丢

初稿踩过的两个陷阱,都让测试在缺陷存在时通过:

  • 脊柱上限遮蔽每文件断言。 SPINE_CEILING 本就允许流程路径簇达到其 allowance 的 1.5 倍,所以没有任何 carry-forward "delivered > reserved" 也为真。carry-forward 夹具刻意建在无脊柱上并断言 1.1 倍余量——变异下实测 9,297 对 7,479。
  • "花完整个池"不是不变量。 小于预留的文件合法地少支(夹具的 response.ts:预留 5,292 交付 1,635)。断言是每文件的——delivered >= min(reservation, 文件尺寸)——express 的 utils.js 违反的正是它,池求和断言表达不出来。

第三个条件——由评审而非测试发现

buy 还必须装得进渲染顶。整文件分支拒绝中途切函数,因此超过 renderCeiling 的整文件渲染被整体跳过——意味着一次被资金池批准却被顶拒绝的 buy,用一个簇段落换了没有段落。这正是资金池存在要拒绝的那笔交易,从另一条路进来。

它只在 24K 档位可达,因此两个新夹具都看不到:

档位 信封 renderCeiling = min(1.5x, 25000) - 600 资金线 = reservedTotal + 0.15x
small 13,000 18,900 约 14,350——不可交叉
medium/large 24,000 24,400 饱和时约 27,200——交叉约 2.8K

所以 buysWhole 还带着 totalChars + size + FILE_OVERHEAD <= renderCeiling(现实现中对照 fundedHeadroomsrc/mcp/tools.ts);不满足则落入簇路径,后者由 headroom 限界、总能渲染出点什么。宽限分支刻意未动——离预留只差一个薄片的文件若仍装不下,是真正处在完整响应末尾,该行为早于本 epic。已在全部三个 A/B 仓库验证惰性(excalidraw 与 client-go 每仓库 3 个查询逐字节一致,express 复现场不变)。

Agent A/B(CG-15 的门槛,重跑)

epic 的门槛是 CG-22 而非本节。CG-22 在 CG-15 的完全相同设置下(RUNS=3、全新克隆、基线以 SHA 49c11fc 钉住)重跑了它,四项标准全部通过——12 个新臂运行中 Read 全部为 0,而基线在 3 次 express 运行中全部有读;确定性复现器在两个构建同一会话重测的情况下成立(lib/utils.js 两者都是 6,380 B 整文件,信封 13,849 → 14,913)。见 docs/benchmarks/explore-allocation-ab-1500.md § CG-22。

该节记录的两个反证——excalidraw 的答案份额低于其基线、client-go +1.5s 中位——是"已归因但未解决",不是被隐藏。n=6 每臂的 express 与 excalidraw 全量记录同样在 docs/benchmarks/explore-allocation-ab-1500.md("Re-run after CG-21" 节)。四项标准全部通过

  • 15 个新臂运行中 Read 全为 0。 把缺陷路由到这里的 express 回归(对 lib/utils.js 的 4 次 Read)在 6 次尝试中未复现——而基线在 6 次中 4 次有读,对照组现在胜过了它先前输掉的那一臂。中位 24.5s → 21.5s。
  • client-go——报告者的形状——答案份额保持 92.7–96.2%,基线运行在 53.8%。
  • Excalidraw 约 8s 的中位差不可归因于该变更。 Explore 自身延迟 374 ms 对 372 ms(n=5,同查询同索引);确定性响应相差 +2% 且一个逐字节一致;而未改动main 构建自己的中位在 CG-15 会话与本会话之间从 34s 移到 26.5s——与差值同量级。该仓库上 Agent 墙钟在此样本量下是噪声主导的(宿主模型思考占主导,而非工具延迟)。

核心不变量小结

从 CG-4 到 CG-21,整条工作线收敛为几条可从源码逐条核对的不变量:

  1. sum(allowances) <= pool <= maxOutputChars——预留精确装进信封,pool = maxOutputChars - FILE_OVERHEAD × 文件数src/mcp/tools.ts);
  2. hardCeiling = min(maxOutputChars × 1.5, 25000) 约束响应,有界超支合法,25K 之上绝不外置(src/mcp/tools.ts);
  3. 悬崖以下 = 指针而非字节:约 100 字符的路径 + 符号 + 行号,且不占 maxFiles 名额;
  4. 预留是承诺:每文件 delivered >= min(reservation, 文件尺寸),未花掉的预留按排名顺序向下流动,双向守恒;
  5. 诊断关闭时逐字节一致ExploreDiagnostics.start() 未设环境变量返回 null,所有调用点是 diag?. 空操作(src/mcp/explore-diagnostics.ts,钉死于 tests/explore-diagnostics.test.ts)。

这些不变量与杠杆一一对应到测试:杠杆移除必变红,常量重调必可见,夹具形状断言防止门控空真通过——这是"既有可复制的实测数字,又有源码级原理支撑"的完整闭环。

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