在潛在空間運作,而非像素
在第 2、3 篇指南中,我們學會了替影像加噪,再訓練一個 U-Net 一小步一小步地把噪聲去掉。這套方法效果極佳,但很慢。為了生成一張圖,取樣器要把 U-Net 跑上數百甚至一千次,而且每一次都得處理每一個像素。一張普通的 512×512 彩色影像,就是一個 512×512×3 = 786,432 個數字的網格。為了產生一張圖,竟要把將近一百萬個數字反覆送進深層網路一千次,代價極其昂貴——對一張普通顯示卡來說實在太重了。
讓高解析度生成變得負擔得起的訣竅,簡單到幾乎令人不好意思:根本不要在像素上跑擴散。早在第 1 篇指南,我們就埋下伏筆,說擴散終有一天會向自編碼器家族借一件工具——現在就是了。我們取一個預訓練好的 VAE 編碼器,用它把大影像壓縮成一個又小又緻密的摘要,稱為潛在向量(latent)。我們不再面對 512×512×3 的像素網格,而是改在一個約 64×64×4 的潛在向量上工作。我們把整個前向加噪與反向去噪的擴散流程,全部搬進這個小小的潛在空間裡進行,只在最後一刻才把完成的潛在向量交給 VAE 解碼器,由它擴張回完整解析度的像素。
一張像素影像流入編碼器、縮小成一個小小的潛在網格;箭頭顯示加噪與去噪的迴圈在潛在空間中進行;解碼器再把最後的潛在向量還原成圖片。
可以把它想成在改一張精簡的藍圖,而不是一個像素一個像素地把整幅壁畫重畫一遍。潛在向量保留了影像中有意義的結構——形狀、版面配置、大塊的紋理——而捨棄掉那些反正解碼器都能忠實重建出來的、冗餘的細節。用這種方式打造、把所有擴散工作都放在壓縮後的潛在向量而非原始像素上進行的模型,就稱為潛在擴散模型。每邊縮小約 8 倍(512→64),讓 U-Net 要處理的數值量大約少了 48 倍(從 786,432 降到 16,384)——正是這一個改動,讓高解析度影像生成得以在消費級 GPU 上跑起來。它就是 Stable Diffusion 的骨幹。
把提示詞變成數字
到目前為止,我們的去噪器只知道怎麼把噪聲清乾淨——它完全不曉得我們想要圖裡有什麼。要「依文字作畫」,我們得先把這些文字變成神經網路能消化的東西:數字。像「一隻戴著太陽眼鏡的柯基」這樣的提示詞,一開始只是純文字。我們先把它分詞(tokenize)——切成一塊塊稱為詞元(token,大致是一個詞或詞的片段)的小單位——再把這些詞元送進一個預訓練好的文字編碼器,例如 CLIP 的文字 Transformer。
文字編碼器輸出的是一串嵌入向量(embedding vector):每個詞元對應一個向量。每個向量都是高維空間中的一個點,這個空間的各個方向都已學會編碼某種意義——有的方向捕捉「狗的特質」、有的捕捉「柯基的特質」、有的捕捉「穿戴」、有的捕捉「太陽眼鏡」。於是我們的句子就變成了一串有順序的意義向量。用這段文字來引導影像生成器,稱為提示詞制約(prompt conditioning);而整件任務——用文字描述一個場景、換回一張對得上的圖——就是文字生成圖像(text-to-image generation)。
# Turn a text prompt into a sequence of embedding vectors prompt = "a corgi wearing sunglasses" tokens = tokenizer(prompt) # -> token ids, e.g. [a][corgi][wearing][sun][glasses] text_embeddings = text_encoder(tokens) # text_embeddings shape: (num_tokens, embed_dim) # one meaning-vector per token; the U-Net consumes THIS, never the raw string
這裡有一個微妙卻關鍵的重點:擴散 U-Net 從來不會去讀你的句子。它看不到 c-o-r-g-i 這幾個字母。它自始至終只看得到這些嵌入向量——也就是你提示詞的數字指紋。這就帶出一個顯而易見的問題:一串詞向量究竟是怎麼伸進去、改變哪些像素該被畫成什麼的?答案是一種叫做交叉注意力(cross-attention)的機制,也正是下一節的核心。
交叉注意力:文字與像素相遇之處
注意力(attention)是一種讓某一組東西去另一組東西裡查找資訊的機制,它用到三種角色。查詢(query)是一個問題——「我在找什麼?」。鍵(key)是一個標籤,宣告「我這裡裝的是什麼」。值(value)則是真正被交付出去的內容。每個查詢都會跟每個鍵比對、看彼此有多匹配;匹配得越好,這個查詢就從那個鍵對應的值裡取走越多。你可以想像對著滿屋子的人問一個問題,然後對回答得最切題的那個人聽得最仔細。
一張示意圖:一個查詢向量與數個鍵向量比對、產生匹配分數,這些分數再對一組值向量做加權求和。
一般的自注意力(self-attention)中,這三種角色都來自同一個來源。交叉注意力的巧妙之處,在於它讓文字能跟像素對話:查詢 Q 來自在 U-Net 中流動的影像特徵,而鍵 K 與值 V 則來自文字嵌入向量。具體來說,半成形影像中的每一個空間位置都在問「哪些字對我重要?」,拿自己去跟每個詞元比對,再把匹配得最好的那些詞元的內容取進來。那塊註定要變成太陽眼鏡的潛在區塊,最後就會強烈地關注(attend)「太陽眼鏡」這個詞元,把它的意義吸收進來。這個機制就叫做交叉注意力制約(cross-attention conditioning)。
縮放點積注意力。在交叉注意力中,Q 是 U-Net 影像特徵的投影;K 與 V 則是文字嵌入向量的投影。
由內往外讀。Q 是一個查詢矩陣——每一列對應一個影像位置,每一列都是 U-Net 影像特徵的線性投影,問著「這裡哪些字重要?」。K 把鍵堆疊起來,每一列對應一個文字詞元,每個詞元都在宣告自己是什麼;V 則堆疊對應的值,也就是要被傳遞過去的文字內容。乘積 QKᵀ 是把每個影像查詢與每個文字鍵做點積,得到一格格的原始相似度分數——分數大就代表「這個位置和這個字非常匹配」。我們除以 √dₖ,其中 dₖ 是每個鍵向量的維度:少了它,dₖ 一大、點積就會變得很巨大,把 softmax 推進梯度消失的區域,所以 √dₖ 能讓數值維持在穩定範圍內。接著 softmax 把每一列的分數變成一組為正、且總和為 1 的權重——也就是對「這個位置在意哪些詞元」做一次柔性的挑選。最後我們乘上 V,依這些權重的比例把選中的文字內容混合起來,再把結果注入回影像特徵裡。舉個例子,設 dₖ = 64,若某個太陽眼鏡區塊的查詢對「太陽眼鏡」鍵得到 9.0 分、對「柯基」只有 1.0 分,除以 √64 = 8 就是 1.125 對 0.125;softmax 把它們變成約 73% 對 27%,於是這塊區塊的更新就由太陽眼鏡的值主導。關鍵在於,這些交叉注意力層被插在 U-Net 內部的多個解析度上,因此提示詞能同時掌舵粗略的版面與細緻的細節——這正是提示詞制約的引擎。
一張疊上熱力圖的影像,每個提示詞各一張熱力圖,顯示哪些空間區域最強烈地關注該詞。
無分類器引導:讓提示詞更聽話
現在我們已經能透過交叉注意力把提示詞餵進去噪器了——但實務上,光靠這樣往往還不夠。一個僅僅以提示詞為條件的模型,常常把提示詞當成一個鬆散的建議:你要「一隻戴著太陽眼鏡的柯基」,結果可能拿到一隻普通的狗,太陽眼鏡被忘得一乾二淨。我們需要一種辦法,把「提示詞被遵守的力度」往上調。這個技術就是無分類器引導(classifier-free guidance)。
這個設計很巧妙。在訓練時,我們隨機地有一定比例(比方說 10~20%)把提示詞丟掉,換成一個空的/空白提示詞。結果就是同一個網路一次學會了兩件事:一個會用提示詞 c 的條件式噪聲預測器 εθ(x, c),以及一個完全不用提示詞、無條件的 εθ(x, ∅)。不需要任何額外的分類器——這就是「無分類器」一名的由來。在取樣時,我們把兩個預測都跑一遍,再從無條件的那個朝條件式的那個方向外推(extrapolate)。這就是無分類器引導(classifier-free guidance)。
無分類器引導把條件式與無條件的噪聲預測結合起來,沿著提示詞的作用方向再多推 w 倍。
把公式拆開來看。εθ(x_t, c) 是網路在帶噪步驟 x_t、能看見提示詞 c 時所預測的噪聲;εθ(x_t, ∅) 則是把提示詞拿掉後它的預測。兩者的差 εθ(x_t, c) − εθ(x_t, ∅),恰恰就是這個預測中由提示詞負責的那一部分——也就是「提到提示詞」把模型推往的方向。引導權重 w 控制我們沿這個方向走多遠:我們從無條件預測出發,再加上 w 倍的提示詞作用。直覺其實就是「把提示詞改變的部分放大」。注意 w = 1 會還原成一般的條件式生成——公式縮回成 εθ(x_t, c)——而 w > 1 則會誇大提示詞。代價是:現在每個去噪步驟都要做兩次網路推論,而不是一次,分別是條件式與無條件的。這正是強力提示詞制約的核心。
引導強度旋鈕
如果你用過影像生成器、看過一個標著「guidance scale」「CFG scale」或乾脆叫「prompt strength(提示詞強度)」的滑桿,那你早就見過 w 了。它就是上一條公式裡的那個 w,也是文字生成圖像中被調得最頻繁的單一超參數。轉動它,會改變你想要、卻無法同時完全擁有的兩件事之間的平衡:對提示詞的忠實度,以及看起來自然的多樣性。
想像把其他一切都固定,只掃過 w 的不同數值。在低 w(約 1~3)時,無條件與條件式的預測幾乎沒被拉開,所以影像多樣又自然,卻只鬆散地跟著提示詞走——細節會飄移或乾脆消失。在中等 w(約 7~8)時,你會落到大多數工具預設的甜蜜點:提示詞被清楚遵守,同時畫面仍然看起來可信。在極高 w(約 15 以上)時,模型會在提示詞方向上衝過頭:顏色過飽和、生硬的邊緣與雜訊瑕疵悄悄冒出來,多樣性也崩潰,因為每一張樣本都被推向同一種被誇大的詮釋。這個旋鈕就是引導強度(guidance scale)。
# Change ONLY the guidance scale and compare results
prompt = "a corgi wearing sunglasses"
for w in [1.5, 3, 7.5, 15, 30]:
image = generate(prompt, guidance_scale=w, seed=42) # fix the seed to isolate w
save(image, f"corgi_w{w}.png")
# expect: low w -> loose/varied, ~7.5 -> sweet spot, high w -> over-saturated artifacts因為它就是引導公式裡乘上提示詞方向的同一個 w,建立直覺最乾淨的方式,就是只改 w、看著忠實度對多樣性的取捨來回移動。兩點提醒:w 會跟食譜的其他部分互相牽動。一個非常強烈或字數很多的提示詞,可能需要較低的 w 才不會「煮過頭」;取樣步數也有影響——高引導配上極少的步數,往往會放大瑕疵。先調好 w,等你改了提示詞或步數之後再回頭重新調。整個旋鈕之所以存在,全是因為有無分類器引導。
Stable Diffusion:整條管線組裝起來
現在每一塊拼圖都齊了。讓我們把它們組裝成 Stable Diffusion 背後那整台機器,並追蹤一張圖從提示詞一路走到像素的全程。每一個部件你都已經在第 1 到第 4 篇指南裡親手打造過,所以現在每一步讀起來都該像一張熟面孔。
- 編碼提示詞:把文字分詞、送進預訓練的文字編碼器,得到一串嵌入向量——每個詞元一個意義向量(第 2 節)。
- 從噪聲開始:抽一個隨機潛在向量——純粹的高斯噪聲,但是在那個小小的潛在空間裡(例如 64×64×4),而非在像素裡(第 1 節)。
- 帶引導去噪:用 DDIM 風格的取樣器,把這個受條件約束的 U-Net 跑 N 步。每一步都透過交叉注意力注入提示詞,並用強度為 w 的無分類器引導把條件式與無條件的預測結合起來(第 3–4 節)。
- 解碼回像素:把最後乾淨的潛在向量交給 VAE 解碼器,由它擴張成完整解析度的影像(第 1 節)。
一張流程圖:文字編碼器把嵌入向量餵進一個 U-Net,U-Net 在無分類器引導下反覆去噪一個帶噪潛在向量,最後由 VAE 解碼器輸出成品影像。
def stable_diffusion(prompt, w=7.5, steps=50):
# 1) Encode the prompt (and an empty prompt for classifier-free guidance)
c = text_encoder(tokenizer(prompt))
empty = text_encoder(tokenizer(""))
# 2) Start from pure noise IN LATENT SPACE (e.g. 64x64x4), not pixels
x = sample_gaussian_noise(shape=(64, 64, 4))
# 3) Iteratively denoise with cross-attention + classifier-free guidance
for t in ddim_schedule(steps):
eps_cond = unet(x, t, context=c) # prompt injected via cross-attention
eps_uncond = unet(x, t, context=empty) # second pass: no prompt
eps = eps_uncond + w * (eps_cond - eps_uncond)
x = ddim_step(x, eps, t) # one reverse step (Guide 3's sampler)
# 4) Decode the finished latent back to full-resolution pixels
image = vae_decoder(x)
return image有兩個名字值得分清楚。潛在擴散模型是一種架構——也就是「在 VAE 潛在空間裡跑擴散、並用交叉注意力注入文字」的通用食譜。Stable Diffusion 則是用這份食譜打造出來、那個著名的開放權重模型。兩者合起來,就是現代文字生成圖像的主力工具。