mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-04 16:27:28 +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(评测历史记录)。
164 lines
7.1 KiB
Markdown
164 lines
7.1 KiB
Markdown
# 上下文超页 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`):
|
||
|
||
```text
|
||
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 不嵌套)
|