社区发现 · 用图聚类切语义批次
在 Understand-Anything 的多 agent 流水线里,把哪些文件发给同一个 file-analyzer 不是随意的——它决定了 LLM 能否看到耦合文件的完整上下文。compute-batches.mjs 对 import 图跑 Louvain 社区发现,用图聚类的自然结构代替"每 N 个文件切一刀"的朴素分批。本页拆解算法选型、批次大小约束、neighborMap 跨批导出与 Dashboard 的 Louvain 可视化集成。
朴素批次的问题:把语义耦合的文件拆散了
如果用最 naive 的方式分批——"每 20 个文件一组,按文件名字母序切"——会发生什么?
- API routes 被切到不同批次:一个文件定义路由
users.ts,另一个文件auth.ts提供 JWT 验证——它们在语义上紧密耦合,应该一起分析。但按字母序切,它们可能分属第 3 批和第 5 批。 - 跨批边丢失:file-analyzer 在处理第 3 批时,看不到第 5 批的内容,所以无法判断
users.ts调用了auth.ts的哪个函数——跨批语义边就断了。 - LLM 上下文窗口浪费:每批 20 个文件,如果它们在语义上不相关(比如一个 UI 组件文件和一个数据库模型文件),LLM 就没有足够的上下文来理解它们之间的依赖关系。
这个问题在大型代码库(1000+ 文件)上会严重放大。朴素的字母序切分在 1000 个文件的代码库里,可能把"同一模块的 15 个文件"拆散到 8 个不同批次。
为什么是 Louvain:模块度优化与代码图的适配性
Understand-Anything 选择了 Louvain 算法来做社区发现,这不是随意选的。Louvain 是为"大尺度网络"设计的模块度优化算法,在代码图上有几个天然优势:
| 算法 | 时间复杂度 | 适合代码图? | 关键问题 |
|---|---|---|---|
| Louvain | O(n log n) | ✅ 极佳 | 模块度优化在 import 图上自然对应"语义模块" |
| Label Propagation | O(n) | ⚠️ 可用 | 结果不稳定(随机初始化),不保证可复现 |
| Girvan-Newman | O(m²n) | ❌ 太慢 | 边介数计算在大型代码库上不可接受 |
| Spectral Clustering | O(n³) | ❌ 太慢 | 对图规模敏感,不适合 1000+ 节点 |
模块度(Modularity):如何衡量"好"的社区划分
模块度 Q 的公式:
Q = (1/2m) × Σ_ij [ A_ij - (k_i × k_j) / 2m ] × δ(c_i, c_j)
# A_ij = 边 (i,j) 的权重(import 关系为 1)
# k_i = 节点 i 的度
# m = 所有边的总权重
# δ(c_i, c_j) = 1 当且仅当 i 和 j 在同一社区
# Q ∈ [-0.5, 1],通常 > 0.3 表示显著社区结构
在代码 import 图上,Louvain 会把"内部互联密集、对外互联稀疏"的文件群识别为一个社区——这恰好就是代码里"一个模块"或"一个包"的定义。模块度高的划分意味着:社区内部的 import 密度远高于社区之间的 import 密度,这正是语义耦合的体现。
算法步骤
- Phase 1(模块度优化):每个节点依次尝试移动到相邻节点的社区,计算模块度增益 ΔQ,保留正向增益的移动。重复直到全局模块度不再提升。
- Phase 2(社区聚合):把 Phase 1 的每个社区缩成一个超节点,社区之间的边权重相加,形成新的简化图。
- 迭代:对简化图重复 Phase 1 + Phase 2,直到模块度收敛。
Louvain 的输出是嵌套的社区层级——顶层是大模块(如整个 frontend),底层是子模块(如某个特定功能的文件组)。Understand-Anything 使用第一层的划分作为批次候选。
检测粒度:import 图的节点和边是什么
Understand-Anything 构建社区发现图时,使用的是import 依赖关系作为边:
- 节点:文件(
.ts、.js、.tsx、.jsx等 TypeScript/JavaScript 文件)。 - 边:文件 A import 文件 B 的符号(
import { foo } from './b') - 边的方向:有向(但 Louvain 本身是无向算法——通常把有向 import 当无向处理,即"两个文件在同一个模块")
注意:这个粒度是文件级的,不是函数级或类级的。社区发现发生在 import 图上,每个节点是一个文件。这在大多数场景下是合理的——一个文件通常对应一个"模块单元",但确实存在"一个文件包含多个语义上独立的类"这种边界情况。
// 从 tree-sitter 的 import 抽取结果构建邻接表
// 节点:文件路径(相对路径)
// 边:import 关系(有向,但 Louvain 按无向处理)
const graph = new Map<string, Set<string>>();
// 社区发现
const communities = louvain(graph);
// communities: Map<filePath, communityId>
// 同一 communityId 的文件被分到同一语义批
粒度的局限
文件级社区发现有一个潜在问题:
- 一个文件,多个语义单元:某些语言/框架习惯把多个不相关的函数或类放在同一个文件里(如 React 的
index.tsx,可能导出多个组件)。这种情况下,文件级的社区发现会把"语义上无关的组件"错误地聚合在一起。 - 循环依赖的社区:A imports B,B imports C,C imports A——这三个文件会被 Louvain 识别为同一个社区,这在语言层面是允许的(循环 import 在 TypeScript 里可以工作),但语义上它们可能属于不同的业务模块。
这些局限目前通过批大小约束和后续的 graph-reviewer 校验来缓解——如果一个社区太大(> 35 个文件),会触发拆分逻辑。
neighborMap:跨批邻居导出,修复被切断的语义边
Louvain 社区发现解决了"哪些文件应该在一起"的问题,但还有一个问题没解决:跨批引用。
当一个社区被拆分后(见下一节),必然存在"社区 A 的文件引用了社区 B 的符号"的情况。如果 file-analyzer 在处理社区 A 时,不知道社区 B 里谁提供了这个符号,就会产生悬空引用——图里有一条边指向不存在的节点。
neighborMap 就是这个问题的解:
// 每个文件计算它的"跨批邻居"——
// 即:它引用了哪些其他社区的符号
const neighborMap = new Map<string, CrossBatchNeighbor>();
for (const file of graph.keys()) {
const crossBatchRefs = [];
for (const imported of graph.get(file)) {
if (communities.get(imported) !== communities.get(file)) {
// 这是一个跨批引用——记录下来
crossBatchRefs.push({
targetFile: imported,
communityId: communities.get(imported),
exportedSymbols: getExportedSymbols(imported) // 从该文件的导出表查
});
}
}
neighborMap.set(file, { crossBatchRefs });
}
// file-analyzer 在处理某批时,会拿到每份文件的 neighborMap,
// 知道"我引用了 community-X 的哪些符号,这些符号在哪个文件里"。
neighborMap 让 file-analyzer 可以在不知道邻居社区内容的情况下,做跨批边的判断。它只需要知道"我要引用 community-X 的 validateToken,这个符号在文件 auth/jwt.ts 里"——而不需要把 community-X 的所有文件都加载到 context 里。
这个设计的精妙之处在于:
超大社区拆分:边介数启发式
Louvain 的输出社区大小是无约束的——理论上,一个有 500 个文件的"monorepo 根部"可能被 Louvain 识别为单一社区(如果根目录有大量 index.ts 互相 import)。这对 file-analyzer 是不现实的:
- 输出截断风险:500 个文件的语义分析结果远超 LLM 的输出上限。
- 并行度损失:一个 500 文件的社区只能串行处理,不能利用多 agent 并行。
所以 compute-batches.mjs 在 Louvain 之后,对超大社区(> 35 个文件)应用边介数启发式拆分:
// 边介数(edge betweenness):一条边被所有最短路径经过的次数。
// 介数高的边是社区之间的"桥梁",去掉它们会把一个大社区拆成多个小社区。
function splitByBetweenness(community, maxSize = 35) {
if (community.size <= maxSize) return [community];
// 计算所有边的介数
const betweenness = computeEdgeBetweenness(community);
// 按介数降序排序边
const sortedEdges = [...betweenness.entries()]
.sort((a, b) => b[1] - a[1]);
// 贪心拆分:每次移除最高介数的边,直到所有子社区 < maxSize
const subCommunities = [community];
for (const [edge, score] of sortedEdges) {
if (subCommunities.every(c => c.size <= maxSize)) break;
// 移除这条边,重新跑连通分量
removeEdge(edge);
const components = findConnectedComponents(community);
if (components.every(c => c.size <= maxSize)) {
return components;
}
// 如果拆分后仍有超大规模子社区,继续拆
}
return subCommunities;
}
这个策略背后的直觉是:边介数高的边是"模块间桥梁",去掉它们自然形成语义更内聚的子社区。例如,根目录的 index.ts 通常 import 了所有子模块,它和子模块之间的边会有很高的介数——去掉这些"再导出"边后,子模块内部的文件会自然聚成独立社区。
目标批大小的选择(5–35 个文件)是经验性的:
选择 35 作为上限的原因是:在 4096–8192 token 输出的 LLM(如 Bedrock Opus)上,35 个中等规模 TypeScript 文件的语义分析结果约等于 6000–7000 token,接近安全上限。
Dashboard 可视化:Louvain 聚类在图上的展示
社区发现的结果不只是给 agent 用——它也直接体现在 Understand-Anything Dashboard 的视觉布局上:
- ELK 布局:Eclipse Layout Kernel 做层次布局,展示代码的调用/继承层次。
- Louvain 视觉聚类:Dashboard 对渲染的图节点按社区 ID 上色——同一社区的节点用相近的色调,形成视觉上的"区块"。这让人一眼就能看出"哪些文件属于同一语义模块"。
- 三种视图切换:structural 视图(按调用/import 图)、domain 视图(按业务域叠加)、knowledge 视图(按知识图谱节点类型)。社区聚类在 structural 视图里最明显。
Louvain 社区 vs 传统目录结构的区别
一个值得注意的设计洞察:Louvain 社区发现产生的聚类,不一定和文件系统的目录结构一致。这是feature,不是 bug。
- 目录结构是人为约定的:
src/utils/可能是"放杂项工具"的地方,并不反映语义耦合。 - import 图是运行时依赖的真实反映:如果
utils/format.ts和services/email.ts通过 import 紧密耦合(email 服务用了 format 工具),Louvain 会把它们放到同一社区——即使目录结构把它们放在了不同位置。
Dashboard 里 Louvain 聚类的视觉叠加,提供了第三种看代码的方式:既不是文件系统视角,也不是纯调用图视角,而是"语义耦合"视角——它告诉你代码在实际运作中是如何组织起来的,而不是人们最初设计目录时期望它如何组织。
与七专家系统的集成:社区结构影响路由
社区发现的结果直接进入 Understand-Anything 的路由器:
- file-analyzer 的批次:每个 Louvain 社区(满足 5–35 文件约束后)作为一份任务,发给 file-analyzer。
- community context:每个批次的输入里包含
community: string字段——告诉 file-analyzer "这批文件属于同一个语义社区,它们之间互相依赖"。这帮助 LLM 在有上下文的情况下分析文件。 - architecture-analyzer 的输入:在 file-analyzer 并行运行之前,architecture-analyzer 已经拿到了完整的文件列表和 import 图,它先跑 Louvain 获得社区结构,再把结果作为分层依据注入到每个批次的 context 里。
生态意义:把"图结构"变成"可操作的批语义"
社区发现在 Understand-Anything 里的角色,远不止"让批次更合理"那么简单。它代表了一种把图论算法引入 LLM agent 编排的思路:
- 图的自然结构 = 任务的语义结构:Louvain 告诉我们 import 图有自然的模块边界,这个边界比文件系统的目录结构更能反映代码的语义耦合。把这个先验知识注入 agent 路由,省去了 agent "发现模块边界"的推理开销。
- neighborMap 把全局图问题变成局部查表:如果没有 neighborMap,file-analyzer 就需要知道整个 import 图才能判断引用完整性——这在 20 万行代码库上是不可行的。neighborMap 通过"预计算跨批依赖"把问题分解了。
- 确定性 + 可复现:Louvain 是确定性算法(给定相同的图,输出相同),这让批划分可复现、可审计、可 git 管理。