资讯详情

160模块Agent项目依赖治理:七层架构与AST闸门实践

📅 2026/10/23 12:57:29 | 华诺云谱 👁 阅读
160模块Agent项目依赖治理:七层架构与AST闸门实践
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 windowL2基础设施层对外部系统的适配模型供应商、向量库、网络、存储llm client、vector store、http client、cacheL3工具层Agent 的双手具体工具实现search tool、code exec、file ops、db queryL4编排控制层Agent 的大脑主循环、规划、上下文管理agent loop、planner、context manager、memory schedulerL5服务接入层对外的门面API 路由、网关、消息消费http api、ws gateway、queue consumerL6应用装配层启动那一刻的装配与入口cli、bootstrap、admin backend、配置装配为什么恰好是七层而不是三层或者十层我当时的判断标准是每一层的职责都要能回答这一层的代码变更受影响的人是谁。L0 改动影响所有人所以必须最稳定L1 是 Agent 项目的核心词汇表消息怎么定义、工具协议长什么样所有模块都要理解它所以必须是最小的公共集合L2 管外部世界的适配换了模型供应商、换了向量库只影响 L2 以上用到它的层L3 是具体的工具实现新增一个工具只需要在 L3 加一个模块L4 管 Agent 的决策逻辑是可以独立测试的核心业务L5 是对外接入的变化通常由接口版本驱动L6 是最容易被替换的一层启动方式变了不会波及其他模块。有一个关键决策值得单独说工具协议被放在了 L1而不是某个中间层。Agent 项目里工具协议是所有模块都要理解的最小公分母L3 的工具实现要实现它L4 的编排层要调用它L5 的 API 层要把工具列表暴露给外部系统。如果把它放到工具层L3L4 和 L5 就都得上溯到 L3层间的单向依赖立刻被打破。放在 L1 之后所有上层模块向下依赖都不会产生交叉。2.2 三条硬规则和一条不得不加的弹性条款分层定下来之后我写了三条规定贴在项目 README 最顶部。第一条是方向红线任何模块不得引用层级编号比自身更大的模块。这就是只能向下的含义L3 不能引用 L4L2 不能引用 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-cruiserdepcruise这个工具很强大支持正则依赖规则、循环检测、可视化依赖图在社区里的口碑也很好。它最大的问题在于规则表达能力分层方向这种语义需要用一套 DSL 才能描述出来比如from和to的正则约束写出来之后可读性已经不太行了。遇到误报时团队成员往往不敢碰那堆复杂的规则配置慢慢规则就变成了僵尸规则——挂了但没人维护报了错也没人理解。还有一个更现实的原因依赖分析工具要长久用下去团队必须对它保有可控感。现成工具是一个黑盒我很难在它的报告里加上业务语义比如工具协议必须定义在 L1 且不得被 L2 反向依赖这种团队内部的约定。自研闸门虽然要写代码但规则就在代码和配置文件里任何成员都能 review、能改遇到新场景还能自己扩展。3.2 AST 闸门真正要守的东西方向和越权有人可能会问TS 编译器不是已经能处理依赖了吗import 写错了编译都过不去为什么还要专门写一个检查工具因为编译器只检查这个模块存不存在、类型对不对它不管你一个底层模块引用一个上层模块是不是合理。从编译器角度看L2 引用 L5 完全合法但站在架构视角这就是一次典型的反向依赖。更麻烦的是动态 importimport(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 处 ERROR1 处 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 闸门的目标从来不是管得越多越好而是在规则、成本和可理解性之间取一个能长期运行的平衡点。下一步我准备把它做成一个支持插件的通用检查框架把七层规则和模块映射从脚本里抽出来作为配置这样其他模块较多的项目也能直接套用同一套思路而不是复制一份充满业务味道的脚本。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑