如果你要应聘大模型应用开发工程师的话,面试官会根据你的技术栈和简历项目去问一些基础八股,如果你的技术栈和简历项目写了python的话,那么问python八股的概率会非常大。以下是一些本人求职中遇到的问题总结,整理成面经仅供参考。(问题是本人面试遇到的问题,答案是AI生成以后自己简化的)

需要注意的是,现在面经很多是AI生成的,非常术语化和标准化,仔细想想,你是面试官。你要面试的是什么人?能进来干活的人,进来就要上手的。你上来背经人家听都不想听。最好是加入自己的理解+结合自己简历上的实习和项目来说。让人觉得你是真上手搞过这个项目的,不是一边vibe coding一边背经书的。

(甚至我认为这部分比Agent开发跟rag,langchain等大模型应用开发的主要技术框架比都重要,因为面试官不一定非常懂Agent,但是python年年考,想蒙混过关基本是不可能的)

1.基础知识

基础知识方面没什么好问的,基本上就是老三样(封装,继承,多态),然后就是基础函数和语法,这一部分不要结合项目,总结几个面试官口头禅的问题。

1.数据类型有哪些?

答:整型,浮点型,布尔型,字符串,元组,字典,列表,集合等等。

如果面试官往下问,“什么是不可变类型,什么是可变类型。”你要记住下面的内容了,不然被问到就要挂掉了哦。

答:“不可变类型指的是数据被修改以后会创建新的对象,可变类型相反,修改以后不会创建新对象。常见的不可变类型有整型(int)、浮点型(float)、布尔型(bool)、字符串(str)、元组(tuple)、冻结集合(frozenset)(这个可以不记,一般没什么人问)。可变类型有列表(list)、字典(dict)、集合(set)。”

2.字典,列表和数组有什么区别?列表的函数有哪些?列表值求和要用到什么函数。

(PS:在 Python 原生语法中,严格意义上的“数组”是 array 模块(需导入),但在日常开发交流中,大家口中的“数组”几乎都是指 列表(list)。如果面试官真问的是import array 的数组,那么标准答案是:array和List的区别在于:它只能存储同一类型的数值,内存更紧凑,但功能远不如列表丰富。)

答:列表(List)指的是有序的元素排列,索引方式是整数下标,有严格的前后位置,内存占用相对较小,使用场景为存储同质数据。字典(Dict)是以任意不可变类型作为键值对(Key-Value)的映射关系构成的集合。通过 Key 来取值的,不依赖位置。相对较大,需要存储键和值以及哈希表开销。适用于存储关联信息(如“姓名:年龄”),需要根据唯一标识快速查询对应的值。Python打哈希表必用。


列表(list)的函数有这些:append(x),在末尾添加元素。extend(iterable),在末尾合并另一个可迭代对象(如列表)的所有元素。insert(i, x),在指定下标 i 处插入元素 x。remove(x),删除第一个值为 x 的元素(若不存在报错)。pop(i),删除并返回下标 i 处的元素(默认删除末尾)。clear(),清空列表所有元素。sort(key=None, reverse=False),原地排序(默认reverse为负数,在reverse为负数的时候,从小到大)index(x),查询第一个值为x的下标,count(x),统计x在列表中出现的次数。

最后一问,记住sum,max,min就行了

3.手撕排序

这里直接推荐大佬labuladong的文章
本章导读https://labuladong.online/zh/algo/intro/sorting/面试可能会让你手撕代码或者写个伪代码他肉编译,或者让你讲思路,一定要会写。

4.哈希表

2.进阶部分

一些工程化的知识了,结合项目去谈。

1.深拷贝和浅拷贝相比,有什么区别?

浅拷贝只复制最外层,嵌套对象共享引用;深拷贝递归复制全部层级,完全独立。

''' 简单代码模拟一下就理解 '''

import copy
a = [[1, 2], [3, 4]]
b = copy.copy(a)        # 浅拷贝:外层新,内层 [1,2] 共享
c = copy.deepcopy(a)    # 深拷贝:全部新建
 
b[0][0] = 99
print(a)  # [[99, 2], [3, 4]]  ← 浅拷贝污染了原对象
c[0][0] = 88
print(a)  # [[99, 2], [3, 4]]  ← 深拷贝互不影响

# list.copy()、dict.copy()、切片 [:] 都是浅拷贝
# b = a 连浅拷贝都不是——纯引用,b is a 为 True
# 深拷贝吃性能(递归复制),性能敏感时慎用

面试话术:「浅拷贝只复制最外层容器,内层还是共享引用,改内层会污染原对象;深拷贝递归复制所有层级,完全独立。深拷贝用于数据要独立修改的场景,浅拷贝用于只读或性能敏感场景。

2. 可变与不可变对象

一句话:list/dict/set 可变(原地修改);tuple/str/int/float/bool 不可变(修改会创建新对象)。

# 可变:原地改
lst = [1, 2]; lst.append(3)   # 同一个对象
# 不可变:改就是新建
s = "ab"; s += "c"            # 新字符串对象
 
# 经典陷阱:函数默认参数
def f(x=[]):        # 默认参数在定义时创建一次!
    x.append(1)
    return x
f()  # [1]
f()  # [1, 1]  ← 两次调用共享同一个列表
# 正确写法:
def f(x=None):
    if x is None: x = []

面试话术:「可变对象能原地修改,不可变对象修改时产生新对象。经典坑是函数默认参数不能用可变对象——默认参数只在定义时创建一次,多次调用会共享状态,正确做法是用 None 占位再初始化。」

3. 装饰器(频率比较高的一个)

一句话:不修改函数内部代码、给函数增强功能;本质是接收函数返回函数,@ 是语法糖。

from functools import wraps
import time
 
def timer(func):
    @wraps(func)              # 保留原函数 __name__/__doc__
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        print(f"耗时 {time.time()-start:.2f}s")
        return result         # 必须返回原函数结果
    return wrapper
 
@timer                        # 等价于 func = timer(func)
def slow(n): time.sleep(n)


# 带参数装饰器(三层):

def retry(max_times=3):
    def decorator(func):              # 中层收函数
        def wrapper(*args, **kwargs): # 内层收参数
            ...
        return wrapper
    return decorator

# 要点:

# wrapper 必须收 *args, **kwargs 且 return result
# functools.wraps 保留元信息(不加的话 func.__name__ 变 wrapper)
# 装饰器在定义时执行(import 时就执行),不是调用时
# 多层装饰器:@a @b def f() = f = a(b(f)),从下往上套、从上往下执行

面试话术:我把装饰器理解为给函数‘套壳’增强的工具。底层原理上,它依赖闭包——外层接住原函数,内层通过 *args 和 **kwargs 把参数原封不动传进去,再把结果吐出来,这样调用者完全无感知。同时我习惯加上 functools.wraps,防止调试时函数名和文档被搞乱。

落地到业务:我在做xx项目调用大模型接口时,就用装饰器把‘重试和降级’逻辑剥离出来。比如遇到 429 限流自动等几秒重试,要是连续失败就直接走缓存兜底。这样业务代码里只管拼 Prompt,健壮性逻辑全挂在装饰器上,干净又统一。(结合项目聊)

以上是标准答案,实际上只要能答出来对函数套壳增强就加分,结合项目来聊更加分。

4. 异步函数 / asyncio

一句话:在 I/O 等待期间让出控制权给事件循环,提高吞吐——解决「等待时 CPU 闲着」的问题。

注意不是多线程!!!不是多线程!!!不是多线程!!!
当初面试官随口一问是单线程还是多线程,我不禁思考直接回答是多线程,直接挂掉。

import asyncio
 
async def fetch():
    await asyncio.sleep(2)    # 挂起点:让出控制权
    return "data"
 
async def main():
    r = await asyncio.gather(fetch(), fetch(), fetch())  # 并发 3 个
asyncio.run(main())

# 核心概念:

# async def:定义协程(可挂起的函数),返回协程对象,要 await 才执行
# await:让出控制权给事件循环,等结果回来再继续
# asyncio.run():创建并运行事件循环
# 异步是并发不是并行:单线程内切换,不是多核同时跑

高频的追问

异步和并行区别?异步 = 单线程内 I/O 等待时切换任务(并发);并行 = 多核同时执行。

同步代码能 await 吗?不能,语法错误。

异步里调同步阻塞函数?会卡死事件循环——用 asyncio.to_thread() 丢线程池

GIL 和异步矛盾吗?不矛盾——GIL 限制 CPU 并行,异步利用的是 I/O 等待,等待时不需要 GIL

并发控制?asyncio.Semaphore(3) 限制同时执行数,配合 gather 用

面试话术:我理解asyncio的核心是一个事件循环在单线程里不停轮询:谁在等待I/O,谁就挂起;谁的数据回来了,谁就恢复执行。它是协作式的,不是抢占式的。这意味着我必须主动在代码里await,把控制权交还给事件循环。

详细可以看这个:(5 封私信 / 27 条消息) 一文读懂Python async/await异步编程 - 知乎

偏向Java的看这个:Java 异步编程 (5 种异步实现方式详解) - mikechen的互联网架构 - 博客园

项目举例:例如我的python智能问答智能体就是FastAPI 基于 asyncio,我的 SSE 流式输出就是 async 接口逐 token await 推送,长回答不阻塞其他请求;Agent 并行检索用 asyncio.gather + Semaphore 限流,制同时进行的LLM调用数,避免打爆第三方 API。

例如我在开发RAG知识库系统的时候没加信号量,直接把100份简历的任务全部asyncio.gather(*tasks)。结果事件循环瞬间发出100个HTTP请求,LLM服务端直接返回429 Too Many Requests,甚至把我的IP封了5分钟。我深刻体会到,异步不是无脑地堆任务,而是要有节奏地“节流”。Semaphore保证了每次只有5个协程在等待LLM响应,其余协程在事件循环里被挂起,既不阻塞主线程,又不触犯对方的限流规则。

例如我在AI智能问诊系统中使用了SSE流式响应。这天然就是asyncio的主场,因为SSE需要长连接保持。我在后端FastAPI定义async def stream_chat(),内部调用vLLM的异步生成器(async for chunk in model.generate())。每生成一个token,就yield出去,前端实时接收。

例如我在基于GraphRAG+Neo4j的多AI协作报考智能体项目中,我使用了asyncio.Queue做生产者-消费者模式。一个协程(生产者)专门去Neo4j和Milvus做耗时的双路召回,把检索到的文档块放入队列;另一个协程(消费者)从队列取块,拼接到Prompt中喂给LLM。await queue.put()和await queue.get()完美地解耦了“检索”和“生成”这两个I/O密集型阶段,让它们看似并行,实则通过事件循环优雅地交替执行。

例如我在AI智能问答医疗系统中犯过一个经典错误:我在异步视图函数里,直接调用了同步的pandas库去读取和清洗患者上传的CSV健康数据。导致了并发上来后,整个接口响应变得极其卡顿,甚至比同步Flask还慢。原因是pandas读取CSV是CPU密集型和同步阻塞I/O。当await挂起时,事件循环可以去干别的,但一旦执行到pd.read_csv(),这个操作会霸占主线程直到文件读完,期间事件循环完全被冻结,无法切换给其他协程。对于这种必须存在的阻塞操作,我使用asyncio.to_thread(),把它丢给线程池执行,让事件循环继续运行。或者,对于超大文件,我直接将其拆解成流式读取,避免一次性加载到内存阻塞主线程。

更多推荐