Understand-Anything · compute-batches.mjs · 语义批处理

社区发现 · 用图聚类切语义批次

在 Understand-Anything 的多 agent 流水线里,把哪些文件发给同一个 file-analyzer 不是随意的——它决定了 LLM 能否看到耦合文件的完整上下文。compute-batches.mjs 对 import 图跑 Louvain 社区发现,用图聚类的自然结构代替"每 N 个文件切一刀"的朴素分批。本页拆解算法选型、批次大小约束、neighborMap 跨批导出与 Dashboard 的 Louvain 可视化集成。

O(n log n)
Louvain 时间复杂度
5–35
文件/批(目标范围)
import
构图边类型
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 个不同批次。

核心洞察:如果两个文件有 import 依赖,它们在语义上就是耦合的,应该被分到同一个批次。import 图的连通分量天然就是语义批次的候选——我们只需要在连通分量内做细粒度的批划分,而不需要跨连通分量合并文件。

为什么是 Louvain:模块度优化与代码图的适配性

Understand-Anything 选择了 Louvain 算法来做社区发现,这不是随意选的。Louvain 是为"大尺度网络"设计的模块度优化算法,在代码图上有几个天然优势:

算法时间复杂度适合代码图?关键问题
LouvainO(n log n)✅ 极佳模块度优化在 import 图上自然对应"语义模块"
Label PropagationO(n)⚠️ 可用结果不稳定(随机初始化),不保证可复现
Girvan-NewmanO(m²n)❌ 太慢边介数计算在大型代码库上不可接受
Spectral ClusteringO(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 图上,每个节点是一个文件。这在大多数场景下是合理的——一个文件通常对应一个"模块单元",但确实存在"一个文件包含多个语义上独立的类"这种边界情况。

compute-batches.mjs — 构图与社区发现
// 从 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 就是这个问题的解:

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 里。

这个设计的精妙之处在于:,不需要在 file-analyzer 运行时动态查询。这把"跨批引用完整性"这个全局问题,转化成了"查表"这个局部操作。

neighborMap 的代价:它让 file-analyzer 能知道跨批符号的存在,但无法理解它们的语义——因为邻居社区的摘要没有在同一个批里生成。这意味着"跨批边"的语义质量,取决于邻居社区是否已经被成功分析。如果邻居分析失败,跨批边就变成悬空的。

超大社区拆分:边介数启发式

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 个文件)是经验性的:

< 5 文件/批
并发开销高
5–20 /批
理想范围
20–35 /批
可接受上限
> 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 里。
①
project-scanner枚举文件列表 + import 抽取
↓
②
Louvain 社区发现对 import 图跑 Louvain,产生 communities.map
↓
③
neighborMap 计算为每个文件计算跨批邻居导出表
↓
④
超大社区拆分边介数启发式,约束在 5–35 文件/批
↓
⑤
路由分发每个社区 + neighborMap 发给 file-analyzer ×N(并行 5)

生态意义:把"图结构"变成"可操作的批语义"

社区发现在 Understand-Anything 里的角色,远不止"让批次更合理"那么简单。它代表了一种把图论算法引入 LLM agent 编排的思路:

  • 图的自然结构 = 任务的语义结构:Louvain 告诉我们 import 图有自然的模块边界,这个边界比文件系统的目录结构更能反映代码的语义耦合。把这个先验知识注入 agent 路由,省去了 agent "发现模块边界"的推理开销。
  • neighborMap 把全局图问题变成局部查表:如果没有 neighborMap,file-analyzer 就需要知道整个 import 图才能判断引用完整性——这在 20 万行代码库上是不可行的。neighborMap 通过"预计算跨批依赖"把问题分解了。
  • 确定性 + 可复现:Louvain 是确定性算法(给定相同的图,输出相同),这让批划分可复现、可审计、可 git 管理。
下一步方向:从"静态社区发现"升级为"动态社区发现"——在代码库经历多次 commit 后,跟踪社区结构如何演变。例如:当一个文件的 import 关系变化导致它从社区 A 移动到社区 B 时,重新运行 file-analyzer 的成本有多大?是否可以增量更新批划分而不重新跑 Louvain?