Text generieren und KV-Cache
Ein trainiertes Modell bewertet nur das nächste Zeichen – wie wird aus Punktwerten ein Gedicht? Diese Lektion probiert mit dem eigenen GPT Temperatur, Top-k, Fortsetzung und Akrostichon aus, setzt dann den KV-Cache um und misst, wie viel doppelte Rechenarbeit er spart.
- Etwa 40 Minuten
- Niveau: Vertiefung
- Getestet: 2026-09-15 torch 2.14, Apple-M4-CPU, fester Zufallsstartwert
Code und Programmausgaben stehen genau so da, wie sie gelaufen sind – Kommentare und Ausgaben sind daher auf Chinesisch.
In Modul 01, Lektion 3 haben wir mit DeepSeek Temperaturexperimente gemacht; damals war das Modell eine Blackbox. Jetzt haben wir das Modell selbst trainiert und können hineinsehen, was es bei jedem Schritt tut.
python generate.py
Das Modell liefert nur Wahrscheinlichkeiten
Hat das Modell einen Text gelesen, bewertet es an der letzten Position jedes der 6289 Zeichen des Vokabulars mit einem Punktwert, der per Softmax zur Wahrscheinlichkeit wird:
== 1. 读完一段文字,模型给下一个字的概率(前 8 名)
「白日依山尽,黄河入海」→ 无 6.9% 间 4.5% 多 4.3% 深 3.7% 边 3.0% 空 2.6% 难 2.3% 遥 2.1%
「床前明月光,疑是地上」→ 天 3.2% 花 2.9% 楼 2.4% 清 1.8% 看 1.7% 游 1.7% 人 1.6% 枝 1.3%
「春风」→ 。 14.2% , 5.1% 吹 3.3% 满 2.9% 雨 1.9% 起 1.9% 尽 1.4% 落 1.3%
Nach „黄河入海“ steht im Original „流“ (der Gelbe Fluss fließt ins Meer), aber „流“ ist nicht unter den ersten 8. Nach „疑是地上“ ist auch „霜“ (Reif) nicht dabei. Die vorige Lektion hat geprüft, dass dieses Modell nicht aufsagt; es hat sich diese beiden berühmten Gedichte nicht gemerkt, sondern gibt nach den gelernten Regeln „sinnvolle“ Zeichen aus.
Nebenbei eine interessante Entdeckung: In der „Vollständigen Sammlung der Tang-Gedichte“ lautet das „Nachtgedanken“ (静夜思) „床前看月光,疑是地上霜。举头望山月,低头思故乡“ und nicht wie im Schulbuch „床前明月光……举头望明月“. Allgemein gilt die bekannte Fassung als Überlieferung aus Anthologien der Ming- und Qing-Zeit. Selbst wenn das Modell das Gedicht auswendig gelernt hätte, wäre es also „看月光“.
Nach „春风“ (Frühlingswind) hat der Punkt die höchste Wahrscheinlichkeit (14,2 %). Ich habe in den Trainingsdaten gezählt: „春风“ kommt 797-mal vor, davon 111-mal direkt gefolgt von einem Punkt, 13,9 %, fast genau die Wahrscheinlichkeit des Modells. Gelernt hat das Modell genau diese Häufigkeitsmuster der Zeichen in Tang-Gedichten.
Text generieren heißt, immer wieder dasselbe zu tun: Wahrscheinlichkeiten holen, ein Zeichen wählen, anhängen, die Wahrscheinlichkeiten des nächsten Zeichens berechnen. Wie „gewählt“ wird, bestimmt, was geschrieben wird.
Temperatur
Modul 01 hat die Temperatur erklärt: die Punktwerte durch die Temperatur teilen, dann Softmax. Unter 1 wird der Abstand zwischen hohen und niedrigen Punktwerten größer, das Modell „vorsichtiger“; über 1 wird er kleiner, das Modell „kühner“.
logits = logits[:, -1, :] / temperature
...
next_id = torch.multinomial(F.softmax(logits, dim=-1), num_samples=1)
Mit demselben Zufallsstartwert schreibt jede der drei Temperaturen 3 Gedichte:
温度 0.3:
白云无限意,白发不如何。何事无人在,无人不可知。
一望东山路,千行万里愁。夜深人不见,秋色月无声。
江南春水上,江上月明时。独有清风起,何人别有期。
温度 1.0:
白露起春光,知君得舞酲。霜时飏先没,桐死势悠扬。
白发摩牛放,真关万趾兴。夜寒休绕郡,秋杀杜娘宫。玉镜收新镜,金尊坐晚麕。何言吊鱼道,双锡空余年。
戍楼映江岸,淼危棹度斜。欲分凝炯娩,归盖乱精冰。
温度 1.5:
淡露依春似映兹,襞舞凋年雪路揉筝衬情。死势悠扬双觉处,摩牛放将溉噭胞。……
Temperatur 0,3 schreibt am flüssigsten; „一望东山路,千行万里愁。夜深人不见,秋色月无声“ (ein Blick auf den Weg zum Ostberg, tausend Reihen, zehntausend Meilen Kummer; tief in der Nacht ist niemand zu sehen, die Herbstfarben, der Mond ohne Laut) könnte fast echt sein. Der Preis: sehr gewöhnliche Zeichen und viele Wiederholungen; „无人“ kommt zweimal vor, „江“ zweimal, und nach einer Weile klingt alles gleich.
Temperatur 1,0 wählt Zeichen nach den ursprünglichen Wahrscheinlichkeiten des Modells. Die Zeichen sind vielfältiger, aber es tauchen seltene Zeichen wie „麕“, „酲“, „炯娩“ auf, und der Sinn hängt schwerer zusammen.
Temperatur 1,5 ist völlig durcheinander: Das Format bricht zusammen („襞舞凋年雪路揉筝衬情“, neun Zeichen in einer Zeile), überall seltene Zeichen. Der Grund: Bei hoher Temperatur wird die Wahrscheinlichkeitsverteilung flach, und die Zeichen mit ursprünglich sehr niedriger Wahrscheinlichkeit unter den über 6000 Zeichen bekommen zusammen einen beträchtlichen Anteil. Wird einmal ein seltsames Zeichen gewählt, wird der folgende Kontext seltsam, und die Fehler häufen sich.
Top-k: nur unter den vordersten Zeichen wählen
Bei hoher Temperatur ist das Problem der „lange Schwanz“: Tausende Zeichen mit niedriger Wahrscheinlichkeit nehmen zusammen zu viel Anteil ein. Top-k löst das direkt: nur die k wahrscheinlichsten Zeichen behalten, alle anderen entfernen und unter diesen k nach Wahrscheinlichkeit wählen.
if top_k is not None:
kth = torch.topk(logits, top_k).values[:, -1:]
logits = logits.masked_fill(logits < kth, float("-inf"))
Ebenfalls Temperatur 1,5, dazu Top-k=20:
== 3. 温度 1.5,但只从概率最高的 20 个字里选(top-k=20)
白头白马在西陵,自是西林下路尘。今日不辞青史士,可堪回首白苹州。
万国风尘里,千金雨色深。不知归未到,应似到人中。
东北山川尽不愁,三千春尽一枝愁。春寒未入千门色,雨尽空闻九夜寒。……
Das Format ist wieder da, die seltenen Zeichen sind verschwunden, aber es ist abwechslungsreicher als bei Temperatur 0,3. Die hohe Temperatur sorgt fürs „Kühne“, Top-k für „nicht zu abwegig“; zusammen wirken sie am besten.
Ein anderes übliches Verfahren heißt Top-p (Nucleus Sampling): keine feste Anzahl, sondern vom wahrscheinlichsten Zeichen an aufsummieren, bis die kumulierte Wahrscheinlichkeit p erreicht (etwa 0,9). Ist die Wahrscheinlichkeit konzentriert, bleiben nur wenige Zeichen, ist sie verteilt, bleiben mehr. Diesen Parameter hast du in Modul 01 bei der API gesehen.
Fortsetzung und Akrostichon
Der Anfang der Generierung muss nicht leer sein. Gibt man eine halbe Zeile vor, schreibt das Modell weiter:
== 4. 给出开头,让它续写
床前明月光,已有四邻情。知君得舞袖,何奈在山期。
大漠孤烟直,高楼楚水清。雁雕摩露下,鸿劒鼓歌中。夜雨初分郡,秋风又过巴。此才应不遇,空与钓鱼舟。
人工智能好,言蠹不见人。徒思一岁月,独在岘生春。
„大漠孤烟直“ (in der großen Wüste steigt einsamer Rauch gerade auf) wurde zu einem vollständigen Lüshi mit fünf Zeichen pro Zeile und acht Zeilen fortgesetzt. Auch „人工智能“ (künstliche Intelligenz), ein Wort, das es in der Tang-Zeit nicht gab, setzt es fort, weil jedes dieser vier Zeichen einzeln in Tang-Gedichten vorkommt.
Man kann noch weiter gehen und während der Generierung eingreifen. Beim Akrostichon (藏头诗) muss das erste Zeichen jeder Zeile vorgegeben sein: Man setzt das vorgegebene Zeichen ein und lässt das Modell die Zeile Zeichen für Zeichen zu Ende schreiben, wobei es keine Satzzeichen und Zeilenumbrüche ausgeben darf; die Satzzeichen setzen wir am Ende selbst:
@torch.no_grad()
def acrostic(heads, n_char=7, temperature=0.8):
ids = encode("\n")
for i, head in enumerate(heads):
ids = torch.cat([ids, encode(head)], dim=1) # 先把指定的字放进去
for _ in range(n_char - 1): # 再让模型一个字一个字地写完这一句
logits = model(ids)[0][0, -1] / temperature
logits[BANNED] = float("-inf")
next_id = torch.multinomial(F.softmax(logits, dim=-1), 1)
ids = torch.cat([ids, next_id[None]], dim=1)
ids = torch.cat([ids, encode(",。"[i % 2])], dim=1) # 标点由我们加,保证格式
return decode(ids[0].tolist()).strip()
== 5. 藏头诗:每句开头的字由我们指定,其余由模型写
春色欲依春似春,眠花先得舞花年。不知诗句先酬情,觉后还随楚水人。
学得重雕出,无情忘却回。止行空向兴,境静更闻闻。
Die Anfänge der Zeilen ergeben zusammen „春眠不觉“ (im Frühlingsschlaf merkt man nicht) und „学无止境“ (Lernen kennt keine Grenzen).
Dieser Trick hat einen offiziellen Namen: eingeschränktes Dekodieren (constrained decoding). Vor dem Wählen werden die Punktwerte unzulässiger Möglichkeiten auf minus unendlich gesetzt. Als DeepSeek in Modul 02, Lektion 4 JSON ausgeben sollte und der Server das richtige Format garantierte, stand dasselbe Prinzip dahinter: Bei jedem Schritt werden alle Tokens ausgeschlossen, die das JSON ungültig machen würden.
Doppelte Rechenarbeit
Nun zur Effizienz der Generierung. Zurück zur Methode generate: Die einfachste Schreibweise schickt bei jedem Schritt den ganzen Text ins Modell:
logits, _ = self(idx) # 不用缓存:每一步都把整段序列从头算一遍
Beim 50. Zeichen muss das Modell alle 49 vorigen Zeichen verarbeiten; beim 51. verarbeitet es diese 49 Zeichen noch einmal, plus das 50.
Genau betrachtet haben sich die Rechenergebnisse der ersten 49 Zeichen aber nicht geändert. Die kausale Maske sorgt dafür, dass jede Position nur nach vorn schaut; ein neues Zeichen hinten beeinflusst die Berechnung der vorigen Positionen überhaupt nicht. Wirklich neu ist nur die letzte Position.
Was braucht die letzte Position, um ihre Attention zu berechnen? Ihre eigene q sowie die k und v aller vorigen Positionen. Es genügt also, die k und v jeder Schicht zu speichern, bei jedem Schritt nur q, k und v des neuen Zeichens zu berechnen und die neuen k, v an die gespeicherten anzuhängen. Das ist der KV-Cache.
Umsetzung
In der Attention von gpt.py braucht der Cache nur ein paar Zeilen mehr:
if cache is not None: # KV 缓存:把之前算过的 K、V 接在前面,不用重算
if "k" in cache:
k = torch.cat([cache["k"], k], dim=2)
v = torch.cat([cache["v"], v], dim=2)
cache["k"], cache["v"] = k, v
Ein Detail ist zu beachten: die kausale Maske. Ohne Cache sind q und k gleich lang, die Maske ist ein quadratisches unteres Dreieck. Mit Cache ist q nur das eine neue Zeichen, k aber umfasst alle Zeichen, und das neue Zeichen steht am Ende und darf alle k sehen. Die Maske muss also versetzt sein:
total = k.size(2)
mask = torch.ones(T, total, dtype=torch.bool, device=x.device).tril(diagonal=total - T)
Auch das Positions-Embedding muss angepasst werden: Das neue Zeichen ist nicht Position 0, sondern Position start. Genau dafür ist der Parameter start in forward da.
Beim Generieren wird im ersten Schritt der ganze Prompt hineingeschickt und k, v aller Positionen im Cache gespeichert; danach wird bei jedem Schritt nur das neueste Zeichen hineingeschickt:
if use_cache:
# 第一步把整段提示词送进去;之后每步只送最新的一个词元,前面的 K、V 从缓存里取
logits, _ = self(idx[:, start:], caches=caches, start=start)
start = idx.size(1)
Messen
== 6. KV 缓存:结果一样吗?快了多少?
同样的随机种子,写 120 个字:用缓存和不用缓存的结果完全一样
写 32 个字:不用缓存 23 毫秒,用缓存 12 毫秒,快了 1.8 倍
写 64 个字:不用缓存 52 毫秒,用缓存 26 毫秒,快了 2.0 倍
写 120 个字:不用缓存 130 毫秒,用缓存 49 毫秒,快了 2.6 倍
这个模型每个词元的缓存:2 × 4 层 × 128 维 × 4 字节 = 4 KB,128 个词元共 512 KB
Zuerst die Korrektheit: Mit demselben Zufallsstartwert sind die 120 geschriebenen Zeichen mit und ohne Cache völlig gleich. Der Cache spart nur doppelte Rechenarbeit, das Ergebnis bleibt gleich.
Dann die Geschwindigkeit. Je mehr Zeichen geschrieben werden, desto mehr wird gespart: 32 Zeichen 1,8-mal schneller, 120 Zeichen 2,6-mal. Ohne Cache muss beim n-ten Zeichen n Zeichen verarbeitet werden, der gesamte Rechenaufwand wächst quadratisch mit der Länge; mit Cache verarbeitet jeder Schritt nur 1 Zeichen.
Bei unserem Modell ist die Beschleunigung nur gut das Doppelte, nicht beeindruckend. Der Grund: Das Modell ist zu klein und der Text zu kurz. Die eigentliche Rechnung jedes Schritts dauert nur Bruchteile einer Millisekunde, und der Overhead von Python selbst für Funktionsaufrufe und das Verketten von Tensoren macht einen großen Teil aus; diesen Overhead spart der Cache nicht. Bei großen Modellen ist der Rechenaufwand pro Schritt viel größer, und Texte haben schnell Tausende oder Zehntausende Tokens; da spart der KV-Cache Größenordnungen.
Der Preis des Caches: Speicher
Der KV-Cache tauscht Speicher gegen Zeit. Für jedes Token muss jede Schicht ein k und ein v speichern. Unser Modell braucht pro Token nur 4 KB, 128 Tokens sind 512 KB, vernachlässigbar.
Bei großen Modellen ist das anders. Ein Modell mit Dutzenden Schichten und Tausenden Dimensionen pro Schicht braucht pro Token womöglich Hunderte KB bis über 1 MB Cache. Ein langes Gespräch mit über hunderttausend Tokens belegt dann Dutzende GB Grafikspeicher für den Cache, oft mehr als das Modell selbst. Werden viele Nutzer gleichzeitig bedient, ist das der größte Posten im Grafikspeicher.
Viele der Verbesserungen, die am Ende der vorigen Lektion genannt wurden, zielen deshalb auf den KV-Cache: Mehrere Köpfe teilen sich k und v (Grouped-Query Attention), oder k und v werden zu kleineren Vektoren komprimiert gespeichert (Multi-Head Latent Attention, MLA, von DeepSeek). Bei vLLM in Modul 10 sehen wir, wie ein Server diese Caches verwaltet.
„Eingaben mit Cache-Treffer sind billiger“ aus Modul 06, Lektion 4 ist ein Cache auf einer anderen Ebene: Der Anbieter bewahrt den KV-Cache häufiger Präfixe (etwa eines festen System-Prompts) auf; nutzt die nächste Anfrage dasselbe Präfix, wird er direkt geholt, ohne Neuberechnung, deshalb ist es viel billiger.
Übungen
- Setz Top-p-Sampling um: Wahrscheinlichkeiten absteigend sortieren und die Zeichen behalten, bis die kumulierte Wahrscheinlichkeit p erreicht. Schreib mit Temperatur 1,5 und p=0,9 einige Gedichte und vergleiche mit Top-k=20.
- Schreib ein eingeschränktes Dekodieren mit „festem Format“: Nur Jueju mit sieben Zeichen pro Zeile sind erlaubt, das 8., 16. und 24. Zeichen darf nur Komma oder Punkt sein, an anderen Stellen dürfen keine Satzzeichen stehen.
- Setz
block_sizeingpt.pyauf 512 und trainiere neu (oder initialisiere nur zufällig), dann miss, wie lange das Schreiben von 500 Zeichen mit und ohne Cache dauert.
Selbsttest
1. Was verändern Temperatur und Top-k jeweils? Warum nutzt man sie oft zusammen?
Die Temperatur skaliert die Punktwerte vor dem Softmax insgesamt und verändert, wie „spitz“ die Wahrscheinlichkeitsverteilung ist: Bei niedriger Temperatur konzentriert sie sich auf wenige hoch bewertete Zeichen, bei hoher ist sie gleichmäßiger. Top-k behält nur die k wahrscheinlichsten Zeichen und entfernt die übrigen. Eine hohe Temperatur bringt Vielfalt, lässt aber sehr viele unwahrscheinliche Zeichen Anteile abzweigen; Top-k entfernt diesen langen Schwanz, und zusammen gibt es Abwechslung, ohne zu abwegig zu werden.
2. Warum verändert der KV-Cache das Ergebnis der Generierung nicht?
Wegen der kausalen Maske hängt die Berechnung jeder Position nur von ihr selbst und den vorigen Positionen ab; ein neues Zeichen dahinter verändert k und v der vorigen Positionen nicht. Der Cache speichert diese unveränderlichen Ergebnisse nur und verwendet sie wieder, spart doppelte Rechenarbeit und ist mathematisch völlig gleichwertig.
3. Was ist der Preis des KV-Caches? Warum ist dieser Preis bei großen Modellen so wichtig?
Der Preis ist Speicher: Für jedes Token muss jede Schicht ein k und ein v speichern. Große Modelle haben viele Schichten und große Dimensionen, der Cache pro Token ist groß, und bei langen Kontexten und vielen gleichzeitigen Nutzern kann der Cache mehr Grafikspeicher belegen als das Modell selbst. Deshalb zielen viele Verbesserungen der Modellarchitektur (etwa Grouped-Query Attention und Multi-Head Latent Attention) darauf, ihn zu verkleinern.
Fragen und Diskussion
Hängst du in dieser Lektion fest? Frag hier. Und wenn du die Frage von jemandem beantworten kannst, tu es gern.
Eine Frage bringt 3 Punkte, eine Antwort 6. Beiträge erscheinen nach der Prüfung.
Diskussion wird geladen…