CodeGraph 自适应 explore 缩放:sibling 骨架化如何让 codegraph_explore 按答案裁剪输出而非填满预算
本文基于 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.ts 与 tests/adaptive-explore-sizing.test.ts 中对应的实现与回归测试。
背景:explore 的预算是"目标",不是"天花板"
codegraph_explore 是 codegraph MCP server 暴露给 Claude Code、Codex、Gemini、Cursor 等 Agent 的核心工具:一次调用聚合多个相关文件的源码片段、符号关系与调用链,让 Agent 不必反复 grep + Read。其输出由 src/mcp/tools.ts 中 getExploreOutputBudget()(约 L220)按项目文件数分档控制:小于 150 个文件的项目 maxOutputChars 为 13000,500 以下为 18000,5000 以上统一为 24000(且硬性上限 hardCeiling 取 min(maxOutputChars * 1.5, 25000),见 src/mcp/tools.ts#L4257——因为超过约 25K 字符的 tool result 会被宿主 Agent 外置成文件再 Read 回来,反而引入额外一次读)。
问题在于:预算内的填充策略是"按相关度排序,把每个相关文件的完整源码塞满预算"(小文件还有"整文件直出"规则:src/mcp/tools.ts#L4829 的 WHOLE_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 的 renderStaticScene、Scene、Collab 之间没有共享超类型——"≥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-L788 的 adaptiveExploreEnabled()):
- 存在流程脊柱(spine)。
buildFlowFromNamedSymbols()(src/mcp/tools.ts#L2549)从 Agent 查询中的具名符号解析出调用链,返回路径节点集pathNodeIds与全部具名可调用集namedNodeIds。没有脊柱,就不骨架化任何文件。 - 链外(off the flow spine)。文件中没有符号落在追踪出的调用链上——那条链是 Agent 正在行走的机制,永远保留完整。
- 是"多态 sibling"。文件的类通过
implements/extends边指向一个有 ≥3 个实现者的超类型。实现是isPolymorphicSibling()(src/mcp/tools.ts#L4203-L4217):遍历文件节点的出边,对每个implements/extends目标查入边计数,并用siblingSuperMap 缓存"该超类型是否有多实现者",使整个检测只花少量边查询。 - 未被豁免(not spared)。文件被豁免(保留完整)当且仅当 Agent 在其中具名了一个可调用——具名的方法/函数是 Agent 要求"看一眼"的东西(如
getResponseWithInterceptorChain、SQLCompiler.execute_sql),不是可互换的叶子——除非该文件自身定义了一个 ≥3 实现的超类型。最后这半句是 override:基类+子类的"家族文件"(Django 的compiler.py,2266 行)又大又"迟早会被 Read",完整拷贝只会吃掉 explore 预算;骨架化它反而释放预算给那些 Agent 本来要 Read 的 sibling 文件。即:具名 ⇒ 豁免,除非它是家族文件 ⇒ 照样骨架化。
用两个基准仓库走一遍:
RealInterceptorChain——proceed在脊柱上 → 完整保留(条件 2)。RealCall——链外,且经由 9 实现的Lockablemixin(而非因为它本身是可互换拦截器)触发了 sibling 信号。但 Agent 在其中具名了getResponseWithInterceptorChain/execute/enqueue,且它没有定义任何 ≥3 实现的超类型 → 被豁免,完整保留(条件 4)。这就是 read-back 回归的修复点:在条件 4 之前它会被骨架化,Agent 随后 Read 了回来。BridgeInterceptor及其他 4 个——链外、≥3 实现 sibling、仅按类型名被提及、不定义超类型 → 骨架化。这就是收益来源。- Django
compiler.py——链外、是 sibling(其子类extendsSQLCompiler)、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而非namedNodeIds。buildFlowFromNamedSymbols只把 token 命中的"(近乎)唯一"可调用(全图 ≤3 个定义)放入uniqueNamedNodeIds(src/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 等,并用superManyMap 缓存。它不是豁免条件,而是对具名豁免的 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 暴露的回归,这部分是本文最有价值的经验沉淀:
- read-back 回归。初版门控只看"链外 + 多态 sibling",结果骨架化了两个 Agent 随后 Read 回来的文件:OkHttp 的
RealCall(它实现了 9 实现的Lockablemixin,虽是编排者也触发了 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。 - 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 的拦截器保持纯签名骨架(其中无具名可调用、不定义超类型)。
- Uniqueness-aware 豁免(如前文所述,
死路清单(不要重走)
设计文档专门列出 6 条被验证失败的路线,对后续演进极有参考价值:
- 降级/排低价值文件(如扩大
isLowValuePath丢弃*-testing-support/夹具):改善内容质量但不减体积——explore 会用其他完整方法体回填释放的预算(28,478 → 28,424 字符)。排序 ≠ 收缩;必须骨架化才能收缩。 - 以入口节点成员资格为门控:精确的符号包 explore 查询具名了链上每个参与者,它们全是"入口节点"——没有区分度,什么都不会骨架化。
- 依赖 interface-impl 合成器边(
synthesizedBy:'interface-impl')作为 sibling 信号:OkHttp 的Interceptor是 Kotlinfun interface,根本没有为它生成合成边——信号必须来自真实implements/extends边。 - 朴素"core-floor"门控(前 N 个保留完整、其余骨架化):骨架化了 Excalidraw 的独立步骤 → +17% 成本回归。sibling 条件正是让它安全的那部分。
- 因为"定义了超类型"就豁免文件(第一次修正尝试):方向反了,Django 回归到贵 9%($0.71)。定义超类型应是对具名家族文件的override(让它照样骨架化)。
- 只靠确定性探针查询验证骨架化:探针(
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-L309、L4257 |
| 回归测试(7 用例) | tests/adaptive-explore-sizing.test.ts |
| 验证脚本(探针与真实 Agent A/B) | scripts/agent-eval/probe-explore.mjs、scripts/agent-eval/run-all.sh |
适用前提与限制:该特性针对 codegraph_explore 的输出裁剪,依赖图中存在真实的 implements/extends 边(Kotlin fun interface 等无合成边覆盖的场景需由提取器直接产出结构边);骨架化只在调用链脊柱成功构建时发生;对无多态家族的仓库(Excalidraw、Tokio、VS Code、Gin)输出与关闭时字节级一致。如需回退行为,设置环境变量 CODEGRAPH_ADAPTIVE_EXPLORE=0 即可。
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 StartedRust0624
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