Files
HomeAgent/internal/memory/blocks_changed_test.go
JianFeeeee 3e201dd4af perf(webui): pulse 端点不再全量扫描图库;修星图每帧全量开销
用户反馈「非常卡」。实测定位到两处(都在轮询/渲染的每帧路径上):

## 1. 后端:/memory/graph/pulse 名为轻量、实为全量

该端点自称轻量(注释写着「只回窗口内变动的实体,与完整图谱差两个数量级」),
但实现是**先调 GraphData() 取全量快照 → json.Marshal 约 3.3MB →
反序列化 → 才按时间过滤**。生产实测与全量端点**同价**:

    /memory/graph          135-147ms
    /memory/graph/pulse    144-157ms   ← 每 10s 被轮询一次

即每 10 秒白付一次全量序列化。生产库 3190 块 / 2692 边。

改法:新增 `GraphDB.BlocksChangedSince(since, limit)` —— 库侧 SQL 过滤
(`WHERE created_at >= ? OR updated_at >= ?` + LIMIT),响应体只含窗口内
的块。limit 上限保护:窗口内可能有很多块(批量迁移),而星图只需知道
「有新东西」。

接口走内置 `sdk.MemoryAPI`(非 pubsdk),与既有 GraphData 同理,
**不影响外部插件**。

## 2. 前端:星图渲染循环每帧全量遍历 + 永不停止

三处,都是我上一批星图改动引入的:

- **RAF 循环从不停**:切到聊天/终端页签、甚至页面不可见时,仍以 60fps
  跑着每帧 3190 节点的遍历。改用显式开关 + `starmapWake()` 重新点火
  (判据问「星图容器是否可见」,不用 document.hidden —— 星图可能在
  未激活的页签里,那时页面可见但星图看不到)。
- **starmapTickMorph 每帧扫全部节点**:3190 × 60fps = 每秒 19 万次
  userData 读取与分支,而绝大多数帧里「正在形变」的节点是 0 个。
  改用 `starmapMorphSet` 活跃集合,空集时 O(1) 返回。
- **标签层每帧重建 plan + measureText**:加 100ms 节流(文字层 10fps
  肉眼无差别,3D 图形仍 60fps)与 measureText 缓存(同步文字布局很贵,
  同文字同字体宽度不变)+ 缓存上限保护。

## 验证

- `blocks_changed_test.go`(新增):窗口语义、空窗口恒 0(否则星图会每次
  轮询重放生长动画)、limit 真的封顶
- 既有 pulse 判据全过(`OnlyRecent` / `SinceWidens` / `NoVector` /
  `IgnoresFrozenLegacyNodes`)—— 改写没破坏语义。
  测试替身 `fakeGraphMemory` 同步实现新方法,且**忠实反映过滤语义**
  (偷懒返回全部会把「窗口过滤」判据架空)
- `go test ./... -count=1` → 48 包 ok / 0 FAIL
2026-10-08 12:35:28 +08:00

66 lines
2.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package memory
import (
"fmt"
"path/filepath"
"testing"
"time"
)
// BlocksChangedSince 是星图轻量轮询端点(/memory/graph/pulse)的数据源。
//
// ★ 为何需要它而不是复用 GraphData:后者是全量快照(生产实测 3190 块 +
//
// 2692 边,JSON 约 3.3MB、135ms)。原 pulse 实现是「先取全量再按时间过滤」,
// 实测与全量端点同价(145ms),而它每 10s 被轮询一次 —— 「轻量端点」名不副实。
// 本方法在库侧用 SQL 过滤,响应体只含窗口内的块。
//
// 判据:窗口语义、limit 上限、空窗口三件事。
func TestBlocksChangedSince(t *testing.T) {
g, err := NewGraphDB(filepath.Join(t.TempDir(), "g.db"))
if err != nil {
t.Fatal(err)
}
defer g.Close()
for i := 0; i < 50; i++ {
if _, _, err := g.Commit([]Triple{{
Subject: fmt.Sprintf("实体_%d", i),
Relation: "关联",
Object: fmt.Sprintf("目标_%d", i),
SentenceText: fmt.Sprintf("句子 %d", i),
}}, "s", i); err != nil {
t.Fatal(err)
}
}
// ① 宽窗口:应能取到(每个三元组产出多块:两端实体块 + 原句块)。
wide, err := g.BlocksChangedSince(time.Now().Add(-time.Hour), 0)
if err != nil {
t.Fatal(err)
}
if len(wide) < 100 {
t.Fatalf("宽窗口应取到 100+ 块(50 三元组 × 多块),实际 %d", len(wide))
}
// ② 空窗口:future 应恒为 0(这是「无新记忆时不误报」的前提,
// 否则星图会每次轮询都重放生长动画)。
future, err := g.BlocksChangedSince(time.Now().Add(time.Hour), 0)
if err != nil {
t.Fatal(err)
}
if len(future) != 0 {
t.Fatalf("未来窗口应返回 0 块,实际 %d", len(future))
}
// ③ limit:窗口内块可能很多(如批量迁移),星图只需知道「有新东西」,
// 必须真的封顶,否则一次迁移就能让响应体回到全量量级。
limited, err := g.BlocksChangedSince(time.Now().Add(-time.Hour), 7)
if err != nil {
t.Fatal(err)
}
if len(limited) != 7 {
t.Fatalf("limit=7 应返回 7 块,实际 %d", len(limited))
}
}