Bài 3 trên 4 · Attention và ngữ cảnh dài
Tóm tắt
- ZHA tóm tắt các khoá thành một cây nhiều tầng, rồi mỗi câu hỏi đi từ trên xuống và chỉ mở những nhánh có vẻ liên quan. Nhánh bị bỏ qua thì mọi khoá bên dưới không phải chấm.
- Top-p mở nhóm nhỏ nhất đủ một phần attention ước tính, nên câu hỏi hẹp đọc ít, câu hỏi rộng đọc nhiều. Top-K thì đọc cùng một lượng trong mọi trường hợp.
- Cây chỉ có lợi khi dữ liệu có cấu trúc. Trên dữ liệu ngẫu nhiên, nó chấm rất nhiều mà vẫn không tìm thấy.
Bạn tìm một đoạn trong cuốn sách dày bằng cách xem mục lục, chọn chương, chọn mục, rồi mới đọc. Bạn không đọc từng câu để biết nên đọc câu nào. Zyphra Hierarchical Attention (ZHA), công bố tháng 11/2026, áp dụng đúng ý đó cho KV cache: dựng một mục lục nhiều tầng rồi đi từ trên xuống.
Tự thử: một câu hỏi đi xuống cây
Mỗi hàng là một tầng của cây. Ô sẫm là nút được mở, ô nhạt là nút được chấm rồi bỏ qua, phần trống là những nút không phải đụng tới. Dải cuối cùng là attention thật, để bạn đối chiếu xem cây có đi đúng chỗ không.
Một ngữ cảnh 4.096 khoá, chia khối 4, có 3 tầng. Đổi câu hỏi, cách chọn nhánh và dữ liệu rồi xem số trong bảng.
Thử theo thứ tự:
- Giữ Hỏi một chỗ với p 90%. Từng tầng lần lượt chấm rồi mở 64 → 4, 16 → 9, 36 → 25. Cuối cùng cây chỉ đọc 100 khoá và tốn 564 phép so sánh thay vì 4.096, giữ được khoảng 73% attention.
- Chọn Hỏi tám chỗ. Không có gì thay đổi ngoài câu hỏi, nhưng cây tự mở rộng hơn: đọc 1.084 khoá, tốn 3.500 phép so sánh.
- Chuyển sang Top-K với 6 nhánh. Cả ba câu hỏi đều đọc đúng 24 khoá. Nghe rất tiết kiệm, nhưng câu hỏi tám chỗ chỉ còn giữ được khoảng 10% attention.
- Quay về top-p, chọn Ngẫu nhiên, không cấu trúc và Hỏi một chỗ. Cây tốn 2.888 phép so sánh mà chỉ giữ được 0,2% attention. Bản tóm tắt không còn nói lên điều gì.
Cây được dựng thế nào?
Cứ một nhóm khoá kề nhau được tóm tắt thành một nút, rồi các nút lại được tóm tắt tiếp lên trên, trong lúc văn bản dài ra. Với khối 4, một tầng tóm tắt 4 khoá, hai tầng 16 khoá, sáu tầng 4.096 khoá.
- Khoá gốcVẫn được giữ nguyên
- Tầng 1Mỗi nút tóm 4 khoá
- Tầng 2Mỗi nút tóm 16 khoá
- Tầng trên cùngNơi câu hỏi bắt đầu
Hai điều quan trọng:
- Khoá gốc không bị xoá. Cây chỉ dùng để tìm. Khi tìm xong, attention bình thường chạy trên đúng những khoá gốc được chọn.
- Rất rẻ để duy trì. Khi viết ra, cứ đủ một khối mới tạo một nút. Paper tính việc này tốn khoảng 1% thời gian hoặc ít hơn trong mô hình hiệu năng của họ.
Một nút tóm tắt điều gì?
Nút không tóm tắt nội dung, nó ước lượng một câu hỏi sẽ chú ý tới vùng này bao nhiêu. Lấy trung bình cộng các khoá sẽ làm mất thông tin, vì những câu hỏi khác nhau quan tâm tới những khía cạnh khác nhau của cùng một vùng.
ZHA dùng vài reference query, những câu hỏi mẫu được học từ trước. Với mỗi câu hỏi mẫu, nút lưu một khoá nén và một số hiệu chỉnh, gọi là bias:
function summarize(children: { keys: Vec[]; biases: number[] }[]) {
const keys: Vec[] = [];
const biases: number[] = [];
references.forEach((reference, r) => {
const scores = children.map((c) => SHARPNESS * dot(c.keys[r]!, reference) + c.biases[r]!);
const weights = softmax(scores);
const key = new Float32Array(DIM);
children.forEach((c, i) => c.keys[r]!.forEach((x, d) => (key[d]! += weights[i]! * x)));
keys.push(key);
biases.push(logSumExp(scores) - SHARPNESS * dot(key, reference));
});
return { keys, biases };
}Khi một câu hỏi thật đến, điểm của nút là tích vô hướng + bias, lấy cao nhất trong các câu hỏi mẫu. Về mặt toán, mỗi câu hỏi mẫu cho một tiếp tuyến của hàm "tổng attention của vùng". Hàm đó lồi, nên tiếp tuyến khớp hoàn toàn tại câu hỏi mẫu và chỉ nằm dưới giá trị thật ở những chỗ khác.
Chúng tôi đã kiểm tra các tính chất này bằng test trên chính mã mà demo đang chạy: ước lượng đúng tại câu hỏi mẫu, không bao giờ vượt giá trị thật, và gộp nút con cho ra đúng kết quả như tóm tắt thẳng từ khoá gốc. Điều cuối cũng là điều paper nêu trong chú thích của phần kiến trúc.
Top-p khác top-K ở đâu?
Top-p mở nhóm nhỏ nhất chiếm đủ một phần attention ước tính. Top-K luôn mở đúng K nút.
function topP(probabilities: number[], p: number): number[] {
const order = probabilities.map((_, i) => i).sort((a, b) => probabilities[b]! - probabilities[a]!);
const picked: number[] = [];
let total = 0;
for (const i of order) {
picked.push(i);
total += probabilities[i]!;
if (total >= p) break;
}
return picked;
}Sự khác biệt này quan trọng vì các tác vụ thật rất khác nhau. Paper đo ở khoảng 64K token: 90% attention nằm trong trung vị là 2,3% số khối 16 khoá trên RULER, 11,7% trên SWE-bench Verified và 15,0% trên truyện dài. Một K đủ cho truyện dài thì lãng phí khoảng sáu lần với RULER.
| Tình huống | Top-K | Top-p |
|---|---|---|
| Tra một sự kiện đơn lẻ | Đọc thừa để đủ K | Đọc ít, dừng sớm |
| Tổng hợp từ nhiều nơi | Thiếu, phải bỏ phần liên quan | Tự mở rộng hơn |
| Ngân sách tính toán | Cố định, dễ dự đoán | Thay đổi theo câu hỏi, khó dự đoán hơn |
| Chi phí chọn | Thấp hơn | Phải xếp thứ tự, nhưng cây giữ tập ứng viên nhỏ |
Có phải huấn luyện lại mô hình không?
Không. Mô hình gốc được đóng băng, và chỉ reference query được học, bằng cách so sánh xem cây phân bổ attention giống mô hình gốc tới đâu (chưng cất KL). Với Qwen3.6-35B-A3B, phần thêm là 81.920 tham số, khoảng 0,00024% mô hình. Họ báo cáo chỉ cần 1 tỷ token huấn luyện ở độ dài 32K.
Lưu ý là paper cũng nói các mô hình nhỏ dưới 1 tỷ tham số thường làm không tốt trên bài toán tìm kiếm khi chuyển sang ZHA, vì điểm attention gốc đã nhiễu sẵn. Kết quả tốt lên rõ từ cỡ 4 tỷ tham số.
Tự chạy một cây nhỏ
Đoạn dưới đây là một cây đầy đủ, không thư viện ngoài, với khối 4 và 4 câu hỏi mẫu. Hai chỗ đơn giản hoá: khoá là vector tự tạo có cấu trúc lồng nhau, và câu hỏi mẫu là cố định chứ không học. Nó dựng tối đa 64 nút ở tầng trên cùng rồi đo thật.
// tree.ts: node tree.ts
type Vec = Float32Array;
const DIM = 32;
const CHUNK = 4;
const REFERENCES = 4;
const SHARPNESS = 20;
// Số ngẫu nhiên có seed, để bạn ra đúng các con số trong bài.
let seed = 1;
function random() {
seed = (seed + 0x6d2b79f5) >>> 0;
let t = seed;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
const dot = (a: Vec, b: Vec) => a.reduce((sum, x, i) => sum + x * b[i]!, 0);
function unit(v: Vec): Vec {
const len = Math.sqrt(dot(v, v)) || 1;
return v.map((x) => x / len);
}
const direction = () =>
unit(Float32Array.from({ length: DIM }, () => Math.sqrt(-2 * Math.log(1 - random())) * Math.cos(2 * Math.PI * random())));
const nudge = (v: Vec, amount: number) => {
const noise = direction();
return unit(v.map((x, i) => x + amount * noise[i]!));
};
// Khoá giống văn bản: các khoá gần nhau nói về cùng một chuyện, và nhóm nằm lồng trong nhóm.
function textKeys(n: number): Vec[] {
const sizes = [4096, 1024, 256, 64, 16];
const stack = sizes.map(() => direction());
const keys: Vec[] = [];
for (let i = 0; i < n; i++) {
sizes.forEach((size, level) => {
if (i % size === 0) stack[level] = nudge(level === 0 ? direction() : stack[level - 1]!, 0.5);
});
keys.push(nudge(stack.at(-1)!, 0.35));
}
return keys;
}
function softmax(scores: number[]): number[] {
const max = Math.max(...scores);
const raw = scores.map((s) => Math.exp(s - max));
const total = raw.reduce((a, b) => a + b, 0);
return raw.map((x) => x / total);
}
const logSumExp = (scores: number[]) => {
const max = Math.max(...scores);
return max + Math.log(scores.reduce((sum, s) => sum + Math.exp(s - max), 0));
};
// Mỗi nút giữ một tiếp tuyến cho mỗi câu hỏi mẫu: một khoá nén và một bias.
type Node = { keys: Vec[]; biases: number[]; children: Node[]; leaves?: number[] };
const references: Vec[] = Array.from({ length: REFERENCES }, (_, r) =>
r === 0 ? new Float32Array(DIM) : direction().map((x) => x * 0.5),
);
function summarize(children: { keys: Vec[]; biases: number[] }[]) {
const keys: Vec[] = [];
const biases: number[] = [];
references.forEach((reference, r) => {
const scores = children.map((c) => SHARPNESS * dot(c.keys[r]!, reference) + c.biases[r]!);
const weights = softmax(scores);
const key = new Float32Array(DIM);
children.forEach((c, i) => c.keys[r]!.forEach((x, d) => (key[d]! += weights[i]! * x)));
keys.push(key);
biases.push(logSumExp(scores) - SHARPNESS * dot(key, reference));
});
return { keys, biases };
}
function buildTree(allKeys: Vec[]): Node[] {
const zero = references.map(() => 0);
let level: Node[] = [];
for (let i = 0; i + CHUNK <= allKeys.length; i += CHUNK) {
const leaves = Array.from({ length: CHUNK }, (_, c) => i + c);
const children = leaves.map((l) => ({ keys: references.map(() => allKeys[l]!), biases: zero }));
level.push({ ...summarize(children), children: [], leaves });
}
while (level.length > 64) {
const next: Node[] = [];
for (let i = 0; i + CHUNK <= level.length; i += CHUNK) {
const children = level.slice(i, i + CHUNK);
next.push({ ...summarize(children), children });
}
level = next;
}
return level;
}
// Đi xuống: chấm các ứng viên, mở nhóm nhỏ nhất giữ đủ p phần attention của chúng.
function route(top: Node[], query: Vec, p: number) {
let candidates = top;
let work = 0;
const read: number[] = [];
while (candidates.length > 0) {
work += candidates.length * REFERENCES;
const scores = candidates.map((node) =>
Math.max(...node.keys.map((key, r) => SHARPNESS * dot(key, query) + node.biases[r]!)),
);
const mass = softmax(scores);
const order = candidates.map((_, i) => i).sort((a, b) => mass[b]! - mass[a]!);
const opened: Node[] = [];
let total = 0;
for (const i of order) {
opened.push(candidates[i]!);
total += mass[i]!;
if (total >= p) break;
}
for (const node of opened) if (node.leaves) read.push(...node.leaves);
candidates = opened.flatMap((node) => node.children);
}
return { work: work + read.length, read };
}
for (const n of [1024, 4096, 16384, 65536]) {
const keys = textKeys(n);
const top = buildTree(keys);
let work = 0;
let kept = 0;
const queries = 12;
for (let q = 0; q < queries; q++) {
const query = nudge(keys[Math.floor(random() * n)]!, 0.2);
const walk = route(top, query, 0.9);
const truth = softmax(keys.map((key) => SHARPNESS * dot(key, query)));
work += walk.work;
kept += walk.read.reduce((sum, i) => sum + truth[i]!, 0);
}
console.log(
`${n} keys: ${Math.round(work / queries)} comparisons per query instead of ${n}, ` +
`keeps ${Math.round((kept / queries) * 100)}% of the attention`,
);
}Ngày 3/11/2026, trên Node 26.11.1, kết quả là:
1024 keys: 365 comparisons per query instead of 1024, keeps 85% of the attention
4096 keys: 411 comparisons per query instead of 4096, keeps 83% of the attention
16384 keys: 422 comparisons per query instead of 16384, keeps 81% of the attention
65536 keys: 572 comparisons per query instead of 65536, keeps 78% of the attentionNgữ cảnh dài gấp 64 lần, chi phí mỗi câu hỏi chỉ tăng chưa tới 2 lần. Nhưng cột cuối cũng đang giảm dần. Bài tiếp theo giải thích vì sao.
Khi nào cây không giúp được?
Khi dữ liệu không có cấu trúc. Paper nói thẳng rằng những chuỗi gồm token ngẫu nhiên, với yêu cầu lấy lại token bất kỳ, sẽ cần attention bậc hai đầy đủ. Khả năng nén không đến từ kiến trúc mà đến từ dữ liệu.
Bằng chứng nằm ở số đo của họ: trên mô hình Qwen, tập top-p tăng theo quy luật lũy thừa dưới tuyến tính so với độ dài văn bản. Tức là văn bản càng dài, phần cần đọc càng tăng, nhưng tăng chậm hơn văn bản. Với Nemotron, mô hình không dùng mã hoá vị trí (NoPE), quy luật này không rõ bằng, và điểm tìm kiếm sau khi chuyển sang ZHA cũng giảm nhiều hơn.
Có ai khác đi hướng này không?
Có, và gần như cùng thời điểm. Latent Sparse Attention (LSA) gom khoá thành các cụm phân cấp trong một không gian đã học, rồi chấm cụm thay vì chấm từng khoá. Nó có hai biến thể: D-LSA chưng cất vào mô hình có sẵn, N-LSA huấn luyện trực tiếp. HiLS thì học việc chọn khối bằng chính hàm mất mát của mô hình ngôn ngữ.
Chúng tôi chỉ đọc phần tóm tắt của hai paper này và chưa so sánh chi tiết với ZHA, nên không kết luận cách nào tốt hơn. Các con số của hai paper này cũng được đo trên những mô hình khác nhau.
Những hiểu lầm hay gặp
- "ZHA tóm tắt rồi đọc bản tóm tắt." Không. Tóm tắt chỉ để tìm đường. Attention cuối cùng chạy trên khoá và value gốc.
- "Đi theo một nhánh như cây tìm kiếm thông thường." Một câu hỏi có thể mở nhiều nhánh cùng lúc, ở những chỗ cách xa nhau trong ngữ cảnh.
- "Câu hỏi đi xuống cây một lần là đủ cho cả mô hình." Mỗi đầu attention của mỗi lớp tự đi riêng cho từng query.
- "p 90% nghĩa là giữ 90% attention." Đó là ngưỡng ở mỗi tầng, và các tầng nhân dồn với nhau. Đây chính là chủ đề của bài cuối.
Kiểm tra lại
Chọn một đáp án để xem vì sao đúng hay sai.
Ngưỡng p là 0,9. Điểm attention ước tính của bốn nút là 50%, 30%, 15%, 5%. Top-p mở mấy nút?
Xem đáp án
3 nút. Lấy từ cao xuống: 50%, rồi 80%, rồi 95%. Tới nút thứ ba mới đạt 90%, nên mở ba nút và bỏ nút cuối.
Vì sao top-K cố định gặp khó khi cùng một mô hình phải tra một sự kiện đơn lẻ và tổng hợp từ nhiều nơi?
Xem đáp án
K lớn thì lãng phí cho câu hẹp, K nhỏ thì thiếu cho câu rộng. Paper đo ở khoảng 64K token: 90% attention nằm trong 2,3% số khối trên RULER nhưng 15% trên truyện dài. Không có một con số K nào phù hợp cho cả hai.
ZHA phải học những gì khi gắn vào một mô hình có sẵn?
Xem đáp án
Chỉ một nhóm nhỏ reference query, còn mô hình được giữ nguyên. Mô hình gốc được đóng băng. Thứ duy nhất được học là reference query. Với Qwen3.6-35B-A3B, đó là 81.920 tham số, khoảng 0,00024% mô hình.
Trên dữ liệu ngẫu nhiên không có cấu trúc, vì sao cây không giúp được?
Xem đáp án
Vì bản tóm tắt của một vùng không cho biết gì về từng khoá bên trong, nên không thể bỏ qua cả vùng một cách an toàn. Cây chỉ tiết kiệm khi được phép bỏ qua cả nhánh. Muốn vậy, bản tóm tắt phải phản ánh nội dung bên trong. Khi các khoá gần nhau không liên quan, điều đó không còn đúng. Paper cũng nói những chuỗi kiểu này cần attention bậc hai.
Nguồn
- Lufkin và cộng sự (2026): Subquadratic Attention with a Hierarchical Tree-Structured Indexer
- Zyphra: ZHA, Zyphra Hierarchical Attention
- Zyphra trên X: thread giới thiệu ZHA
- Latent Sparse Attention: Towards Subquadratic Ultra-Long Context Attention (2026)
- Hu và cộng sự (2026): Hierarchical Sparse Attention Done Right