首页
/ Codegraph 实践:codegraph_explore 的跨调用会话状态与源码去重设计(CG-17 / CG-18)

Codegraph 实践:codegraph_explore 的跨调用会话状态与源码去重设计(CG-17 / CG-18)

2026-09-04 19:44:42作者:平淮齐Percy

Codegraph 的 codegraph_explore 工具原本对每一次调用都"当作第一次"来应答,同一 MCP 会话内的第 4 次调用会原封不动地重发第 1 次已经交付过的源码主干。本文基于 Codegraph 仓库的设计文档 docs/design/explore-session-dedup.md,完整拆解它的两层修复:记录"本会话已交付过什么"的状态层(CG-17,src/mcp/explore-session-state.ts),以及建立在它之上的跨调用源码去重(CG-18,src/mcp/explore-dedup.ts)——从数据结构、四条硬约束、五个准入门到字节回收的完整设计,读完你可以掌握一套"有状态 MCP 工具"的会话级记账与去重实现范式。

问题背景:无状态应答为什么会烧掉整个预算

codegraph_explore 没有任何跨调用的记忆:它不知道这个会话里自己已经发送过哪些文件、哪些行。设计文档引用的 #1500 报告描述了这个后果——在一个"tier 预算只允许 2 次调用"的项目上,Agent 发起了 4 次 explore 调用,而重复交付的字节毫无价值;更糟的是,tier 的调用预算只能以"文字恳求"的方式写在回复里,Agent 经常无视。

设计文档把修复拆成三个消费者:

  • CG-17 状态层:记录每次 explore 调用交付了什么(本文主角);
  • CG-18 跨调用去重:基于记录,把重复的源码替换成"回指"(back-reference);
  • CG-19 预算衰减:超出 tier 调用预算后逐步压缩响应(状态层的另一个消费者,本文不展开)。

值得注意的是:状态层本身不改变任何一次响应的内容,它是纯粹的账本;改变响应的只有 CG-18。

状态层(CG-17):每次会话、每个项目记一笔

数据结构:记什么

每个 MCP 会话持有一个 ExploreSessionState 实例;实例内部按解析后的项目根目录(resolved project root)分桶。每个桶里记两类信息:

  1. 累计计数器callCount / responseBytes):该会话针对该项目已应答的每次 explore 调用,包括被逐出(evict)的部分;
  2. 近期调用明细calls[]):每一次近期调用一条记录,包含——
    • 归一化后的 query 文本(normalizeQuerySpelling 之后);
    • 逐文件输出的行区间(1 基、闭区间,src/mcp/explore-session-state.ts 中的 ExploreLineRange);
    • 内容指纹fingerprint):这些行区间是从哪一份字节上切出来的,用于 CG-18 的"内容门";
    • 源码字符数(sourceBytes)、响应总字符数(responseBytes);
    • 该调用在"本会话 × 本项目"范围内的 1 基序号(index),且该序号在明细被逐出后依然单调递增。

对应源码类型如下(src/mcp/explore-session-state.ts):

export interface ExploreFileEmission {
  path: string;              // 项目相对路径,与响应文件头拼写一致
  ranges: ExploreLineRange[]; // 合并后的行区间
  bytes: number;             // 本文件输出的源码字符数(不含文件头/围栏)
  fingerprint?: string;      // 字节身份;缺失 = 不可证明,去重按"重发"处理
  rangesTruncated?: boolean; // 区间被上限裁剪时置位
}

export interface ExploreCallRecord extends ExploreEmission {
  index: number; // 本会话内该项目的 1 基调用序号,逐出后仍保持
}

区间从哪里来:让渲染循环自己汇报

文档特别强调:行区间来自渲染循环本身——buildSection 在返回文本的同时返回它切出的 span;整文件 / 聚焦 / 骨架这几条渲染路径也在把源码推入响应的那一刻上报自己的 span。原因很实际:如果另写一个函数去"镜像"窗口与 padding 规则,两边必然发生漂移;而这里的漂移不是对称的——多记了区间会让后续调用扣留 Agent 从未见过的源码(见后文"哪种错法更安全")。

另外两条记录规则同样关键(在 src/mcp/tools.ts 的收尾处实现):

  • 只有最终硬上限截断后幸存的文件才入记录。被天花板丢弃的 section 从未送达 Agent,若记入,后续调用就会去扣留一份 Agent 根本没拿到的源码;
  • 被回指(back-referenced)的文件以 0 字节记录其 span。记录的含义是"Agent 持有该文件的这份源码",而不是"本次调用花了多少字节";0 字节记账防止长会话中这个 span 老化出保留窗口、被白白重发。

四条约束:每条排除什么实现

约束 原因 排除掉的方案
会话内有效,永不持久化 新 Agent 什么都没看到过 按项目为键的磁盘缓存
解析后的项目根分桶 一个会话可以用 projectPath 查询多个项目 按 Agent 敲入的路径做键——/repo/repo/internal 是同一个项目
内存有界 会话可能持续数小时 calls[] 无限增长
Daemon 安全 一个 daemon 对所有已连接客户端共享同一个 ToolHandler 和一个 worker 线程池 把状态放在 handler 上、worker 里、或模块级单例

其中"daemon 安全"是最尖锐的一条。从源码结构看,daemon 模式下所有客户端会话共享一个 ToolHandler 实例(src/mcp/tools.ts),状态若挂在 handler 上,两个 Agent 的历史会混在一起,建立在混合历史之上的去重就会向"从未见过"某段源码的 Agent 扣留它——迫使它发起一次 Read,恰好是这个功能要消灭的失败。

因此状态挂在 MCPSession 上(src/mcp/session.tsprivate readonly exploreSession = new ExploreSessionState()),与 socket 同生共死。跨线程的管道设计如下(源自设计文档,两侧均已在 src/mcp/tools.ts 中实现):

MCPSession(拥有状态)
  └─ ToolHandler.execute(tool, args, sessionState)
       ├─ 下行:session view 挂到 args 上     (可被 structured clone → worker)
       └─ 上行:emission 挂到 result 上        (可被 structured clone ← worker)
            └─ 在主线程记账,然后从 result 上删除

两个方向都用普通属性承载(_cgExploreSession_cgExploreEmission,定义见 src/mcp/explore-session-state.ts),因为任一侧都可能跨越 worker 边界,而闭包或 handler 字段跟不过去。几个防御性细节值得注意:

  • emission 在 execute 中被无条件剥离takeExploreEmission)——包括 CLI 这类不记账的调用方,所以 Agent 收到的响应字节级不变;
  • 记账被 try/catch 包住:簿记 bug 绝不允许弄挂一次已经成功的工具调用;
  • 客户端自己拼写出来的 view 会被丢弃而不是信任withSessionViewdelete 再重新注入)——因为 view 决定了后续调用可以扣留什么,它必须只来自服务端自己的记录。

有界性:上限只限"明细",不限"计数"

EXPLORE_SESSION_LIMITS 给出的五个边界:

export const EXPLORE_SESSION_LIMITS = {
  MAX_PROJECTS: 4,        // 每会话保留的项目数,LRU 逐出
  MAX_CALLS_RETAINED: 8,  // 每项目保留的调用记录数
  MAX_FILES_PER_CALL: 24, // 每调用保留的文件数(源码最多的那些)
  MAX_RANGES_PER_FILE: 24, // 每文件合并后保留的区间数(最大的那些)
  MAX_VIEW_CALLS: 4,      // 交给单次调用的 view 里最近几次调用
} as const;

这些数值是按真实会话行为定尺寸的:Agent 通常探索一个项目(monorepo 时偶尔第二个),tier 调用预算是 1–5 次,所以保留窗口能覆盖整个正常会话,上限只在病态会话上生效。

关键是:每个边界都只限"明细"callCountresponseBytes 在逐出之后继续累计——CG-19 的衰减读的是这个计数,如果某个边界把它重置了,衰减就会每 8 次调用自复位一次。

逐出语义上,项目级 LRU 直接丢弃整个项目(而不是它的明细):一个已经转向另外四个仓库的会话,不太可能再回头问第一个。

哪种错法更安全:宁少记,不多记

当边界迫使取舍时,记录少记区间,绝不多记

  • 少记(under-report)→ 后续调用重发一份 Agent 已经有的源码。浪费,但无害;
  • 多记(over-report)→ 后续调用扣留一份 Agent 从未见过的源码。Agent 只好去 Read 这个文件,而一次 Read 的花费超过去重省下的一切字节

这个偏置落在三处具体实现上(coalesceRanges 及相关路径):

  1. 合并区间触顶时,coalesceRanges 丢弃最小的 span(保留最大的,然后重新按行号排序,使结果仍可读为自上而下),并置位 rangesTruncated
  2. 非法 span(非有限数、start > endstart < 1)被丢弃而不是夹取(clamp);
  3. 被硬上限截断过的文件 section 根本不入记录(前文已述)。

跨调用去重(CG-18):指针,而非静默省略

设计目标:永远给指针

如果一个调用要重发某次更早调用已经交付过的源码,就改发一个指针(back-reference)。文档开宗明义地拒绝"静默省略":让 Agent 感觉"不够"的响应恰恰是驱使它去 Read 的东西,而会话早期一两次这样的体验会教它整个放弃 codegraph。

指针因此携带让那份拷贝"可用"的全部要素——文件、符号、行 span,以及两个事实:它来自本次对话,且文件自那以后未变。实际渲染形态(见 formatBackReference):

**`internal/usecase/payroll/cycle.go`** — Cycle, PayslipsForCycle, Service, …

> **Already sent earlier in this conversation:** `internal/usecase/payroll/cycle.go`
> L42-76, L78-215 (Cycle, PayslipsForCycle, Service, +6 more) — unchanged on disk since,
> so that copy is still exact. Only the NEW lines are shown below; scroll back for the
> rest. Do NOT Read this file.

指针措辞有严格的测试约束(tests/explore-cross-call-dedup.test.ts 中 "the back-reference itself" 一节):必须点名文件、span、符号;永不说 "omitted";永不把 Agent 引向 Read。

同一约定还在另外两处声明:一是作为响应中"逐字源码"保证的例外条款内联追加(与 #1474 处理 drift 的形态一致);二是写进 src/mcp/server-instructions.ts——"'Already sent earlier in this conversation' is a pointer, not a gap",指导 Agent 回卷上下文找那份拷贝而不是重新获取。

五个门(Gate)

规则
会话门 项目在本会话的首次调用上去重关闭——还没有任何东西可指
内容门 仅当文件当前仍哈希为当初切片所用的字节时,span 才被扣留
尺寸门 只有被覆盖且长度 ≥ MIN_COVERED_LINES(8 行)的连续段才替换
余量门 新增源码不足 MIN_DELTA_CHARS(160 字符)时,折叠进指针而不是自成代码围栏
开关 CODEGRAPH_EXPLORE_DEDUP=0 让所有调用按"会话无历史"渲染

源码中对应 EXPLORE_DEDUP 常量表,另有两个指针排版上限:MAX_SPANS_IN_POINTER: 4(再多以 +N more 收口)、MAX_SYMBOLS_IN_POINTER: 5

内容门是重点,且它不是索引的 drift 标志。 去重的门是一个内容指纹length:sha1前缀fileFingerprint,逐文件逐调用记录),回答的是"这份源码和 Agent 手里那份逐字节相同吗";而索引的 isFileStaleOnDisk 回答的是"文件自上次索引同步以来变过吗"——两个问题的答案可以不同:

  • 两次调用落在同一个 drift 窗口内:两次服务的是同一份当前字节 → 去重正确(尽管 drift 标志可能已置位);
  • 文件在两次调用之间被编辑且重新同步:它永远"不 stale",但 Agent 手里的拷贝已经错了 → 去重会主动有害

所以 #1474 的 drift 处理在上游且未改动——drift 文件要么整份发出,要么整份不发。另外,指纹带上长度前缀,是为了防止两个不同大小的文件撞上 16 位哈希前缀。无指纹的记录(fingerprint 缺失)在 servedRangesForFile 中被忽略而非信任——无法证明匹配,就按"重发"处理。

尺寸门的存在是因为指针句本身约 140 字符。 若用指针替换一行签名或 cluster padding 的 ±3 行,响应会变大且读起来千疮百孔。dedupeRange 的实现把低于 8 行的被覆盖段留在输出集里——一个"几乎全持有"的 span 会整份重发,而不是碎成一圈指针。MIN_DELTA_CHARS(160)是设计中唯一扣留 Agent 未见过的内容的地方:文档给出的形状是"第三次调用中唯一未持有的行只是文件尾部空行,渲染成一个含 228\t 的代码围栏"——一个装着两行空白的围栏读起来像坏响应,而"读起来像坏的"是最贵的失败。它被限定在"约两行、紧贴 Agent 已持有源码"的范围内,且文件仍会以符号名被点名,一次后续 explore 即可整份取回。

区间代数

去重判断建立在四个纯函数上,全部在 src/mcp/explore-dedup.ts

  • mergeRanges:排序 + 合并重叠/相邻 span(相邻 next.start <= cur.end + 1 也视为重叠——两个挨着的 span 描述的是同一块连续源码);
  • intersectRange:本次想发的 span 中"已被覆盖"的部分;
  • subtractRange:减去被覆盖部分后剩余的待输出 span;
  • dedupeRange:组合前三者,输出 { emit, covered } 二分——emit 是现在要渲染的(一切"未被证明已持有"的部分),covered 是被回指替换的部分。

渲染循环中,每条渲染路径(整文件 / 聚焦 / 骨架 / cluster)的 span 都经过同一入口 dedupeSpanssrc/mcp/tools.ts 附近),那里是 span 被丢弃的唯一地点。

回收的字节去了哪里

两条通道,都指向 Agent 没看过的文件:

  1. sourceSpent:被去重的文件花费更少,CG-21 的 carry-forward 池把差额沿排名顺序传给后面的文件,其后每个文件的 headroom 都变大;
  2. maxFiles 槽位:被完全回指的文件不占用一个文件槽(与"cliffed"文件同等待遇),于是原本塞不下的文件现在能渲染出来。

还有一个细节:文件内部的 shrink 决策读的是去重后的长度——若按原始尺寸收缩 cluster,就会为了给"根本没打算发送"的源码腾地方而砍掉新符号。

设计文档明确了一个反直觉的取舍:"花掉"而非"存下"回收字节。这保证响应总字节数不变,但其中 Agent 从未见过的份额上升——也正是 CG-20 实测"残余上下文占用持平"的原因:一个花掉每一分回收字节的设计降不了字节数,它降的是这些字节里的重复份额(CG-20 的 agent 运行中为 −87%)。文档给出的两组配对 3 次调用回放数据:client-go 44,740 → 46,957 唯一源码字符(响应大 3.2%),excalidraw 39,575 → 43,973 唯一字符(响应反而小 5.4%)。文档同时提醒:若目标某天改写成"更少字节",这是唯一一条要反转的规则——因为"存字节"会让重复调用返回严格更少的内容,需要自己独立的"放弃门"。

最后一条诚实的度量说明:dedup.savedChars 是截断前(pre-clip)的数字——它统计的是去重从未裁剪候选渲染中压掉了多少,而不是最终留在窗口外的量;被压掉的 range 大部分本来就会被预算裁掉。client-go 实测:报告 11,450,基线实际重发只有 1,042 字符。应把它读作"排序本打算发出多少重复",绝不能当"省了多少"——过度解读会把收益放大约 7 倍。

全指针守卫:防止"读起来像什么都没找到"

如果去重把一切压掉、又没有新内容补进来,响应就只剩指针——这个形状会被 Agent 读成"codegraph 什么都没找到"。

实现上,渲染循环把第一个被完全压制的文件的真实 section 留在手里(suppressedFallbacksrc/mcp/tools.ts 中的 "Anti-abandonment hold-back (CG-18)"),当循环结束时新源码字符数为零,就把它的指针块换回真实 section。代价是:在最容易全省的那一种调用形状上,重发一个文件。这是安全方向,也是为什么"跨调用不出现重复 range"这条不变式对"所有有新内容可说的调用"成立,而非无例外成立。

CG-20 在真实 Agent 上验证过这个门:client-go 与 excalidraw 两个项目、双组 codegraph-on 对照,n=3 与 n=6。24 次运行 Read = 0,无 isError,每次运行 codegraph 都在最后,两个臂上"Read 了我们返回的文件" / "Read 了我们没返回的文件"两个桶都为空,回指在 9 次多调用运行中的 8 次被证明送达了 Agent。守卫在真实查询中从未触发——那 21 次调用中最薄的一次也携带 12,011 字符新源码,newSourceChars === 0 从未在真实场景出现,这个阈值在野外仍属未测。完整数据在 docs/benchmarks/explore-dedup-ab-cg20.md

可观测性:CG-4 诊断里的 session 块

CG-4 诊断(CODEGRAPH_EXPLORE_DEBUG,机制见 docs/design/explore-budget-allocation.md)在每份报告上携带一个 session 块(数据结构 ExploreDiagnosticSession,渲染 src/mcp/explore-diagnostics.ts):

  session call #2 for this project · 1 prior call · 18,204 chars already served
    already served internal/usecase/payroll_cycle.go · 4,928 chars · L1-159
  • callIndex:本次调用在会话中的位置;
  • priorFiles:按文件联合(union)已交付区间,最近一次调用在前;
  • 该块在"调用方不记账"时是整体缺席而非清零——这是让"未追踪"与"被追踪会话的首次调用"两种情况保持可区分的方式(对应 viewForProjectnull 与"空状态"的语义区别:null = 没有任何人在追踪,如 CLI;空状态 = 追踪中但本项目还没被查过)。

去重本身也通过同一诊断上报:dedup.savedChars、逐文件 dedupSavedChars / dedupCoveredrender: 'backref'

测试与验证

tests/explore-session-state.test.ts 分三层:

  1. 容器层:键规则(解析 + 平台相关大小写折叠,见 exploreProjectKey)、逐出后序号仍单调、每条边界的行为;
  2. handler 缝隙:对真实索引的真实 explore 记录真实区间;一个会话的首次调用与不追踪时的响应字节级相同;同一 handler 上两个状态互不串扰;
  3. 会话缝隙:同一 engine 上两个 MCPSession 各持各的状态,且每次调用携带的是它自己会话的状态。

tests/explore-cross-call-dedup.test.ts 覆盖去重本体:区间代数与阈值、指纹门(被编辑过的文件重发;不可证明的记录被忽略)、指针措辞(点名文件/span/符号,永不说 "omitted",永不引向 Read),然后是缝隙测试——真实的第二次调用不再重发第一调用发过的任何一行,且带着 >20 行第一调用从未发过的新源码回来(证明是预算回收而非响应缩水),无论会话已持有多少内容响应总是含真实源码,并在 CODEGRAPH_EXPLORE_DEDUP=0 下整体关闭。

还有两件事 vitest 覆盖不到,文档记录为对 dist/ 的手工验证:

  • worker 路径——挂上 QueryPool 后,emission 经受住从 worker 回来的 structured clone,在主线程完成记账,且不出现在结果里;
  • 同一会话中两个真实不同的项目——在 vitest 内打开第二个索引会因惰性的 require('../index') 失败,所以测试套件内的替身用一个项目、两条路径(裸调用,以及指向子目录的 projectPath)断言两者落入同一个桶;多项目键控本身在容器层被覆盖。

小结

这套设计的取舍高度统一,可以用三条原则概括:

  1. 状态跟着会话走,不跟着进程走——会话即生命期,daemon 多客户端共享 handler 的现实决定了状态只能挂在 MCPSession 上,用两个可序列化的普通属性穿越 worker 边界;
  2. 宁少记不多记——所有边界裁剪都偏向"少知道",因为多记一次就等于逼 Agent 多付一次 Read;
  3. 只按可证明的字节去重——内容指纹而非 drift 标志、无指纹即忽略、全指针守卫兜底,共同保证 Agent 上下文里永远有一份"逐字精确、随时可回卷"的源码。

想继续深入相关设计,可参见仓库中的 docs/design/explore-budget-allocation.md(CG-4/CG-21 预算分配与 carry-forward)、docs/benchmarks/explore-dedup-ab-cg20.md(CG-20 A/B 实测)以及 docs/benchmarks/residual-context-occupancy.md(重复份额度量)。

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

项目优选

收起
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++
904
1.82 K
docsdocs
暂无描述
Markdown
889
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.52 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