graphify 增量更新与集群重跑实战:掌握 --update 与 --cluster-only 两种非默认构建流程
本篇技术指南聚焦 graphify 的两种非默认构建子命令:--update(增量重提取,只重建新增或修改过的文件)与 --cluster-only(在既有知识图谱上仅重新聚类与命名社区,不触碰提取阶段)。文中将还原 Trae 技能参考文档 graphify__skills__trae__references__update.md 的完整操作序列,并结合仓库源码(detect.py、build.py、cli.py、analyze.py)讲清每一步背后的设计动机。读完你能独立驱动一次安全的增量构建——包括纯代码快路径、语义文件重提取、删除文件剪除、manifest 落盘与图差异汇报。
本文档的适用前提
该参考手册被 skill.md 显式指认为 --update 与 --cluster-only 的标准执行剧本:"--update re-extracts only new or changed files; --cluster-only reruns clustering on the existing graph. See references/update.md for both flows."
因此两条铁律必须先记住:
- 首次全量构建永远不读本文档。只有当用户传入了
--update或--cluster-only时才加载; - 在运行任何子命令之前,应先确认
graphify-out/.graphify_python存在(skill.md 要求:若缺失,例如用户删除了graphify-out/,需先重新解析解释器),后续所有内联脚本都通过$(cat graphify-out/.graphify_python) -c "..."调用同一 Python 环境。
--update:增量重提取的完整流程
--update 适用于"上次构建后又新增或修改了文件"的场景:只重提取变更文件,从而节省 token 与时间。整条流水线围绕三类中间状态文件展开,分别由不同的源码函数产出与消费:
| 状态文件 | 产生函数 | 消费方 | 语义 |
|---|---|---|---|
.graphify_incremental.json |
detect_incremental | 后续所有内联脚本 | 本次相对上次的变更集快照 |
.graphify_detect.json |
内联脚本改写 | Steps 3A–6 | 驱动 AST/缓存/语义各步骤的检测状态 |
.graphify_extract.json |
提取与合并两阶段 | Step 4 及之后 | 本轮新增提取 + 合并后的完整图 |
第一步:调用 detect_incremental 计算变更集
$(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.')
"
从源码看,detect_incremental 并非独立扫描,而是先调用完整的 detect() 拿到当前语料,再与上次保存在 manifest.json 中的 mtime + 内容哈希逐文件比对,返回带 incremental/new_files/unchanged_files/new_total/deleted_files/excluded_files 键的结果:
- 快路径:mtime 未变且哈希匹配 → 判定未变,只付出一次 stat 开销;
- 慢路径:mtime 变动 → 再比对 MD5 内容哈希决定是否真的要重提取(detect.py);
- 排除 vs 删除的区分(#1908):manifest 中某行不在当前语料里时,若文件在磁盘上仍存在,则归入
excluded_files(被 ignore 规则新排除),否则才是真正的deleted_files; - 全空结果(无新增无删除)时立即
SystemExit(0),干净退出,避免无意义的后续流程。
注意此函数默认 kind="semantic",即语义哈希缺失或内容变化都算"变更"。上文捕获 graphify-out/.graphify_incremental.json 后,后续脚本都以它为准。
第二步:改写 .graphify_detect.json,统一后续步骤的可见状态
Steps 3A–6 会无条件读取 .graphify_detect.json,所以必须把增量结果重新映射进去——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\")
"
第三步:判断本轮变更是否"纯代码",决定走哪条分支
在真正提取前先探测一次:如果所有变更文件都是代码文件,就走免 LLM 的 AST-only 快路径;否则需要完整语义管线。下述脚本维护了一份常见的代码扩展名集合:
$(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)
"
分支 A:code_only = True —— 跳过语义提取,零 LLM 成本
若上述脚本输出 code_only: True,打印提示 [graphify update] Code-only changes detected - skipping semantic extraction (no LLM needed),然后:
- 只在变更文件上执行 Step 3A(确定性 AST 解析);
- 完全跳过 Step 3B(不派发任何子 Agent);
- 直接进入 merge 与 Steps 4–8。
这对应仓库中 AST 层与语义层"分层共存(COEXIST)"的图模型:每个文件有两个生产者——确定性 AST pass 与语义/LLM pass,节点集在图中并存。纯代码改动只刷新 AST 那一层即可。
分支 B:code_only = False —— 全量语义管线 + 视频预转写
只要任一变更文件是文档/论文/图片/视频,就说明语义层需要刷新。此时若变更落在 new_files['video']:
- 先对视频文件执行 transcribe.md(Step 2.5 转写流程);
- 重写
.graphify_detect.json:把转写生成的文本路径移入files['document'],并删除files['video']——否则原始.mp4/.mp3路径会被当作不可读媒体喂给语义子 Agent(#1392); - 随后按正常的 Steps 3A–3C 全管线执行。
第四步:只有删除、没有新增时的"空提取"
当 new_total == 0 但存在删除文件时,需要创建一个空提取文件,让后面的 merge 步骤得以执行剪除:
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
第五步:build_merge 合并且落盘
这是增量流程的心脏。注意该步骤不依赖 NetworkX 往返读写,直接读取 graph.json,保证边方向(calls/implements/imports)永远不被破坏(#801):
$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
from graphify.build import build_merge
from graphify.detect import save_manifest
# Load new extraction and incremental state
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_sources is ONLY for genuinely DELETED files. Changed/re-extracted files are
# handled by build_merge's replace-on-re-extract (#1344): every source_file in
# new_chunks is dropped from the base before merge, so old/stale nodes don't survive.
# Do NOT add `changed` here: with root= passed, prune_set relativizes to the same base
# as the freshly merged nodes and would DELETE the re-extracted content (#1178 is moot
# now that replace — not the dedup pass — reconciles changed files).
prune = list(deleted) or None
# Use build_merge() — reads graph.json directly without NetworkX round-trip
# so edge direction (calls, implements, imports) is always preserved (#801).
# Pass root= so prune_sources (absolute paths from detect_incremental) are
# relativized to match the graph's relative source_file values; without it
# nothing is pruned and stale nodes accumulate on every update (#1361).
# directed=IS_DIRECTED: replace IS_DIRECTED with True if --directed was given, else
# False. Without it a --directed --update silently rebuilds undirected and collapses
# reciprocal A<->B edges (#1392).
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')
# Write merged result back to .graphify_extract.json so Step 4 sees the full graph
merged_out = {
'nodes': [{'id': n, **d} for n, d in G.nodes(data=True)],
'edges': [
# Explicit source/target last so they win over any stale attrs in d.
{**{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)
],
# G.graph[\"hyperedges\"] holds hyperedges from both existing graph.json
# and new_extraction (build_merge combines them). Falling back to
# new_extraction only would silently drop prior-run hyperedges (#801).
'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)')
# Save manifest so next --update diffs against today's state, not the
# prior run's baseline (prevents ghost-node reports on subsequent updates).
# root= matches the build_merge call above so the manifest keys stay relative to
# the scan root — portable across clones/machines, so --update keeps matching
# cached files instead of missing every one after a move (#1417).
#
# Only stamp semantic files (docs/papers/images) that ACTUALLY produced output
# THIS run (new_extraction is this run's fresh extraction, read above before the
# merge overwrote the file): a changed doc whose chunk failed must stay unstamped
# so the next --update re-queues it, otherwise it is marked done and its content
# is lost forever (#2015). Mirrors the library extract path
# (cli._stamped_manifest_files + clear_semantic + scan_corpus).
from graphify.cli import _stamped_manifest_files
_manifest_files = _stamped_manifest_files(incremental['files'], new_extraction, Path('INPUT_PATH'))
# Changed semantic files dispatched this run but NOT stamped had their chunk fail
# or be omitted; clear any stale semantic_hash so they are re-queued (#1948).
_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_corpus = the RAW full corpus so in-root files newly excluded since last run
# are dropped rather than masquerading as deletions; untouched rows preserved (#1908).
_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.')
"
关键参数与底层语义
这段脚本里浓缩了仓库中最容易踩坑的三个设计决策:
prune_sources 只放真正删除的文件。参考 build_merge 的源码可知,重提取文件走的是 replace-on-re-extract(#1344) 机制:new_chunks 中出现过的 source_file,其旧节点/边会先从基线图中剔除再合并——因此"修改过的文件"绝不能同时塞进 prune_sources,否则刚建好的新节点会被立即剪除(#1178 已因 replace 机制取代 dedup pass 而失效)。并且删除集在带 root= 时会被相对化到与合并节点相同的基准,这是 root 参数存在的意义之一(#1361)。
root='INPUT_PATH' 必须与 merge 基准一致。detect_incremental 返回的 deleted_files 是绝对路径,而图中节点的 source_file 通常是相对路径,不带 root 做相对化,剪除将全部落空,每次更新都会累积幽灵节点。
directed=IS_DIRECTED 必须显式。若用户带了 --directed,此处替换为 True,否则 False。源码层面 build_merge 规定:directed=None 时继承磁盘图上记录的 directed 标志、无旧图时回退 False,但显式布尔值始终覆盖磁盘标志——因此 --directed --update 若不显式传 True,会静默地把有向图重建为无向图,使 A→B 与 B→A 这类互反边坍缩为一条(#1392)。
manifest 落盘的三个正确姿势
结尾的 manifest 保存调用,同时解决了三个增量正确性问题(save_manifest):
root=可移植性(#1417):键被相对化为正斜杠形式再写入磁盘,使 manifest 在不同机器/克隆位置间可移植,--update才不会因路径漂移而每次都 miss 缓存文件;_stamped_manifest_files只盖章真正产出结果的语义文件(#2015/#933):参考 cli.py 的实现,它只把nodes/hyperedges里确实带source_file的文档计入盖章集合——chunk 失败的文件保持语义哈希为空,下次--update会重新派发,避免内容永久丢失;clear_semantic+scan_corpus(#1948/#1908):本轮被派发却未盖章的文件(LLM 遗漏 chunk)强制清空其残留semantic_hash以便重排;scan_corpus则携带原始全语料,把"因 ignore 规则变化被排除"的文件行删除掉,使其不再伪装成删除。
第六步:合并完成后进入 Steps 4–8
合并结果已回写进 .graphify_extract.json(Step 4 能看到全图),随后按常规流程跑 Steps 4–8 即可。
汇报图差异:备份旧图 + graph_diff
在 merge 步骤之前先备份旧图:cp graphify-out/graph.json graphify-out/.graphify_old.json
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
# Load old graph (before update) from backup written before merge
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']))
"
差异计算由 graph_diff 完成:先做新旧节点集的差集,再对边做 (端点, relation) 键的集合差(有向图按 (u, v, relation)、无向图按归一化后的无序端点键),最后拼出形如 "3 new nodes, 5 new edges, 1 node removed" 的 summary。
收尾清理:差异展示完毕后删除备份文件 —— rm -f graphify-out/.graphify_old.json
注意:示例中所有
rm均为文档流程的收尾动作;日常操作时请自行按安全方式管理临时文件。
--cluster-only:在既有图上重跑聚类
当只需要刷新社区划分、社区命名与报告产物,而代码/文档本身没有任何变化时,使用 --cluster-only。它跳过 Steps 1–3,直接对现有图重新聚类:
graphify cluster-only .
从 CLI 实现看(cli.py 将 cluster-only 与 label 归入同一分支,wiki.py 也提示先运行 graphify extract . 或 graphify cluster-only .),该命令具备完整闭环能力:重新聚类、为社区命名,并从既有 graph 直接重新生成 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摘要呈现给用户即可。
何时该用哪个:决策速查
| 场景 | 命令/流程 | 关键状态文件 | 是否涉及 LLM |
|---|---|---|---|
| 上次构建后新增/修改了代码文件 | --update(分支 A) |
变更集 + AST 提取 | 否(纯 AST 快路径) |
| 上次构建后变更含文档/图片/视频 | --update(分支 B) |
变更集 + 视频转写 + 语义管线 | 是 |
| 只有文件被删除 | --update(空提取 + merge 剪除) |
空提取 + 删除集 | 否 |
| 内容未变,只想重跑社区划分 | --cluster-only |
仅最终产物(report/json/html) | 视命名配置而定 |
--update 的一切工作都围绕一个核心不变式展开:manifest 记录"上次真正成功构建的状态",而图本身的 stale 节点靠 build_merge 的重提取替换与删除剪除双机制清除——两者必须严格区分,这是避免幽灵节点与内容丢失的根源。
延伸阅读
- graphify/skill.md —— 主技能剧本,第 677–679 行显式委托本文档处理两个非默认子命令,第 661–662 行说明了运行前的
.graphify_python检查; - tools/skillgen —— 该参考文档的生成器与预期输出(
tools/skillgen/expected/目录),可对照不同平台的同名参考文档差异; - graphify/detect.py ——
detect_incremental(第 2366 行起)与save_manifest(第 2112 行起)的完整实现与 mtime/hash 判定逻辑; - graphify/build.py ——
build_merge(第 1626 行起)的重提取替换、删除剪除与有向性继承逻辑; - graphify/cli.py ——
_stamped_manifest_files(第 88 行起)对语义文件盖章的过滤规则; - graphify/analyze.py ——
graph_diff(第 556 行起)的差异汇总实现。
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