mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-03 07:47:45 +00:00
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(评测历史记录)。
7.1 KiB
7.1 KiB
上下文超页 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 不嵌套)