graphify 增量更新与簇重建实战:--update / --cluster-only 参考手册深度解析
本文围绕 graphify 的 增量更新参考手册 展开,完整覆盖 --update(增量重抽取)与 --cluster-only(仅重新聚类)两条运行路径:从变更检测(detect_incremental)、变更子集检测文件填充、code-only 快速路径、删除修剪与 build_merge 合并,到 manifest 持久化与 graph_diff 图差异展示,并深入 graphify/detect.py、graphify/build.py 等源码,印证每个参数背后的实现动机。读完本文,你可以理解 graphify 如何"只重抽变过的文件、绝不把旧节点留在图里",并掌握在 Agent 技能运行手册中正确执行增量更新全流程的方法。
参考手册的定位:只在增量路径加载
这份 reference 文件是 graphify 面向 Claude Code 等 Agent 平台提供的技能参考文档(graphify/skills/claude/references/update.md),原文明确规定:
Load this only when the user passed
--updateor--cluster-only. A first-time full build never reads this file.
即:只有当用户传入 --update 或 --cluster-only 时才加载本文件;首次全量构建永远不会读取它。这与同目录下的 transcribe.md(视频转写子步骤)、query.md(查询)等参考文档共同构成技能的按需加载体系。下文按原文两条主线分别展开。
一、--update:增量重抽取的完整流程
增量更新适用于"上一次运行之后新增或修改了文件"的场景,只重抽变更文件,从而节省 token 和时间。整个流程分为四段:变更检测 → 填充检测文件 → 分支判定(code-only / 全量语义)→ 合并、manifest 与图 diff。
1. 变更检测:detect_incremental
第一步调用 graphify.detect.detect_incremental 对比上次运行以来的文件变化,并把结果落盘到 .graphify_incremental.json:
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.detect import detect_incremental, save_manifest
from pathlib import Path
result = detect_incremental(Path('INPUT_PATH'))
new_total = result.get('new_total', 0)
print(json.dumps(result, indent=2, ensure_ascii=False))
Path('graphify-out/.graphify_incremental.json').write_text(json.dumps(result, ensure_ascii=False), encoding=\"utf-8\")
deleted = list(result.get('deleted_files', []))
if new_total == 0 and not deleted:
print('No files changed since last run. Nothing to update.')
raise SystemExit(0)
if deleted:
print(f'{len(deleted)} deleted file(s) to prune.')
if new_total > 0:
print(f'{new_total} new/changed file(s) to re-extract.')
"
(INPUT_PATH 是技能执行时由 Agent 填入的实际项目路径占位符。)
结果中 new_total 为新增/变更文件总数,deleted_files 是真正被删除的文件。若两者皆空,直接退出——无事可做。
源码印证:mtime 快速路径 + 内容哈希慢路径
从 graphify/detect.py 的 detect_incremental 实现看,变更判定采用两级策略:
- 快速路径:文件的 mtime 未变且与 manifest 记录的 mtime 一致,则判定为未变更(只做 stat,不做磁盘 IO);
- 慢速路径:mtime 被刷新后,用 MD5 内容与 manifest 中对应哈希字段比对,内容一致才算未变更。
其中有一个值得注意的细节——mtime 精度窗口防护。源码定义了 _MTIME_COARSE_S = 2.0 与 _MTIME_SUBSECOND_S = 0.05 两个常量(graphify/detect.py):当 manifest 记录时间戳与文件 mtime 落在同一"文件系统刻度"内时(粗粒度文件系统会把 mtime 舍入到整秒),一次同长度编辑可能不会移动 mtime,导致文件被静默跳过。此时 _mtime_may_hide_a_rewrite 会强制走一次内容哈希校验,避免图持续提供旧内容。这个窗口机制与 graphify/cache.py 哈希缓存层的假设保持一致,并有专门的回归测试 tests/test_incremental_mtime_collision.py。
另一个实现细节是双哈希 schema:manifest 每个文件行同时保存 ast_hash 与 semantic_hash,并通过 kind 参数区分语义——kind="ast" 服务于 graphify update(AST-only 重建),kind="semantic" 服务于 graphify extract。缺少 semantic_hash 的文件(比如只跑过 AST 更新的文档)在语义检测中始终视为"需重抽"。此外,删除文件与"被排除文件"被严格区分:磁盘上文件已不存在才计入 deleted_files;文件仍在磁盘但被 .graphifyignore/.gitignore/--exclude 排除的计入 excluded_files,不会误报为删除(见 tests/test_detect.py 中对 test_detect_incremental_* 系列用例的覆盖,包括 mtime 回退、schema 漂移等边界场景)。
2. 填充 .graphify_detect.json:让后续步骤看到正确的状态
检测到变更子集后,需要重写 .graphify_detect.json,使无条件读取该文件的 Step 3A–6 看到增量运行应有的状态。原文的关键说明是:
files携带变更子集——驱动 Step 3A 的 AST 抽取,以及只对变更文件做 Step 3B0 缓存检查;all_files携带全量语料——供任何需要全库上下文的步骤使用。
$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
r = json.loads(Path('graphify-out/.graphify_incremental.json').read_text(encoding=\"utf-8\"))
Path('graphify-out/.graphify_detect.json').write_text(json.dumps({
'files': r.get('new_files', {}),
'all_files': r.get('files', {}),
'total_files': r.get('new_total', 0),
'total_words': r.get('total_words', 0),
'skipped_sensitive': r.get('skipped_sensitive', []),
'needs_graph': True,
}, ensure_ascii=False), encoding=\"utf-8\")
"
字段对应关系值得注意:new_files(变更子集)映射为 files,而 detect_incremental 返回的完整扫描结果 files(全量语料)映射为 all_files。
3. 分支判定:code-only 快速路径 vs 完整语义管线
若存在新文件,先判断所有变更文件是否都是代码文件:
$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
result = json.loads(open('graphify-out/.graphify_incremental.json', encoding='utf-8').read()) if Path('graphify-out/.graphify_incremental.json').exists() else {}
code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'}
new_files = result.get('new_files', {})
all_changed = [f for files in new_files.values() for f in files]
code_only = all(Path(f).suffix.lower() in code_exts for f in all_changed)
print('code_only:', code_only)
"
原文据此给出两条分支:
code_only == True:打印[graphify update] Code-only changes detected - skipping semantic extraction (no LLM needed),只对变更文件执行 Step 3A(AST 确定性解析),完全跳过 Step 3B(LLM 语义子代理),直接进入合并与 Steps 4–8。这与项目"本地确定性 AST 解析、无向量库"的核心设计一脉相承——代码结构图不消耗 LLM token。code_only == False(任一变更文件是文档/论文/图片/视频):若变更文件中有new_files['video'],先按 transcribe.md(Step 2.5)对视频转写,然后重写.graphify_detect.json,把转写产出路径移入files['document']、删除files['video']——否则裸的.mp4/.mp3路径会被当作文本喂给语义子代理,导致不可读媒体进入抽取(原文标注对应 issue #1392)。之后按正常流程执行完整 Steps 3A–3C。
与 detect.py 扩展名分类的对照
这份 code_exts 集合是运行手册内的自包含判定。对照 graphify/detect.py 的官方分类,CODE_EXTENSIONS 覆盖面更广(还包括 .vue、.svelte、.zig、.sql、.terraform 系、PowerShell、C# 工程文件等),并额外区分 DOC_EXTENSIONS(.md/.mdx/.rst 等)、PAPER_EXTENSIONS(.pdf)、IMAGE_EXTENSIONS 与 VIDEO_EXTENSIONS(含 .mp3/.wav 等音频)。可以推断,参考手册中的精简集合是刻意为 Agent 内联脚本保持轻量——FileType 枚举(code/document/paper/image/video)才是分类权威,内联集合只服务于"是否需要 LLM"的二元判定。
4. 仅有删除:构造空抽取以供合并修剪
若没有新/变更文件、只有删除,需要创建空抽取文件,让合并步骤据此做节点修剪:
if [ ! -f graphify-out/.graphify_extract.json ]; then
echo '[graphify update] Only deletions -- creating empty extraction for merge.'
$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
Path('graphify-out/.graphify_extract.json').write_text(json.dumps({'nodes':[],'edges':[],'hyperedges':[],'input_tokens':0,'output_tokens':0}), encoding='utf-8')
"
fi
5. 合并:build_merge 的四个关键参数
合并阶段是整个增量流程的核心,原文以详尽注释给出了调用与理由:
$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
from graphify.build import build_merge
from graphify.detect import save_manifest
new_extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
incremental = json.loads(Path('graphify-out/.graphify_incremental.json').read_text(encoding=\"utf-8\"))
deleted = list(incremental.get('deleted_files', []))
prune = list(deleted) or None
G = build_merge(
[new_extraction],
graph_path='graphify-out/graph.json',
prune_sources=prune,
root='INPUT_PATH',
directed=IS_DIRECTED,
)
print(f'[graphify update] Merged: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges')
merged_out = {
'nodes': [{'id': n, **d} for n, d in G.nodes(data=True)],
'edges': [
{**{k: val for k, val in d.items() if k not in ('_src', '_tgt', 'source', 'target')},
'source': d.get('_src', u), 'target': d.get('_tgt', v)}
for u, v, d in G.edges(data=True)
],
'hyperedges': list(G.graph.get('hyperedges', [])),
'input_tokens': new_extraction.get('input_tokens', 0),
'output_tokens': new_extraction.get('output_tokens', 0),
}
Path('graphify-out/.graphify_extract.json').write_text(json.dumps(merged_out, ensure_ascii=False), encoding=\"utf-8\")
print(f'[graphify update] Merged extraction written ({len(merged_out[\"nodes\"])} nodes, {len(merged_out[\"edges\"])} edges)')
from graphify.cli import _stamped_manifest_files
_manifest_files = _stamped_manifest_files(incremental['files'], new_extraction, Path('INPUT_PATH'))
_sem_types = ('document', 'paper', 'image')
_dispatched = {f for t, fl in incremental.get('new_files', {}).items() if t in _sem_types for f in fl}
_stamped = {f for fl in _manifest_files.values() for f in fl}
_cleared = _dispatched - _stamped
_scan = {f for fl in incremental['files'].values() for f in fl}
save_manifest(_manifest_files, root='INPUT_PATH', scan_corpus=_scan, clear_semantic=_cleared or None)
print('[graphify update] Manifest saved.')
"
原文为 build_merge 给出的每条参数理由(IS_DIRECTED 同样为 Agent 按 --directed 开关替换的占位符):
| 参数 | 原文理由(含 issue 溯源) | 源码印证 |
|---|---|---|
prune_sources=prune |
只用于真正被删除的文件。变更/重抽文件由 build_merge 的 replace-on-re-extract 机制处理(#1344):new_chunks 中每个 source_file 在合并前从基图中剔除,旧节点不会残留。切勿把 changed 加入 prune:当传了 root= 时,prune 集合会与刚合并节点相对化到同一基准,反而删掉刚重抽的内容 |
graphify/build.py 注释明确"Re-extracted files REPLACE their prior contribution",且替换是**按层(tier-scoped)**的:AST 层与语义层各自独立替换,重抽一层不会抹掉另一层的节点 |
root='INPUT_PATH' |
让 detect_incremental 返回的绝对路径 prune 集合相对化,与图中相对 source_file 匹配;缺省则什么都剪不掉,每次更新都累积幽灵节点(#1361/#1571) |
build_merge 内部 _eff_root 逻辑:未传 root 时会回退推断扫描根,避免绝对 win32 路径与相对 posix 键不匹配(#1007) |
directed=IS_DIRECTED |
必须显式传入:否则 --directed --update 会静默重建为无向图,把互反的 A↔B 边坍缩(#1392) |
directed=None 时 build_merge 继承磁盘上图自身的 directed 标记(#2342),显式值总是覆盖 |
graph_path(只读) |
直接读 graph.json,无 NetworkX 往返,边方向(calls/implements/imports)恒保真(#801) |
docstring 明确 "Does NOT write to disk — the caller persists the result" |
合并后 G.graph["hyperedges"] 同时包含旧图与新抽取的超边,原文特别警告:只回退到 new_extraction 会静默丢弃前次运行的超边(#801)——上表代码中 'hyperedges': list(G.graph.get('hyperedges', [])) 正是对应处理。边序列化时显式把 source/target 放在最后,使其优先于 d 中任何陈旧属性。
6. manifest 持久化:让下一次 --update 对比今天的状态
合并后必须保存 manifest,否则下次 --update 仍会对比上次的基线,产生"幽灵节点"报告。原文还解释了三个精细参数(对应 graphify/detect.py 中 save_manifest 的 docstring):
root='INPUT_PATH':manifest 键相对化到扫描根,跨 clone/机器可移植。不传的话,目录一移动,--update就会对每个缓存文件全部 miss(#1417/#777)。- 只盖章真正产出输出的语义文件:
_stamped_manifest_files(graphify/cli.py)只对本次实际产出了节点/超边的文档盖章;某个 chunk 抽取失败的文件保持未盖章,下次--update自动重新排队——否则它会被标记完成、内容永久丢失(#2015/#933)。 clear_semantic:本次被派发但未盖章的文件(chunk 失败或被 LLM 遗漏)清除其陈旧semantic_hash,强制重排(#1948)。scan_corpus:传入原始全量语料,使自上次运行以来新被排除的根内文件被正常丢弃,而不是伪装成删除;未触碰的行保留(#1908)。
7. 图差异展示:graph_diff
Steps 4–8 在合并后的完整图上照常执行。Step 4 之后,用更新前的备份图做差异对比:
$(cat graphify-out/.graphify_python) -c "
import json
from graphify.analyze import graph_diff
from graphify.build import build_from_json
from networkx.readwrite import json_graph
import networkx as nx
from pathlib import Path
old_data = json.loads(Path('graphify-out/.graphify_old.json').read_text(encoding=\"utf-8\")) if Path('graphify-out/.graphify_old.json').exists() else None
new_extract = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
G_new = build_from_json(new_extract, directed=IS_DIRECTED)
if old_data:
G_old = json_graph.node_link_graph(old_data, edges='links')
diff = graph_diff(G_old, G_new)
print(diff['summary'])
if diff['new_nodes']:
print('New nodes:', ', '.join(n['label'] for n in diff['new_nodes'][:5]))
if diff['new_edges']:
print('New edges:', len(diff['new_edges']))
"
配套操作(原文原样保留):
- 合并步骤之前:
cp graphify-out/graph.json graphify-out/.graphify_old.json - 结束后清理:
rm -f graphify-out/.graphify_old.json
graph_diff(graphify/analyze.py)返回 new_nodes、removed_nodes、new_edges、removed_edges 与一行 summary(形如 "3 new nodes, 5 new edges, 1 node removed")。从源码看,无向图的边按 (min(u,v), max(u,v), relation) 归一化,有向图按 (u, v, relation) 区分,保证互反边不会被误判为增删——这与 --directed 必须显式传参的要求形成闭环。
二、--cluster-only:对现有图单独重跑聚类
原文对该模式的说明极其明确:
Skip Steps 1–3. Re-run clustering on the existing graph:
graphify cluster-only .
graphify cluster-only . 是自包含的:重新聚类、命名社区,并从现有图重新生成 GRAPH_REPORT.md、graph.json 与 graph.html。切勿重跑 Steps 5–9——这些步骤读取的中间文件(.graphify_extract.json、.graphify_detect.json、.graphify_analysis.json)已在此前构建的清理步骤(Step 9)中被删除,重跑会抛 FileNotFoundError(#1392)。完成后照常呈现刷新后的 GRAPH_REPORT.md 摘要。
从 graphify/cli.py 的 cluster-only 分支可印证该命令的实际能力面:
- 与
label子命令共用代码路径(label是"总是重新生成社区名"的 cluster-only); - 支持
--no-viz、--no-label、--missing-only、--timing、--backend=、--model=、--batch-size=、--min-community-size=(默认 3)等标志; - 重聚类后会用
remap_communities_to_previous将新社区 ID 映射回上一次标注(#1027),避免标签因原始 cid 索引变化而错位; - token 用量来自真实的标注 LLM 调用而非硬编码零值(#1694);
--no-label的纯占位标签不会被持久化复用(#2073)。
适用场景:调整了社区命名后端/模型、想刷新报告与可视化但不想重新支付抽取成本时,cluster-only 是最小代价路径。
三、设计要点小结
综合参考手册与源码,graphify 增量更新的设计可以归纳为五条不变量,每条都有对应的源码与测试证据:
- 只重抽变更文件:mtime 快速路径 + 内容哈希慢路径 + 双哈希(
ast_hash/semantic_hash)区分 AST 与语义两个抽取层(graphify/detect.py,测试见 tests/test_detect.py、tests/test_incremental_mtime_collision.py); - 变更文件"替换"而非"追加":
build_merge按层替换重抽文件的既有贡献,删除文件走prune_sources,二者机制严格分离(graphify/build.py,测试见 tests/test_build_merge_shrink_guard.py、tests/test_prune_sweeps_orphans.py); - 失败不盖章:未产出输出的文件永远不写入 manifest 哈希,下次自动重排队(graphify/cli.py);
- 路径可移植:manifest 与图键均相对化到扫描根,跨机器/跨 clone 依然命中缓存(#777/#1417);
- 方向性不静默丢失:
directed必须显式传入合并调用,图自身的 directed 标记只作兜底(#1392/#2342)。
此外,仓库根目录下的 docs/superpowers/specs/2026-05-04-incremental-updates-dedup-design.md 与 docs/superpowers/plans/2026-05-04-incremental-updates-dedup.md 记录了增量更新与去重合并的设计演进,可作为进一步深入阅读的材料。需要说明的是,文中所有 $(cat graphify-out/.graphify_python) 调用都假定 graphify-out/ 中已由首次构建写入解释器路径标记,且 INPUT_PATH、IS_DIRECTED 等占位符由 Agent 在执行时按实际上下文替换——脱离首次构建直接运行这些片段不会成立,这是使用本参考手册的前提限制。
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