学習済みの大規模言語モデル(LLM)の注意重みをヒートマップで眺めると、多くの人が最初に驚く光景があります。文の内容とはほとんど関係なく、系列のいちばん先頭のトークンだけが、どのヘッドでも異常に明るく光っているのです。文頭が特別に意味を持つ場面でなくても、この現象は判で押したように現れます。まるで注意という限られた資源が、そこに吸い込まれていくかのようです。
この現象を Attention Sink(注意の吸い込み/シンク) と呼びます。2023年に Xiao らが StreamingLLM の論文で明確に指摘し、命名しました。一見すると「無駄な注意」に見えますが、実はこれはモデルの正常な振る舞いです。そして厄介なことに、この先頭トークンをうっかり捨てると、長文を流し込む推論でモデルの出力が突然崩壊します。
Attention Sink を理解すると、次のような実務的・理論的な問いに答えられるようになります。
- なぜ LLM を長文でストリーミング推論すると、古い KV キャッシュを捨てた瞬間に生成品質が崩壊するのか — その原因が先頭トークンの喪失にあること、そして StreamingLLM が「先頭数トークンだけは残す」というシンプルな処方で安定化する理由がわかります。
- なぜ Vision Transformer に register token を足すと注意マップが綺麗になるのか — 画像でも「余った注意の捨て場」が必要という同じ原理が働いていることがわかります。
本記事の内容
- softmax の「合計1」という制約が生む、注意の捨て場問題の直感
- 「見るべきものが無い」状況を softmax が表現できないことの数値的な確認
- なぜ捨て場が先頭トークンに集中するのか(因果マスクと学習の両面から)
- Attention Sink を模擬した注意重みヒートマップの再現
- スライディング窓で先頭を捨てると分布が崩れ、sink を残すと保たれることの概念実証
- StreamingLLM の構成、register token、softmax_1(分母に+1)といった対策
なお本記事は、実際の巨大 LLM を動かす実験ではなく、numpy による小さな概念実証で仕組みを腹落ちさせる構成です。数値は概念を説明するために設計したもので、特定のモデルの実測値ではないことを最初にお断りしておきます。
前提知識
この記事を読む前に、以下の記事を読んでおくと理解が深まります。
まず直感 — softmaxは「どこも見ない」を表現できない
Attention Sink の根っこにあるのは、注意の重みを計算する softmax の、ごくシンプルな性質です。難しい話に入る前に、まずこの一点だけ押さえておきましょう。
Self-Attention では、あるトークン(クエリ)が他の各トークン(キー)とどれくらい関係が深いかをスコアで測り、そのスコアを softmax にかけて「注意の重み」に変換します。softmax の出力は、必ずすべて非負で、合計がちょうど 1 になります。これは確率分布としては都合が良い性質です。しかし、ここに落とし穴があります。
想像してみてください。あるヘッドが担当している役割にとって、今のトークンにとって見るべき相手が系列の中にひとつも無いという状況です。たとえば「主語と述語の対応を見張るヘッド」があったとして、まだ主語も述語も出てきていない文の途中では、そのヘッドは「参照すべき相手なし」という状態になります。
人間なら「今回はパス」と手を挙げれば済みます。ところが softmax は、合計を 1 にしなければならないので、「どこにも注目しない(全部 0)」を出力できません。1 という総量を、必ずどこかのトークンに配らなければならないのです。この配りきれない「余った注意」を、どこか無害な場所に捨てる必要があります。

この図が本記事の主題を一枚で表しています。クエリ(今のトークン)から見て、本当に関係が深い相手がいないとき、softmax の合計1制約のせいで注意はゼロにできません。その余りが、系列の中で常に見える先頭トークンへ、太い矢印となって流れ込みます。これが Attention Sink です。
では「合計1だから0にできない」という主張を、具体的な数値で確かめてみましょう。
softmaxが全成分を0にできないことの確認
言葉だけだと半信半疑かもしれないので、実際に softmax を動かして「どうやっても全部を 0 にはできない」ことを見ておきます。ここで確認したいのは、スコアをどれだけ小さく・平坦にしても、重みはゼロにならず、むしろ均等分配(1/n)に近づいていくだけという事実です。
import numpy as np
def softmax(x, axis=-1):
x = x - np.max(x, axis=axis, keepdims=True)
e = np.exp(x)
return e / np.sum(e, axis=axis, keepdims=True)
# 「見るべき相手が無い」= スコアに差が無く平坦な状況を模擬する
base = np.array([0.2, -0.1, 0.0, 0.1, -0.2]) # 5トークンへの生スコア
for s in [1.0, 0.3, 0.05]: # スコアをどんどん平坦(小さく)にしていく
w = softmax(base * s)
print(f"縮小率 {s}: 重み = {np.round(w, 3)} 合計 = {w.sum():.2f}")
このコードを走らせると、縮小率を小さくするほど 5 つの重みが 0.2(= 1/5)へ近づき、合計は常に 1.00 のままです。「差がない=どこも重要でない」状況を作っても、softmax は「全員に均等に少しずつ配る」という答えしか返せません。全部 0 という選択肢は原理的に存在しないのです。

3枚のパネルは、左から右へスコアを平坦にしていったときの重みの分布です。平坦にするほど棒の高さが赤破線(均等値 1/5 = 0.2)にそろっていくのが読み取れます。どのパネルでも合計は 1.00 で、いずれのトークンも重みは正のままです。つまりモデルは「配らない」という選択ができず、この余った 1 の総量をどこかに押しつける先が必要になります。
ここで自然な疑問が生まれます。押しつける先はどこでもよいはずなのに、なぜ現実のモデルでは決まって先頭のトークンなのでしょうか。次にその理由を、因果マスクと学習の両面から見ていきます。
なぜ捨て場は「先頭トークン」なのか
余った注意の捨て場は、理屈のうえでは系列のどのトークンでも構いません。しかし現実の自己回帰型 LLM では、ほぼ例外なく先頭付近のトークンが選ばれます。理由は大きく2つあります。
理由1: 因果マスク下で先頭は「全員から見える」唯一の存在
自己回帰型の言語モデルは、因果マスク(causal mask) をかけて学習します。これは「各トークンは自分より過去のトークンしか参照できない」という制約です。未来を見てしまうとカンニングになるので、未来方向の注意は禁止されます。
この制約のもとで、系列の各位置がどれだけ多くのクエリから「見られる」かを考えてみましょう。系列の後ろのほうにあるトークンは、それより後ろのクエリからしか見られません。ところが先頭のトークンだけは、系列中のすべてのクエリの参照範囲に入っています。誰から見ても必ず視界に入る、いわば「常設の捨て場」です。

このマスク行列で、青いマスは「参照できる」、白いマスは「未来なので参照できない」を表します。行がクエリ、列がキーです。赤枠で囲った先頭列(列0)だけが、上から下まですべての行で青くなっています。つまり先頭キーはどのクエリからも到達可能です。捨て場を作るなら、全員が確実にアクセスできるここが最も都合が良い、というわけです。
理由2: 学習の過程で「無害な捨て場」として定着する
先頭が構造的にアクセスしやすいだけでは、なぜそこに注意が集中するのかの半分しか説明できません。もう半分は学習のダイナミクスにあります。
モデルは膨大なテキストで訓練されるうちに、「余った注意をどこに逃がせば損失が増えないか」を自然に学びます。もし余りを意味のあるトークンに配ってしまうと、その Value ベクトルが出力に混ざり込み、予測を歪めてしまいます。一方、先頭トークンに配れば、そのトークンの Value 寄与を出力側でほぼ打ち消すように学習できるため、実質的に「何もしない」に近い効果が得られます。
先頭トークン(多くのモデルでは文頭の特殊トークン BOS)は、内容として特別な意味を担うことが少なく、しかも全クエリから見えます。この2条件がそろっているため、学習を重ねるうちに「困ったら先頭に捨てる」という戦略がヘッド全体に定着していきます。これが Attention Sink の正体です。
ここまでで「なぜ先頭に集まるのか」がわかりました。次は、この吸い込みが注意重みの図にどう現れるかを、実際に再現してみましょう。
注意重みヒートマップにsinkを再現する
現象を目で確かめるのがいちばんです。ここでは、Attention Sink が起きているモデルを模した注意重みの行列を作り、ヒートマップに描きます。確認したいのは、先頭列だけが飛び抜けて明るくなるという特徴です。
実際の LLM を動かす代わりに、次の3つの要素を持つスコア行列を組み立てます。(1) ランダムな微小スコア(見るべき相手が特にない状態)、(2) 近傍ほどわずかに高いスコア(局所性)、(3) 先頭トークンへの下駄(sink バイアス)。そして因果マスクをかけて softmax します。
import numpy as np
def softmax(x, axis=-1):
x = x - np.max(x, axis=axis, keepdims=True)
e = np.exp(x)
return e / np.sum(e, axis=axis, keepdims=True)
n = 24
rng = np.random.default_rng(1)
idx = np.arange(n)
scores = rng.normal(0, 0.3, size=(n, n)) # (1) ほぼ差のない微小スコア
scores += -0.15 * np.abs(idx[:, None] - idx[None, :]) # (2) 近傍ほど高い
scores[:, 0] += 3.0 # (3) 先頭への sink バイアス
scores[:, 1] += 1.2 # 2番目にも弱いバイアス
causal = np.tril(np.ones((n, n))) # 因果マスク
scores = np.where(causal == 1, scores, -np.inf)
W = softmax(scores, axis=1)
head = W[:, :2].sum(axis=1) # 各クエリが先頭2トークンに払う注意
print(f"先頭2トークンが受ける平均注意 = {head.mean():.3f}")
このコードの出力は 先頭2トークンが受ける平均注意 = 0.482 です。つまり各クエリは、注意の総量 1 のうち平均で約 48% を、たった先頭 2 トークンだけに払っています。系列に 24 トークンあることを考えると、これは異常な偏りです。

ヒートマップの縦軸がクエリ、横軸がキーです。右下の三角形が空白なのは因果マスクで未来を見られないためです。シアンの枠で囲った先頭2列だけが、行の下のほうに行くほど(=参照できる過去が増えるほど)くっきりと明るく光り続けています。本来なら「近くのトークンに注目する」局所性で対角線付近が明るくなるはずですが、それを押しのけて先頭列が支配的です。これが Attention Sink の典型的な見た目です。
同じ行列を「各トークンがどれだけ注意を受け取ったか」という列方向の集計で見ると、偏りがさらにはっきりします。
col = W.sum(axis=0) / W.shape[0] # 各キーが平均で受ける注意量
print(f"先頭トークン col[0] = {col[0]:.3f}")
print(f"2番目 col[1] = {col[1]:.3f}")
print(f"中間(10番目) col[10] = {col[10]:.4f}")
出力は col[0] = 0.412、col[1] = 0.070、col[10] = 0.0318 です。先頭トークンが受ける注意量(0.412)は、中間のトークン(0.0318)の実に十数倍にのぼります。

棒グラフは各トークンが平均で受け取る注意量です。赤い先頭トークンだけが突出して高く、残りのトークンは低く横ばいです。これは学習済み LLM の注意を集計したときに実際に観察される形とよく似ています。先頭トークンが、系列全体の「注意のゴミ箱」として機能しているわけです。
この偏り自体は無害で、モデルの正常な動作の一部です。問題は、この先頭トークンをうっかり捨ててしまったときに起きます。次はその破綻を、長文ストリーミング推論の文脈で見ていきましょう。
実害 — スライディング窓で先頭を捨てると崩壊する
LLM で非常に長いテキストを扱うとき、素朴には困った問題が起きます。KVキャッシュは系列が伸びるほど際限なく膨らむため、どこかで古いキャッシュを捨てなければメモリが尽きます。もっとも単純な対策は、Sliding Window Attention のように「直近の数千トークンだけ残して、それより古い KV は捨てる」というスライディング窓方式です。
ところが、この素朴な窓方式には落とし穴があります。窓が系列の先頭を通り過ぎた瞬間、Attention Sink の受け皿である先頭トークンまで一緒に捨ててしまうのです。捨て場を失った余った注意は、行き場をなくして本来は無視すべき近傍トークンに押し寄せます。その結果、注意分布が学習時とはまるで違う形になり、モデルの出力が突然崩壊します。実際の LLM では、この崩壊は perplexity(言語モデルの当てにくさの指標。低いほど良い)が跳ね上がる形で観測されます。
本記事では巨大モデルを回さないので、perplexity の代わりに注意分布そのもののズレを代理指標にします。具体的には、「全キャッシュを保持したときの注意分布」を基準(正解)とみなし、そこから各方式の注意分布がどれだけズレたかを 全変動距離(Total Variation, TV) で測ります。TV は2つの確率分布の差の絶対値の総和を2で割った量で、0 なら完全一致、1 なら完全に別物です。この指標が大きいほど、モデルは学習時と違う注意をしており、崩壊に近いと解釈できます。
比較するのは次の3方式です。
- 全キャッシュ保持 — すべての過去 KV を残す(基準)。
- 素朴な窓 — 直近 8 トークンだけ残し、先頭も含めそれ以外は捨てる。
- StreamingLLM — 直近 8 トークンに加えて、先頭2トークン(sink)だけは常に残す。
import numpy as np
def softmax(x, axis=-1):
x = x - np.max(x, axis=axis, keepdims=True)
e = np.exp(x)
return e / np.sum(e, axis=axis, keepdims=True)
def masked_softmax_row(row, allowed):
return softmax(np.where(allowed, row, -np.inf))
n, win = 40, 8
rng = np.random.default_rng(2)
idx = np.arange(n)
scores = rng.normal(0, 0.3, size=(n, n)) - 0.15 * np.abs(idx[:, None] - idx[None, :])
scores[:, 0] += 3.0
scores[:, 1] += 1.2 # 先頭2トークンに sink バイアス
causal = np.tril(np.ones((n, n))).astype(bool)
tv_window, tv_stream = [], []
for i in range(win, n):
p_full = masked_softmax_row(scores[i], causal[i]) # 基準: 全キャッシュ
win_allowed = np.zeros(n, bool)
win_allowed[max(0, i - win + 1):i + 1] = True
win_allowed &= causal[i]
p_win = masked_softmax_row(scores[i], win_allowed) # 素朴な窓
stream_allowed = win_allowed.copy()
stream_allowed[:2] = causal[i][:2] # 先頭2トークンを残す
p_stream = masked_softmax_row(scores[i], stream_allowed) # StreamingLLM
tv_window.append(0.5 * np.abs(p_full - p_win).sum())
tv_stream.append(0.5 * np.abs(p_full - p_stream).sum())
print(f"平均分布ズレ 素朴な窓 = {np.mean(tv_window):.3f}")
print(f"平均分布ズレ StreamingLLM = {np.mean(tv_stream):.3f}")
出力は 素朴な窓 = 0.380、StreamingLLM = 0.216 です。先頭2トークンを残すか捨てるか、という違いだけで、基準分布からのズレが 0.380 から 0.216 へと大きく縮みました。捨てるトークン数はたった2個しか違わないのに、この差が生まれるのは、その2個が Attention Sink の受け皿だからです。

横軸はクエリの位置、縦軸は基準分布からの全変動距離です。赤い曲線(素朴な窓)は、窓が先頭を追い越して sink を失うため、位置が進むほど大きなズレを保ち続けます。緑の曲線(StreamingLLM)は、先頭 sink を残しているので終始低く安定しています。実際の LLM でこのズレが perplexity の暴騰として現れるのが、素朴な窓方式の崩壊の正体です。

平均値の棒グラフで比べると差は一目瞭然です。先頭 sink を残すだけで平均ズレがおよそ4割減っています。ここで強調したいのは、この改善が特別なファインチューニングを一切要さないという点です。すでに学習済みのモデルに対して、KV キャッシュから捨てるトークンの選び方を変えるだけで効くのです。
この観察がそのまま StreamingLLM の設計思想になります。次に、その具体的な構成を整理しましょう。
StreamingLLMの仕組み
StreamingLLM(Xiao et al., 2023)のアイデアは、これまでの観察をそのまま形にしたものです。ひとことで言えば、「古い KV は捨てる。ただし先頭の数トークン(attention sink)だけは何があっても残す」 という KV キャッシュ管理ルールです。

3段の図が、KV キャッシュの残し方の違いを示しています。(a) 全キャッシュ保持は品質は最良ですが、系列長に比例してメモリが増え続けます。(b) 素朴なスライディング窓は、現在位置の周辺だけを残すのでメモリは一定ですが、先頭の sink(赤いブロックに相当する位置)を捨てるため崩壊します。(c) StreamingLLM は、直近の窓に加えて先頭数トークン(赤い sink)だけを飛び地として常に確保します。これでメモリはほぼ一定に保ちつつ、注意の捨て場を維持できます。
実装上のポイントは2つあります。ひとつは、残す sink トークンの個数は多くなくてよいことです。論文の報告では、先頭4トークン程度を残すだけで長文ストリーミングが安定します。もうひとつは、位置エンコーディングの扱いです。sink トークンと直近窓は系列上で離れていますが、注意計算ではキャッシュ内の相対位置で位置情報を振り直すことで、モデルが混乱しないようにします。この処理により、追加学習なしで数百万トークン規模の連続生成が可能になります。
StreamingLLM は「学習済みモデルのキャッシュ運用を工夫する」という後付けの対策でした。では、そもそもモデルを作る段階で sink を意識するとどうなるでしょうか。次にその発想を見ます。
学習段階での対策 — sink token と register token
StreamingLLM が示したのは「先頭トークンが捨て場として必要」という事実でした。ならば、意味のある本文トークンに捨て場を兼任させるのではなく、最初から専用の捨て場トークンを用意しておけばよいという発想が自然に出てきます。

上段は専用トークンが無い場合で、意味を持つはずの先頭の本文トークンが、余った注意の捨て場を押しつけられています。下段は先頭に学習可能な専用トークン(図では [SINK])を1つ置いた場合で、このトークンが余った注意を一手に引き受けます。こうすると本文トークンは本来の役割に専念でき、注意マップも解釈しやすくなります。学習時に置くこの種のトークンは sink token と呼ばれ、StreamingLLM の論文でも、あらかじめ学習可能な sink トークンを1つ足しておくと、推論時に先頭を4つ残さなくても1つで足りることが示されています。
同じ発想は言語モデルに限りません。画像を扱う Vision Transformer でも、Darcet ら(2023)が register token という追加トークンを提案しました。ViT の注意マップを可視化すると、背景の意味の薄いパッチに不自然な高注意(アーティファクト)が現れることが知られていましたが、これも「余った注意の捨て場」不足が原因でした。数個の register token を足すだけで、この汚れが消えて注意マップが綺麗になり、下流タスクの性能も向上します。言語でも画像でも、softmax が生む「捨て場が要る」という同じ力学が働いているわけです。
トークンを足すのではなく、softmax の式そのものを手直しする方向もあります。最後にそれを見ておきましょう。
softmaxを手直しする — softmax_1(分母に+1)
ここまでの問題の根本は、softmax が「合計を必ず1にする」せいで「どこも見ない」を表せない点にありました。ならば、その制約を緩めればよいという直接的な発想があります。俗に softmax_1 や softmax-off-by-one、あるいは quiet attention と呼ばれるアイデアです。
通常の softmax は、分母がスコアの指数和だけでした。softmax_1 では、この分母にもう1項、定数 1(= $e^0$)を足します。
$$ \text{softmax\_1}(\bm{z})_i = \frac{e^{z_i}}{1 + \sum_{j} e^{z_j}} $$
分母に増えた 1 は、「どこにも注目しない」という架空の選択肢へのスコア(対数で 0)に対応します。この項があるおかげで、出力の重みの合計は 1 未満になれます。すべてのスコアが低ければ、重みの総和は 0 に近づき、余った分は「どこも見ない」に吸収されます。これで、捨て場のためにわざわざ先頭トークンを酷使する必要がなくなります。
実際に、スコアを平坦にしていったとき、どれだけの注意が「どこも見ない」に回るかを数値で見てみましょう。
import numpy as np
base = np.array([0.2, -0.1, 0.0, 0.1, -0.2])
for s in [1.0, 0.3, 0.05]:
z = base * s
m = z.max()
e = np.exp(z - m)
soft1 = e / (e.sum() + np.exp(-m)) # 分母に +1(max減算に合わせて exp(-m))
print(f"縮小率 {s}: どこも見ないに回った割合 = {1 - soft1.sum():.3f}")
出力は縮小率 1.0 / 0.3 / 0.05 に対してそれぞれ 0.165、0.167、0.167 です。スコアが平坦になるほど、注意の16〜17%が「どこにも配らない」に回っています。通常の softmax なら 0% だったはずの量です。

棒グラフは「どこも見ない」に回った注意の割合です。通常の softmax ではこの値は常に 0 ですが、softmax_1 では正の値を取れています。この「捨てる自由」があれば、モデルは先頭トークンに無理やり注意を押しつける必要がなくなります。ただし softmax_1 は softmax の定義自体を変えるため、既存の学習済みモデルにそのまま差し替えるのではなく、学習の段階から採用する必要がある点には注意が必要です。sink token や register token と同様、「捨て場を明示的に用意する」という同じ思想を、トークンではなく式の側で実現したものと捉えると見通しが良くなります。
まとめ
本記事では、Attention Sink(注意の吸い込み)について、その原理から対策までを解説しました。
- 原因は softmax の合計1制約 — softmax は「どこにも注目しない(全部0)」を表現できず、注意の総量1を必ずどこかに配る。見るべき相手が無いとき、この余った注意を捨てる先が必要になる。
- 捨て場が先頭に集まる理由は2つ — 因果マスク下で先頭トークンは全クエリから見える唯一の存在であり、かつ学習を通じて「無害な捨て場」として定着する。ヒートマップでは先頭列が突出して明るくなる。
- 実害はストリーミング推論での崩壊 — スライディング窓で古い KV を捨てると、先頭 sink まで捨ててしまい注意分布が壊れる。概念実証では、先頭2トークンを残すだけで基準分布からのズレが 0.380 から 0.216 に減った。
- StreamingLLM の処方 — 「直近窓+先頭数トークン」を残すだけで、追加学習なしに長文の連続生成が安定する。
- 設計段階の対策 — 学習可能な sink token や、Vision Transformer の register token で専用の捨て場を用意する。あるいは softmax_1(分母に+1)で「どこも見ない」を式に組み込む。
Attention Sink は、一見すると「モデルの奇妙なクセ」に見えますが、その正体は softmax という基本部品の制約から必然的に生じる現象でした。この視点を持つと、効率的な推論やモデル設計の多くの工夫が「捨て場をどう用意するか」という一貫したテーマでつながって見えてきます。
次のステップとして、以下の記事も参考にしてください。