☰
160模块Agent项目依赖治理:七层架构与AST闸门实践
2026/10/22 10:38:41 网站建设 项目流程

160 个模块的项目,听起来不算夸张,但把依赖图画出来的时候,连我自己都沉默了。我维护的这个 Agent 项目就是这样——最初十来个模块时大家都在一个目录里互相 import,谁也没当回事;等业务铺开,模块膨胀到 160 个,工具适配器、模型接入、编排逻辑、管理后台全堆在一起之后,改一个底层函数就能扯出一长串报错。头痛的不只是编译失败,而是你根本说不清这个模块到底应该被谁依赖、又能依赖谁。

这篇文章想讲的就是我后来做的一整套结构治理方案:把整个项目按领域切成七层,再写一个基于 AST 的依赖闸门脚本,在 CI 里强制守住依赖方向。整个过程没有高大上的框架,全是工程里最朴素的规则和脚本,但它实实在在把项目从"改哪都怕炸"拉回到了"改之前心里有数"的状态。如果你也在维护一个模块数量上了三位数、依赖关系靠人自觉的项目,这篇文章应该能给你一些可复用的思路。

1. 160 个模块的失控前夜:Agent 项目为什么特别容易烂

1.1 一个周末的真实事故:改底层工具,炸了一串上层模块

有天周五下午,我想把基础设施层的一个请求封装从回调式改成 Promise 式。动手前我习惯性地用全局搜索查了一下引用,发现直接引用它的模块有 9 个。改了一半编译一跑,报错的文件变成了 31 个——因为那 9 个模块里,有好几个本身又被更上层的模块引用,改动的效应顺着依赖链被放大了好几轮。

最离谱的是,有个工具适配器模块,它根本不该依赖这个底层请求封装,却因为在两个月前图省事直接 import 了它。也就是说,底层请求封装面临的消费者,远远超过了架构上应当出现的范围。我那天晚上一边修一边想:这不是编译报错的问题,是项目里根本没有任何机制在阻止这种"看着顺手就 import"的行为,等代码合并进去,再想纠正就得付出十倍成本。

1.2 我盘完依赖图之后,找到了三个典型病灶

那周我花了两天把全项目 160 个模块的依赖关系导出来看,发现烂得很有规律,主要有三类问题。

第一个是底层模块被当成了公共垃圾场。日志、配置、序列化、HTTP 客户端这类基础能力,几乎被所有层次引用,这本身没什么,问题在于很多人为了省事,把本应属于业务层的常量、枚举、映射表也塞进了底层公共模块,底层模块越来越胖,改起来越来越小心。

第二个是循环依赖比想象中多。编译期 TypeScript 对一部分循环 import 是能忍的,运行时也不一定立刻炸,但它会让模块初始化的顺序变得完全不可控,有些 Bug 是"本地没重现、部署到线上偶现",查到最后都是循环依赖导致的。

第三个是团队对"这个函数应该放在哪一层"没有共识。同一种工具能力,有的模块从工具层引,有的模块直接绕到底层重新实现了一遍,看起来只是代码重复,实际上让重构变得无从下手——你不知道改动会影响哪些模块。

Agent 项目还有一个天然的放大因素:模块职责太琐碎。一个 Agent 要管上下文、规划、工具调用、记忆、模型供应商适配、外部 API 接入,每个功能拆出来都是模块,但彼此交叉调用的冲动特别强。再加上大量使用动态 import 做插件化注册,很多依赖关系在编译期根本不会被发现,等到运行时才暴露。传统"靠自觉维护架构"的做法,在这种规模的 Agent 项目里注定走不远。

2. 七层依赖:从"谁能用谁"到"只能向下"的秩序

2.1 每层到底放什么:七层的职责边界与模块清单

分层的目标不是画一张漂亮的架构图,而是让任何一个模块的依赖方向都能被快速判断。我最终把整个 Agent 项目切成了七层,每层解决一个不同生命周期的问题,层号从 0 到 6,依赖只能从高层流向低层。

层级名称核心职责典型模块
L0基础能力层日志、配置、通用数据结构、错误定义logger、config、errors、序列化
L1领域模型层Agent 的"词汇表":消息、会话、工具协议、模型抽象session、message、tool protocol、context window
L2基础设施层对外部系统的适配:模型供应商、向量库、网络、存储llm client、vector store、http client、cache
L3工具层Agent 的"双手":具体工具实现search tool、code exec、file ops、db query
L4编排控制层Agent 的"大脑":主循环、规划、上下文管理agent loop、planner、context manager、memory scheduler
L5服务接入层对外的门面:API 路由、网关、消息消费http api、ws gateway、queue consumer
L6应用装配层启动那一刻的装配与入口cli、bootstrap、admin backend、配置装配

为什么恰好是七层,而不是三层或者十层?我当时的判断标准是:每一层的职责都要能回答"这一层的代码变更,受影响的人是谁"。L0 改动影响所有人,所以必须最稳定;L1 是 Agent 项目的核心词汇表,消息怎么定义、工具协议长什么样,所有模块都要理解它,所以必须是最小的公共集合;L2 管外部世界的适配,换了模型供应商、换了向量库,只影响 L2 以上用到它的层;L3 是具体的工具实现,新增一个工具只需要在 L3 加一个模块;L4 管 Agent 的决策逻辑,是可以独立测试的核心业务;L5 是对外接入的变化,通常由接口版本驱动;L6 是最容易被替换的一层,启动方式变了不会波及其他模块。

有一个关键决策值得单独说:工具协议被放在了 L1,而不是某个中间层。Agent 项目里工具协议是所有模块都要理解的最小公分母,L3 的工具实现要实现它,L4 的编排层要调用它,L5 的 API 层要把工具列表暴露给外部系统。如果把它放到工具层(L3),L4 和 L5 就都得上溯到 L3,层间的单向依赖立刻被打破。放在 L1 之后,所有上层模块向下依赖都不会产生交叉。

2.2 三条硬规则,和一条不得不加的弹性条款

分层定下来之后,我写了三条规定,贴在项目 README 最顶部。

第一条是方向红线:任何模块不得引用层级编号比自身更大的模块。这就是"只能向下"的含义,L3 不能引用 L4,L2 不能引用 L5,谁违反谁就是在制造最危险的反向依赖。这条规则没有任何例外,是闸门里优先级最高的 error。

第二条是跳层申报:允许向任意下层依赖,但跳过两层以上时必须在注册表里说明理由。比如 L4 编排层直接用 L0 的 logger,这不算违规,但 L4 直接操作 L0 的某个内部数据结构,就属于跳层,需要写明"为什么 L2 和 L3 里没有提供对应的封装"。这条规则是为了防止底层细节被上层到处直接操作,把中间层架空了。

第三条是环检测:同一层内部的模块之间也不允许形成循环依赖。跨层循环我们通过方向红线拦截,同层循环则需要图算法来查,闸门里单独跑了一层环检测。

实际操作中还有一个灰色地带:某些模块天然会被所有层依赖,比如日志。我把这类模块标记为 shared,允许任意层引用,但同时也限制它自身只能依赖 L0——一个被所有人用的模块,自己不干净,整个项目的风险就会被放大。

这三条规则听起来简单,落地之后团队的反馈是"终于不用靠猜了"。以前新同学来,问"这个文件应该放哪个目录",我只能说"看感觉";规则明确后,他只需要看自己写的 import 指向哪一层,方向对不对。

3. 为什么是自研 AST 闸门:现成工具卡不住分层方向

3.1 我试用过的现成方案,各自卡在哪

我的第一反应不是写工具,而是找现成的。先把 ESLint 生态翻了一遍,eslint-plugin-import 确实能查很多问题,但要它实现"这个目录属于 L2、只能依赖 L0 和 L1",只能靠手写import/no-restricted规则,把禁止导入的路径一条条列出来。160 个模块真要这么写,规则文件会变成一个没人敢动的怪物。

接着试了 dependency-cruiser(depcruise),这个工具很强大,支持正则依赖规则、循环检测、可视化依赖图,在社区里的口碑也很好。它最大的问题在于规则表达能力:分层方向这种语义,需要用一套 DSL 才能描述出来,比如from和to的正则约束,写出来之后可读性已经不太行了。遇到误报时,团队成员往往不敢碰那堆复杂的规则配置,慢慢规则就变成了僵尸规则——挂了但没人维护,报了错也没人理解。

还有一个更现实的原因:依赖分析工具要长久用下去,团队必须对它保有"可控感"。现成工具是一个黑盒,我很难在它的报告里加上业务语义,比如"工具协议必须定义在 L1 且不得被 L2 反向依赖"这种团队内部的约定。自研闸门虽然要写代码,但规则就在代码和配置文件里,任何成员都能 review、能改,遇到新场景还能自己扩展。

3.2 AST 闸门真正要守的东西:方向和越权

有人可能会问:TS 编译器不是已经能处理依赖了吗?import 写错了编译都过不去,为什么还要专门写一个检查工具?

因为编译器只检查"这个模块存不存在、类型对不对",它不管"你一个底层模块引用一个上层模块是不是合理"。从编译器角度看,L2 引用 L5 完全合法,但站在架构视角这就是一次典型的反向依赖。更麻烦的是动态 import:import(path)里的路径如果是变量拼接出来的,编译期根本不知道它指向哪个模块,很多违规就藏在这种地方。

所以这个 AST 闸门要守的东西,本质上不是语法正确性,而是两点:方向对不对,以及越没越权。方向对错靠七层规则来判断;越权则是看一个模块是否访问了超出它所在层级授权范围的内容。比如工具层某个模块直接调用了应用装配层的配置常量,方向上是 L3 引用 L6,一看就违规;又比如编排层的模块跳过基础设施层直接操作存储客户端,这属于跳层,必须申报。

自研的另一个好处是,它能和我们的模块命名、目录结构深度耦合。我们已经有一套模块解析规则和路径别名体系,检查工具可以直接复用这些规则,把"这个 import 实际指向哪个模块、属于哪一层"算得清清楚楚。现成工具反而要花大力气去适配这些内部约定。

4. AST 闸门实现拆解:从扫描文件到拦截合并

4.1 扫描、解析、提取依赖:一条 AST 解析链路

这个工具我把它做成了一个 Node 脚本,整体流程分四步:扫描模块文件、解析 AST、提取依赖边、做分层规则校验。核心解析用的是@babel/parser加@babel/traverse,因为这套组合对 TypeScript 语法支持得很好,而且遍历 API 足够灵活。

第一步扫描模块。我按约定的目录结构收集所有.ts和.tsx文件,跳过测试文件、fixture、构建产物。第二步对每个文件做 AST 解析,然后提取五类依赖关系。

// 依赖提取的核心片段(简化版) import { parse } from '@babel/parser'; import traverse from '@babel/traverse'; function extractDeps(sourcePath, code) { const ast = parse(code, { sourceType: 'module', plugins: ['typescript', 'jsx'], }); const deps = []; traverse(ast, { // import xxx from 'module' ImportDeclaration(path) { deps.push({ from: sourcePath, to: path.node.source.value, line: path.node.loc.start.line, }); }, // export xxx from 'module' ExportNamedDeclaration(path) { if (path.node.source) { deps.push({ from: sourcePath, to: path.node.source.value, line: path.node.loc.start.line, }); } }, // 动态 import('module') CallExpression(path) { const callee = path.node.callee; if (callee.type === 'Import' && path.node.arguments[0]) { deps.push({ from: sourcePath, to: path.node.arguments[0].value, line: path.node.loc.start.line, }); } }, }); return deps; }

这里有一个容易漏掉的地方:import './polyfill'这种副作用导入没有from的命名空间,但它是真实的运行依赖,必须收集。另外import type在编译后会被擦除,纯类型依赖和值依赖的语义不一样,我在工具里做了开关——默认统一收集,但对于纯类型模块可以配置为忽略,避免误报。

4.2 路径别名与分层映射:别让解析器把模块认错

160 个模块的项目几乎一定会用到路径别名,我们项目里就有@/、@tools/、@domain/好几套。如果解析器不认识这些别名,它看到import xxx from '@/orchestration/planner'会拿原始字符串去查文件,查不到就误报或者漏报。

我先读取tsconfig.json里的compilerOptions.paths,生成别名映射表。比如@/*对应src/*,@tools/*对应src/tools/*。解析依赖时,先把别名替换成真实绝对路径,如果指向的仍然不是项目内的已知模块,就把这条边标记为 unknown 并输出警告。unknown 比当作不存在的处理更稳妥:很多内部包、外部共享库不在扫描范围内,但它们同样可能隐藏违规依赖。

分层映射用的是最朴素的目录前缀匹配。每个模块的绝对路径会去匹配分层配置,比如src/infra/**对应 L2。为了保证规则不散落在一堆文件里,我把分层配置单独放在一个 JSON 文件里,git 提交后任何人都能 Review。

// 分层判定逻辑(伪代码) const LAYER_RULES = [ { pattern: 'src/foundation/**', layer: 0 }, { pattern: 'src/domain/**', layer: 1 }, { pattern: 'src/infra/**', layer: 2 }, { pattern: 'src/tools/**', layer: 3 }, { pattern: 'src/orchestration/**', layer: 4 }, { pattern: 'src/service/**', layer: 5 }, { pattern: 'src/app/**', layer: 6 }, ]; function layerOf(absPath) { for (const rule of LAYER_RULES) { if (matchGlob(absPath, rule.pattern)) return rule.layer; } return null; }

4.3 规则引擎与违规报告:什么样的输出团队才愿意看

依赖边提取完之后,剩下的就是判断每一条边是否符合规则。核心判断逻辑不复杂:源模块层号小于目标模块层号就是反向依赖,直接报 error;源层和目标层的层号差超过 2 且没有申报记录,报 warning;同层环检测单独跑一个 DFS 找环。

真正花心思的是输出格式。好的违规报告要回答三个问题:谁违了规、在哪一行、该怎么改。我为了让报告可读,把所有违规按照层号和文件路径排序,并且带上了目标模块所在的层号。

$ node scripts/deps-gate.mjs --scan src/ 扫描模块: 160 个 解析依赖边: 2841 条 [ERROR] src/infra/vector-store.ts:12 -> src/orchestration/context.ts 反向依赖:L2(基础设施层) 引用了 L4(编排控制层) ✗ 应改为 L2 依赖 L0/L1 范围内的模块 [ERROR] src/tools/search.ts:31 -> src/app/bootstrap.ts 反向依赖:L3(工具层) 引用了 L6(应用装配层) ✗ 该常量/函数应下沉到共享层 [WARN ] src/orchestration/router.ts:8 -> src/foundation/logger.ts 跨层跳过:L4 -> L0,未在申报表中登记 ✗ 若属有意设计,请在 dep-registry.json 中登记 扫描完成: 2 处 ERROR,1 处 WARN exit code: 1

脚本退出码的设计也很重要。CI 里只要出现 ERROR 就返回非零退出码,直接把流水线卡住;WARN 只打印到日志里,不阻塞合并。这样团队既能立刻被强制处理红线问题,又不会因为一堆历史欠账天天跑红——这为后面做存量债务治理留了余地。

5. 上线之后学会的事:误报、存量债务和持续威慑

5.1 把误报率压下去的三个工程化手段

闸门脚本写完之后,我先在本地跑了一遍全项目,结果发现除了真实的 40 多处违规之外,还有不少误报。误报这东西,对于一个要长期卡 CI 的工具来说,比漏报还致命——报错报得太频繁,团队第一反应不是改代码,而是无视检查结果。

第一个大坑是动态 import 的变量拼接。代码里有不少import(\./templates/${name}`)这种写法,AST 拿到的是一个模板字符串,不是完整路径。我的处理方式是约定:动态 import 的第一个路径段必须是字面量,./或@/` 开头,这样解析器只追第一段,后面的变量部分当作黑盒跳过。同时要求所有插件注册路径必须显式列在一张表中,新插件加进去时必须更新表,否则检查工具直接报 error。

第二个坑是测试和 fixture。测试文件天然会引用大量模块来验证行为,单测引用了编排层又引用了工具层,并不会有架构问题——测试是横切整个系统的。我在脚本里加了一个排除规则,__tests__、fixtures、mocks目录直接跳过。但有个例外:如果测试代码里出现了反向依赖,它可能意味着测试设计有问题,这两年我遇到过几次,后来把测试目录的反向依赖也设成了 error。

第三个是最关键的存量债务处理。闸门第一次全量扫描时发现了 47 条历史违规,如果全部要求改完再上 CI,项目会停下来一周,团队怨气会爆表。我采用了"存量白名单"机制:脚本第一次运行时生成一个dependency-baseline.json,记录当前所有违规。之后的每一次检查只做差分对比,新增违规直接卡死,存量违规单独建了一个清理清单排期消化。新债不欠,老债慢慢还,这套机制让团队接受度大大提高。

5.2 闸门第一个月的拦截记录,和团队态度的转变

闸门正式跑在 CI 里之后,我每个月拉一次统计。第一个月拦截了 47 次违规提交,其中有 9 次是反向依赖,也就是底层模块偷偷引用了上层模块。这些如果等上线后再发现,基本都是一周起步的排查工作量。第二个月开始数字明显下降,因为团队的肌肉记忆形成了:写 import 的时候会先想一下我这是在哪一层,目标又在哪一层。

让我印象最深的一件事是,某开发在重构自己的模块时,发现自己引用的一个工具类已经不属于工具层了,他主动在那个模块上写了一段注释,说明它应该迁移到 L2,而不是顺手把它留在原处。这说明什么?说明规则的威慑力其实来自"确定性"——当你知道有一条检查会明确告诉你在哪一行、哪个方向出了问题的时候,你会不自觉地往正确的方向写。

还有一个额外收获是,新同学入职之后不再需要花一周时间问"我们项目的依赖规范到底是什么"了。直接跑一遍检查工具,绿色就是合规,红色就是有问题,报错信息里就连改法都写了。这种确定性带来的效率提升,比任何架构文档都靠谱。

写在最后的一点体会

这套方案拆开看,每一个零件都很朴素:AST 解析是成熟技术,七层划分也只是普通的分层思想,自研脚本不过两百多行。真正的价值在于,我把"依赖方向要保持清晰"从一个模糊的感觉,变成了一道能具体报错、能追到行号、能在合并前拦住新债的闸门。架构治理这件事,靠自觉走不远,必须靠基础设施来兜底。

最后分享一个我自己的判断标准:任何架构工具,如果跑一轮的成本超过了一分钟,或者误报率高到让人习惯性忽略它,那它就离被废弃不远了。所以这个 AST 闸门的目标从来不是"管得越多越好",而是在规则、成本和可理解性之间取一个能长期运行的平衡点。下一步我准备把它做成一个支持插件的通用检查框架,把七层规则和模块映射从脚本里抽出来作为配置,这样其他模块较多的项目也能直接套用同一套思路,而不是复制一份充满业务味道的脚本。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询