最近发现个事,如果你一个不小心,给deepseek输入<think>,他会一不小心吐出来别人的对话,问题原因如下:
特殊 Token 注入防御缺失<think> 是模型内部用于触发或标识“思维链(Chain of Thought)”的控制字符(Special Token)。系统前端或中间件未对用户的输入进行安全校验和转义。用户直接输入控制字符,打破了系统默认的 Prompt 模板结构。
全局 KV Cache 调度隔离失效(核心原因) 大模型推理框架(如 vLLM、SGLang)应用前缀缓存(Prefix Caching 或 RadixAttention)技术。系统在显存中维护一棵全局 Prefix Tree,跨用户共享相同的上下文 KV Cache 以提高吞吐量。当用户强制输入 <think> 时,调度引擎的哈希匹配逻辑可能发生异常,导致当前请求的指针错误地命中并挂载到了其他用户刚好包含 <think> 状态的缓存节点上。结果就是直接把内存中其他用户的推理缓存输出了出来。
接前边的文章,虚拟内存深度剖析:页表、TLB 与 Linux 内核机制(卷一)
Alloca:「你说过代码段只读、可执行不可写。现在我懂页表了,可到底是谁在执行这条规则?代码页也有 PTE,凭什么拦我写?」Kernel:「PTE 里不只有帧号,还有权限位。writable 为 0 时 MMU 拒绝写入并 fault:访问半途停下,把控制权交给我。executable(或 NX/XD)控制能否取指执行。我创建你时把代码页标成可执行不可写;data/heap 可写不可执行。MMU 每次访存都检查。」
Alloca:「fault 了会怎样?比如我写自己的代码页?」Kernel:「我来处理。权限违例几乎总是 bug 或攻击,我通常会终止进程。」Alloca:「除了权限位还有别的位吗?」Kernel:「有,很关键:present 位,每一级表项都有。若为 0,walk 在该层停止并 fault。但 not-present 不一定等于错误:可能只是我还没给这页分配物理帧,或页曾被换出到盘。」
Alloca:「权限位隔开代码与数据,present 位表示页是否真有物理 backing。」Kernel:「正是。」
每个 PTE 除帧号外还有若干 权限位,MMU 在每次访存完成之前检查:
权限违例多视为 bug/攻击,内核常直接终止 faulting 进程。硬件强制的代码/数据分离是许多漏洞利用的基线防线。
过了一阵子,Alloca 已更熟悉这个世界。她要处理大数据集,需要空间存中间结果。
她像所有进程那样发起系统调用要内存;地址空间里出现新区间,Kernel 给她地址 0x55a3c2f00000。她立刻去写第一个字。
时间仿佛顿了一瞬又恢复,写入成功——她几乎没察觉发生了什么。
Alloca:「刚才……卡了一下?」Kernel:「你触发了 page fault(缺页异常)。别担心,我处理了。」Alloca:「缺页是什么?你做了什么?」
Kernel:「给你地址时,我并没有立刻配上物理内存。我记下承诺:这段虚拟地址归你,但还没找帧背在后面。」Alloca:「没物理 backing 就给我地址?像骗人。」Kernel:「是效率。你可能要 100 MB 只用 10 MB;若每页都预分配,会浪费大量物理页。于是我等:你第一次真正访问时,MMU 查表见 present=0,无映射,就 trap 进内核。」Alloca:「你怎么知道我这次访问合法?万一我乱指地址?」
Kernel:「给你区间时我记了 VMA(Virtual Memory Area):『虚拟地址 X–Y 承诺给 Alloca,权限如此』。VMA 不是 PTE,而是更高层的意图记录。」Alloca:「所以有两套结构?」Kernel:「对。VMA 描述允许访问的区间;页表描述哪些合法页当前有物理帧。创建你时为 text/data/stack 建了 VMA;后来
malloc/mmap再建新 VMA,但不立刻建对应 PTE。」
Alloca:「MMU 见缺 PTE 就 fault?」Kernel:「对。我处理 fault:先看 fault 地址是否落在合法 VMA。是则访问合法,只是尚未 backing;若落在任何 VMA 外,就是越界访问——segmentation fault,我杀进程。」Alloca:「VMA 是承诺清单,页表是兑现记录。」Kernel:「说得好。确认合法后,我找已清零的帧,写 PTE 指向它,恢复执行,CPU 重试那条指令,写入成功。」

Alloca:「为何要清零?不能直接给帧吗?」Kernel:「绝不能。帧会复用,可能刚装过别的进程秘密;不清零你就能从未初始化内存读到他人数据。零页保证是安全不变量。」
Alloca:「若没有空闲帧?物理内存满了?」Kernel:「很常见,那时 present 的含义会更丰富。」

Aside 1:栈如何用按需分页增长
布局一节说过栈向低地址长,这也是按需驱动:内核把栈 VMA 标为可增长,但不会预先映射栈的每一页。栈指针进入当前栈底之下新的一页时触发 fault;若地址紧贴当前栈底且 VMA 允许增长,内核把 VMA 向下扩展一页、分配帧、恢复执行——对 Alloca 而言栈「默默」变大了。
两件事防止无限增长:其一,最大栈深(Linux 常由 ulimit -s 控制,默认约 8 MB),栈 VMA 不能超过该上限。其二,极限之下常设 guard page:故意不映射、无 VMA 覆盖的一页;若栈因深递归、大栈数组或坏指针冲进 guard,fault 找不到覆盖 VMA,判非法并发 SIGSEGV。
guard page 把「静默栈溢出踩坏 mmap/堆」变成可检测崩溃。
Aside 2:内存超卖(Overcommit)——按需分页的后果
只有首次访问才分配帧时,malloc(10GB) 在只有 4 GB RAM 的机器上也可能先成功:内核记 VMA 并立即返回,零帧分配。这叫 overcommit:所有进程 VMA 总「承诺」可远超 RAM+swap。
内核赌的是统计规律:大量分配从未完全 touch;JVM 先预留大堆再懒填充等。赌输时:多进程同时 fault 入页、压力暴涨、无空闲帧——OOM killer 按启发式给进程打分,杀掉高分者以回收帧。
Linux 上可观察:
grep CommitLimit /proc/meminfogrep Committed_AS /proc/meminfodmesg | grep -i "oom\|killed process"journalctl -k | grep -i oom/proc/sys/vm/overcommit_memory:0 启发式(默认);1 总允许分配;2 把总承诺限制在 overcommit_ratio × RAM + swap 并拒绝超限的 malloc。
进程通过 malloc、栈增长或 mmap 申请内存时,内核不会立刻为每一页分配物理帧,而是在内存描述符里建 VMA:「这段虚拟地址合法、属本进程、权限如下」;对应 PTE 常保持缺席(present=0)。
首次读写尚未映射区间时,MMU 见 present=0 触发 page fault。缺页处理大致:
present=1。进程只感到微秒级停顿,此即 demand paging:物理内存在首次访问时分配,而非在 malloc 调用时投机分配。
上述路径无磁盘 I/O,称 minor page fault(次缺页):凡内核纯在内存内解决的 fault 都算,包括零填、数据已在页缓存/他进程共享只需装 PTE 等。需要读盘的另一类是 major page fault(主缺页),下节。
副作用:物理帧逐次、就地分配,相邻虚拟页不必相邻物理帧;栈帧可与其他进程帧交错——页表让进程仍见连续虚拟空间。
Alloca:「没有空闲帧时要分配怎么办?」Kernel:「举例:要给你一帧但全被占用,我就得回收一页:找最近少用的页,可能来自别的进程甚至你自己。把内容写到磁盘保留区 swap,再腾出帧给你。」
Alloca:「原主再访问那页?」Kernel:「把帧给你之前,我更新其 PTE:清 present=0,在余项里私藏 swap 坐标(硬件在 present=0 时不解释这些位,但我的 fault 路径会读)。」
Alloca:「它再 touch?」Kernel:「MMU 见 present=0 又 fault;我仍先查 VMA,合法则看 PTE 里 swap 坐标,从盘读回新帧,装 PTE present=1,恢复后重试指令——进程不知道页曾离开。」
Aside:次缺页 vs 主缺页
前面次缺页无盘 I/O,例如 malloc 只建 VMA,首次访问才分配帧。访问已换出页需要读盘,则是主缺页。
Alloca:「present=0 总表示数据在 swap?」Kernel:「不。swap 只是一种去向;非 present 也可能表示数据在文件里。」
Alloca:「除了 swap 还能在哪?」Kernel:「磁盘上的文件。并非每页都来自
malloc/栈;有些页直接对应文件内容。」Alloca:「怎么做?」Kernel:mmap把文件映进地址空间;我建 VMA,PTE 仍可先缺席,与malloc类似。」Alloca:「首次访问?」
Kernel:「又是 absent PTE→fault,但处理路径不同于新分配匿名页,也不同于从 swap 恢复。」Alloca:「差别在哪?」Kernel:「第一步仍查 VMA;之后取决于映射类型。」
Alloca:「文件映射有何不同?」Kernel:匿名:fault 要么是给新零页,要么按 PTE 里 swap 坐标从盘恢复。文件后备:通常无 swap 项,而由 VMA 记录文件与偏移,读入帧、装 PTE。」
Alloca:「PTE 层 present=0 只表示『不在 RAM』,去哪找要看映射类别?」Kernel:「对。匿名且已换出:非 present PTE 可带 swap 坐标;文件映射尚未装入:我常靠 VMA 的文件+偏移重建。」
物理内存紧张时内核必须 reclaim 帧,选冷页换出。匿名页(堆、栈、malloc)无文件可 reload,脏内容写 swap,PTE present=0 且余项存 swap 坐标(硬件忽略,仅供内核 fault 路径)。再次访问时 MMU 见 absent→主缺页,handler 读 swap、装入新帧、present=1。
文件后备 fault 略不同:VMA 携带文件与文件内偏移以填充帧。
fault handler 两条轴即可覆盖情形:

Aside:固定内存(Pinned)与 GPU 传输
前述默认可在压力下换出;有些场景不行。Pinned / page-locked 内存禁止换出;mlock() 后内核保证在锁持有期间底层帧不迁移、不被回收。
最常见动机是 GPU 数据传输:DMA 在主机 RAM 与 GPU 显存间搬数据时,缓冲区的物理地址在传输期间必须固定;若中途换出帧被复用,DMA 会读写到错误位置。训练框架因此 pin 主机输入缓冲;PyTorch 的 torch.cuda.pin_memory()、DataLoader(pin_memory=True) 底层会 mlock(),便于 CUDA 直接 DMA、并与 GPU 计算重叠。
代价:pinned 页不可回收,过量使用会挤压页缓存与其它进程,增加别处 swap 压力。
Alloca 接到大任务,想找人分担。
Alloca:「真希望有个分身一起干。」Kernel:「用
fork()。」Alloca:「怎么工作?」Kernel:「fork()时我造一个新进程,几乎是你此刻的拷贝:同代码、复制文件描述符表、甚至内存。」
Alloca 调用 fork(),得到子进程 Forka。两人开始干活;不久 Alloca 写入内存,又感到一瞬停顿。
Alloca:「又是缺页?可页明明是 present,我刚还在读。」Kernel:「页在,但我把它标成只读,你写入触发了 fault。」Alloca:「为何只读?那内存明明要读写。」
Kernel:「创建 Forka 时的优化。若把堆每页立刻物理拷贝,堆可达数 GB,且她可能永不写——
fork会极慢。于是我让她的页表先指向与你相同的物理帧;双方都只读时共享安全。任一方首次写入该共享页时再 fault,我给写入方私有拷贝。这就是 CoW。」Alloca:「只读是为了抓住第一次写。」Kernel:「对。我分配新帧、拷贝 4 KB、更新你的 PTE 指向新帧并恢复写权限;Forka 的 PTE 仍指旧帧。」
Alloca:「于是我们各有一份那页?」Kernel:「仅被写过的页。从未写的页永远共享,拷贝次数为零。」Forka:「若父进程先退出我还没写那页?」
Kernel:「我跟踪每帧的引用与映射状态。父退出后若页不再共享,你下次写 fault 时有时只需恢复写权限而无需拷贝。」

Aside:fork + exec 为何进程创建便宜
常见模式是 fork 后立即 exec 丢弃子进程地址空间、装入新程序(shell 执行命令即如此)。因此 fork 必须便宜,推迟拷贝是关键优化。
fork() 创建与父进程时刻一致的子进程。朴素实现需拷贝父虚拟内存每一字节,大进程可达数 GB。CoW 把拷贝推迟到真正需要时。
fork() 时:
内核跟踪每物理帧的引用/共享状态。之后任一方写 CoW 页:MMU 见写只读 PTE→保护 fault;CoW handler 判断是否仍共享:若仍共享则分配新帧、拷贝、更新 faulting 进程 PTE 可写;若已可判定仅一方相关,有时只需恢复写权限而无需拷贝。