LangGraph 智能体项目实战问题:循环导入的根因分析与三层解法(Python langgraph)

2 阅读13分钟

核心结论(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.13from ... import 形式(最常见):
ImportError: cannot import name 'a_func' from partially initialized module 'a'
(most likely due to a circular import) (/path/a.py)

# Python 3.14from ... import 形式(笔者在 3.14.7 实测):
ImportError: cannot import name 'a_func' from 'a'

问题产生的根本原因:部分初始化

这不是"死循环", 因为 Python 不会无限递归导入,真正的问题是某个模块还没执行完就被别人引用了,别人拿到的是一个"半成品"。

二、根因分析

2.1 Python 的执行模型

要理解循环导入,得先理解 Python 的执行模型(execution model)。

简单说,Python 不是"先把所有函数类都定义好再开始执行",而是编译成字节码后,由虚拟机逐条执行——defimportfrom ... 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 a1. 检查 sys.modules 里有没有 'a'
   ├── 有 → 直接返回,不重新执行代码
   └── 没有 → 继续下一步
  ↓
2. 创建一个空的模块对象,放入 sys.modules['a'](先占位,此时它什么属性都没有)
  ↓
3. 从上到下执行 a.py 的代码,定义的变量/函数/类逐步成为模块对象的属性
  ↓
4. a.py 执行完毕,sys.modules['a'] 才是一个完整的模块对象

这个缓存有三个作用:

  1. 防止重复执行:同一个模块被多次 import 时只执行一次;
  2. 保证单例:整个程序里同一个模块只有一个实例,所有地方共享;
  3. 打破死循环:A 导入 B、B 又导入 A 时,不会无限递归下去。

也就是说,sys.modules 保证了你的代码不会死循环,但代价是会出现部分初始化问题

2.3 根因解析:部分初始化是怎么发生的

Python 虚拟机逐条执行代码,一开始在 a 模块执行,当执行到 from b import b_func 这条导入语句时:

  1. sys.modules 里没有 b → 创建空的 b 模块对象占位 → 去执行 b 的代码;
  2. b 逐行执行,到 from a import a_func 时,发现 a 已经在 sys.modules 里了;
  3. 但此时的 a 只是部分初始化状态——它执行到导入 b 的那一行就"暂停"了,a_func 要在导入语句之后才会被 def 创建;
  4. 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_PROMPTPython 先执行 services/agent/__init__.py(包初始化)
  ↓
__init__.py 里:from .agent_service import chat_with_agent
  ↓
执行 agent_service.pyfrom services.agent.graph import agent_graph
  ↓
执行 graph.pyfrom services.agent.nodes import memory_node, router_node, tool_node
  ↓
执行 tool_node.pyfrom services.agent.tools import TOOL_REGISTRY
  ↓
执行 tools/__init__.pyfrom .history_tool import get_recent_chats, get_chat_messages
  ↓
执行 history_tool.pyfrom 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