读 session_controller.py 与 SessionAwareCache#

这章解决什么问题#

前面的 request lifecycle 已经在 2.3 会话、超时与中止路径 里解释了 session 为什么会改变请求生命周期,调度与内存部分也在 4.5 Runtime checker、SessionAwareCache 与内存不变量 里解释了 SessionAwareCache 为什么要接管 streaming session 的 KV 生命周期。但如果你真正打开源码,仍然会遇到一个更具体的问题:

session 相关实现到底应该从哪几棵树开始读,怎样把它们接起来?

这章的目标,就是把:

  • session_controller.py
  • session_aware_cache.py

收成一条稳定的源码阅读路径。

为什么这两棵树必须配对读#

单读 session_controller.py,你会知道:

  • session 怎样被打开 / 关闭
  • append / replace / drop_previous_output 怎样改变请求链
  • streaming session 有哪些限制

单读 session_aware_cache.py,你会知道:

  • SessionSlot 怎样接管 req_pool_idx
  • match_prefix(...) 怎样恢复会话态 KV
  • release_session(...) 怎样释放 slot 占用的资源

但只有把两者配在一起,你才会真正看清一件事:

session 不只是“请求之间共享逻辑上下文”,它还会改变 KV 所有权与缓存生命周期。

这就是为什么它不是一个轻量 API 特性,而是一条贯穿请求、缓存和回收的系统支线。

一张图:session 在源码里并不是单树问题#

这张图解决的理解障碍是:很多读者会把 session 理解成 SessionController 自己的一棵树,但真正的实现其实横跨请求链和缓存链。

flowchart LR
    Open["open_session / close_session"] --> Ctrl["SessionController"]
    Ctrl --> Req["Session / SessionReqNode / Req chain"]
    Req --> Cache["SessionAwareCache"]
    Cache --> Slot["SessionSlot"]
    Slot --> KV["req_pool_idx / KV ownership"]

图比纯文字多解释的一点是:session 的复杂度不只在“请求如何串起来”,还在“谁拥有 KV,什么时候转交和释放”。

第一层:先抓 SessionController 的三件事#

更稳的阅读顺序,不是从文件头一路扫,而是先抓这三件事:

  1. Session 自身怎样表示一条会话
  2. Session.create_req(...) 怎样把新请求接到已有链上
  3. SessionController 怎样打开 / 关闭 / 回收会话

只要先抓住这三点,后面 SessionAwareCache 的存在就会变得很自然。

Session 为什么不是普通的请求列表#

Session 最值得先记住的几个字段是:

  • session_id
  • streaming
  • timeout
  • req_nodes

这说明它并不是“为每个 session 保存一串 request id”那么简单,而是维护一棵基于 SessionReqNode 的请求树。

也就是说,session 的抽象主语不是线性历史,而是:

  • 某条请求接在哪个父请求之后
  • 某次 replace / append 会删掉哪些子树

从技术书角度看,这一点特别重要,因为它解释了为什么 session 语义天然会改变请求生命周期,而不只是附加一个 id。

Session.create_req(...) 应该怎么读#

这是 session_controller.py 里最值得重点看的函数。更稳的阅读问题是:

  1. 当前请求是在 streaming session 还是普通 session
  2. 它是 append、replace,还是 drop previous output
  3. 它最后怎样构造成新的 Req

从实现上看,这个函数做的关键工作有:

  • 检查 streaming session 是否非法使用 replace / offset / drop_previous_output
  • 决定上一个 request node 是谁
  • 必要时对旧请求树做 abort()clear_children(...)
  • 按上一轮输出拼接新的 origin_input_ids
  • 最后构造新的 Req(...)

这说明 session 的本质不是“保存上下文”,而是“重新编译下一轮请求的输入与父子关系”。

为什么 streaming session 的限制这么强#

源码里对 streaming session 的限制非常明确:

  • 不支持 replace
  • 不支持 drop_previous_output
  • 不支持非零 offset

这说明 streaming session 不是普通 session 的一个松散变体,而是一种更严格的运行人格。原因也很明确:

  • 普通 session 还能在逻辑上重写请求链
  • streaming session 已经开始把输出暴露给外部,再允许任意重写会让内外状态一致性变得很脆弱

这不是“上游偷懒”,而是很典型的 runtime consistency 约束。

SessionReqNode 为什么值得单独记住#

这不是简单的树节点实现,而是 session 对请求历史的真实建模方式。它负责:

  • parent / children 关系
  • 递归清子树
  • 把未完成请求标记为 FINISH_ABORT

这说明 session 生命周期的很多动作不是“删除历史记录”,而是要把一整片请求子树转成 abort 或清理状态。

也就是说,session 本质上让 request lifecycle 从“单请求状态机”扩成了“请求树”。

第二层:SessionAwareCache 为什么是另一半实现#

如果 SessionController 只负责请求链,而不碰 cache,那 session 仍然只是逻辑层功能。但 SessionAwareCache 的存在说明:

session 还会改变 KV 所有权与缓存回收语义。

它是一个 wrapper / decorator,而不是重写全部 prefix cache。这个设计特别值得技术书强调,因为它体现了一种很稳的工程策略:

  • 不去撕开 scheduler 主循环
  • 先在 prefix cache 边界包一层会话态逻辑

这既保留了原有主线,又给 streaming session 插入了正式扩展点。

SessionSlot 才是 streaming session 的 KV 持有人#

SessionSlot 最值得记住的是:

  • req_pool_idx
  • kv_committed_len
  • kv_allocated_len
  • cache_protected_len
  • last_node

以及 save_from_req(...) / restore_to_req(...) 两个方向相反的动作。

这说明会话态 KV 并不是仍然永久挂在某个 Req 上。更准确地说:

  • request 完成后,slot 可以接管这组 KV
  • 下一轮请求进入时,再从 slot 恢复到 request

这就是为什么 session 会真正改变缓存生命周期,而不是只改变输入拼接逻辑。

match_prefix(...) 为什么是 streaming session 最关键的 cache 钩子#

SessionAwareCache.match_prefix(...) 的语义非常清楚:

  • 非 streaming 请求:完全 pass-through 给 inner cache
  • streaming 请求:优先看当前 session_id 是否已有 slot
  • 如果 slot 持有 KV,就 restore_to_req(req) 并直接构造 prefix match 结果

这说明 streaming session 的 prefix 复用,并不是让树缓存自己神奇记住会话,而是通过 slot 主动恢复。

这样设计的好处是边界非常清晰:

  • 普通请求不受影响
  • streaming session 的特殊性被限定在 wrapper 层

为什么 save_from_req(...) 不会把 slot 立刻清空#

源码注释已经点得很清楚:在 chunked prefill + streaming session 场景下,请求可能因为 budget 不足被 scheduler 拒绝,然后下一轮重试。如果此时 slot 过早清空,就会丢掉幂等恢复能力。

这是一处特别值得写进书里的“反直觉点”:

  • 看到 slot 没清,初看像泄漏
  • 但它其实是在为 chunked 重试保留恢复点

这种设计只有放在“请求会重试、KV 需要跨轮保存”的背景下才会变得合理。

release_session(...) 真正在释放什么#

这段函数做的不是简单的 del session。它会:

  • 释放 last_node 上的 lock ref
  • 计算 slot 持有的 KV 区间
  • 把相应 KV indices 还给 allocator
  • req_pool_idx 放回 free slots

也就是说,会话关闭真正释放的是一整组缓存持有关系,而不只是控制器里的元数据。

这再次说明 session 是 request lifecycle 与 cache lifecycle 的交叉点。

这两棵树对排障有什么直接价值#

session 相关问题很容易被误判成普通请求问题,但其实常常出在这两棵树之一:

  • append / replace 行为不符合预期:优先看 Session.create_req(...)
  • 请求树被过早清空或错误 abort:优先看 SessionReqNode
  • streaming session 下一轮复用错上下文:优先看 SessionAwareCache.match_prefix(...)
  • 会话结束后内存不回落:优先看 release_session(...)

换句话说,session 故障不是一类统一问题,而是逻辑链与缓存链两个层次的问题。

如果你要顺着源码读这条支线,推荐顺序是什么#

建议按下面顺序:

  1. Session
  2. Session.create_req(...)
  3. SessionController.open/close/_close
  4. SessionSlot
  5. SessionAwareCache.match_prefix(...)
  6. SessionAwareCache.cache_finished_req(...)
  7. SessionAwareCache.release_session(...)

这样读,你会先建立“请求树”的理解,再建立“KV 所有权”的理解,两层会自然接上。

这一层最容易出现的误判#

1. 以为 session 只是给请求附一个 session_id#

实际上它会改变请求树与输入重组逻辑。

2. 以为 SessionAwareCache 只是优化 prefix match#

它真正改变的是 streaming session 的 KV 生命周期。

3. 以为 session 关闭只会删一条控制器记录#

它还会释放 lock、KV 和 req_pool_idx 所有权。

小结#

这一章真正要补齐的,是代码导读里 session 支线的正式源码入口:

  • SessionController 负责把 session 建成一棵请求树
  • SessionAwareCache 负责把会话态 KV 生命周期包装进 prefix cache
  • 两者配在一起,才构成 session 的完整实现

到这里,请求生命周期、调度与内存两部分里反复出现的 session 概念,就终于在源码层真正汇合了。