Codegraph 实践:codegraph_explore 的跨调用会话状态与源码去重设计(CG-17 / CG-18)
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)分桶。每个桶里记两类信息:
- 累计计数器(
callCount/responseBytes):该会话针对该项目已应答的每次 explore 调用,包括被逐出(evict)的部分; - 近期调用明细(
calls[]):每一次近期调用一条记录,包含——- 归一化后的 query 文本(
normalizeQuerySpelling之后); - 逐文件输出的行区间(1 基、闭区间,src/mcp/explore-session-state.ts 中的
ExploreLineRange); - 内容指纹(
fingerprint):这些行区间是从哪一份字节上切出来的,用于 CG-18 的"内容门"; - 源码字符数(
sourceBytes)、响应总字符数(responseBytes); - 该调用在"本会话 × 本项目"范围内的 1 基序号(
index),且该序号在明细被逐出后依然单调递增。
- 归一化后的 query 文本(
对应源码类型如下(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.ts:private 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 会被丢弃而不是信任(withSessionView 先
delete再重新注入)——因为 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 次,所以保留窗口能覆盖整个正常会话,上限只在病态会话上生效。
关键是:每个边界都只限"明细"。callCount 与 responseBytes 在逐出之后继续累计——CG-19 的衰减读的是这个计数,如果某个边界把它重置了,衰减就会每 8 次调用自复位一次。
逐出语义上,项目级 LRU 直接丢弃整个项目(而不是它的明细):一个已经转向另外四个仓库的会话,不太可能再回头问第一个。
哪种错法更安全:宁少记,不多记
当边界迫使取舍时,记录少记区间,绝不多记:
- 少记(under-report)→ 后续调用重发一份 Agent 已经有的源码。浪费,但无害;
- 多记(over-report)→ 后续调用扣留一份 Agent 从未见过的源码。Agent 只好去 Read 这个文件,而一次 Read 的花费超过去重省下的一切字节。
这个偏置落在三处具体实现上(coalesceRanges 及相关路径):
- 合并区间触顶时,
coalesceRanges丢弃最小的 span(保留最大的,然后重新按行号排序,使结果仍可读为自上而下),并置位rangesTruncated; - 非法 span(非有限数、
start > end、start < 1)被丢弃而不是夹取(clamp); - 被硬上限截断过的文件 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 都经过同一入口 dedupeSpans(src/mcp/tools.ts 附近),那里是 span 被丢弃的唯一地点。
回收的字节去了哪里
两条通道,都指向 Agent 没看过的文件:
sourceSpent:被去重的文件花费更少,CG-21 的 carry-forward 池把差额沿排名顺序传给后面的文件,其后每个文件的headroom都变大;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 留在手里(suppressedFallback,src/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)已交付区间,最近一次调用在前;- 该块在"调用方不记账"时是整体缺席而非清零——这是让"未追踪"与"被追踪会话的首次调用"两种情况保持可区分的方式(对应 viewForProject 中
null与"空状态"的语义区别:null= 没有任何人在追踪,如 CLI;空状态 = 追踪中但本项目还没被查过)。
去重本身也通过同一诊断上报:dedup.savedChars、逐文件 dedupSavedChars / dedupCovered、render: 'backref'。
测试与验证
tests/explore-session-state.test.ts 分三层:
- 容器层:键规则(解析 + 平台相关大小写折叠,见 exploreProjectKey)、逐出后序号仍单调、每条边界的行为;
- handler 缝隙:对真实索引的真实 explore 记录真实区间;一个会话的首次调用与不追踪时的响应字节级相同;同一 handler 上两个状态互不串扰;
- 会话缝隙:同一 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)断言两者落入同一个桶;多项目键控本身在容器层被覆盖。
小结
这套设计的取舍高度统一,可以用三条原则概括:
- 状态跟着会话走,不跟着进程走——会话即生命期,daemon 多客户端共享 handler 的现实决定了状态只能挂在
MCPSession上,用两个可序列化的普通属性穿越 worker 边界; - 宁少记不多记——所有边界裁剪都偏向"少知道",因为多记一次就等于逼 Agent 多付一次 Read;
- 只按可证明的字节去重——内容指纹而非 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(重复份额度量)。
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 StartedRust0622
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