核心结论(TL;DR)
循环导入报错的本质不是"死循环",而是部分初始化(partially initialized):
Python 逐行执行,而 sys.modules 会先创建空模块占位、再逐步填充,
当依赖形成环时,某个模块在被引用的那一刻还没初始化完,引用了它尚未定义的属性,于是报错。
本文基于我在多智能体项目(LangGraph 多流水线)中遇到的真实循环导入问题,讲清楚四件事:
- 根因:从 Python 执行模型和 sys.modules 缓存机制,推导"部分初始化"是怎么发生的
- 一个隐蔽陷阱:导入包内的一个子模块,会先执行包的
__init__.py,引发级联导入 - 三层解法:函数内延迟导入(治标)、分层解耦下沉公共依赖(治本)、依赖倒置面向抽象(优雅治本)
- 可迁移的架构原则:如何让模块依赖成为有向无环图(DAG)
运行环境:Python 3.11 / LangGraph 0.x,最后更新 2026-09。
一、什么是循环导入
做 Python 项目时(比如 LangGraph、LangChain 这类智能体项目),做分层架构有时候会出现循环导入的问题。先用一个最简单的例子来体现:
# a.py
print("a.py 开始执行")
from b import b_func # 第2行:导入b
print("a.py 继续执行")
def a_func():
return "I am a"
print("a.py 执行完毕")
# b.py
print("b.py 开始执行")
from a import a_func # 第2行:导入a
print("b.py 继续执行")
def b_func():
return "I am b"
print("b.py 执行完毕")
当前有 a 和 b 两个模块,当 Python 在执行阶段时,Python 是逐行执行的,每行根据它是什么语句做对应的事:当在模块 a 执行到 from b import b_func 时,如果 b 不在模块缓存里,就会创建空 b 的模块对象;
创建完后,去 b 的模块执行 b 的代码,到 from a import a_func 时,Python 解释器会去看 a 缓存存不存在——前面创建了 a 缓存,就去 a 缓存里拿 a_func,但是 a 里还没执行到定义该函数,抛异常(报错文案随 Python 版本和导入方式略有不同):
# Python 3.8 ~ 3.13,from ... import 形式(最常见):
ImportError: cannot import name 'a_func' from partially initialized module 'a'
(most likely due to a circular import) (/path/a.py)
# Python 3.14,from ... import 形式(笔者在 3.14.7 实测):
ImportError: cannot import name 'a_func' from 'a'
问题产生的根本原因:部分初始化
这不是"死循环", 因为 Python 不会无限递归导入,真正的问题是某个模块还没执行完就被别人引用了,别人拿到的是一个"半成品"。
二、根因分析
2.1 Python 的执行模型
要理解循环导入,得先理解 Python 的执行模型(execution model)。
简单说,Python 不是"先把所有函数类都定义好再开始执行",而是编译成字节码后,由虚拟机逐条执行——def、import、from ... import ... 这些都是执行语句,执行到才创建:
x = 1 # 这行是赋值,执行赋值
def 函数名(): # 这行是def,执行它 = 创建函数对象
pass
print(x) # 这行是print,执行打印
import os # 这行是import,执行它 = 触发模块加载,去对应模块执行代码
关键点:函数在执行到 def 之前是不存在的。
如果在 def 函数名 之前调用 函数名(),会直接报 NameError。这和 C/Java 这类编译型语言不同——它们在编译阶段所有函数就已经存在了,而 Python 是执行到哪、定义到哪。
2.2 Python 的模块缓存 sys.modules
sys.modules 是 Python 的模块缓存,它是一个全局字典,key 是模块名,value 是模块对象,作用就是缓存模块对象,后续使用到相同模块里的内容,直接从缓存取。
一次完整的导入流程是这样的:
执行 import a
↓
1. 检查 sys.modules 里有没有 'a'
├── 有 → 直接返回,不重新执行代码
└── 没有 → 继续下一步
↓
2. 创建一个空的模块对象,放入 sys.modules['a'](先占位,此时它什么属性都没有)
↓
3. 从上到下执行 a.py 的代码,定义的变量/函数/类逐步成为模块对象的属性
↓
4. a.py 执行完毕,sys.modules['a'] 才是一个完整的模块对象
这个缓存有三个作用:
- 防止重复执行:同一个模块被多次 import 时只执行一次;
- 保证单例:整个程序里同一个模块只有一个实例,所有地方共享;
- 打破死循环:A 导入 B、B 又导入 A 时,不会无限递归下去。
也就是说,sys.modules 保证了你的代码不会死循环,但代价是会出现部分初始化问题。
2.3 根因解析:部分初始化是怎么发生的
Python 虚拟机逐条执行代码,一开始在 a 模块执行,当执行到 from b import b_func 这条导入语句时:
- sys.modules 里没有 b → 创建空的 b 模块对象占位 → 去执行 b 的代码;
- b 逐行执行,到
from a import a_func时,发现 a 已经在 sys.modules 里了; - 但此时的 a 只是部分初始化状态——它执行到导入 b 的那一行就"暂停"了,
a_func要在导入语句之后才会被def创建; - Python 直接从这个半成品 a 里找
a_func,找不到 → 抛ImportError。
用时间线表示更清楚:
T0 开始执行 a.py,创建空模块 a 放入 sys.modules
T1 a.py 执行到 from b import b_func
T2 去执行 b.py,创建空模块 b 放入 sys.modules
T3 b.py 执行到 from a import a_func
T4 发现 a 已在缓存中 → 直接取(但 a 只执行了 T0~T1,a_func 还没定义)
T5 找不到 a_func → 抛异常
所以循环导入的本质是:Python 逐行执行 + sys.modules 先占位后填充,导致环上的某个模块被引用时还没初始化完。
补充一点:循环导入不一定报错。如果 b 引用的是 a 在导入语句之前就已经定义好的属性,就能正常拿到。报错与否取决于"被引用的属性,在它所在模块被暂停的那一刻是否已经定义"。
三、案例解析:包初始化的级联导入
3.1 背景
我在做一个多智能体项目(ai-agent-lab),采用分层架构:common 通用层、services 服务层、services/agent Agent 层。之前想统一管理提示词,把系统提示词放到了 services/agent/prompts 目录下,结果跑测试时遇到了一个隐蔽的循环导入。
问题描述:
`conversation_service`(services 层)顶部导入 `agent.prompts`(agent 层);
触发 agent 包初始化,`agent.tools.history_tool` 又导入 `conversation_service`,形成环。
3.2 关键陷阱:导入一个子模块,会先执行整个包的 init.py
这里最容易被忽略的点是:导入包内的任何一个子模块,Python 都会先执行这个包的 __init__.py,而 __init__.py 里导入的东西会引发一连串的级联导入。
我当时的依赖链路是这样的:
conversation_service 执行:from services.agent.prompts import CHAT_SYSTEM_PROMPT
↓
Python 先执行 services/agent/__init__.py(包初始化)
↓
__init__.py 里:from .agent_service import chat_with_agent
↓
执行 agent_service.py:from services.agent.graph import agent_graph
↓
执行 graph.py:from services.agent.nodes import memory_node, router_node, tool_node
↓
执行 tool_node.py:from services.agent.tools import TOOL_REGISTRY
↓
执行 tools/__init__.py:from .history_tool import get_recent_chats, get_chat_messages
↓
执行 history_tool.py:from services.conversation_service import load_chat_list, get_sorted_chat_list
↓
回到了 conversation_service —— 但它只执行到导入 agent.prompts 那一行,是个半成品 → 报错
我以为只是导入了一个很轻量的 prompts(提示词常量),实际上却像多米诺骨牌一样,被 __init__.py 带着把 agent_service → graph → nodes → tools → history_tool 整条依赖链全部触发了一遍,最后绕回自己,形成环。
3.3 问题代码
# services/conversation_service.py(当时的写法)
from services.agent.prompts import CHAT_SYSTEM_PROMPT # 顶部导入,触发整个 agent 包初始化
def add_new_chat(username):
...
init_messages = [deepcopy(CHAT_SYSTEM_PROMPT)] # 深拷贝一份系统提示词
...
# services/agent/tools/history_tool.py
from services.conversation_service import load_chat_list, get_sorted_chat_list
3.4 根因
从依赖方向上看,这是一个典型的依赖方向反转问题:
conversation_service(services 层)去依赖了agent层;- 而 agent 层的工具
history_tool又回过头来依赖 services 层的conversation_service; - 两层互相依赖,依赖关系形成了一个环(图论里的环),没有任何一个模块能"先完整初始化"。
架构层面的根因:依赖方向反了。上层可以导入下层,但下层绝不能反向导入上层。
四、解决方案
针对循环导入,有三种层次的解法:延迟导入(治标)、分层解耦(治本)、依赖倒置(优雅治本)。
4.1 方案一:函数内延迟导入(治标)
做法:把顶部的 import 移到函数内部,让它在函数被调用时才执行,而不是模块加载时就执行。此时所有模块早就初始化完了,自然不会有半成品问题。
# services/conversation_service.py
# 顶部不再导入,改为在函数内部导入
def add_new_chat(username):
...
with get_user_lock(username):
history = load_chat_list(username)
history.insert(0, new_chat_meta)
save_chat_list(username, history)
# 延迟到函数调用时才导入,此时 short_term_memory_service 已初始化完成
from services.short_term_memory_service import save_chat_messages
save_chat_messages(chat_id, init_messages)
最初,我项目2,3个地方都用了这种方式来规避 services 层, common 层内部的相互导入。
- 优点:改动极小,快速止血;
- 缺点:治标不治本——依赖环依然存在,只是把它推迟到运行时绕过去了;每次调用都要查一次
sys.modules(开销很小但存在);而且顶部看不到完整依赖,可读性变差。 - 适用场景:临时救火、第三方库内部的循环导入、两个模块确实有弱耦合时。
4.2 方案二:分层解耦,下沉公共依赖(治本)
后来我把底层知识吃的相对透彻以后,回头看我的案例,conversation_service 为什么要导入 agent 层?只是为了拿一个系统提示词常量 CHAT_SYSTEM_PROMPT。提示词常量本质上是一个谁都可能用的公共资源,根本不该放在 agent 层。
做法:把被多方依赖的公共部分下沉到更底层的模块,让依赖方向重新变成单向的。
重构前(有环):
conversation_service ──→ agent.prompts
↑ │
└────────────────────┘
(history_tool 经 agent 包反向依赖 conversation_service)
重构后(单向无环):
conversation_service ──→ common.prompt(下沉到通用层)
agent 层 ──→ common.prompt
大家都只依赖最底层的 common,环被打破
具体操作:把提示词从 services/agent/prompts 移到 common/prompt/chat_prompt.py,由 common/prompt/__init__.py 统一导出:
# common/prompt/__init__.py
from .chat_prompt import CHAT_SYSTEM_PROMPT
__all__ = ["CHAT_SYSTEM_PROMPT"]
# services/conversation_service.py(重构后)
from common.prompt import CHAT_SYSTEM_PROMPT # 改为依赖更底层的 common,不再碰 agent 包
这样 conversation_service 不再触发 agent 包初始化,那条级联导入链从源头被切断,环就消失了。
- 优点:从根本上消除环,依赖关系变成清晰的有向无环图(DAG) ,架构更干净;
- 缺点:需要判断"哪些东西该下沉",有一定重构成本;
- 核心原则:上层可以导入下层,下层绝不能导入上层;被多方依赖的东西,放到更底层。
4.3 方案三:依赖倒置,面向抽象(优雅治本)
有些场景下两个模块确实存在"概念上的相互需要",硬下沉会很别扭。这时可以用依赖倒置原则(DIP) :上层不依赖下层的具体实现,而是定义一个抽象接口(Protocol / ABC),下层去实现这个接口。
我学了harness的设计逻辑,目前当前项目里的 HarnessBuilder(流水线构建器)就是这个思路。它要被各个具体的 Agent 图使用,但它自身绝不反向依赖任何业务模块:
# common/agent/harness.py —— 纯通用层,零业务依赖
class HarnessBuilder:
"""
设计原则:零业务依赖——不 import 任何 state、节点、prompt,纯通用层
"""
def __init__(self, state_class, name: str):
self._workflow = StateGraph(state_class) # 状态类由外部传入
...
def node(self, name: str, func: Callable): # 节点函数由外部传入
...
self._workflow.add_node(name, func)
return self
# services/agent/graph.py —— 业务层依赖通用层,把具体的 state 和节点"注入"进去
agent_graph = (
HarnessBuilder(AgentState, name="agent_react") # 传入具体状态类
.node("memory", memory_node) # 传入具体节点函数
.node("router", router_node)
.node("tool", tool_node)
.entry("memory")
.edge("memory", "router")
.build()
)
通用层 harness 不知道也不关心具体的 state、节点长什么样,它只依赖 Callable 这样的抽象;具体的业务模块反过来依赖通用层。依赖的箭头被"倒置"了,环自然无法形成,同时还获得了扩展性——新增一条流水线时,通用层一行代码都不用改。
- 优点:最优雅,符合依赖倒置原则,扩展性最好,通用能力可复用;
- 缺点:需要抽象思维,代码量略多;
- 适用场景:复杂系统、需要复用的通用框架、预期会扩展的地方。
4.4 三种方案对比
| 方案 | 解决程度 | 改动量 | 优雅度 | 适用场景 |
|---|---|---|---|---|
| 函数内延迟导入 | 治标(环还在) | 小 | 低 | 临时救火、弱耦合 |
| 分层解耦、下沉公共依赖 | 治本(消除环) | 中 | 中 | 绝大多数业务场景 |
| 依赖倒置、面向抽象 | 治本(环无法形成) | 中 | 高 | 通用框架、需要扩展的复杂系统 |
五、总结
1. 循环导入的本质是"部分初始化",不是死循环。 Python 逐行执行,def/import 都是执行语句;sys.modules 会先创建空模块占位、再逐步填充,这避免了无限递归,却也让环上的模块在被引用时可能还是个半成品——引用了它尚未定义的属性,就会报 `cannot import name ...
2. 警惕"包初始化的级联导入"。 导入包内的一个子模块,会先执行包的 __init__.py,而它导入的内容会触发整条依赖链。所以一个看似无关的轻量导入,可能把整个包都拉进来。实践中应让 __init__.py 尽量轻量。
3. 解决循环导入的核心是消除依赖环,让依赖关系成为有向无环图(DAG)。 三个层次的解法:
- 延迟导入:把 import 放进函数,运行时再加载——快速止血但环还在;
- 分层解耦:把公共依赖下沉到更底层,保证依赖方向单向——最常用的根治手段;
- 依赖倒置:上层依赖抽象、下层实现抽象并注入——最优雅,兼顾扩展性。
4. 两条可以一直用的架构原则:
- 依赖方向必须是单向的:上层依赖下层,下层绝不反向依赖上层;
- 抽象不应该依赖细节,细节应该依赖抽象。
遇到循环导入时,先别急着延迟导入"绕过去",而是画出模块依赖图、找到那个环,判断被多方依赖的东西是不是放错了层级——大多数情况下,把公共部分下沉、或者抽出一层抽象,环就从根本上消失了。
如果我有理解错的地方,可以积极指出来,有问题也可以在评论区互相讨论,一起互相提升做agent的水平。
如果对你有帮助, 方便的话可以给我点个赞支持一下,谢谢QwQ