QiuQiu

9-19 记录

# 2026 年 9 月 19 日|从 KV Store 到 KV Cache:思路与疑问


> 根据今天两段录音(约 9 分 55 秒、3 分 48 秒),对照《公开面试题官网吗》聊天导出整理。下文是整理稿,不是逐字转写;题面、个人复盘和整理时的技术说明分开记录。
>
> 聊天来源:我的gpt对话: `ChatGPT-公开面试题官网吗-20260919-2141.md`。题目是聊天中给出的 Versioned Key-Value Store 练习,不能据此当作官方逐字原题。


## 前情提要


今天看了一些 OpenAI、Anthropic 面试题的讨论,觉得这类题可以拿来做自我训练,就让 ChatGPT 挑了一题给我试试。当时没有电脑,我先用口头方式推演。


以下保留当天 16:14:49 聊天中给出的练习题面,仅调整标题层级。


### 题目:Versioned Key-Value Store


实现一个内存中的 key-value store:


```python
class VersionedKVStore:
    def set(self, key: str, value: str, timestamp: int) -> None:
        ...


    def get(self, key: str, timestamp: int) -> str | None:
        ...
```


语义:


```python
store.set("a", "hello", 10)
store.set("a", "world", 20)


store.get("a", 5)    # None
store.get("a", 10)   # "hello"
store.get("a", 15)   # "hello"
store.get("a", 20)   # "world"
store.get("a", 100)  # "world"
```


`get(key, timestamp)` 应返回:


> 对这个 key,所有 `timestamp <= 查询 timestamp` 的记录中,**时间最新的一条 value**。


#### 额外条件


`set()` **不保证按照 timestamp 顺序到达**。


例如:


```python
store.set("a", "v3", 30)
store.set("a", "v1", 10)
store.set("a", "v2", 20)
```


仍然需要:


```python
store.get("a", 25)   # "v2"
```


如果同一个:


```text
key + timestamp
```


被 set 两次,**后一次覆盖前一次**。


例如:


```python
store.set("a", "cat", 10)
store.set("a", "dog", 10)


store.get("a", 10)   # "dog"
```


假设最多大约:


```text
100,000 次 set/get 操作
```


#### 我现在想看你做四件事


你可以直接用 Python 写。


1. 先讲一下你准备使用什么数据结构。
2. 实现 `set()` 和 `get()`。
3. 告诉我两者的时间复杂度。
4. 自己写几个你认为重要的 test case。


**不要考虑并发、磁盘、TTL 等问题,现在题目里都没有。**


第二段录音对应当天 16:53:01 聊天中的下列问题。题面原文保留,编号按本篇顺序调整。


### 第二题:Transformer KV Cache


假设有一个 decoder-only Transformer:


```text
输入:
[A, B, C, D]


模型生成:
E


然后再生成:
F
```


普通 attention 在生成 `E` 时,会计算:


```text
Q_A Q_B Q_C Q_D
K_A K_B K_C K_D
V_A V_B V_C V_D
```


生成完 `E` 后,下一步要生成 `F`。


我的问题只有一个:


> **为什么生成 F 时,不需要重新计算 A、B、C、D 的 K 和 V?**


你先不用管代码,也不用背公式。


就按你喜欢的“**拓扑 + 标准件 + 数据流**”方式想:


```text
token

某些算子

K / V

attention
```


你告诉我:**哪些东西是过去已经确定、可以缓存的,哪些东西每生成一个新 token 必须重新算?**


你回答完,我再继续追问下一层。


## 第一段复盘:从需求想到标准件


我一开始想到的,还是用“数据结构加标准件”的方式来拼解法。


最直观的想法是用 list,把 timestamp 当成下标,并把某些没有记录的位置也填上对应的值。下标天然有顺序,看起来可以直接查询。但继续想就发现,数组究竟要开多长?如果 timestamp 很大、很稀疏,空间会浪费很多;遇到乱序写入,又该修改哪些位置?录音时,我没有把填充范围完整说清楚,这个方案还没有形成精确的规则。


我也想过把时间范围写成一条条 if/else,有点像自己构造一个状态机。它能表达不同时间区间对应不同的值,但每增加或修改一条历史记录,都可能要改动原来的判断规则。如果把这些规则存成字符串,存储和维护也会变得别扭。


后来,ChatGPT 给出的方向是把哈希表、有序列表和二分查找组合起来。外层通过 key 找到一份历史记录,内层保存按 timestamp 排序的记录。查询时,在这份历史里找到不超过查询时间的最大 timestamp,返回它对应的 value。


我真正想追问的是:为什么能想到这些组件?


回头看,我一开始喜欢 list,是因为下标似乎顺带提供了顺序。但直接把时间戳当下标,也把存储空间绑到了时间戳的取值范围上。其实可以把需求拆开:先找到某个 key 的历史;再让历史按时间排列;最后找到查询时间的前驱。对应起来,就是哈希表负责定位历史,有序列表负责保存顺序,二分查找负责定位边界。


今天值得记住的,是从操作需求反推组件的过程。我已经能复述这个组合的大致思路,但录音里对写入步骤、dict 和 list 的具体关系还有反复。接下来可以用几次乱序写入和查询,把每一步存下来的内容写出来,检查自己的理解是否完整。


## 第二段复盘:知道 K/V 可以缓存,但还没弄懂为什么


这一部分根据第二段录音整理;原先只依据聊天写下的 KV Cache 补记,以这次更具体的自述为准。


第二个问题是 Transformer 的 KV Cache。输入 A、B、C、D,生成 E,再生成 F,哪些东西可以不用重新计算?我之前回答,过去的 K 和 V 不用再算。


但再回头想,我发现自己还没有真正弄明白原因。我知道“不用重算”这个结论,就顺着它想到:那应该是后面新加入的内容不会影响之前的 K 和 V。可为什么不会影响?这一步我其实没有讲清楚。


我开始往前追:key 和 value 到底是怎么来的?我记得似乎要对输入矩阵做线性运算,但再往下想,Q、K、V 各自怎么计算,我的记忆并不完整。录音中提到了矩阵和“旋转”之类的操作,也没有把它们准确对应到具体步骤。


更让我困惑的是:Q 和 K 的计算方式不是类似的吗?如果新增输入不会影响旧的 K,为什么之前的解释又把 Q 单独拿出来说?究竟是旧的 Q 会变,还是每个新位置需要算它自己的 Q?这里我还没有理顺。


另外,decoder-only 究竟指什么,我也没有弄懂。我知道 attention 这个模块,但这还不足以让我说明整个模型为什么能够这样缓存。


这一段先把没有想明白的地方记下来:Q、K、V 的来源;新输入对旧位置计算的影响;以及 decoder-only 与因果注意力的关系。这些问题还需要继续往计算过程里追。


## 整理补充:澄清原聊天中关于 Q 的表述


以下是整理时补充的解释,不代表录音中已经掌握。


原聊天把“旧的 K/V 可以复用”和“新位置的 Q 必须计算”并列,容易让人误以为旧位置的 Q 会被新增 token 改变。这两句话比较的其实不是同一个位置。


在标准因果 Transformer 的推理过程中,若参数、已有前缀和位置约定保持不变,新增 E 不会改变 A、B、C、D 在各层的已有计算结果。**旧位置的 Q、K、V 都不因此改变。**


处理 E、准备预测 F 时,需要计算每层 E 位置自己的 Q、K、V。新 Q 要与历史 K 匹配,再对历史 V 加权,所以历史 K/V 在这一步还有用途;旧 Q 不参与这个新位置的注意力计算,通常没有必要保存在 KV cache 中。


用单个注意力头的基本形式表示,省略偏置等实现细节:


```text
该层输入 H
  ├─ Q = H W_Q
  ├─ K = H W_K
  └─ V = H W_V


注意力权重 = softmax(Q Kᵀ / sqrt(d_k) + 因果掩码)
注意力输出 = 注意力权重 × V
```


因此,V 本身也是输入的线性投影;“注意力权重乘 V”得到的是注意力输出,不能把这两个步骤混在一起。录音中的“旋转”具体指什么尚不明确,整理时不擅自认定为转置或某种位置编码。


在这道题的语境下,decoder-only 指采用解码器堆叠、自回归预测下一个 token 的模型结构;因果注意力限制每个位置只读取自己和更早的位置。缓存成立的关键,需要落实到这种依赖关系上,不能只记一个模型名称。


## 第一题的技术校正


以下为整理时的校正与补充,不计作录音中已经独立讲清楚的内容。


1. **查询的是时间戳的前驱。** 开头说的“最小值”应改为“不超过查询时间的最大 timestamp”,再返回对应的 value。
2. **两层存储可以直接表达。** `key -> [(timestamp, value), ...]`,每个列表按 timestamp 排序即可。不必先建一份 timestamp 到 value 的哈希表,再把它转换成列表。有序列表是数据组织方式,二分查找是算法。
3. **写入时保持有序。** 先在该 key 的列表里二分定位 timestamp;若已存在,覆盖 value;否则在正确位置插入。无需每次追加后再全量排序。题面允许乱序写入,所以不能假设直接追加就能保持有序。
4. **定位和插入的成本不同。** 对普通数组实现的列表,查询是 O(log n);覆盖已有时间戳需要 O(log n) 定位;插入新时间戳最坏为 O(n),因为后面的元素需要移动。n 指这个 key 的历史记录数。十万次操作下,若大量写入集中在同一个 key 且频繁从中间插入,这个成本需要评估;有序数组是一个可解释的基础方案,不能仅凭二分查找就认为整体高效。
5. **区分题面条件和泛化思考。** 本题 timestamp 明确是整数。录音中“小数不能直接作下标”的想法可以用于拓展,但当前数组方案更直接的问题是时间戳范围、稀疏性和乱序写入时的维护。
6. **保持当前练习的范围。** 同时支持乱序、覆盖和空结果,是这次练习明确的要求。并发、TTL、持久化尚未进入当前阶段。


## 第一题下一次可以用来检验理解的小例子


这是整理时补充的练习,不代表已经实现或测试通过。


```text
set("a", "v3", 30)
set("a", "v1", 10)
set("a", "v2", 20)
get("a", 25)          → "v2"
set("a", "new", 20)
get("a", 25)          → "new"
get("a", 9)           → None
get("missing", 25)    → None
```


尝试不看答案,写出每次操作后的内部结构,再解释为什么查询始终能得到正确的历史版本。