首页
/ CodeGraph 自适应 explore 缩放:sibling 骨架化如何让 codegraph_explore 按答案裁剪输出而非填满预算

CodeGraph 自适应 explore 缩放:sibling 骨架化如何让 codegraph_explore 按答案裁剪输出而非填满预算

2026-09-06 11:53:58作者:邬祺芯Juliet

本文基于 codegraph 仓库的设计文档 adaptive-explore-sizing 展开,讲清 codegraph_explore 工具如何区分"可互换的多态兄弟实现"与"流程中的独立步骤",对前者渲染"类 + 成员签名"骨架、对后者保留完整源码,从而在 OkHttp、Django 等 sibling-heavy 代码库上把 explore 输出从约 28 KB 冗余全量源码压缩到贴合答案的体量,同时在 Excalidraw、Tokio、VS Code、Gin 等仓库上保持字节级不变的输出。读完本文,你将掌握这套骨架化门控(gate)的四个判定条件、两次 A/B 修正的来龙去脉、CODEGRAPH_ADAPTIVE_EXPLORE 逃生开关,以及 src/mcp/tools.tstests/adaptive-explore-sizing.test.ts 中对应的实现与回归测试。

背景:explore 的预算是"目标",不是"天花板"

codegraph_explore 是 codegraph MCP server 暴露给 Claude Code、Codex、Gemini、Cursor 等 Agent 的核心工具:一次调用聚合多个相关文件的源码片段、符号关系与调用链,让 Agent 不必反复 grep + Read。其输出由 src/mcp/tools.tsgetExploreOutputBudget()(约 L220)按项目文件数分档控制:小于 150 个文件的项目 maxOutputChars 为 13000,500 以下为 18000,5000 以上统一为 24000(且硬性上限 hardCeilingmin(maxOutputChars * 1.5, 25000),见 src/mcp/tools.ts#L4257——因为超过约 25K 字符的 tool result 会被宿主 Agent 外置成文件再 Read 回来,反而引入额外一次读)。

问题在于:预算内的填充策略是"按相关度排序,把每个相关文件的完整源码塞满预算"(小文件还有"整文件直出"规则:src/mcp/tools.ts#L4829WHOLE_FILE_MAX_LINES 为 220 行,中央文件 280 行)。当答案横跨许多"同形状"的类时,这个策略会付出巨大代价。

问题一图流:28 KB 里大多是冗余的拦截器

以基准问题"How does OkHttp process a request through its interceptor chain?"为例,答案涉及约 14 个 class … : Interceptor 实现。handleExplore 会返回:

OkHttp explore (shipped):  RealCall (full) + RealInterceptorChain (full)
                         + CallServerInterceptor (full, 8.7k)
                         + Bridge/Connect/Cache/… (full, ~4-5k each)   ← all ~same shape
                         = ~28k, most of it redundant interceptor bodies

Agent 真正需要的只有机制RealInterceptorChain.proceed 遍历链)+ 每个拦截器实现的契约 + 也许一个具体例子。其余五个完整方法体是填充物——但前提是它们"可互换"。对于扩散型问题(如 Excalidraw 渲染流水线 mutateElement → … → renderStaticScene),链外文件是不同的步骤,方法体承担真实工作,把它们省略只会让 Agent 从签名去反推(更多推理、净成本更高)。

所以核心命题只有一句:廉价地、结构地告诉"可互换 sibling"与"独立步骤"的区别。

为什么"共享超类型且 ≥3 个实现者"是正确信号

让 OkHttp 拦截器可互换的,恰恰是它们是同一接口的 N 个实现、被多态调用。这是图里以 implements/extends 边记录的结构性质:

14 classes ──implements──▶ Interceptor      (BridgeInterceptor, CacheInterceptor,
                                              CallServerInterceptor, … )

而 Excalidraw 的 renderStaticSceneSceneCollab 之间没有共享超类型——"≥3 实现者"的查询对它们返回空。该信号因此干净地分离了两类仓库。

≥ 3 阈值(源码常量 MIN_SIBLINGS = 3,见 src/mcp/tools.ts#L4201)很重要:1:1 的"服务接口 → 单实现"对(Spring/Java 中最常见的形态)不是 sibling,保持完整;只有真正的多实现家族(拦截器链、策略/访问者家族、编解码注册表)才触发门控。

门控(gate)四条件:一个文件何时被骨架化

一个文件被骨架化,当且仅当以下全部成立(且 CODEGRAPH_ADAPTIVE_EXPLORE 未设为 0/false;开关实现见 src/mcp/tools.ts#L786-L788adaptiveExploreEnabled()):

  1. 存在流程脊柱(spine)buildFlowFromNamedSymbols()src/mcp/tools.ts#L2549)从 Agent 查询中的具名符号解析出调用链,返回路径节点集 pathNodeIds 与全部具名可调用集 namedNodeIds。没有脊柱,就不骨架化任何文件。
  2. 链外(off the flow spine)。文件中没有符号落在追踪出的调用链上——那条链是 Agent 正在行走的机制,永远保留完整。
  3. 是"多态 sibling"。文件的类通过 implements/extends 边指向一个有 ≥3 个实现者的超类型。实现是 isPolymorphicSibling()src/mcp/tools.ts#L4203-L4217):遍历文件节点的出边,对每个 implements/extends 目标查入边计数,并用 siblingSuper Map 缓存"该超类型是否有多实现者",使整个检测只花少量边查询。
  4. 未被豁免(not spared)。文件被豁免(保留完整)当且仅当 Agent 在其中具名了一个可调用——具名的方法/函数是 Agent 要求"看一眼"的东西(如 getResponseWithInterceptorChainSQLCompiler.execute_sql),不是可互换的叶子——除非该文件自身定义了一个 ≥3 实现的超类型。最后这半句是 override:基类+子类的"家族文件"(Django 的 compiler.py,2266 行)又大又"迟早会被 Read",完整拷贝只会吃掉 explore 预算;骨架化它反而释放预算给那些 Agent 本来要 Read 的 sibling 文件。即:具名 ⇒ 豁免,除非它是家族文件 ⇒ 照样骨架化。

用两个基准仓库走一遍:

  • RealInterceptorChain——proceed 在脊柱上 → 完整保留(条件 2)。
  • RealCall——链外,且经由 9 实现的 Lockable mixin(而非因为它本身是可互换拦截器)触发了 sibling 信号。但 Agent 在其中具名了 getResponseWithInterceptorChain/execute/enqueue,且它没有定义任何 ≥3 实现的超类型 → 被豁免,完整保留(条件 4)。这就是 read-back 回归的修复点:在条件 4 之前它会被骨架化,Agent 随后 Read 了回来。
  • BridgeInterceptor 及其他 4 个——链外、≥3 实现 sibling、仅按类型名被提及、不定义超类型 → 骨架化。这就是收益来源。
  • Django compiler.py——链外、是 sibling(其子类 extends SQLCompiler)、Agent 在其中具名了 execute_sql——但它定义了 SQLCompiler 超类型,override 触发 → 骨架化(释放预算)。若反向豁免它(最初那次错误尝试),成本更高、Read 更多。

源码实现:豁免判定与"聚焦视图"渲染

门控的汇聚点在 src/mcp/tools.ts#L4677-L4698

const spareNamed = group.nodes.some(n => flow.uniqueNamedNodeIds.has(n.id));
const fileDefinesSuper = definesPolymorphicSupertype(group.nodes);
const spared = spareNamed && !fileDefinesSuper;
...
if (!fileStale && adaptiveExploreEnabled() && flow.pathNodeIds.size > 0
    && (onSpineGodFile || (!hasSpineNode && isPolymorphicSibling(group.nodes) && !spared))) {

注意三个细节:

  • 豁免读的是 uniqueNamedNodeIds 而非 namedNodeIdsbuildFlowFromNamedSymbols 只把 token 命中的"(近乎)唯一"可调用(全图 ≤3 个定义)放入 uniqueNamedNodeIdssrc/mcp/tools.ts#L2573-L2578)。这是"uniqueness-aware spare":as_sql 在 Django 的每个 Compiler/Expression 子类中有 110 个定义,具名它绝不能让每个后端变体保持完整(会淹没预算);而 getResponseWithInterceptorChain(1 个定义)仍豁免 RealCall
  • definesPolymorphicSupertype()src/mcp/tools.ts#L4230-L4243)检查文件节点中是否存在"入边 implements/extends 计数 ≥3"的类/接口/struct/trait/protocol 等,并用 superMany Map 缓存。它不是豁免条件,而是对具名豁免的 override
  • 脊柱上的 god-file 分支onSpineGodFile):当流程穿过一个同时持有大量其他具名方法的大文件(如 Alamofire 的 Session.swift),也进入 per-symbol 视图,保住脊柱完整、把离路径的具名方法折成签名,防止单文件撑爆整个响应预算。

骨架与聚焦视图的渲染

进入分支后按 per-symbol 两遍处理(src/mcp/tools.ts#L4702-L4770):

  • 第一遍选体:按优先级 0=脊柱符号、1=唯一具名、2=本文件定义家族超类型时的共名方法、99=其他 贪心分配完整方法体,总字节受 bodyCap = min(allowance, fundedHeadroom) 约束——即这个文件的预算预留(score-proportional 分配见 allocateExploreBudget()src/mcp/tools.ts#L674)。这样 SQLCompiler.execute_sql/as_sql 保住完整体,同文件另外约 80 个符号与冗余子类只剩签名,不会 Read-back。
  • 第二遍渲染:按行序输出选中的完整体;其余符号输出签名行。由于符号节点的 startLine 可能指向装饰器/注解(@Throws@Override@objc),渲染时向前扫描最多 4 行找到真正"给符号命名"的那一行(src/mcp/tools.ts#L4753-L4758),确保骨架展示的是真实签名;签名数受 SIG_MAX 上限并附 … +N more (signatures elided) 尾注。

输出示例(文档中的真实渲染):

#### …/CallServerInterceptor.kt — CallServerInterceptor, intercept, … · skeleton (signatures only; Read for a full body)
```kotlin
30  object CallServerInterceptor : Interceptor {
32  override fun intercept(chain: Interceptor.Chain): Response {
194 private fun shouldIgnoreAndWaitForRealResponse(code: Int): Boolean =

文件头仍列出该文件的符号清单。源码中当前的引导语已改为(src/mcp/tools.ts#L4781-L4783):有完整体时标 focused (the methods you named in full, the rest as signatures — codegraph_explore a signature by name for its body; do NOT Read),纯骨架时标 skeleton (signatures only — codegraph_explore a name for its full body; do NOT Read)——永远把 Agent 引向再次 codegraph_explore,而非 Read:旧的"Read for a full body"措辞曾诱导 Agent 去 Read 刚被骨架化的文件,在中心文件上触发过度挖掘螺旋。

验证:两次 A/B 修正与成本数据

验证采用 headless claude -p(Opus 4.8),WITH vs WITHOUT CodeGraph 的真实基准臂(而非第一版所用的确定性探针),成本取 total_cost_usd 中位数:

仓库 WITH→WITHOUT 成本 WITH reads WITHOUT reads RealCall/compiler read-back
OkHttp (n=4) $0.45 → $0.50(约省 10%) 2 3.5 0 / —(RealCall 完整)
Django (n=6) $0.56 → $0.63(约省 10%) 2 8.5 一半运行 0 reads

两者此前都是基准中的成本离群点(OkHttp 贵 3%、Django 贵 10%),修正后都翻转为明确胜出。WITHOUT 基线与基准表吻合($0.50/$0.63 对 $0.57/$0.64),说明增益来自 WITH 臂的改善。决定性的检查"以正确的理由通过":有了具名可调用豁免后,RealCall 保持完整且从未被 Read back(修复前 4 次运行中 3 次被 Read back)。惰性仓库(Excalidraw / Tokio / VS Code / Gin)保持 0 骨架——由探针验证——因为修正后的门控骨架化的是原始门控的严格子集(只增加豁免条件),这些仓库的流中不存在链外 ≥3 实现者的 sibling 组。

2026-05-29 修正史:从"整文件"到"聚焦视图"

设计文档完整记录了两次真实 Agent A/B 暴露的回归,这部分是本文最有价值的经验沉淀:

  1. read-back 回归。初版门控只看"链外 + 多态 sibling",结果骨架化了两个 Agent 随后 Read 回来的文件:OkHttp 的 RealCall(它实现了 9 实现的 Lockable mixin,虽是编排者也触发了 sibling 信号)与 Django 的 compiler.py(它定义 SQLCompiler 并同文件安置子类)。修复即上文条件 4 的"具名豁免 + 家族 override"。代价从 OkHttp 贵 3% → 便宜约 10%(RealCall 完整、0 read-back),Django 贵 10% → 便宜约 10%(compiler.py 骨架化释放 28 KB 预算中约 6.5 KB;一半运行 0 reads 即出答案)。注意:超类型信号最初被用作豁免——方向反了,把 Django 回归到贵 9%(饿死预算);现改为对具名豁免的 override
  2. per-symbol 聚焦视图 + 具名簇存活。整文件骨架/豁免对 Django 仍然太粗:Agent 还是 Read 回了 compiler.py(折叠后 execute_sql/as_sql 方法体被省略)和 query.py(非 sibling 大文件,其 _fetch_all 簇被裁掉)。四项改动把两个仓库从约省 9–10% 推到省 14–17%、中位 0 reads
    • Uniqueness-aware 豁免(如前文所述,as_sql 有 110 个定义,具名它不得保持每个后端变体完整);
    • per-symbol 聚焦视图:折叠的家族文件中,脊柱上 / 唯一具名 / 基类超类型的方法显示完整方法体,其余只显示签名;
    • 测试文件全档排除custom_lookups/tests.py 曾吃掉 Django 28 KB 预算中的 2.3 KB——测试很少回答架构问题(此前仅 <500 文件档排除测试文件);
    • 非 sibling 文件中的具名簇存活:把 Agent 具名的方法定义注入文件簇(即使 gather 漏掉),按重要性 9 排序,并把簇选择上限取 min(per-file, remaining-total),使高重要性具名簇存活而非被源序裁剪(Django 的 _fetch_all,L2237,是四个大文件中最晚输出的一个)。 对照保持:OkHttp 便宜 14% / 0 RealCall read-back;Excalidraw 便宜 31% / 0 reads(其大文件最先输出,预算上限从不约束它,god-file 聚类不受影响);OkHttp 的拦截器保持纯签名骨架(其中无具名可调用、不定义超类型)。

死路清单(不要重走)

设计文档专门列出 6 条被验证失败的路线,对后续演进极有参考价值:

  1. 降级/排低价值文件(如扩大 isLowValuePath 丢弃 *-testing-support/ 夹具):改善内容质量但不减体积——explore 会用其他完整方法体回填释放的预算(28,478 → 28,424 字符)。排序 ≠ 收缩;必须骨架化才能收缩。
  2. 以入口节点成员资格为门控:精确的符号包 explore 查询具名了链上每个参与者,它们全是"入口节点"——没有区分度,什么都不会骨架化。
  3. 依赖 interface-impl 合成器边synthesizedBy:'interface-impl')作为 sibling 信号:OkHttp 的 Interceptor 是 Kotlin fun interface,根本没有为它生成合成边——信号必须来自真实 implements/extends 边。
  4. 朴素"core-floor"门控(前 N 个保留完整、其余骨架化):骨架化了 Excalidraw 的独立步骤+17% 成本回归。sibling 条件正是让它安全的那部分。
  5. 因为"定义了超类型"就豁免文件(第一次修正尝试):方向反了,Django 回归到贵 9%($0.71)。定义超类型应是对具名家族文件的override(让它照样骨架化)。
  6. 只靠确定性探针查询验证骨架化:探针(scripts/agent-eval/probe-explore.mjs 的符号包查询)与 Agent 的真实 explore 查询具名符号不同,会形成不同脊柱、骨架化不同文件。探针说"Django:0 骨架 / reads 持平",真实 Agent 查询却骨架化了 compiler.py 并 Read 回来。永远用真实 Agent A/B(scripts/agent-eval/run-all.sh)确认,而非仅靠探针。

回归测试:七个用例守住两条回归轴

tests/adaptive-explore-sizing.test.ts 用"OkHttp 拦截器链缩影"作为夹具:一个 4 实现的 Interceptor 接口(≥3 ⇒ sibling 家族)、一条 dispatch → proceed → handleLogging 的 3 跳调用脊柱穿过 LoggingInterceptor(链上范例)、链外的 Bridge/Cache/RetryInterceptor(骨架化对象)、一个仅 1 实现的 Formatter 接口(<3 ⇒ 独立步骤),外加 AuthInterceptor(具名可调用豁免,对应 RealCall)与 Codec 基类+3 子类同文件(家族 override,对应 compiler.py)。七个用例分别守住:

  • 夹具健全性(Interceptor ≥3 实现、Formatter <3——整个门控的信号源,若 TS 抽取行为变化应在此响亮失败);
  • 链外 sibling 骨架化:SKELETON_MARK· skeleton (signatures only)出现、签名行存活、方法体标记(如 BRIDGE_BODY_MARKER)消失;
  • 链上范例即使也是 sibling 也保持完整;
  • 独立步骤(超类型 <3 实现)保持完整;
  • CODEGRAPH_ADAPTIVE_EXPLORE=0 禁用骨架化(siblings 完整渲染);
  • 具名可调用豁免(auth-interceptor.ts 完整、同族 bridge-interceptor.ts 仍骨架化);
  • 家族文件折叠为聚焦视图· focused 标记:具名的基类方法 Codec.encode 保住方法体,非具名子类 XmlCodec 只剩签名)。

边界与未来工作

设计文档明确划出了当前机制的边界:

  • 非接口型 sibling 家族未被覆盖:Go 的 HandlerFunc 切片、函数指针注册表没有 implements/extends 边,门控抓不到——例如 Gin 的中间件链(handlers 是函数而非接口实现)不触发骨架化。
  • 无链上拦截器时的范例选择:今天所有 sibling 都骨架化、Agent 依赖接口契约作答;强制展示一个作范例或许可读性更好(未测试)。
  • 非 sibling 大文件是 Django 残余 reads 的主要来源query.py(3040 行)与 sql/query.py 不是多态家族,骨架化够不到;这是 explore 预算 / 大文件聚类的边界问题,而非骨架化问题。

关键路径速查

主题 位置
设计文档(含完整 A/B 修正史与死路清单) docs/design/adaptive-explore-sizing.md
开关 adaptiveExploreEnabled()CODEGRAPH_ADAPTIVE_EXPLORE=0/false 关闭,默认开) src/mcp/tools.ts#L786-L788
buildFlowFromNamedSymbols()(spine + namedNodeIds/uniqueNamedNodeIds src/mcp/tools.ts#L2549
MIN_SIBLINGS / isPolymorphicSibling() / definesPolymorphicSupertype() src/mcp/tools.ts#L4201-L4243
门控汇聚 + per-symbol 聚焦/骨架渲染 + 引导语 src/mcp/tools.ts#L4659-L4805
输出预算分档(13K/18K/24K)与 25K 内联上限 src/mcp/tools.ts#L220-L309L4257
回归测试(7 用例) tests/adaptive-explore-sizing.test.ts
验证脚本(探针与真实 Agent A/B) scripts/agent-eval/probe-explore.mjsscripts/agent-eval/run-all.sh

适用前提与限制:该特性针对 codegraph_explore 的输出裁剪,依赖图中存在真实的 implements/extends 边(Kotlin fun interface 等无合成边覆盖的场景需由提取器直接产出结构边);骨架化只在调用链脊柱成功构建时发生;对无多态家族的仓库(Excalidraw、Tokio、VS Code、Gin)输出与关闭时字节级一致。如需回退行为,设置环境变量 CODEGRAPH_ADAPTIVE_EXPLORE=0 即可。

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