Files
HomeAgent/docs/zh/context-overflow-l4-design.md
JianFeeeee 1d52ba85d5 docs: 术语与架构措辞对齐(coldcontext / stablemem / security+device 模块)
1. 术语统一:
   - doc_query / doc_commit → coldcontext_query / coldcontext_commit
   - 「图记忆 / Graph 层」→「稳定记忆 / Stablemem 层(L3)」
   - 分层命名补上 L1/L2/L3 语义:Context(热) / Cold-context(冷) / Stablemem(持久)
   涉及 README.md、README_EN.md、assets/docs/{zh,en}/{ARCHITECTURE,OVERVIEW}.md

2. 补上本次最大的结构变化(文档里此前完全没有):
   - 安全防火墙作为**独立内核模块**:凭据闸门、工具黑白名单+沙箱、
     敏感词与提示词注入预警、按主体授权与插件命名限式;显式代理而非 monkey patch
   - device 数据面作为**独立内核模块**:工具注册与路由、inputch/outputch、设备登记;
     路由表在 internal/toolroute 以避免 core ↔ kernelboot 成环

3. 历史计划文档(memory-*.md / context-overflow-l4-design / entities-retirement /
   git-branching)加**归档说明**:明确术语已过时、现行口径以 ARCHITECTURE.md 为准。
   刻意不改写历史正文 —— 那是决策记录,改写等于伪造。

剩余旧词只出现在归档文档与 scripts/capability-bench/PLAN.md(评测历史记录)。
2026-10-27 09:58:14 +08:00

7.1 KiB
Raw Blame History

上下文超页 L4 中断 —— 设计与落地

归档说明(2026-10-27):本文是历史计划,术语已过时 —— doc_query / doc_commit 已改名为 coldcontext_query / coldcontext_commit, 图记忆(Graph)在对外语义上改称 stablemem(稳定记忆)。 现行架构与术语以 assets/docs/zh/ARCHITECTURE.md 为准;本文保留作为决策记录。

特性分支 feat/context-overflow-l4 起因:2026-10-01 记忆召回跑分(v4)实测到HA 上下文管理失控,本文档是那次归因的产出。

一、问题现象(实测,不是推演)

同材料、同窗口(50k)、同判据(v4,10 个探针)的两侧跑分:

pi HA
主召回 4/9 4/9
累计 prompt 831k 9.58M
缓存命中率 75.4% 85.9%

HA 单轮 prompt 的实测轨迹(/var/tmp/mem/v4-ha-50k/partial.jsonl):

310099 → 182527 ↓41%   ← 修剪
182527 → 206801 → 281597 → 342446 ↑88%   ← 工具回灌堆积
342446 → 185325 ↓46%   ← 修剪
185325 → 347256 ↑87%
```text

**锯齿波,峰值是窗口的 7 倍。** 每轮都在「远超窗口 → 紧急修剪」。

## 二、根因(一):topK 与窗口脱钩

```go
// internal/agent/core/memorypass.go:152
topK := a.maxContextSize - 1
return a.context.Prune(query, topK, a.docStore)

// internal/agent/core/agent.go:288
MaxContextSize int // 活跃上下文最大条数,超出按相关性裁剪
// 默认 30(core.agent.max_context_size)
```text

`Prune` 的 topK 是**事件条数**,与 `context_window`(token)没有任何换算关系。
于是「30 条」这个容量对上 34 万 token 的实际占用毫无约束力——每轮必然超限、必然修剪。

这直接回答了「1M 上下文能否改善」:**只调 `context_window` 无效**,必须同时让 topK 随窗口变化
(按 token 估算反推条数,或直接改成 token 预算)。

## 三、根因(二):超页的三条路径没有统一出口

现状(`internal/agent/core/`):

| 路径 | 现状 | 位置 |
| --- | --- | --- |
| 上游 context_full 错误 | **完全没接**,整轮 `outcomeFailed` | `stepLLM` 的 `llmErr != nil` 分支 |
| 本地积累超限 | 只在轮首 `checkContextFull`,且**根 agent no-op**、一次性 | `resident.go:535` |
| 子 agent contextfull | 已走 L4 上报,但**只推信号不携带动作** | `resident.go:520` |

第三条已经在用 L4,前两条没有——同一类事件三个出口,这正是要收敛的地方。

## 四、既有L4 机制的关键约束(落地前必须知道)

读 `scheduler.go` 得到四条硬约束,直接决定方案形态:

1. **L4 遇 L4 不能抢占**:`canPreempt` 用严格大于,`effectiveLevel` 封顶 L4 ⇒ 第二个 L4 只能排队。
   *推论*:方案不能让「一次超页」反复触发 L4,否则排队堆积。

2. **中断栈深度上界 4**(`maxInterruptFrames` = 中断级数,非配置项),且**绝不丢弃帧**。

3. **挂起现场是整个 `TaskFrame`**(含 `f.Msgs`)。复原后必须重建 `f.Msgs`,
   否则等于把已经超限的请求原样再发一次。

4. **L4 任务看不到被打断者的上下文**(`suspend` 的 `D1=B` 注释明确)。
   ⇒ 「已裁剪什么」必须**显式写进中断消息**,不能指望它自己知道。

## 五、方案

### 5.1 收敛到 L4 唯一入口

`raiseKernelInterrupt` 已是 L4 唯一入口(panic / selfip 都走它)。超页成为**第三个来源**:

```go
// scheduler.go
func (a *Agent) raiseContextOverflow(pruned int) {
 a.raiseKernelInterrupt("kernel/overflow", "kernel",
  fmt.Sprintf("[内核] 上下文超页,已裁剪 %d 条低相关事件到文档记忆。"+
   "被裁内容仍可检索,但需显式查询;查不到不等于不存在。", pruned))
}
```text

### 5.2 Prune 同步做,L4 只负责打断+告知

**关键取舍**:`Prune` 放在 `raise` 之前同步执行,不放进 L4 任务里。

理由(已核实 `Prune` 全路径:`DenseCosine` 无越界、无IO、纯内存排序 + 可选 docStore 写入):

- 它没有实质失败模式,唯一「0 条」是「本来就装得下」而非错误
- 放 L4 任务里做会多付一次完整任务调度开销
- **避免「L4 里 Prune 失败再触发 L4」的可能**——用户口径:Prune 出错则保存现场并终止,不重试

```go
func (a *Agent) handleContextOverflow(query string) {
 before := a.context.Len()
 pruned := a.pruneByQuery(query)          // 同步,无 IO
 if pruned == 0 {
  // 装得下却报超页 ⇒ 判定逻辑有 bug:保存现场 + 明确终止,**不再触发中断**
  a.abortWithDiagnostics("context-overflow", before)
  return
 }
 a.raiseContextOverflow(pruned)
}
```go

### 5.3 触发条件(两条路径,同一出口)

| 来源 | 判据 | 位置 |
| --- | --- | --- |
| 本地预判 | 积累上下文 > `overflowRatio × 窗口`(默认 1.25) | 每次发 LLM 请求**之前**(覆盖轮内 tool 回环) |
| 上游报错 | `ProviderError.Kind == ErrContextFull` | `stepLLM` 的 `llmErr` 分支,**在 provider fallback 之前** |

本地判据必须用**未裁剪的积累量**(`a.context` 全部事件估算),不能用 `f.Msgs`——
后者被 `buildMessages` 按 `targetUsage = 0.8×窗口` 裁过,**结构上永不成立**(`resident.go:524` 注释已记录该坑)。

### 5.4 错误分类

```go
// internal/agent/api/provider.go
type ErrKind uint8
const (
 ErrUnknown ErrKind = iota
 ErrTransient
 ErrCredential
 ErrContextFull // 新增
)
```go

判别规则**必须拿真网关实测**,不能凭猜:已观察到不同上游把 context_full 报成 400 / 413 /
`invalid_request_error`,只能靠 body 文本匹配 + `StatusCode` 组合判定。

## 六、测试项

| # | 判据 | 类型 |
| --- | --- | --- |
| T1 | 积累量 > 125% 窗口时,轮内 LLM 请求前触发裁剪 | 单测(构造超限会话) |
| T2 | 裁剪后请求成功,且 `f.Msgs` 确实变小 | 单测 |
| T3 | 上游返回 context_full ⇒ 走裁剪恢复,不 `outcomeFailed` | 单测(mock provider) |
| T4 | `pruned == 0` ⇒ 保存现场并终止,**不再触发 L4** | 单测(变异:把 topK 设成超大值) |
| T5 | 同一 TaskFrame 内超页恢复次数 ≤ 上限(默认 2) | 单测 |
| T6 | 中断消息含「查不到不等于不存在」提示 | 单测(断言 payload) |
| T7 | 打断后复原的 TaskFrame **重建** `f.Msgs`,不复用超限那份 | 单测(断言 Msgs 指针/长度) |
| T8 | L4 遇 L4 不产生栈溢出、不丢帧 | 回归(`maxInterruptFrames` 边界) |
| T9 | topK 随窗口换算(1M 下 topK 不再是 30) | 单测 |
| T10 | 端到端:50k 窗口跑v4,HA 累计 prompt < v4 的 1/3 | 跑分判据 |

T4/T5 是**变异验证项**:把防护去掉必须变红,否则说明测试没测到东西。

## 七、不做的事

- 不改 `Prune` 的打分逻辑(用本轮 query 给所有候选打分确有缺陷,但那是独立议题)
- 不做「换入」策略(`docStore` 已有 `doc_query` 召回,但何时主动捞回是另一个设计)
- 不动 panic 的递归保护(超页不需要:Prune 不 panic,且 L4 遇 L4 不嵌套)