160 个模块,我第一次把这数字列出来时头皮发麻。这还不是一个“微服务遍地走”的分布式系统,而是一个单体 Agent 项目:模型接入、工具调用、长期记忆、编排循环,全在一个仓库里长着。模块数一多,“这项目还能不能维护”就成了日常灵魂拷问。后来我用“七层依赖 + 一道 AST 闸门”把依赖图收拾住了:先按职责把所有模块塞进七个明确的层,再写一个基于抽象语法树的静态依赖检查脚本,挂在 CI 上,谁违反规则谁别想合并。这篇文章想把整条治理路线拆开讲清楚:160 个模块为什么会出现依赖腐化,七层依赖的边界到底怎么划,AST 闸门怎么实现、怎么落地,以及你会踩到哪些坑。
1. 160 个模块怎么开始“烂”:依赖失控的典型症状
1.1 Agent 项目为什么容易长成大杂烩
Agent 项目和普通业务后端的最大区别,是它的模块天然不是一个“树”,而更像“工具箱 + 大脑 + 插座”的合体。工具箱这边要挂一大排工具和技能模块,功能彼此重叠、互相调用;大脑这边有模型适配、上下文编排和执行循环,模型服务经常换、编排逻辑也经常改;插座这边要接各种外部系统、协议、回调和事件。三类代码混在一起,模块数必然膨胀,而且膨胀速度远比你想象的快。
我们内部这个 Agent 项目,模块数从二十多个涨到 160,也就用了一年多。最初的二十多个模块,大家还能凭印象说出依赖关系;到 60 个左右时,PR 评审里已经有人开始漏看 import;到 100 以后,问题集中爆发。我后来统计过这 160 个模块的大致构成:工具和技能类约 90 个,模型接入和提示相关约 15 个,记忆和上下文约 8 个,编排和执行约 12 个,对外接口和 CLI 约 8 个,基础工具和公共库约 27 个。看这个数字就知道,真正的“大头”是技能层,而技术债最深的也恰好是这一层。
还发生过一件印象很深的事:某同学在代码里搜“谁还在用某某状态枚举”,grep 出来只有两处引用,他以为改起来很轻松。结果合并当天,三个技能模块在运行时直接炸了。原因是我们项目里存在一堆动态导入和字符串路径注册,静态搜索根本搜不到真实调用链。从那以后我意识到,靠人肉 review、靠 grep、靠大家的自觉去维护 160 个模块的依赖关系,基本等于用胶带修水坝——迟早要出事。
1.2 失控依赖的四种典型形态
依赖失控不是一夜之间发生的,它通常表现为四种形态,而且这四种形态往往同时存在。
第一种是循环依赖。A 模块注册工具时引用了 B 模块的 schema,B 模块执行时又回到 A 模块的注册表里取配置。循环依赖在 Python 里最常见的表现是ImportError: cannot import name,但它真正的杀伤力在于“随机性”。同样的代码,今天启动能过,明天启动顺序变一下,就导入失败了。这种问题在 CI 全量测试时还不容易暴露,因为测试脚本经常用 pytest 的 import 缓存把真实时序掩盖掉了。
第二种是上帝模块。某个编排器把策略、调度、工具调用、异常处理全写在一个文件里,谁想干活都得先 import 它。我们项目里就有一个所谓的“万能入口”,历史最高被 30 多个模块直接引用。它里面一个函数签名改掉,六个 PR 同时红,每次改动都要拉上七八个人看 review。这个模块本身已经严重违反了单一职责,但只要依赖关系不清,拆它就意味着动几十个文件的 import,没人敢拆。
第三种是逆向依赖。底层基础设施不认识业务概念,结果我们某个公共库里有判断“是不是 Agent 技能”的逻辑,技能层反过来依赖公共库里的业务标签。后果是公共库想发新版本,必须先等技能层改完,否则兼容性立刻出问题。这已经不只是代码问题,而是发布流程被依赖关系绑架了。
第四种是暗依赖。通过importlib.import_module动态加载插件,或者用字符串路径在配置里注册技能,静态检索工具默认瞎眼。它比前三种都隐蔽,因为只有在特定配置、特定触发路径下才会炸。你说不清谁依赖谁,自然也就说不清某个模块能不能删、能不能替换。
这四种形态叠在一起,项目就会进入“不敢重构、不敢删代码、不敢升级基础库”的僵局。要打破僵局,光靠自觉不够,得先把规则写清楚,再让机器去执行。
2. 七层依赖架构:把“允许谁依赖谁”变成明确规则
2.1 七层怎么分才不教条
一个 Agent 项目到底应该分几层,没有标准答案。我们最后选了七层,不是因为“七”这个数字好,而是因为这七种模块的职责差异足够大,放到任意两层之间都有清晰的边界感。你可以根据自己的项目调整合并层数,但关键是分完之后,每个模块都得有唯一且合理的归属。
| 层号 | 目录 | 包含什么 | 典型限制 |
|---|---|---|---|
| 1 | core | 日志、配置、错误码、通用工具 | 只允许依赖标准库和第三方基础库,不得出现业务概念 |
| 2 | schema | 数据结构、协议模型、消息定义 | 依赖 core |
| 3 | llm | 模型厂商适配、提示词封装、结果解析 | 依赖 schema 和 core,不直接触碰业务逻辑 |
| 4 | memory | 会话记录、向量存储、KV 缓存 | 依赖 schema 和 core,不依赖特定模型服务 |
| 5 | skills | 工具、技能、外部系统适配 | 可依赖 4 层以下,同层默认不互相依赖 |
| 6 | orchestrator | 规划、调度、执行状态机、Agent 主循环 | 依赖 5 层以下所有层 |
| 7 | entry | HTTP API、CLI、事件消费者 | 可以依赖所有下层,是唯一对外边界 |
为什么把“工具和技能”放在中间第五层?因为 Agent 项目的绝大多数模块都落在这一层。它既要调用模型能力去理解任务,又要访问记忆和上下文,还要被上层编排器统一调度。它放在中间,天然形成“下为支撑、上为调度”的局面,而不是让各模块到处乱引。
我常跟新同学说,分层这事别总想着“架构洁癖”。如果你的项目只有 20 个模块,分六层可能都嫌多;但当模块数超过 100,边界定义带来的收益会迅速超过心智负担。这就像搬家时给箱子贴标签:东西少的时候随便堆,东西多了以后,标签不只是帮你找东西,更是防止你把碗和马桶刷放同一个箱子。
2.2 依赖规则的三条铁律
分层之后,规则一定要薄。我们只定了三条,越少越容易被记住和执行。
第一条,只能向下依赖。层号为 n 的模块,只能导入层号小于 n 的模块。这条规则让依赖方向始终从下往上流,上层可以换,下层尽量稳定。想象城市供水系统:水管可以按需延伸到任何一栋楼,但供水管网不能反过来依赖每户人家的装修风格。一旦底层基础设施开始关心“我是被哪个技能调用的”,架构就在崩。
第二条,同层默认不互相依赖。这条争议最大,后面我会单独讲。它先把“技能之间随便调”的口子收紧,倒逼大家把公共逻辑下沉到更低层,或者通过显式白名单申请例外。
第三条,禁止循环依赖。无论是同层还是跨层,出现环就说明抽象错了。AST 闸门不光查单条 import,还会基于整个依赖图做圈检测,把环直接打出来给你看。
这三条规则都不是什么原创发明,它们本质上是“依赖倒置 + 单向依赖 + 无环图”的组合。难点不在于设计,而在于有人维护。如果这三条只写在架构文档里,没人执行,两三个月后项目又会自然腐化成原始森林。
2.3 同层调用怎么办:升层、下沉还是白名单
“同层默认不互相依赖”在技能层最容易遇到灵魂拷问。比如我们的搜索技能和网页抓取技能,都想用同一个“把 HTML 转成干净正文文本”的函数。按照“同层不互依赖”的规则,搜索技能不能直接 import 抓取技能的某个内部工具函数。这时候有三种处理方式。
第一种是升层。把公共函数下沉到 core 或 schema 这样的低层公共包,彼此都从低层拿。这个方案最干净,因为我们那个 HTML 转文本函数本质上是通用工具,放技能层本身就放错了位置。凡是“多个模块都要用”的能力,大概率就是公共基础设施,呆在低层才合理。
第二种是下沉到一个独立的“共享技能”模块。如果能力不足以放进 core,但确实被若干技能共用,那就专门建一个更低的公共技能模块,把它划到第五层之下的“伪第四层”或者干脆放 memory 层之外的独立公共包子目录。名字上可以是skills_shared,约定它只能依赖 4 层以下,其他技能可以依赖它。
第三种是进入白名单。如果两个技能之间存在稳定的、业务上绕不开的调用关系,比如“搜索技能”必须调用“网页抓取技能”的某个调度入口,那就把这条依赖显式写入 AST 闸门的白名单,注明理由和负责人。注意白名单不是让你随便填的,它代表“我们已经讨论过、接受这个例外”,而不是“暂时这么写以后再说”。
这三条路径拼在一起,就是我们说的“七层依赖”:层级只定大方向,白名单承托例外,升层和下沉负责把例外变成规范。
3. 为什么用 AST 做“闸门”,而不是字符串扫描
3.1 正则/字符串扫描的五个盲区
写守卫脚本的第一反应通常是“用正则扫 import 行”。我第一次做也是这么干的,而且很快发现这条路会越走越脏。
盲区之一是注释和字符串。代码里写# import requests或者"fake_import()"这类字符串,正则照样能搜到,但它是假依赖。为排除假阳性,你得先剥注释、剥字符串,而且 Python 的 tokenize 规则很细,剥完正则等于自写半个 lexer。
盲区之二是from x import *。这种写法在源码里看起来是from pkg import *,但真实依赖可能是pkg下的几十个符号,可你在 import 行根本看不见。更别提包里的__init__.py会各种 re-export,你根本说不清星号到底导进来什么。
盲区之三是类型注解导入。代码里可能大量使用if TYPE_CHECKING:来避免循环运行时导入,正则根本分不清这是运行时依赖还是类型检测依赖。它会一刀切把TYPE_CHECKING里的 import 也算成依赖,然后报出一堆假违规。
盲区之四是多行 import 和别名。from x import (a, b, c)在多行括号里换行时,正则需要额外处理;import os as operating_system这种别名,你得额外建立别名表。
盲区之五是动态 import。importlib.import_module(f"skill_{name}")这种调用根本没有显式 import 字符串可扫。如果你指望字符串扫描把所有暗依赖都抓出来,那完全是异想天开。字符串扫描只能算“尽力而为”,而无力作为“闸门”。
3.2 AST 闸门的工作原理
后来我把扫描器重写成基于 AST,原因很简单:AST 直接面对语法结构,而不是文本。Python 内置的ast模块能把源码解析成一棵语法树,这棵树上每一个 import 都是一个标准节点,不归正则管的那堆脏东西,在树上一眼就能分辨。你也不用自己写 lexer 来剥注释和字符串,因为解析器本来就把它们排除在语法结构之外。
核心代码其实不复杂。下面这段就是最简版的收集器:
import ast from dataclasses import dataclass @dataclass class ImportRecord: module: str lineno: int class ImportCollector(ast.NodeVisitor): def __init__(self): self.imports: list[ImportRecord] = [] def visit_Import(self, node: ast.Import) -> None: for alias in node.names: self.imports.append(ImportRecord(alias.name, node.lineno)) def visit_ImportFrom(self, node: ast.ImportFrom) -> None: if node.module is None: # from . import xxx,强制迁移成绝对导入后基本不会到这里 return self.imports.append(ImportRecord(node.module, node.lineno))访问 AST 时,visit_Import处理import a.b.c,visit_ImportFrom处理from a.b import c。拿到的是模块名、源码文件名、行号,后面做归属判定时,这些信息足够精确到“哪个文件的哪一行”。
额外要说明的是,AST 不只适用于 Python。TypeScript 项目有基于 TypeScript Parser 或 ESLint 的 AST,Java 项目可以用 JavaParser 的 AST,原理都一样:越过文本,直接在语法树层面检查 import 节点。这套方法论是可以平移到其他语言的。
3.3 边界映射与违规判定流程
有了 import 列表,还得知道这个 import 属于哪个层。我们项目里所有代码都在一个统一的包根下,比如agent_project,目录第一层就是层名:agent_project.core、agent_project.skills、agent_project.orchestrator。所以层号判定可以简单做:
def layer_of(module: str) -> int | None: parts = module.split(".") if len(parts) < 2 or parts[0] != "agent_project": return None return LAYER_ORDER.get(parts[1])检查流程分五步:
- 遍历项目下所有
.py文件,排除测试目录、虚拟环境、生成代码目录。 - 对每个文件做
ast.parse,用上面的ImportCollector收集 import 记录。 - 解析 import 的目标模块,先判断是否属于本项目;标准库和第三方包直接跳过。
- 对比源文件所在层和目标模块所在层,按“只能向下,同层默认拒绝”的规则判定。
- 把所有模块之间的依赖关系建一张有向图,再做圈检测,发现循环依赖立即报告。
判定结果要绝对可读。比如“搜索技能的tool_search.py第 42 行向上依赖了编排器模块”,比单纯报“依赖违规”有用得多。开发者能直接看到文件和行号,修起来就不用翻遍整个 PR。
4. AST 闸门落地实战:从脚本到 CI 闸门
4.1 核心脚本的骨架长什么样
我们最终的dep_gate.py大概 200 多行,核心逻辑可以浓缩成下面这段主线:
def run_gate(root: pathlib.Path, config: dict, fail_hard: bool) -> int: violations = [] for py_file in iter_project_files(root): module = module_of_path(py_file, root) source_layer = layer_of(module) for rec in extract_imports(py_file): target_module = resolve_project_module(rec.module) if target_module is None: continue target_layer = layer_of(target_module) issue = judge_dependency( source_layer, target_layer, module, target_module, rec, config, ) if issue: violations.append(issue) cycles = find_cycles(build_dep_graph(root, config)) report(violations, cycles) return 1 if (violations or cycles) and fail_hard else 0iter_project_files负责过滤文件目录;module_of_path从文件路径反推模块名;judge_dependency按规则输出违规;find_cycles做环检测。最后fail_hard决定这个进程是“只报告”还是“直接返回非 0 阻断合并”。
跑法也很简单:
python dep_gate.py --root src --config dep_gate.tomlCI 里只要把它当成一条检查 job,类似跑测试、跑 lint,失败就让 pipeline 红掉,PR 就不能合。
4.2 配置与豁免清单怎么设计
设计配置时我最大的感受是:豁免机制必须要有,否则闸门会在第一天被存量违规淹死。我们项目第一次全量扫描报了 80 多个违规,根本不可能一次清完。如果不能豁免,团队的第一反应一定是把这脚本删了,而不是继续修。
配置长这样:
[layer_map] core = 1 schema = 2 llm = 3 memory = 4 skills = 5 orchestrator = 6 entry = 7 [allow_cross_layer] "agent_project.skills.search" = ["agent_project.skills.fetch"] [ignore_patterns] "agent_project.legacy.*" = "存量模块,迁移期内豁免,待清理"allow_cross_layer管“同层白名单”,比如搜索技能被允许调用网页抓取技能;ignore_patterns管“存量豁免”,比如遗留模块迁移期间先不拦截。为了让豁免不过期,我们同时配了一个定期 review 的机制:每两周跑一遍当前白名单,白名单里的项必须有负责人在 PR 里说明为什么还没清理。
我不推荐让豁免清单无限膨胀。如果某个白名单项目一放就是半年,那说明底层模块划分有问题,比起继续豁免,不如做一次小重构。
4.3 渐进式治理:先用告警模式再升级为闸门
工具写出来直接上“闸门”是很危险的,因为你会在第一个小时就收到一堆差评。我们按三周节奏走的渐进方案,你可以直接抄。
第一周:告警模式。fail_hard=False,闸门只打印违规列表,不阻断合并。团队在这几天内可以用这个输出对照自己手头的模块,心里有数。同时把违规按模块归属导出 JSON,方便后续做对比。
第二周:存量清理。把历史违规分到各模块 owner,能当场修的下沉或升层,短期修不完的进入ignore_patterns豁免。这一步特别重要:存量债必须显式放进豁免清单,而不是让它继续留在灰色地带。
第三周:启用 fail-hard。从这周开始,任何新增违规都会把 CI 干红。新代码不可能再用“意外”当借口,因为闸门给的行号足够精确。
4.4 性能数据与挡住的典型违规
性能顾虑基本可以放下。我们项目的 160 个模块、大概两千个.py文件,单核全量扫描约 4 到 6 秒。后来改成多进程扫描加上简单的缓存,缩到 1 到 2 秒。放在 CI 里完全没负担,放在本地 pre-commit 里也不会让人烦躁。
上线八周内,我统计到闸门一共拦截了四十多次违规合并。其中有几种非常有代表性:一个技能偷偷 import 了编排器的内部状态机;一个公共库模块新增了业务判断逻辑,造成逆向依赖;还有一次是某同学把工具模块整体移到了 entry 层,结果下层模块反向依赖上层。如果没有闸门,这些代码大概率会静默合入,然后在未来某个夜间发布时变成一个需要回滚的大事故。
5. 常见问题与排查技巧实录
5.1 高频误报与精准豁免
AST 闸门比正则干净太多了,但误报还是存在,这里列几个最典型的。
第一种是__init__.py纯 re-export。很多包在__init__.py里统一导出子模块,例如从skills.search里 import 某个函数再 re-export。这会让扫描器认为包和子模块之间产生了依赖,实际上它只是一个门面转发。处理方式是在__init__.py上单独走“宽松模式”,或者把它加入allowlist。
第二种是TYPE_CHECKING导入。代码在if TYPE_CHECKING:里导入类型,运行时根本不执行,不会形成运行时循环。我们默认把这类导入单独标记出来,只报告、不阻断。但要注意,如果类型导入在 IDE 层面形成了环,对 autocomplete 还是有影响,只是优先级低很多。
第三种是测试目录的依赖。测试天然要访问各个层的模块,不能拿业务层的依赖规则约束测试。我们把tests目录整体排除在业务扫描之外,另外给测试目录配置了更宽松的规则,只检查“测试不得反向影响被测模块”,也就是测试不许去修改依赖图的拓扑。
误报出现的时候,先不要立刻加豁免。先确认是不是代码结构本身就比较拧巴,比如明明是一个公共函数却放在上层模块里。很多“误报”其实是架构问题露出来的头。
5.2 动态导入、TYPE_CHECKING 与init重导出
动态导入是 AST 闸门的天然盲区。我们项目里不少插件是运行时通过字符串路径加载的,比如importlib.import_module("agent_project.skills.tool_" + name)。AST 看不到这个字符串依赖,所以单纯靠 AST 闸门防不住。
我们的解法是建立“插件注册表”。所有动态加载的模块必须在plugins.toml或一个专门的registry.py中显式登记,登记内容包含模块名、所属层级、负责人。闸门会额外扫描注册表,把动态依赖也纳入依赖图。这样,静态扫描和动态导入之间形成闭环:动态加载不再溜出检查范围。
__init__.py重导出还有一个更隐蔽的问题:模块从agent_project.skills.search导入一个符号,但语义上真正的依赖可能是agent_project.skills这个包做的门面转发。处理时,我们把“解析目标模块”写成:先尝试module直接对应的.py文件是否存在;如果不存在,再看它对应的包里是否 re-export 了该符号。找不到明确归属时,保守视为“依赖到更靠近根的层级”,宁多报不漏报。
5.3 与 pytest、代码生成工具打架怎么办
pytest 是第一个提出反弹的。它内部会通过conftest.py改sys.path,导致某些模块在测试进程里能 import,但用我们的文件路径方式映射不到标准模块名。后来处理也简单:先让扫描器基于源码目录的根包,而不是sys.path;不要把conftest.py当普通业务文件扫描,而是给它打一个“测试辅助模块”的标签。
代码生成工具也是麻烦源。凡是 protobuf、client stub、mock 生成出来的代码,通常带一堆自动 import,而且这些 import 的层级不一定符合规则。对生成目录,我们统一加ignore标记,扫描器直接跳过。但要小心:如果生成代码被重新生成到同一个目录,你得保证每次 CI 都跑在生成动作之后,否则可能出现“生成还没跑,扫描先报错”的时序问题。做法很简单,把依赖检查 job 排在代码生成 job 之后。
6. 七层依赖 + AST 闸门的长期收益
6.1 新同学接入速度变快
最直接的变化是新人接入变快了。以前新同学进来,光是理解“这个模块该放哪、能不能 import 那个”就要问好几个人。有了七层依赖表,他们拿到一个需求,先判断这是什么类型的模块,然后照着同层已有模块的模板抄即可。一个新技能模块大概率只需要依赖低层公共包,不需要碰编排器。
更妙的是,AST 闸门给了新人一个“不会出错的闭环”:他们按直觉写,合 PR 时如果违反了规则,CI 会报出精确的文件和行号,不需要资深员工去逐行 review import。这其实把架构规范从“老人口耳相传”变成了“机器强制执行”。
6.2 模块可测试性与可替换性上升
依赖被捋顺之后,测试工作也跟着变轻松。技能模块不再被一堆同层模块拖累,单元测试可以只 mock 低层边界,不用再起一整个 Agent 上下文。模型接入层被隔离在第三层之后,切换模型厂商变成只改llm层的事,编排器和技能层完全无感。我们后来把一个线上模型的对接从某厂商切到另一个本地推理服务,改动面从几十个文件缩小到四五个文件,这对一个 160 模块的项目来说是相当难得的体验。
依赖图也从“一团毛线”变成了“一个漏斗”。上层入口可以访问下层任意模块,下层公共库对上层一无所知。任何一次重构,影响面都可以用“它在第几层”快速估算出来。
6.3 还能接着扩展的治理点
这套闸门的思路不只停在“层间依赖”上,后来我们还往里面扩展了其他检查项。比如模块文件行数上限、函数圈复杂度红线、以及禁止某个公共库被业务模块直接排除的“约定边界”。每加一个检查项,都尽量做到“有精确位置、有豁免机制、有渐进节奏”。
我个人还做了一次 MySQL 版本:把最终依赖关系导出成 HTML 页面,每个模块都能点击跳转,看它的上游依赖和下游被依赖。可视化出来之后,你可以非常直观地看到编排层还是靠上,技能层像扇子一样铺开,整个结构像一张整洁的分层地址簿。看到那个图的时候,我才真正觉得 160 个模块是有救的。
最后再分享一点个人体会:架构治理这件事,文档定义理想,工具守住底线,白名单容纳旧债,最后靠统计说话。AST 闸门不是银弹,它没有消灭复杂度的能力,它只是把“别乱 import”这句话从一个建议变成了一条不可协商的契约。如果你的项目也快被依赖关系压垮,第一步不是去搞微服务,也不是去推“领域驱动设计”,而是先数清楚自己有多少模块,给它们分好层,然后写一道自己的 AST 闸门。大概率,还来得及。