大模型应用开发社招常考面经(python八股篇)(中)
想看上直接去我主页搜上就行
5. 生成器和迭代器
迭代器是惰性遍历的对象(有
__iter__/__next__);生成器是用 yield 写迭代器的最简单方式——边用边算,不占内存。
def gen(n): # 生成器函数(有 yield)
for i in range(n):
yield i * 2 # 每次 next() 才执行到这里
g = gen(5)
next(g) # 0
next(g) # 2
# 大文件逐行读(经典场景)
for line in open("big.txt"): # 不是 readlines()(会全读进内存)
process(line)
# yield 暂停函数保存状态,next() 恢复
# 生成器表达式:(x*2 for x in lst)(对比列表推导式 [x*2 for x in lst])
# 生成器只能遍历一次
面试话术:生成器是惰性求值——每次 next 才计算一个值,内存占用 O(1)。处理大文件/大数据流时用生成器逐行读,而不是 readlines 全部加载。在我的理解里,迭代器是一种“按需取用”的数据访问协议,而生成器是实现这种协议最优雅的语法糖。它们的核心价值不是“省代码”,而是解耦“生产数据”和“消费数据”的节奏,并极大降低内存水位。
例如我的企业内部RAG和智能医疗系统中,我需要处理几十MB甚至上百MB的PDF、DOCX和CSV文件。如果直接把整个文件读进内存(pd.read_csv()或file.read()),在高并发下,内存会瞬间爆炸。所以我自定义了一个文档块生成器(def chunk_reader(file_path): ... yield chunk)。在解析DOCX时,我利用python-docx或pypdf的底层流式接口,逐段落读取并yield文本块,随后流式送入Embedding模型。在项目中,生成器在这里扮演了生产者-消费者之间的“缓冲水龙头”。生产者(磁盘I/O)每吐出一段文本,消费者(Embedding模型)就立刻拿去算向量。两者不是串行等待,而是交错执行。内存里永远只存在当前正在处理的一个chunk,使得处理1GB文件的内存消耗与处理1MB文件几乎一致——这才是生成器最强的“内存防火墙”能力。
例如我的项目的SSE流式响应,如果不用生成器,技术上根本无法优雅实现。所以我在在后端FastAPI接口中,定义async def stream_chat():,内部调用vLLM的async for生成器。每生成一个token,就yield f"data: {json.dumps(token)}\n\n"。这里我深刻体会到生成器的“懒执行”特性对于用户体验的决定性作用。如果不用生成器,我必须等LLM生成完1000个token(耗时10秒)才一次性返回,前端用户盯着空白屏幕干等。而用了生成器,yield让数据一产生就立刻通过网络发出去,首字延迟(TTFT)从10秒骤降至几百毫秒。此外,在“小乐AI”的Multi-Agent协同中,不同Worker的推理速度不同,我利用生成器的send()或throw()机制,在外部迭代时根据Supervisor的反馈提前终止某些慢Worker的生成,避免资源浪费。
6. with 上下文管理器
with 语句的1作用是自动管理资源(文件/锁/连接),退出时靠
__enter__/__exit__来自动清理。
with open("f.txt") as f: # 退出自动 f.close(),异常也会关闭
data = f.read()
# 自定义
class MyCtx:
def __enter__(self): print("进入"); return self
def __exit__(self, exc_type, exc_val, tb):
print("退出(异常也会走这里)")
return False # False = 异常继续抛;True = 吞掉异常
面试话术:with 的功能是保证资源无论正常还是异常都释放。通过exit 的返回值控制异常是否被吞——返回 False 继续抛,True 吞掉。数据库连接、文件、线程锁都用它管理。
在我的理解里,with上下文管理器绝不是open()文件的专属语法糖,它的本质是“资源获取与释放(RAII)”的模式在Python中的落地——即在进入代码块前自动申请资源,在退出代码块时(无论正常还是异常)自动且确定性地释放资源。它赋予了我编写健壮、防泄漏、可读性强的后端代码的能力。
例如我在编写AI智能医疗库系统的时候,我不手动conn.commit()或conn.rollback(),而是使用with conn:。with进入时获取连接,退出时,如果代码抛异常,__exit__会自动调用rollback();如果无异常,则自动commit()。
(踩坑:初期我曾手动try...finally去关游标,结果在复杂的嵌套逻辑里漏写了rollback,导致死锁。自从统一用with包裹psycopg2的游标后,事务边界变得极其清晰,即使在高并发压测(JMeter)下,也从未出现连接泄漏或事务未关闭的情况。)
我设计的多Agent协作系统在多Agent协同部分,不同Worker需要临时改变LLM的温度参数(temperature)或切换vLLM的推理配置。为了解决这个问题,我自定义了一个TempLLMSettings上下文管理器。在__enter__里保存当前的全局推理配置(如temperature=0.7),在__exit__里强制恢复原配置。
例如在大模型微调的数据清洗过程中,我要临时重定向sys.stdout或临时修改环境变量(如HF_ENDPOINT)来拉取模型权重。所以,我直接使用contextlib.redirect_stdout()捕获清洗日志。最关键的是,我用@contextmanager装饰器包装了“临时文件交换”:在__enter__生成一个校验文件(.md5),在__exit__中不仅删除临时文件,还会对比MD5值。如果解析失败,__exit__会主动标记该批次数据为“脏数据”并抛出定制异常。这让数据清洗管线的“原子性”得到了保障——要么全部成功生成清洗后数据,要么一个临时文件都不留。
7. GIL(全局解释器锁)
因为CPython 同一时刻只允许一个线程执行字节码,多线程不能利用多核做 CPU 并行。所以需要做全局管理。CPython 内存管理的方式是引用计数,这种方式不是不是线程安全的,需要使用GIL 做简单保护。
I/O 密集 (网络/文件/数据库):多线程有效(等待时释放 GIL)
CPU 密集(计算):多线程没用,用多进程(multiprocessing,每个进程独立解释器)
异步(asyncio)和 GIL 不冲突:单线程内切换,不需要多核
面试话术:GIL 是 CPython 的全局解释器锁,同一时刻只有一个线程执行 Python 字节码。I/O 密集用多线程或异步(等待时释放 GIL),CPU 密集用多进程。这也是为什么 Python 擅长胶水/IO 场景,计算密集任务要靠 C 扩展或多进程。
(面试官如果问道在AI应用开发中这个的用处可以这样回答)AI应用的本质是I/O密集型服务,很大一部分的时间在等待外部:LLM API响应(网络I/O)、数据库查询(磁盘I/O)、向量库检索(网络I/O)。在这种场景下,GIL的影响微乎其微,因为:线程在I/O操作时会主动释放GIL。所以我选择使用异步进程asyncio因为协程是单线程内的“协作式调度”,完全不涉及GIL竞争,切换开销极小,能承载更高的并发连接数。
(面试官如果单纯问概念就这样回)我对大模型进行微调的过程中,我需要利用python和pandas或者pyspark等工具编写自动化数据清洗工具做文本预处理(正则替换、分词、去重)。这是CPU密集型计算。多线程并行处理很难跑满,甚至因为线程切换和GIL竞争带来了额外开销,耗时比单线程还长。所以我断弃用多线程,改用multiprocessing(多进程)。每个进程有自己独立的Python解释器和内存空间,不受GIL限制。利用Pool.map()将数据分片到8个进程,CPU瞬间跑满,处理时间从单线程的180秒压缩到25秒。所以我的认知是:在AI工程中,“预处理/后处理”这种CPU重活要用多进程,而“模型推理/网络请求”这种I/O重活要用异步或多线程。
(注意是数据清洗部分内容,别扯到微调上面去了。虽然用VLLM+Flask的入口是Python,但核心的张量运算(矩阵乘法、Attention计算)完全在C++/CUDA层执行,期间会释放GIL。所以,即便是在多线程环境下,模型推理的吞吐量依然可以线性扩展,GIL在这里不构成瓶颈。)
追问,你对GIL有什么看法
你要是回答是GIL是“Python之耻”,展现“Python就是慢”,那包挂你的。
正常回答:我对GIL的使用遵从我的技术原则和开发的实际环境,参考因素有选对工具:GIL限制的是“CPU密集型多线程”,我就用多进程、异步或直接用Java;架构隔离:我将CPU密集型任务用RabbitMQ消息队列来多开Python线程剥离到独立的Worker服务,而主Web服务(Spring Boot)只负责接收请求和推送任务,完美绕开了GIL对主线程的潜在拖累。而如果未来与大盘极端CPU并发场景,我会考虑使用其他语言例如Rust来写核心模块,通过FFI调用来规避GIL,而不是指望CPython在短期内移除GIL,因为相关技术并不成熟(当然这里有个高风险高回报选项,如果面试官是懂技术的,突然听到Rust感兴趣了。如果你准备充分就是大加分,如果你没准备充分就全力把话题引回python,或者坦言我对rust不是很了解。但是我知道.....说点会的就行。JD没写让你会Rust所以不太会扣分。)
8. 多线程,多进程,异步三者的区别
本质:多线程的本质是单进程,进程内共享内存。多进程的本质是每个进程为独立进程和独立 GIL管理。异步管理的本质是单线程内事件循环。
CPU并行:多进程可以CPU并行,多线程收到GIL限制,异步不行。
I/O 并发:三者均可,异步最佳
数据共享:多线程需要加锁,多进程需要IPC(Queue/Pipe),异步单线程,无数据竞争。
适用:多线程适用I/O 密集,单线程适用CPU 密集,异步适用于高并发 I/O。
创建开销:多线程小,多进程大,异步最小。
9. 异常处理
try:
result = risky()
except ValueError as e: # 捕获特定异常
...
except (TypeError, KeyError):
...
else: # 没异常才执行
...
finally: # 无论怎样都执行(清理)
...
# 自定义异常
class MyError(Exception): pass
raise MyError("出错了")
要点:finally 保证清理;else 少用但要知道;捕获顺序从具体到宽泛。
面试回答:异常处理指在程序运行过程中,当出现错误或异常情况时,通过特定机制捕获并处理这些问题,从而避免程序崩溃并提升稳定性。我在项目开发中使用了全局异常处理器来进行异常的统一处理,使用
@ControllerAdvice+@ExceptionHandler,拦截所有自定义业务异常(如BusinessException)和系统异常。当大模型数据返回异常,端口返回异常。
(以下是加分模板,根据自己简历选择一个回答)例如我在RAG个人知识库项目的简历解析模块,当上传的PDF/DOCX文件损坏或格式不匹配时,解析器会抛出
FileParseException。全局处理器捕获后,不会把晦涩的堆栈信息抛给前端,而是统一封装成{code: 4001, msg: "文件格式不支持或已损坏,请重新上传"}返回给HR。同时,配合@Transactional(rollbackFor = Exception.class),确保解析失败的简历记录不会残留在数据库中,避免产生脏数据。
例如我在AI与Agent调用层中的LLMProvider工厂中,我引入了指数退避重试(利用resilience4j或Python的tenacity库)。当遇到429 Too Many Requests(限流)或5xx(服务端错误)时,会在1秒、2秒、4秒后最多重试3次。如果3次全部失败,触发熔断降级——不再傻等,而是直接返回预设的“服务繁忙,请稍后再试”兜底回复,同时通过Sentinel(简历中提到的)记录失败指标,防止请求堆积拖垮整个线程池。
例如在多Agent协作的ReAct Agent应用中,LLM有时会生成格式错误的JSON或调用不存在的工具名。如果直接在代码里JSON.parse(),程序会直接崩溃。我的做法是捕获JsonParseException,将错误信息(如“工具参数缺失”)拼接到下一条系统提示词中,触发简历中提到的“自我反思循环”,让Agent有机会修正自己的输出,而不是中断整个对话流。
例如我在Agent全栈开发中,在边界层(如API入口),异常要转化为用户能懂的提示;在核心业务层(如金融/医疗逻辑),异常要触发事务回滚保证数据一致;而在不可靠的AI调用层,异常则要转化为重试或降级策略。只有这样,系统才能在高压和不确定性下做到“挂而不倒,倒而不崩”。
如果面试官追问你知道异常检查有哪些方式,这样回答。
检查型异常(Checked Exception):编译期必须显式处理,如 IOException、SQLException。
非检查型异常(Unchecked Exception):运行时出现,如 NullPointerException、ArrayIndexOutOfBoundsException。
10. *args / **kwargs
def f(*args, **kwargs): # args=位置参数元组, kwargs=关键字参数字典
print(args) # (1, 2, 3)
print(kwargs) # {'a': 1, 'b': 2}
f(1, 2, 3, a=1, b=2)
# 解包(反向)
nums = [1, 2, 3]
f(*nums) # f(1, 2, 3)
d = {"a": 1}
f(**d) # f(a=1)
面试话术:*args 收位置参数成元组,**kwargs 收关键字参数成字典,装饰器透传参数就靠它们;调用时 * 和 ** 是解包。
基础问题,聊聊概念就行,没什么好强行硬扯项目的,会让面试官觉得你话多。
11. 闭包 / nonlocal / 作用域
def outer(x):
def inner(y): # 闭包:inner 引用 outer 的 x
return x + y
return inner
add5 = outer(5)
add5(3) # 8 ← x=5 被 inner 记住(即使 outer 已返回)
# 修改外层变量要 nonlocal
def counter():
n = 0
def inc():
nonlocal n # 声明修改的是外层 n
n += 1
return n
return inc
要点:LEGB 作用域查找顺序——Local → Enclosing → Global → Builtin;闭包 = 函数 + 捕获的外部变量;装饰器本质就是闭包。
12. 列表推导式 / 字典推导式 / lambda
# 列表推导式
squares = [x**2 for x in range(10) if x % 2 == 0]
# 字典推导式
d = {k: v for k, v in zip(keys, vals)}
# lambda(匿名函数)
add = lambda a, b: a + b
# map/filter(现在一般用推导式代替)
list(map(str, [1, 2, 3])) # ['1', '2', '3']
list(filter(lambda x: x > 0, [-1, 2, -3, 4])) # [2, 4]
要点:推导式比 for 循环快且简洁;lambda 用于一次性简单函数(sorted 的 key 参数常用)。
如果是使用Python写算法题的同学,应该对list(map(str...不陌生
13. is vs ==
a = [1, 2]; b = [1, 2]
a == b # True 值相等
a is b # False 不是同一个对象
# 小整数缓存陷阱
x = 256; y = 256
x is y # True(-5~256 被缓存,指向同一对象)
x = 257; y = 257
x is y # False
要点:== 比值,is 比身份(内存地址);None 判断用 is None(惯例)。
更多推荐

所有评论(0)