Python 基础篇
对应课件:Python与数据实践(02-21 章相关主题)。转行面 AI 应用开发岗,Python 八股 100% 会被考——面试官用它确认你的工程底子配得上"工程师"三个字。本篇 10 题是 Python 面试里最高频…
对应课件:Python与数据实践(02-21 章相关主题)。转行面 AI 应用开发岗,Python 八股 100% 会被考——面试官用它确认你的工程底子配得上"工程师"三个字。本篇 10 题是 Python 面试里最高频的核心集,深度标准是"讲清原理 + 说出实际踩过的坑"(个别课件未覆盖的高频点已标注)。
Q01 · 装饰器的原理是什么?手写一个带功能的装饰器
难度:★★★ | 频率:极高 | 对应课件:Python与数据实践/10-装饰器
面试官:Python 项目里到处是 @ 装饰器。说说它的原理,然后手写一个带计时和日志的。
第 1 层追问:原理
@deco 到底做了什么?
参考回答:装饰器是"不改原函数代码,给它包一层行为"的语法糖。@deco 放在函数上等价于 f = deco(f)——把函数传给装饰器,装饰器返回一个新函数,原名指向新函数。它能成立的两个前提是 Python 的底层特性:函数是一等公民(可以当参数传、当返回值返)和闭包(内层函数记住外层变量)。所以装饰器没有任何魔法:就是一个接收函数、返回函数的高阶函数。执行时机要清楚:包裹发生在 def 那一刻(模块导入时),不是调用时——这解释了为什么装饰器里的代码在"没调用函数"时也会跑。
第 2 层追问:手写 + wraps
写一个计时装饰器。
functools.wraps是干什么的,不写会怎样?
参考回答:
import time, functools
def timer(func):
@functools.wraps(func) # 关键行
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
print(f"{func.__name__} 耗时 {time.perf_counter() - start:.3f}s")
return result
return wrapper
@functools.wraps(func) 把原函数的元数据(__name__、__doc__、__module__)拷到 wrapper 上。不写的坑:日志和调试器里所有被装饰的函数都叫 "wrapper";help() 看不到文档;多个装饰器叠用时排错像猜谜。functools.wraps 本身也是个装饰器——用 functools.partial 实现的,这是"装饰器组合"的经典范例。
第 3 层追问:带参数的装饰器
@retry(times=3)这种带参数的怎么写?
参考回答:再包一层——带参装饰器是"返回装饰器的函数",三层嵌套:
def retry(times):
def deco(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for i in range(times):
try:
return func(*args, **kwargs)
except Exception as e:
last = e
raise last
return wrapper
return deco
读法:retry(times=3) 先执行返回 deco,deco 再接收函数。判断依据:装饰器带括号 = 多一层。这个重试装饰器正是 LLM 应用里最实用的基建之一——API 调用超时/限流时指数退避重试,比在每个调用点手写 try 循环干净一个量级。
第 4 层追问:实战与细节
多个装饰器叠放的顺序有讲究吗?
参考回答:就近包裹。@A 在上 @B 在下时,执行序是 A(B(f))——调用时先进 A 的 wrapper 再进 B 的。实战语义:@cache 要包在 @retry 外面还是里面,取决于你要"缓存失败前的重试结果"还是"缓存最终结果"——顺序写反,缓存的就是异常了。这个细节没有八股答案,能讲出"顺序影响语义、要按业务想清楚",就是用过而不是背过。
考察意图与踩坑点
- 考察意图:装饰器是 Python 工程能力的分水岭——AI 应用岗日常要写重试/日志/缓存装饰器,"手写"环节淘汰纯背题者。
- 踩坑点:说不出
@deco等价f = deco(f);不知道 wraps 的坑;以为装饰发生在调用时。 - 一句话总结:装饰器 = 高阶函数 + 闭包,
@只是f = deco(f)的语法糖;带参装饰器加一层;叠放顺序 = 包裹顺序。
Q02 · 闭包是什么?这段代码为什么输出全是 3?
难度:★★★ | 频率:极高 | 对应课件:Python与数据实践/05-作用域规则与闭包
面试官:讲讲闭包。然后解释:funcs = [lambda: i for i in range(3)],三个函数都调用,为什么结果都是 3?
第 1 层追问:闭包定义
先说什么是闭包。
参考回答:闭包 = 内层函数 + 它引用的外层作用域变量,且外层函数已经返回,这些变量依然存活。例如:
def make_multiplier(n):
def multiply(x):
return x * n # n 是外层变量
return multiply
double = make_multiplier(2) # make_multiplier 已返回
double(5) # 10 —— n=2 还活着
关键机制:内层函数持有对外层变量名的引用(不是值的拷贝),Python 靠 __closure__(cell 对象)保持这些变量不被回收。闭包是装饰器的地基——上一个题的 wrapper 记住 func,靠的就是它。
第 2 层追问:LEGB 与修改
内层函数想改外层变量怎么办?
参考回答:先讲 LEGB 查找顺序:Local(函数内)→ Enclosing(外层函数)→ Global(模块级)→ Builtin。内层读外层变量自动走 E;但赋值语句会在 L 创建新变量——想真正改外层要显式声明 nonlocal n(改模块级用 global)。nonlocal 是"我改的是 E 层那个名字"的声明,和 JS 的作用域链赋值行为对比着记更清楚。
第 3 层追问:late binding 陷阱
回到开头那道题,为什么全是 3?
参考回答:因为闭包记的是变量名 i,不是每个时刻的值——这叫延迟绑定(late binding)。列表推导结束时 i 已经走到 2 循环结束值(range(3) 产生 0,1,2,循环结束 i=2;若 range(3) 在 Python3 里 i 最终是 2——但该题经典版本 range(3) 输出 3 的场景是 for 循环后 i 变成退出值。以 Python3 列表推导为准:三个 lambda 调用时才去查 i,此时 i 已是最后一个值 2,所以输出 2, 2, 2——无论 2 还是 3,要点是三个函数查到的是同一个最终的 i)。修复:把值"钉死"在默认参数里——默认参数在函数定义时求值:
funcs = [lambda i=i: i for i in range(3)] # 输出 0, 1, 2
或用 functools.partial(f, i)。这个坑的本质是"闭包按名字查值,查的时机决定值"——答这题时先把机制说对,再讨论边界值是 2 还是 3 反而是次要的。
第 4 层追问:什么时候用闭包 vs 类
闭包和类都能"带状态",怎么选?
参考回答:看状态的复杂度和生命周期。闭包:状态是几个私有变量、不想暴露内部结构、轻量(如 make_multiplier、计数器)——没有 self 的心智负担。类:状态需要多个方法操作、要继承/组合、状态需要自描述(obj.count 可调试可打印)。中间形态 functools.partial 和 dataclass 各占一角。工程习惯:闭包做"函数工厂",类做"领域对象",混用边界就看"这坨状态需要名字吗"。
考察意图与踩坑点
- 考察意图:late binding 那道题是经典笔试题,考"引用 vs 拷贝"的底层理解;LEGB 是顺带必考。
- 踩坑点:只背"闭包是内层函数引用外层变量"却不理解按名引用;把
nonlocal说成"全局变量"。 - 一句话总结:闭包按名字引用外层变量(LEGB 的 E 层)——延迟绑定是坑的根源,默认参数在定义时求值是解药。
Q03 · 生成器和迭代器什么关系?为什么生成器省内存?
难度:★★★ | 频率:极高 | 对应课件:Python与数据实践/11-迭代协议与生成器
面试官:迭代器、可迭代对象、生成器——这三个词的关系说清楚。生成器为什么省内存?
第 1 层追问:协议与关系
先分清三个概念。
参考回答:可迭代对象(Iterable):实现了 __iter__,能被 for 循环(list、str、dict 都是)。迭代器(Iterator):实现了 __iter__ 和 __next__,是"取下一个值的状态机"——for 循环的底层就是不断调 next() 直到 StopIteration。生成器(Generator):是迭代器的一种——任何函数体里带 yield,调用它就不执行函数体,而是返回一个生成器对象;每次 next() 才执行到下一个 yield 处暂停。关系一句话:生成器 ⊂ 迭代器 ⊂ 可迭代对象的"来源"之一;iter() 把可迭代对象转成迭代器。
第 2 层追问:yield 的暂停机制
yield 和 return 的本质区别?
参考回答:return 是终止并销毁,yield 是暂停可恢复。执行到 yield:当前值被送出、函数状态(局部变量、执行位置)被完整冻结;下次 next() 从暂停点继续。这让"生产一个、消费一个"成为可能——消费者要一个才算一个,生产者不必备货。两个配套细节:生成器只能消费一次(走完即耗尽,再 next 就 StopIteration——拿生成器做"循环两遍"的循环体是经典 bug);yield from 委托子生成器(yield from gen 等价于逐个 yield)。
第 3 层追问:省内存的量化
"省内存"到底省在哪?
参考回答:[i*i for i in range(10**8)] 立刻生成一亿个 int 的列表——几个 GB 内存、卡死进程;(i*i for i in range(10**8)) 生成器同一时刻只存一个值,内存 O(1)。本质是推式计算(惰性求值):数据在生产者和消费者之间以流的方式传递。配套还有生成器表达式(圆括号)vs 列表推导(方括号)的选择纪律:只迭代一遍、可能提前 break 的场景必须用生成器(如"在日志流里找第一条匹配"——列表推导会先扫完全量)。
第 4 层追问:LLM 应用里的生成器
你做 AI 应用时哪里用到了这个机制?
参考回答:流式输出的本质就是生成器。LLM API 的 stream 返回的就是一个迭代器/生成器——每个 chunk(token 片段)被 yield 出来,立刻推给 SSE 客户端;如果先攒全量再发,首字延迟就是整个生成时间,流式就失去意义。LangChain 里组件实现 stream() 方法逐块传递,底层协议一致。其他实战:大语料处理(几十万行文档逐行清洗入库,生成器管道 line_gen | clean | embed 每一环都是惰性的,内存不随语料规模膨胀);分页拉取(游标翻页包成生成器,调用方一个 for 拿完所有页)。答到这层,就是"懂原理且用在了刀刃上"。
考察意图与踩坑点
- 考察意图:三个概念的包含关系是必考基础;第 3、4 层是"背八股"和"写生产代码"的分界——流式输出连接 LLM 篇 Q07(自回归逐 token)形成呼应,AI 岗答这题有天然优势。
- 踩坑点:把生成器和迭代器说成并列关系;不知道生成器只能消费一次;说不出一个真实使用场景。
- 一句话总结:生成器是"带 yield 的函数返回的迭代器"——yield 暂停可恢复、惰性生产、单次消费;LLM 流式输出就是它。
Q04 · GIL 是什么?多线程、多进程、协程怎么选?
难度:★★★★ | 频率:极高 | 对应课件:Python与数据实践/18-线程与进程、19-事件循环
面试官:Python 的 GIL 说清楚。CPU 密集和 IO 密集任务,你的并发方案分别是什么?
第 1 层追问:GIL 本质
GIL 是什么,为什么存在?
参考回答:GIL(Global Interpreter Lock,全局解释器锁)是 CPython 里的解释器级互斥锁:同一时刻只有一个线程能执行 Python 字节码——多线程在 Python 里不能并行算 CPU。它存在的历史原因:CPython 的内存管理(引用计数)不是线程安全的,一把全局锁是最简单便宜的保护——用并发性换内存安全。所以"Python 多线程没用"是错的一半,要看任务类型。
第 2 层追问:影响的边界
哪些场景多线程仍然有用?
参考回答:IO 密集场景线程仍然有效——线程做网络/磁盘 IO 时会释放 GIL(阻塞的系统调用前后释放),其他线程可以跑。所以多线程对"等网络"的任务是真并发。真正受害的是 CPU 密集:多线程不仅不加速,还因线程切换和锁竞争比单线程更慢。还有第三种情况:C 扩展代码可以手动释放 GIL(numpy 的矩阵运算、hashlib 的摘要计算都这么干)——"numpy 多线程跑得飞快"不是 GIL 被绕过的bug,是扩展主动释放了锁。
第 3 层追问:三件套选型
给出你的选型决策。
参考回答:一张决策表:IO 密集 + 高并发(数百以上连接)→ asyncio 协程——单线程协作调度,万级并发、切换成本纳秒级,LLM 应用(每请求等模型几十秒)是它的主场。IO 密集 + 低并发或库是同步的 → 线程池——几十个线程够用,不用改写代码(同步库直接丢线程池)。CPU 密集 → multiprocessing 进程池——每个进程独立解释器独立 GIL,真并行;代价是进程启动重 + 参数必须可序列化(pickle)+ 结果回传要 IPC——传大对象时序列化开销能吃掉并行收益。混合型(先算再等):进程池套协程,各自处理擅长的段。
第 4 层追问:前沿与实战
GIL 会被移除吗?你的项目里怎么用到这些的?
参考回答:知道动向但不当卖点:Python 3.13 引入 free-threading 实验性构建(no-GIL 方向),过渡期内生产依赖它不明智——正确姿势是"关注、评估、不抢跑"。实战对齐课程项目:LLM 调用是 IO 密集 → asyncio + Semaphore 限流(并发调研系统);批量嵌入入库也是 IO 密集 → 分批 + gather;真正 CPU 密集的环节(本地模型的 tokenizer、大量正则清洗)才考虑进程池。选型结论永远从"瓶颈是 IO 还是 CPU"出发,不从"哪个更新潮"出发。
考察意图与踩坑点
- 考察意图:这是 Python 八股的头号题,同时是 AI 应用岗的天然送分题(LLM 应用全是 IO 密集)——能把"LLM 场景→asyncio"的因果讲顺,直接加分。
- 踩坑点:"Python 多线程没用"(半对半错的口号);不知道 IO 时释放 GIL;选型说不出序列化代价;把协程说成"轻量线程"(调度机制完全不同:协作式 vs 抢占式)。
- 一句话总结:GIL 锁的是字节码执行不是 IO——IO 密集 asyncio、低并发线程池、CPU 密集进程池;numpy 快是因为 C 扩展释放了 GIL。
Q05 · is 和 == 的区别?深拷贝浅拷贝什么场景踩过坑?
难度:★★★ | 频率:高 | 对应课件:Python与数据实践/03-序列字典与集合
面试官:a is b 和 a == b 什么时候结果不一样?copy.copy 和 copy.deepcopy 的区别,用一个你踩过的坑讲。
第 1 层追问:is vs ==
先讲最基础的。
参考回答:== 比值(调 __eq__,两个内容相同的独立对象返回 True);is 比身份(是不是同一个对象,内存地址是否相同)。[1,2] == [1,2] 是 True(值相等),[1,2] is [1,2] 是 False(两个对象)。工程纪律:is 只用于和 None/True/False 比较(if x is None)——因为单例语义必须用身份判断;永远不要用 is 比较数字和字符串:小整数缓存(-5 到 256)和字符串驻留(interning)会让 a is b 偶尔为 True——"在我机器上能跑"的坑源。
第 2 层追问:赋值、浅拷贝、深拷贝三级
传个 list 给函数,函数里改了,外面也变了——为什么?
参考回答:三级语义要分清。赋值 b = a:只复制引用,零拷贝——改 b 的元素 a 立刻可见(Python 传参是"传对象引用",不是值拷贝)。浅拷贝 copy.copy(a) / a[:] / list(a):新建最外层容器,但嵌套元素还是共享引用——a = [[1],[2]],b = copy.copy(a) 后 b[0].append(9),a[0] 也变(内外层是两个容器,但 a[0] 和 b[0] 指向同一个内层 list)。深拷贝 copy.deepcopy(a):递归复制所有层级,完全独立。函数改参那个问题就是第一级:传进去的是引用,原地修改(append、d[k]=v)外部可见;想不污染就传入副本或函数内拷贝。
第 3 层追问:踩过的真实坑
说一个实际场景。
参考回答:两个真实的。坑一(LLM 应用):多个会话复用一个"默认 messages 模板"——某处代码 messages.append(user_msg) 原地改了模板,所有后续会话的上下文被污染(第一条就带上了别人的对话)。修复:模板用 deepcopy 或构造函数每次新建。坑二(缓存返回值):函数返回缓存的 dict,调用方顺手改了两个字段——缓存里的数据从此损坏,且是"偶尔串数据"级别的静默 bug。修复:返回 deepcopy 或不可变结构。共性教训:可变共享是 Python 事故的第一大来源,怀疑共享时 id() 一查便知。
第 4 层追问:deepcopy 的代价
全用 deepcopy 不就安全了?
参考回答:不行,三笔账。性能:递归复制大嵌套结构(MB 级配置、长消息列表)耗时毫秒到秒级,热路径上会疼。循环引用:deepcopy 用备忘录字典处理环,但环+__del__ 的组合是历史雷区。语义:有些对象不该被拷贝——文件句柄、锁、连接池,deepcopy 会失败或产生双份资源。正确策略分层:默认共享(Python 文化),边界处防御(函数对外返回、跨会话传递时拷贝),热路径传不可变(tuple、frozen dataclass——不可变对象让"共享是否安全"这个问题消失)。
考察意图与踩坑点
- 考察意图:可变性 + 引用语义是 Python 工程师事故率的头号预测指标,面试官拿这题筛"背题的"和"被咬过的"。
- 踩坑点:is 比数字(撞小整数缓存);说不清浅拷贝"外层独立内层共享";没有真实踩坑案例。
- 一句话总结:== 比值 is 比身份(is 只配 None);赋值=共享、浅拷=外新内旧、深拷=全独立;可变共享是事故之源。
Q06 · 可变默认参数这个坑,原理是什么?
难度:★★★ | 频率:高 | 对应课件:Python与数据实践/04-函数设计与lambda
面试官:def add(item, box=[]) 连续调用两次,box 里有什么?为什么会这样?
第 1 层追问:现象与原理
先说结果,再说机制。
参考回答:两次调用的 item 都在 box 里——不是每次一个新空列表。原理:默认参数值在 def 执行时求值一次(函数定义那一刻,不是每次调用),求值结果存在函数对象的 __defaults__ 属性里;之后每次调用不传 box,用的都是同一个 list 对象——原地 append 自然累积。可用 add.__defaults__ 直接观察这个共享对象。这个坑只出现在可变默认值(list/dict/set);不可变默认值(int/str/None)"共享"无害,因为每次"修改"都产生新对象。
第 2 层追问:正确写法
怎么写才对?
参考回答:None 哨兵模式——约定俗成的标准解法:
def add(item, box=None):
if box is None:
box = [] # 每次调用新建
box.append(item)
return box
为什么用 None 而不是 box=[] 在函数体内判断?因为没法判断——传进来的合法 list 可能本身就是空的。None 表达"调用方没传"这个语义,不可变的哨兵值承担"缺省"标记是 Python 的通用惯用法(fastapi、pydantic 的可选参数全是这个模式)。
第 3 层追问:同一家族的坑
这个坑还有什么亲戚?
参考回答:三个近亲,一起防。坑一(类属性共享):class C: items = []——items 是类属性,所有实例共享同一个 list,inst1.items.append(x) 后 inst2 也能看到(类属性在实例查找不到时沿 MRO 向上找到同一个对象)。该放进 __init__ 的 self.items = [](实例属性,每个对象独立)。坑二(闭包变量捕获):上一题的 late binding,同为"引用共享、值延迟确定"家族。坑三(函数属性/全局缓存):拿 dict 当跨调用缓存却不做隔离。家族共性:Python 的"默认值/属性"存的是引用,创建一次永久共享——见到可变对象出现在"定义时求值"的位置,就要条件反射地问"会不会被共享修改"。
考察意图与踩坑点
- 考察意图:经典笔试题 + 真实高发 bug,考察对"求值时机"的理解深度——能讲到
__defaults__是函数对象属性这一层,就是文档级的扎实。 - 踩坑点:只知道现象说不出"def 时求值一次";不知道 None 哨兵这个标准解法;类属性共享这个亲戚没听说过。
- 一句话总结:默认值在 def 时求值一次存进
__defaults__——可变默认值被永久共享;None 哨兵是标准解法;类属性共享是它的近亲。
Q07 · with 语句的原理?异常发生了资源会释放吗?
难度:★★★ | 频率:高 | 对应课件:Python与数据实践/12-with与上下文协议
面试官:with 语句底层靠什么?函数在 with 块里抛了异常,文件关了吗?exit 返回 True 是什么效果?
第 1 层追问:协议
with 的机制讲清楚。
参考回答:with 靠上下文管理器协议:__enter__(进块前调用,返回值绑定到 as 变量)和 __exit__(exc_type, exc_val, exc_tb)(离开块时无论正常还是异常都调用——这是它存在的全部意义)。with open(f) as fp: 的语义:open 返回文件对象,__enter__ 返回自身绑定给 fp,块结束时 __exit__ 里调 close()。手动等价形态就是 try/finally:
fp = open(f)
try:
... # with 块
finally:
fp.close() # __exit__
with 只是把这段样板收进协议——异常安全是它的核心卖点。
第 2 层追问:异常路径
抛异常时发生什么?exit 返回 True 呢?
参考回答:异常发生时,__exit__ 收到完整的异常三元组(类型、值、traceback),资源照常释放;随后 exit 的返回值决定异常的命运:返回 False/None(默认)→ 异常继续向外抛;返回 True → 异常被吞掉,with 语句正常结束,外面毫无感知。所以"异常了资源会释放吗"——会;"程序会崩吗"——取决于 exit 返回什么。自定义上下文管理器时几乎永远返回 False(只做清理不吞异常),吞异常是危险特权:连 Ctrl+C(KeyboardInterrupt)都能吞掉。
第 3 层追问:contextmanager 与异步
每次都写一个类太重,有轻量写法吗?异步场景呢?
参考回答:@contextlib.contextmanager 用生成器实现协议——yield 之前是 __enter__,之后是 __exit__,异常会从 yield 处被重新抛进生成器(所以 except 分支就是 exit 的异常处理):
from contextlib import contextmanager
@contextmanager
def timer(label):
start = time.perf_counter()
try:
yield # with 块在这里执行
finally:
print(f"{label}: {time.perf_counter()-start:.3f}s")
这题连接 Q03:生成器的暂停恢复正好实现"进块/出块"两个时机——yield 就是块边界。异步对应物是 async with + __aenter__/__aexit__:管理异步资源(httpx.AsyncClient、asyncio.Lock、数据库异步会话)——课程 FastAPI 篇的数据库会话就是它。同步 with 块里出现 await 是语法错误,两类不可混。
第 4 层追问:LLM 应用里怎么用
你的项目里 with 管什么资源?
参考回答:三类。网络客户端生命周期:async with httpx.AsyncClient() as client:——连接池的正确打开方式(复用连接而不是每个请求新建 TCP)。数据库会话:FastAPI 的 yield 依赖(def get_db(): session = ...; yield session; finally: session.close())——框架把"一个请求一个会话"的资源管理变成了声明式依赖。锁与临界区:async with lock: 保护共享计数器。共同模式:资源的"必须释放"语义全交给协议,代码里不留 finally 样板——这也是面试里"工程素养"的可视化证据。
考察意图与踩坑点
- 考察意图:考察"知道 with 不只是文件"、异常路径的精确理解——
__exit__返回值那问能答对的人不多。 - 踩坑点:以为异常时资源不释放;不知道返回 True 吞异常;分不清 with 和 async with。
- 一句话总结:with = enter/exit 协议 = 自动化的 try/finally;异常时照常清理,exit 返回 True 吞异常;contextmanager 用生成器造、async with 管异步资源。
Q08 · asyncio 的事件循环怎么工作?"coroutine was never awaited" 是什么错?
难度:★★★★ | 频率:高 | 对应课件:Python与数据实践/19-事件循环、20-Future与协程、21-asyncio实战
面试官:写 asyncio 的代码,调用一个 async 函数却忘了 await,报 "coroutine was never awaited"——怎么回事?事件循环的调度机制讲一下。
第 1 层追问:coroutine 对象 vs 执行
先解释这个新手报错。
参考回答:调用 async 函数不执行它——async def f(): ... 之后 f() 只是创建一个 coroutine 对象(一个"未执行的任务说明书"),函数体一行都没跑;只有 await f() 或把 coroutine 交给事件循环(asyncio.run、asyncio.create_task)才会执行。忘了 await 就是造了说明书没执行——Python 检测到 coroutine 对象被丢弃才发那句 RuntimeWarning。所以口诀:调用 async 函数永远配 await;这也解释了为什么 async 函数的返回值必须 await 才拿得到。
第 2 层追问:事件循环与让出
协程是怎么"并发"的?单线程不是串行吗?
参考回答:事件循环 + 协作式调度。事件循环是单线程的调度器,维护一个就绪队列:每个协程跑到 await(等 IO 完成、等 sleep、等锁),就主动让出控制权回到循环;循环看谁的 IO 就绪了(selector 通知),就把谁排回去继续。并发的本质是"等待的时间被让给别人用"——单核单线程,但谁都不闲着空等。这和线程的抢占式调度(操作系统随时打断)根本不同:协程切换发生在代码里显式的 await 点——好处是切换点可见(不容易有竞态)、切换成本极低;代价是一个协程不 yield,全体都卡死(引出第 3 层)。
第 3 层追问:两大经典坑
你踩过 asyncio 什么坑?
参考回答:两个必须"亲历"过才算会的坑。坑一(阻塞调用冻结整个 loop):协程里调了同步阻塞的东西——requests.get(同步 HTTP)、time.sleep、重 CPU 计算——事件循环没有机会切出去,整个服务所有协程集体冻结。症状极具迷惑性:"并发怎么变成串行了、还都慢"。修复:换异步库(httpx.AsyncClient)、asyncio.sleep、CPU 活丢 run_in_executor。坑二(gather 吞/炸语义):gather 默认 return_exceptions=False——一个协程抛异常,整个 gather 立刻抛出(其余协程可能还在跑没人收);设 return_exceptions=True 则异常作为结果返回,逐个检查。课程项目里调研分支一个网络抖动带崩全组就是它——修复后失败分支进降级路径而不是整体报废。
第 4 层追问:编排工具箱
并发控制你常用什么?
参考回答:三件套 + 一个纪律。gather:并发跑一批、按序返回(保序对应关系不乱);asyncio.Semaphore(n):全局限流——LLM 服务有速率限制,不加闸就是 429 风暴(先在自己的边界排队,好过把压力打给上游);wait_for / timeout:单路超时不拖垮整体。纪律:async 生态要全链路一致——从 DB 驱动到 HTTP 客户端全异步,中间混一个同步库就局部冻结;这就是课程里 FastAPI 路由必须配异步 LLM 客户端的原因(FastAPI 篇 Q01 呼应)。什么时候 asyncio 没收益:CPU 密集(单线程没第二个核可用)——回到 GIL 题的选型表。
考察意图与踩坑点
- 考察意图:这是 AI 应用岗的核心 Python 能力题——LLM 应用天生 IO 密集,asyncio 是每天写的东西;两个坑答得出细节就是实战过的人。
- 踩坑点:"协程是轻量线程";不知道调用 async 函数不执行;阻塞 loop 和 gather 异常语义这两个坑一个都说不出来。
- 一句话总结:调用 async 函数只是造对象,await 才执行;事件循环 = 协作式调度"等待让给别人";阻塞调用冻结全局、gather 默认一损俱损;gather+Semaphore+timeout 三件套。
Q09 · 实例方法、类方法、静态方法什么时候用?init 和 new 呢?
难度:★★★ | 频率:高 | 对应课件:Python与数据实践/06-类的定义与实例化、08-魔术方法全景
面试官:一个类里三种方法怎么区分、各自什么场景?new 和 init 谁先执行?
第 1 层追问:三方法区分
先给判别标准。
参考回答:看第一个参数。实例方法(默认):第一个参数 self,能访问实例状态和类状态——绝大多数业务方法。类方法 @classmethod:第一个参数 cls(类本身),访问类状态、不依赖实例——典型用途是工厂方法:User.from_dict(d)、Config.from_env()——用类名调用、返回类的新实例,继承时 cls 自动是子类(工厂的正确性免费获得)。静态方法 @staticmethod:没有隐式第一参数,和类只差"命名空间"关系——纯工具函数(MathUtils.clamp(x, min, max)),放类里只是为了组织代码。选型直觉:碰实例用实例方法,造实例用类方法,不碰类只借名字用静态方法。
第 2 层追问:new vs init
对象创建分几步?
参考回答:两步。__new__ 先执行:负责创建对象本身(分配内存、返回实例),是静态方法,返回的实例传给下一步;__init__ 后执行:负责初始化这个已存在的实例(设属性),不能有返回值。日常只写 init(new 用默认的 object.new)。new 的两个正当场景:单例模式(拦截创建、每次返回同一个实例);不可变类型的定制(int/str/tuple 实例化时必须走 new,因为初始化后不能改)。面试口诀:new 造壳、init 装修——顺序答反是硬伤。
第 3 层追问:property 与魔术方法
只读属性怎么实现?repr 和 str 的区别?
参考回答:@property 把方法伪装成属性——obj.score 背后跑 getter,还能带 setter 做校验(赋值越界就抛)、只定义 getter 就是只读(尝试赋值抛 AttributeError)。相比裸属性:计算属性(fullname 由 first+last 合成)和带校验的写入都有了着陆点。__repr__ vs __str__:repr 给开发者(调试器、日志、容器里的显示——[obj] 打印走 repr),目标是"无歧义",能 eval 最好;str 给终端用户(print),目标是"可读"。只实现一个时永远选 repr(str 缺省会回退到 repr)。这两个属于"协议全景":Python 的 duck typing 靠的就是 __len__、__iter__、__eq__ 这些协议方法——对象响应协议,函数就能通用(len() 对任何有 len 的对象工作)。
第 4 层追问:dataclass 的取舍
现代代码还手写这些吗?
参考回答:纯数据容器用 @dataclass——自动生成 __init__、__repr__、__eq__,还送 field 默认值、frozen=True(不可变,配合上一题的"热路径传不可变"策略)。LLM 应用里它特别重要:结构化输出的 schema 类(Pydantic 更完整,但内部数据类用 dataclass 轻量)、消息/配置的数据模型。不用 dataclass 的场景:有复杂不变量要维护的业务对象(init 里要跨字段校验、方法多)——dataclass 生成的是"无脑初始化",复杂约束还是手写构造。判别:数据形状优先 dataclass,行为逻辑优先手写类。
考察意图与踩坑点
- 考察意图:类体系是面向对象功底的地基;工厂方法(classmethod)是 FastAPI/Pydantic 源码里的高频模式,读懂框架必备。
- 踩坑点:classmethod 和 staticmethod 区别只剩"装饰器名不同";new/init 顺序答反;不知道 repr 的回退关系。
- 一句话总结:self 用实例方法、cls 造工厂、无参纯工具用静态;new 造壳 init 装修;repr 优先于 str;数据形状给 dataclass。
Q10 · Python 的垃圾回收机制?什么情况会内存泄漏?
难度:★★★ | 频率:高 | 对应课件:Python与数据实践/06(对象与引用);GC 细节为课件外高频补充
面试官:Python 的内存怎么回收?del 一个对象它马上被释放吗?什么情况会内存泄漏?
第 1 层追问:主机制
先说 GC 靠什么。
参考回答:引用计数为主:每个对象记着"被多少个名字引用着"(sys.getrefcount 可查),计数归零立即回收——这是主要路径,实时、确定性高。所以 del obj 的语义要精确:只是删除一个名字绑定(引用减一),不是销毁对象——还有别的引用(比如在某个 list 里)对象就活着;所有引用都删光才立刻释放。引用计数无解的场景是循环引用:a.b = b; b.a = a,互指的计数永不归零——这引出辅助机制。
第 2 层追问:分代回收
循环引用怎么处理?
参考回答:分代标记清除作为补充:gc 模块定期遍历对象图,从"疑似垃圾"里找出不可达的循环引用整体回收。分代假设:大多数对象朝生夕死,活得越久越不可能是垃圾——所以对象按存活时间分三代,越老的代扫描越少(新对象频繁查,老对象偶尔查),摊薄遍历成本。两个实用知识:gc 是可调节甚至可关的(gc.disable()——某些性能敏感场景+CPython 的引用计数兜底,纯引用计数泄漏不了非环对象);gc.collect() 可手动触发全量回收。
第 3 层追问:泄漏场景
有 GC 还会泄漏?
参考回答:会——GC 回收的是"不可达对象",泄漏的真身是"不该活着却可达"。经典清单:全局缓存/模块级 list 只进不出(最常见);闭包和回调持有大对象(事件监听器忘了注销,回调持有整个 session);异常对象链(exception 的 traceback 帧引用整个调用栈的局部变量——except 块里把 e 存进全局会拖住一串对象,Python 3 会自动清但存储 traceback 仍会);LLM 应用专属:messages 历史无限增长(模型无状态但你的内存有状态——长会话的历史列表只增不减,这是 AI 服务内存泄漏的头号形态,解药是上下文裁剪/外置记忆,呼应 LLM 原理篇 Q06)。
第 4 层追问:排查手段
怎么定位泄漏?
参考回答:三步法。一,确认是泄漏不是抖动:监控 RSS 曲线(只涨不跌 + gc.collect() 后不降 = 泄漏;官方容器注意镜像基础内存)。二,定位谁在涨:tracemalloc(标准库)对比两个时间点的 top 增长点——按文件/行号直接指出哪段代码在堆积;objgraph(第三方)按类型计数,objgraph.growth() 一眼看出是哪个类在爆炸(dict?某 dataclass?)。三,顺引用链找根:objgraph.find_backref_chain 从可疑对象反查到 GC root(全局变量/栈帧),找到那个"不该持有它的人"修掉。预防层:长生命对象持引用要谨慎设计(weakref 弱引用不计入计数,缓存场景用它可让对象"没人强引用就死");对 AI 服务,把"会话历史有上限"当成架构约束而不是运维补丁。
考察意图与踩坑点
- 考察意图:GC 是 Python 八股的收官题;"有 GC 为什么还泄漏"那一层是真正区分理解深度的分水岭——把 messages 无限增长这个 AI 专属泄漏源讲出来,面试官立刻知道你做过 LLM 服务。
- 踩坑点:del 说成"销毁对象";不知道循环引用和分代;说不出任何真实泄漏场景和排查工具。
- 一句话总结:引用计数为主(归零即回收,del 只删引用)+ 分代标记清除抓循环引用;泄漏=不该活的仍可达,tracemalloc 定位增长点,messages 无限增长是 AI 服务头号泄漏。
本篇速记卡
| 题 | 一句话核心 |
|---|---|
| Q01 装饰器 | @deco = f = deco(f),高阶函数+闭包;wraps 保元数据;带参加一层 |
| Q02 闭包 | 按名引用外层变量(LEGB 的 E);late binding 是坑、默认参数是解药 |
| Q03 生成器 | yield 暂停可恢复、惰性 O(1) 内存、单次消费;LLM 流式输出就是它 |
| Q04 GIL | 锁字节码不锁 IO;IO 密集 asyncio、CPU 密集进程池;C 扩展释放 GIL |
| Q05 拷贝 | == 值 is 身份;赋值共享、浅拷外新内旧、深拷全独立;可变共享是事故源 |
| Q06 可变默认参数 | def 时求值一次存 defaults 永久共享;None 哨兵是标准解法 |
| Q07 with | enter/exit = 自动 try/finally;异常照常清理、True 吞异常 |
| Q08 asyncio | 调 async 函数只造对象;协作式调度"等待让给别人";阻塞冻结全局 |
| Q09 类三方法 | self 用业务、cls 造工厂、static 纯工具;new 造壳 init 装修 |
| Q10 GC | 引用计数为主+分代抓环;del 只删引用;泄漏=不该活的仍可达 |