docs: 架构节写清上下文管理与记忆的实际做法

## 补上真正的差异点

之前架构节只有一张图和七阶段表,把最核心的上下文管理省略了。
用 codegraph 查了 `RelevanceContext` / `PruneWithProtected` /
`coldcontext.Store` / `block_recall`,确认实际做法,逐条对照常见 agent:

### 上下文:按相关度取舍,不是截断或摘要

常见做法是超窗裁掉旧对话,或让模型自己摘要 —— 共同问题是**丢什么由位置决定**。
这里是给每条历史事件按当前输入打分,低分下沉到文档层。

打分主路径是稠密余弦(与媒体、文档同一向量空间),模型未就绪退到 TF-IDF。

关键在 `effectiveProtectedCount`:保护条数受预算约束。

```go
budgetCap = ContextTokens / avgEventTokens
pCount = min(配置值, budgetCap)
```

源码里记着这条约束的来历:50k 窗口下单条事件约 5800 token(工具回灌型),
预算 40000,「钉住 10 条」本身就装不下 —— 每轮裁剪都撞保护下限,
两轮后预算耗尽,任务直接终止,召回率从 44% 掉到 22%。

### 三层是迁移,不是复制

`ColdDoc` 的注释写得很清楚:「块的真实归属在 Events[i].Blocks 里」,
块在任一层只有一个持有者,不靠引用计数保活。

回灌用 `Peek` + 确认 + `Remove` 两阶段,源码明确标注「**禁止先删后灌**
—— 删除必须排在确认之后,回灌失败即永久丢数据」。

### 场面:两条路并行,且互不吞并

声明场景**不吸收整轮指纹**(`EnsureScene` 的注释):否则它会以接近 1.0 的相似度
吃掉后续所有同类轮次,被动聚类再也长不出更细的场面。

这条边界是踩出来的,不是设计时想到的。

## 开头相应收缩

原来开头四段「**粗体断言** + 三行解释」与架构节重复。改成一句介绍 + 安装 +
三段话点出差异,细节留给架构节。

两版 163 / 191 行,9 个标题对齐,代码块 10/10 配对,无死链。
This commit is contained in:
JianFeeeee
2026-10-31 11:30:18 +08:00
parent 5f570075a3
commit 152dc680ad
2 changed files with 123 additions and 51 deletions

View File

@ -13,22 +13,9 @@ make build build-cli
API 密钥在 WebUI(`http://localhost:8080`)里填。需要 Linux。
## 几个设计
**启动顺序在编译期定序。** 20 个内核模块各自声明 `Requires`/`Provides`,构建时算出 6 层
依赖图写进 `depgraph.bin`,digest 用 `-ldflags` 注入二进制。运行期只读图,不重算。
图与二进制不配套则拒绝启动。
**插件按设施就绪提前启动。** 声明 `requires_facilities: [config]` 的插件在第 1 层完成时就起,
不必等第 4 层 agent。实测省 35 秒,那是 agent 层做 dense backfill 750 条事件、重建索引的时间。
**两级输入、四级中断。** 排队输入无级别,任何中断可打断。中断带 L1–L4,高抢占低并将现场
压栈待恢复,同级不抢占。L4 内核独占,插件声明会被夹到 L3。抢占计数可提级,上限两档
(`preemptPromotionCap = 2`),避免低优先级任务长期得不到执行。
**记忆按场面分。** 指纹由通道、对话对象、工具、话题、时段构成,权重 1.0/1.0/0.8/0.4/0.2。
同一指纹出现到第 2 次才建场景(`minSceneEvidence = 2`)。归属阈值 0.5、唤起阈值 0.35,
唤起比归属宽松:想不起来是损失,多想一条只是多几行上下文。
跟常见 agent 框架的主要差别在上下文管理:不是超窗就截断或让模型摘要,
而是按相关度取舍,低分的下沉成文档、再蒸馏进图数据库。另外两处不太一样的是
启动顺序在编译期定序,以及插件按设施就绪提前启动(详见下方架构)。
## 架构
@ -39,7 +26,7 @@ API 密钥在 WebUI(`http://localhost:8080`)里填。需要 Linux。
┌──────────────┼──────────────┐
│ │ │
stdio RPC 共享内存 事件环
控制 大块数据 通知
控制 大块数据 通知
│ │ │
└──────────────┼──────────────┘
│
@ -50,7 +37,57 @@ API 密钥在 WebUI(`http://localhost:8080`)里填。需要 Linux。
内核不发 HTTP、不读文件。所有接触外部世界的能力都在插件里,插件崩溃不影响内核,
重启退避 1s / 2s / 3s,5 分钟内第 4 次崩溃停止自动重启等人工介入。
**消息进入后的七个阶段**(`internal/agent/core/stages.go`):
### 上下文不是「截断 + 摘要」
常见做法是把超窗的对话裁掉或让模型自己摘要,两者的共同问题是**丢什么由位置决定**。
这里换成了按相关度取舍:用当前输入给每条历史事件打分,低分的下沉到文档层。
打分用稠密向量余弦(与媒体、文档共享同一向量空间),模型未就绪时退化到 TF-IDF。
裁剪时无条件保住最近 N 条,但 N 本身受预算约束:
```go
// 保护条数不超过预算能装下的条数
budgetCap = ContextTokens / avgEventTokens
pCount = min(配置值, budgetCap)
```
这条约束来自一次实测:50k 窗口下单条事件约 5800 token(工具回灌型),预算约 40000,
「钉住 10 条」本身就装不下,于是每轮裁剪都撞保护下限,两轮后预算耗尽,任务直接终止。
召回率从 44% 掉到 22%。现在窗口越小越自动收紧,1M 窗口下则能用满配置值。
被裁掉的事件不是丢弃,是迁移:事件连同它持有的媒体块一起进文档层,块的 ID 不变、
只换持有层。回灌时用 `coldcontext_query` 取回,两阶段(先 Peek 确认、再 Remove),
不用「先删后灌」——那会在回灌失败时永久丢数据。
### 三层记忆是迁移,不是复制
```
L0 上下文 相关性裁剪,最近 N 条恒受保护
↓ 低分事件下沉(带媒体块)
L1 文档 稠密检索,访问计数 ≤2 且久未访问则继续下沉
↓ 蒸馏成三元组
L2 图 实体关系 + 场面索引,支持按场面召回
```
块在任一层只有一份持有者,不靠引用计数保活。媒体不是附件:图片、音频是图里的一等节点,
有自己的向量,和文本在同一空间里检索。
### 记忆按场面分,不按会话
普通 agent 通常一条会话一条历史,换个话题就失忆,或者全塞进一个上下文里互相干扰。
这里按「可观察的场所指纹」分:通道、对话对象、工具、话题、时段,权重 1.0/1.0/0.8/0.4/0.2。
同一指纹出现到第 2 次才建场景(`minSceneEvidence = 2`),只发生一次的不算。
归属阈值 0.5、唤起阈值 0.35,唤起比归属宽松——想不起来是损失,多想一条只是多几行上下文。
场景每次重现强度 +1,久不重现按半衰期衰减。
两个来源并行:**声明**(插件在注入点说明"这是哪个场面")与**涌现**(指纹自己聚类)。
声明场景不吸收整轮指纹,否则它会以接近 1.0 的相似度吃掉后续所有同类轮次,
被动路再也长不出更细的场面。
### 七个阶段
`internal/agent/core/stages.go`
| 阶段 | 时机 | 插件可做 |
|---|---|---|
@ -65,9 +102,6 @@ API 密钥在 WebUI(`http://localhost:8080`)里填。需要 Linux。
`post_action → [before_toolcall → 执行 → after_toolcall] → post_action` 是内循环,
退出条件:模型不再调工具、全部被拒、或达到上限。
**三层记忆**:上下文(最近 10 条恒受保护)→ 文档(冷数据下沉)→ 图数据库(蒸馏成三元组)。
媒体是图里的一等节点,不是附件。
## 其余
- **通道**:WebUI、命令行、QQ 群与私聊、A2A、IDE 里的 ACP

View File

@ -13,28 +13,10 @@ make build build-cli
API keys go in the web UI at `http://localhost:8080`. Requires Linux.
## Design notes
**Startup order is resolved at build time.** Twenty kernel modules declare `Requires`/`Provides`.
The build computes a 6-layer graph into `depgraph.bin` and injects its digest into the binary via
`-ldflags`. At runtime the kernel reads the graph rather than recomputing it. A mismatch between
graph and binary refuses startup.
**Plugins launch as soon as their facilities are ready.** A plugin declaring
`requires_facilities: [config]` starts when layer 1 completes instead of waiting for the agent
layer at layer 4. That saves a measured 35 seconds, which the agent layer spends on dense
backfill of 750 events and reindexing.
**Two input classes, four interrupt levels.** Queued input has no level, so any interrupt can
preempt it. Interrupts carry L1–L4: higher preempts lower and pushes the preempted state onto a
stack for later resume, same level never preempts. L4 is kernel-only; plugins declaring it get
clamped to L3. Preemption count can raise a level by at most two steps
(`preemptPromotionCap = 2`), so low-priority work can't starve indefinitely.
**Memory is partitioned by scene.** A fingerprint is built from channel, partner, tools, topic,
and time of day, weighted 1.0/1.0/0.8/0.4/0.2. A scene exists only after the second matching
fingerprint (`minSceneEvidence = 2`). Join threshold 0.5, recall 0.35 — recall is looser because
failing to recall loses information while an extra scene costs a few lines of context.
The main difference from typical agent frameworks is context handling: instead of truncating
on overflow or asking the model to summarize, it scores history by relevance and sinks the
low-scoring events into documents, then distills those into a graph. Two other things differ:
startup order is resolved at build time, and plugins launch as soon as their facilities are ready.
## Architecture
@ -53,11 +35,70 @@ failing to recall loses information while an extra scene costs a few lines of co
web UI · QQ · CLI · files · browser
```
The kernel makes no HTTP calls and reads no files. Everything that touches the outside world is a
plugin. A plugin crash leaves the kernel running; restart backs off 1s / 2s / 3s, and the 4th
crash within 5 minutes stops automatic restarts for human intervention.
The kernel makes no HTTP calls and reads no files. Everything touching the outside world is a
plugin. A plugin crash leaves the kernel running; restarts back off 1s / 2s / 3s, and the 4th
crash within 5 minutes stops automatic restarting for human intervention.
**Seven stages after a message arrives** (`internal/agent/core/stages.go`):
### Context is not "truncate and summarize"
The usual approach drops old turns or asks the model to summarize them. Both share a flaw: what
gets lost is decided by position. Here each historical event is scored against the current input,
and low scorers sink into the document layer.
Scoring uses dense cosine similarity in the same vector space as media and documents, falling
back to TF-IDF when the model isn't loaded. The most recent N events are always kept, but N is
itself bounded by budget:
```go
// protected count cannot exceed what the budget can hold
budgetCap = ContextTokens / avgEventTokens
pCount = min(configured, budgetCap)
```
That bound came from a measurement: with a 50k window, one event averaged 5800 tokens (tool
results), while the budget was 40000, so "pin 10 events" didn't fit. Every prune hit the
protection floor, the budget was exhausted after two rounds, and the task terminated. Recall
dropped from 44% to 22%. Smaller windows now tighten automatically, and a 1M window can use the
full configured value.
Pruned events aren't discarded, they migrate: the event and the media blocks it holds move into
the document layer with block IDs unchanged, only the holding layer changes. Retrieval uses
`coldcontext_query` in two phases (Peek to confirm, then Remove) rather than delete-then-refill,
which would lose data permanently if the refill failed.
### Three layers migrate, they don't copy
```
L0 context relevance pruning, last N always protected
↓ low scorers sink (with media blocks)
L1 documents dense retrieval; access count ≤2 and stale means sink further
↓ distilled into triples
L2 graph entities, relations, scene index; recall by scene
```
A block has exactly one holder at any layer, with no reference counting to keep it alive. Media
are not attachments: images and audio are first-class graph nodes with their own vectors,
searchable in the same space as text.
### Memory is partitioned by scene, not by session
Typical agents keep one history per session, so switching topics loses context or crams
everything into one window. Here memory is partitioned by observable scene fingerprint: channel,
conversation partner, tools, topic, time of day, weighted 1.0/1.0/0.8/0.4/0.2.
A scene comes into being only on the second matching fingerprint (`minSceneEvidence = 2`).
Join threshold 0.5, recall 0.35 — recall is looser because failing to recall loses information
while an extra scene costs a few lines of context. Each recurrence adds to scene strength;
scenes that stop recurring decay by half-life.
Two paths run in parallel: **declared** (a plugin states at the injection point which scene this
is) and **emergent** (fingerprints cluster on their own). Declared scenes deliberately don't
absorb the full fingerprint, otherwise they'd score near 1.0 against every later turn and the
emergent path could never grow finer-grained scenes.
### Seven stages
`internal/agent/core/stages.go`
| Stage | When | Plugins can |
|---|---|---|
@ -67,14 +108,11 @@ crash within 5 minutes stops automatic restarts for human intervention.
| `before_toolcall` | before one tool runs | audit, deny, rewrite args |
| `after_toolcall` | after one tool runs | redact, sort results |
| `before_output` | final text ready | format adaptation |
| `after_output` | already sent | statistics |
| `after_output` | after sending | statistics |
`post_action → [before_toolcall → run → after_toolcall] → post_action` is the inner loop. It exits
when the model stops calling tools, all calls are denied, or the limit is reached.
**Three memory layers**: context (last 10 always protected) → documents (cold data sinks) → graph
database (distilled into triples). Media are first-class graph nodes, not attachments.
## Everything else
- **Channels**: web UI, CLI, QQ groups and DMs, A2A, ACP in your IDE