跳转到内容

Token、Attention与上下文

一段文本进入自回归语言模型,大致经过:

文本
→ Tokenizer
→ Token IDs
→ Token Embedding + Position
→ N个Transformer Block
→ 最后一个位置的Logits
→ 概率分布与采样
→ 新Token
→ 把新Token接回输入,继续Decode

“模型读懂一句话”把太多步骤压成了一句拟人描述。工程上更有用的问题是:输入被切成了什么、张量形状是什么、每一层能访问哪些位置、哪部分计算被缓存、预算在哪一步用完。

还有一个版本边界要先说明:2017年的《Attention Is All You Need》研究的是Encoder–Decoder机器翻译Transformer。今天常见的Decoder-only LLM在归一化位置、激活函数、位置表示和Attention实现上可能不同,但Token表示、Q/K/V、缩放点积、Mask、多头、残差与逐Token解码仍是理解底层机制的主干。

词元(Token)词元Token模型处理文本时使用的离散单位;它不一定等于一个字或一个单词。打开术语条目 →是Tokenizer词表中的离散单位。Tokenizer通常试图在“词表大小”和“序列长度”之间取平衡:

  • 只有字符或字节,词表小,但序列会很长;
  • 整词词表能缩短常见句子,却无法覆盖所有新词、拼写和代码;
  • 子词方法把常见片段保留为一个Token,把少见内容拆成多个片段;
  • 不同语言、空格、大小写、Unicode规范化和前导换行都会改变切分。

下面的切分只是示意,不代表某个具体模型:

"unbelievable" → ["un", "believ", "able"]
"HTTPStatus" → ["HTTP", "Status"]
"网络错误" → ["网络", "错误"] 或 ["网", "络", "错", "误"]
"https://..." → 可能拆成很多标点和片段

真正部署时必须调用目标模型对应的Tokenizer。不能用“中文一个字约等于一个Token”或“英文四个字符约一个Token”作为硬限制。这些估算只能做粗略容量预警。

模型最终接收的是整数ID:

text = "取消任务"
token_ids = [4217, 993, 18] // 仅示意

序列前后还可能加入特殊Token,Chat Template也可能插入角色标记、分隔符或工具调用格式。API传入的可见文字相同,换一个模板后实际输入Token数可能不同。

工程上至少记录:

tokenizer_id
chat_template_version
input_token_count
reserved_output_tokens
truncation_policy

否则一次Prompt改动导致成本或质量变化时,无法判断是正文变长、模板改变还是Tokenizer不同。

整数ID本身没有可计算的语义关系。模型用一张可学习矩阵把每个ID查成嵌入表示(Embedding)嵌入表示Embedding把离散对象映射成连续向量,使相似性或后续模型能够进行数值运算。打开术语条目 →

EmbeddingTable ∈ ℝ^(vocab_size × d_model)
X_token ∈ ℝ^(n × d_model)

n是序列长度,d_model是每个位置的表示维度。输入有n个Token,就得到n行向量。

这不是“从数据库查一个固定词义”。Embedding在训练中与其他参数一起更新,它的作用是提供一组可学习坐标。经过多层Transformer后,同一个Token在不同上下文里的隐藏表示会发生变化。

如果只有Token Embedding,Attention看见的是一组向量,却不知道排列顺序。模型还需要位置表示(Positional Representation)位置表示Positional Representation向Token表示加入顺序或相对距离信息,因为单独的注意力运算并不知道输入排列。打开术语条目 →

X = TokenEmbedding(token_ids) + PositionRepresentation(positions)

原始论文使用不同频率的正弦和余弦位置编码,并把它与Token Embedding相加。后续模型也可能使用学习位置、相对位置或旋转式位置表示。实现不同,但问题不变:交换两个Token的位置,不应得到完全相同的序列表示。

考虑一个Attention Head,输入为:

X ∈ ℝ^(n × d_model)

模型学习三张投影矩阵:

W_Q ∈ ℝ^(d_model × d_k)
W_K ∈ ℝ^(d_model × d_k)
W_V ∈ ℝ^(d_model × d_v)

然后计算查询、键和值(Query, Key, Value (Q/K/V))查询、键和值Query, Key, Value (Q/K/V)由输入表示经过不同线性投影得到的三组向量:Q与K计算权重,权重再组合V。打开术语条目 →

Q = XW_Q
K = XW_K
V = XW_V

得到:

Q ∈ ℝ^(n × d_k)
K ∈ ℝ^(n × d_k)
V ∈ ℝ^(n × d_v)

每个位置都有自己的Q、K和V。可以把职责写得很具体:

  • 当前行的Q提出“这个位置需要匹配什么”;
  • 所有位置的K参与匹配;
  • 匹配结果经过归一化后,用来组合对应的V;
  • 输出仍然是一行新的位置表示,不是一个检索文档ID。
ONE ATTENTION HEAD从输入表示到加权输出
  1. 01输入表示X ∈ ℝⁿˣᵈ每行对应一个Token位置
  2. 02三组投影Q=XW_Q · K=XW_K · V=XW_VQ/K负责匹配,V承载被组合的信息
  3. 03两两打分S=QKᵀ/√dₖS是n×n矩阵
  4. 04Mask与归一化A=softmax(S+M)每一行权重之和为1
  5. 05组合ValueO=AV得到每个位置的新表示
这是一层中的一个Attention Head。多头结果还要拼接并投影,随后经过残差连接、归一化和前馈网络。

完整公式是:

Attention(Q, K, V) = softmax(QK^T / √d_k)V

也就是softmax(QK^T / √d_k)V。拆成五步:

S_raw = QK^T

Qn × d_kK^Td_k × n,所以:

S_raw ∈ ℝ^(n × n)

i行第j列是位置i的Query与位置j的Key点积。每一行表示“位置i对所有可访问位置的原始匹配分数”。

S = S_raw / √d_k

如果Q和K各维近似均值0、方差1,点积的方差随d_k增长。维度越大,未经缩放的分数幅度通常越大,Softmax更容易落入接近One-hot的饱和区域,梯度变小。除以√d_k把分数量级拉回更稳定的范围。

这个缩放不是为了让矩阵维度匹配;矩阵乘法在缩放前已经合法。它处理的是数值尺度。

Decoder-only自回归模型不能让位置i看到未来位置j > i因果掩码(Causal Mask)因果掩码Causal Mask在自回归生成中屏蔽当前位置之后的Token,防止预测时读取未来答案。打开术语条目 →在Softmax前把非法位置加上负无穷:

S_masked[i,j] = -∞, when j > i

例如四个位置的Mask是:

[ 0 -∞ -∞ -∞ ]
[ 0 0 -∞ -∞ ]
[ 0 0 0 -∞ ]
[ 0 0 0 0 ]

Softmax后,被屏蔽位置权重为0。Padding Mask解决的是“不要读取补齐位置”,Causal Mask解决的是“不要读取未来位置”,二者不能混为一谈。

A = softmax(S_masked)

A仍是n × n。每一行都是非负权重且和为1。

O = AV

An × nVn × d_v,结果:

O ∈ ℝ^(n × d_v)

所以Attention不是把最相关位置“复制过来”,而是对所有允许位置的Value做加权和。

假设某个Query与三个Key的原始点积是:

[2, 1, 0]

并且d_k = 4。除以√4 = 2后:

scaled scores = [1.0, 0.5, 0.0]

Softmax权重约为:

[0.506480, 0.307196, 0.186324]

它们之和约为1。再假设三个Value是:

V₁ = [1, 0]
V₂ = [0, 2]
V₃ = [1, 1]

输出是:

0.506480·V₁ + 0.307196·V₂ + 0.186324·V₃
≈ [0.692804, 0.800715]

这个例子说明两件事:

  1. 最大分数对应的位置影响最大,但其他位置没有自动消失;
  2. Attention输出是Value向量的混合,不是那三个权重本身。

如果第三个位置被Mask,先把第三个分数变成负无穷,再对剩下两项重新Softmax。不能先算三项Softmax,再把第三项权重直接设为0,因为那样剩余权重之和不再是1。

错误后果检查方法
√d_model而不是当前Head的√d_k缩放分数尺度与公式不一致打印每个Head的维度与缩放常量
沿错误的轴做Softmax权重不是“每个Query对所有Key”归一化检查Attention矩阵每一行之和是否约为1
Softmax后才应用Mask非法位置已经参与了归一化在Softmax前检查未来位置是否为负无穷
只屏蔽未来位置,不屏蔽Padding模型会组合补齐Token的Value分别测试Causal Mask与Padding Mask
把Attention权重直接乘Q或K输出语义和维度都错误最后一步必须是A @ V
Decode时缓存整张Attention矩阵内存迅速膨胀且不能直接复用历史缓存保存各层K/V,新Token重新产生Query

批处理实现通常还会出现batch × heads × sequence × head_dim四维张量。调换headssequence有时仍能完成矩阵乘法,却得到完全错误的语义,因此形状断言和小矩阵Golden Test比“能运行”重要。

多头注意力(Multi-Head Attention)多头注意力Multi-Head Attention用多组独立投影并行计算注意力,再拼接和投影回模型维度。打开术语条目 →不是把同一张Attention矩阵复制多份。第h个Head有自己的投影:

head_h = Attention(XW_Q^h, XW_K^h, XW_V^h)

各Head结果拼接后,再投影回模型维度:

MultiHead(X) = Concat(head_1, ..., head_H)W_O

原始论文使用8个Head,并把每个Head的d_kd_v设为d_model / H,使总计算量与一个全维单头保持在相近量级。这个数值是原论文配置,不是所有模型必须遵守的标准。

多头的价值在于不同投影能在不同表示子空间和位置上建立关系。需要避免两种过度解释:

  • 不能规定“Head 1负责语法,Head 2负责事实”;训练不会接受这种人工职责表;
  • 某个Head出现可读模式,不代表它单独决定了最终输出,后面还有投影、残差、其他Head和许多层。

只讲Attention容易给人一种错觉:模型每层只是重新分配上下文权重。原始Transformer的一个子层结构是:

LayerNorm(x + Sublayer(x))

其中Sublayer可能是Multi-Head Attention或前馈网络。Decoder层还会加入Mask。现代架构可能把LayerNorm放在子层之前,但仍能看到三类职责:

x_next = x + Sublayer(x)

子层学习对当前表示的修正,而不是每层从头重建全部信息。残差也为深层网络提供更直接的梯度路径。

归一化控制激活的数值尺度,提高深层训练稳定性。它不是把Token长度归一化,也不是概率归一化;Softmax和LayerNorm解决不同问题。

逐位置前馈网络(Position-wise FFN)

Section titled “逐位置前馈网络(Position-wise FFN)”

原始论文写成:

FFN(x) = max(0, xW₁ + b₁)W₂ + b₂

同一组参数独立应用于每个位置。Attention在位置之间混合信息,FFN则对每个位置的通道表示做非线性变换。现代模型可能换激活函数或门控结构,但“跨位置混合”和“逐位置变换”仍是两个不同步骤。

完整Self-Attention要形成n × n分数关系。原始论文给出的每层计算复杂度是:

O(n²d)

其中n是序列长度,d是表示维度。把上下文长度翻倍时,两两关系数量接近四倍。优化Kernel可以减少中间矩阵写回和显存流量,局部或稀疏Attention也可以改变实际计算范围,但“所有位置两两交互”的基本版本具有平方项。

长上下文还会增加:

  • Prompt处理时间;
  • Attention与中间激活的内存压力;
  • 键值缓存(KV Cache)键值缓存KV Cache自回归解码时保存历史Token各层的K/V,避免每生成一步都重复计算整段前缀。打开术语条目 →占用;
  • 传入无关或冲突证据的概率;
  • 评测覆盖难度。

所以“模型支持更长窗口”和“模型能稳定利用窗口里任意位置的事实”是两种不同声明。上限是接口容量,不是检索质量保证。

自回归推理分成预填充与解码(Prefill and Decode)预填充与解码Prefill and Decode推理的两个阶段:先并行处理已有输入,再逐Token生成后续输出。打开术语条目 →

模型一次处理已有Prompt的所有Token,建立各层表示和K/V。这个阶段能在Token维度上较充分并行,工作量受输入长度影响明显。

模型根据最后位置的Logits选出一个新Token,把它接到序列末尾,再算下一个Token。生成在时间上是串行的:第t+1个输出依赖第t个已经选定。

没有缓存时,每生成一个Token都要重新为完整前缀计算K和V。键值缓存(KV Cache)键值缓存KV Cache自回归解码时保存历史Token各层的K/V,避免每生成一步都重复计算整段前缀。打开术语条目 →保存历史Token在每一层的K/V,新一步只计算新Token的Q/K/V,再让新Query访问历史缓存。

缓存改变了重复计算,但不是免费:

KV Cache大小大致随以下因素线性增长:
层数 × 已缓存Token数 × K/V维度 × 数值字节数 × Batch大小

具体布局还受Multi-Query或Grouped-Query Attention等架构影响。工程上应读取模型配置和推理引擎实际指标,不要只用参数量估算。

如果吞吐突然下降,需要区分:

  • Prefill太长,首Token延迟高;
  • Decode序列太长,单请求占用时间长;
  • KV Cache容量不足,Batch无法继续增大;
  • 调度器被少数长请求拖住;
  • 输出Token上限设置过宽。

10. 上下文窗口是一个硬约束,不是资料仓库

Section titled “10. 上下文窗口是一个硬约束,不是资料仓库”

上下文窗口(Context Window)上下文窗口Context Window一次推理中模型能够读取和生成的 Token 总预算。打开术语条目 →要同时容纳:

系统指令
+ Chat Template与角色标记
+ 用户当前输入
+ 工具定义
+ 对话历史
+ 检索证据
+ 工具结果
+ 预留输出
+ 安全余量
≤ 模型窗口上限
上下文窗口Token预算示意图固定指令、用户输入、检索证据、历史记录和预留输出共同占用一个有限的上下文窗口。CONTEXT WINDOW · 100%输入 + 输出共享同一预算SYSTEM14%INPUT16%RETRIEVED EVIDENCE31%HISTORY19%OUTPUT20%ATTENTION关联上下文先保留输出空间,再选择证据;不要把“塞入全部历史”当成记忆设计。
Token预算是工程约束。相关证据、历史长度、输出空间和延迟需要一起取舍。

预算可以写成一个可执行约束:

B_input = B_system + B_template + B_user + B_tools + B_history + B_evidence
B_input + B_output + B_margin ≤ C_context

先预留B_outputB_margin,再分配输入。不能让历史和检索先把窗口塞满,最后把输出截成半个JSON。

假设:

C_context = 32768
B_margin = 1024
B_output = 4096

可用输入预算是:

32768 - 1024 - 4096 = 27648

一份明确分配可以是:

部分预算
System与固定边界1536
工具定义3072
当前用户输入1024
检索证据14336
对话历史7680
合计27648

这不是推荐比例,而是一份能被程序检查的方案。任务变了,预算也要变:代码生成可能需要更大输出;证据问答应优先保留来源;工具很多时应按当前步骤只暴露必要定义。

context-budget.ts
type Budget = {
contextLimit: number;
outputReserve: number;
safetyMargin: number;
system: number;
tools: number;
user: number;
evidence: number;
history: number;
};
function assertBudget(b: Budget) {
const input = b.system + b.tools + b.user + b.evidence + b.history;
const total = input + b.outputReserve + b.safetyMargin;
if (total > b.contextLimit) {
throw new Error(`context-overflow:${total - b.contextLimit}`);
}
return { input, total, remaining: b.contextLimit - total };
}

11. 超预算时按信息职责删,不按位置硬切

Section titled “11. 超预算时按信息职责删,不按位置硬切”

直接保留最后N个Token很简单,但可能把System边界、问题主语或证据标题切掉。更稳妥的顺序是:

  1. 固定保留安全边界、当前任务和输出契约;
  2. 删除完全重复的检索片段;
  3. 按问题相关性和来源质量选择证据;
  4. 把较老历史压缩成带版本的摘要;
  5. 只保留当前步骤需要的工具定义;
  6. 对超长工具结果提取结构化字段,原始结果留在外部存储;
  7. 仍然超预算时,明确返回“证据过多”或拆分任务。

每次截断都应该生成记录:

original_tokens
kept_tokens
removed_sections
summary_version
retrieval_query
chunk_ids
output_reserve

否则模型答错时,只能看到最终Prompt,无法知道哪些关键信息在组装阶段被删除。

历史摘要会丢信息,也会固化错误。不要每轮都“摘要上一版摘要”,那会产生累积漂移。更可靠的做法是保留原始事件或消息,在需要时从受控范围重新生成摘要,并记录摘要使用的源消息ID。

把十个相似Chunk都放入上下文,既浪费Token,也会重复放大同一来源。检索组装至少要考虑:

  • Chunk是否直接回答当前问题;
  • 多个Chunk是否来自同一段落的重叠窗口;
  • 来源之间是否冲突;
  • 标题、日期和实体指代是否保留;
  • 证据位置是否能映射回引用;
  • 没有足够证据时是否允许回答“不知道”。

一个可检查的组装流程是:

召回候选
→ 去掉精确重复
→ 按来源与文档聚类
→ 重排
→ 在每个来源内选择互补片段
→ 加入来源标识
→ 计算真实Token数
→ 超预算时重新选择,而不是截断到半句

“Lost in the Middle”一类位置效应提醒我们:信息在窗口里,不等于模型会稳定使用。不要只做一个正例。把同一证据放在开头、中间、结尾,加入相似干扰项,再比较准确率与引用一致性。

Attention权重不是解释。 它是某一层、某个Head、某个位置在一次前向计算中的中间量。

权重图能帮助检查:

  • Causal Mask是否正确;
  • Padding位置是否被屏蔽;
  • 某个Head是否完全塌缩;
  • 输入变化后关联模式是否改变;
  • 实现与预期张量形状是否一致。

但它不能单独证明:

  • 某个Token“导致”了最终答案;
  • 模型使用了最高权重位置里的事实;
  • 低权重信息对后续层没有影响;
  • 一个漂亮的Head模式代表整个模型的推理过程。

原因很直接:输出还会经过Value混合、Head拼接、输出投影、残差、FFN和后续许多层。同一个最终Logit来自整条计算图。

更严格的检查需要干预:删除、替换或移动候选证据,固定其他输入,再测最终输出、Logit或任务指标是否稳定变化。可视化是检查工具,不是因果结论。

最后一层隐藏状态经过输出投影,得到词表大小的Logits:

logits ∈ ℝ^(vocab_size)

Softmax把Logits转成概率分布。解码策略再决定选哪个Token:

  • Greedy每次选最大值,稳定但不一定得到全局更好序列;
  • Temperature改变分布锐度;
  • Top-p只保留累计概率达到阈值的候选集合;
  • 固定随机种子也不保证跨模型版本、硬件和推理实现完全复现。

这一步说明“Attention找到信息”和“模型输出正确Token”之间还有距离。上下文证据可能被正确关联,但输出仍可能受采样、指令冲突或后续层变换影响。

结构化输出(Structured Output)结构化输出Structured Output让模型按可解析、可验证的数据结构返回结果,而不是只给自然语言。打开术语条目 →能约束输出格式,不会自动授权业务动作。程序至少分两层验证:

validated-output.ts
const parsed = actionSchema.safeParse(modelOutput);
if (!parsed.success) {
return { kind: 'stop', reason: 'invalid-schema' };
}
const decision = authorize(parsed.data, currentUser, currentState);
if (!decision.allowed) {
return { kind: 'stop', reason: 'forbidden-action' };
}
return execute(decision.action);

模式约束(Schema)模式约束Schema对数据字段、类型、必填项和取值范围的机器可检查约束。打开术语条目 →检查字段、类型和取值;Authorization检查这个用户在当前状态下能不能做。即使模型生成了完美JSON,也不能跳过第二层。

16. 三个实验,比“理解概念”更可靠

Section titled “16. 三个实验,比“理解概念”更可靠”

选择固定的中文、英文、代码、URL和Emoji样本,对目标模型Tokenizer记录:

text_hash, tokenizer_id, token_ids, token_count

然后修改空格、换行、大小写和Unicode表示,观察Token数变化。验收标准不是背下平均换算,而是能预测哪些输入会破坏预算,并用真实Tokenizer确认。

用本课的三个Key例子:

  1. 自己计算缩放分数与Softmax;
  2. 屏蔽第三个位置并重新归一化;
  3. 交换第二、第三个Value,只改变输出,不改变权重;
  4. 交换Key则观察权重变化;
  5. d_k缩放去掉,比较分布锐度。

做到这里,Q/K决定权重、V决定被混合内容的区别才算真正清楚。

构造20个固定问题,每题只有一段证据。分别运行:

证据在开头
证据在中间
证据在结尾
证据 + 5段相似干扰
证据 + 1段冲突材料
无证据

记录:答案正确率、引用正确率、无答案准确率、输入Token、首Token延迟和总成本。不要只展示一个成功对话。

你应当能在白板上完成:

  • 给定XW_QW_KW_V,写出Q/K/V形状;
  • 解释为什么QK^Tn × n,为什么要除以√d_k
  • 在Softmax前正确加入Causal Mask;
  • 手算一个Query对三个Value的加权输出;
  • 说明多头结果如何拼接并投影;
  • 解释Attention、FFN、Residual和Normalization的不同职责;
  • 说明为什么完整Self-Attention含O(n²d)项;
  • 区分Prefill与Decode,并解释KV Cache省了什么、占了什么;
  • 为一个32768 Token任务写出硬预算和删减顺序;
  • 设计干预实验,而不是把Attention热力图当成模型解释。

如果只能说“Attention会关注重要词”“上下文越长知道得越多”,这部分还停留在科普层。