From 3e201dd4af05d92d9291e25e594f9e6459a2ee1a Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Thu, 8 Oct 2026 12:35:28 +0800 Subject: [PATCH] =?UTF-8?q?perf(webui):=20pulse=20=E7=AB=AF=E7=82=B9?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E5=85=A8=E9=87=8F=E6=89=AB=E6=8F=8F=E5=9B=BE?= =?UTF-8?q?=E5=BA=93=EF=BC=9B=E4=BF=AE=E6=98=9F=E5=9B=BE=E6=AF=8F=E5=B8=A7?= =?UTF-8?q?=E5=85=A8=E9=87=8F=E5=BC=80=E9=94=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户反馈「非常卡」。实测定位到两处(都在轮询/渲染的每帧路径上): ## 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 --- internal/memory/block.go | 39 +++++++++++ internal/memory/blocks_changed_test.go | 65 +++++++++++++++++++ .../plugins/webui/handler_graph_pulse_test.go | 25 +++++++ internal/plugins/webui/handler_memory.go | 39 ++++------- internal/sdk/memory.go | 33 +++++++++- internal/sdk/memory_impl.go | 30 +++++++++ 6 files changed, 202 insertions(+), 29 deletions(-) create mode 100644 internal/memory/blocks_changed_test.go diff --git a/internal/memory/block.go b/internal/memory/block.go index a1388634..88bf0ba7 100644 --- a/internal/memory/block.go +++ b/internal/memory/block.go @@ -173,6 +173,45 @@ func (g *GraphDB) PutDocumentNode(id, summary string) error { } // MemoryBlocks 查询 Graph 层实际持有的一等记忆节点。 +// BlocksChangedSince 返回自 since 之后**创建或更新过**的块(供星图轻量轮询)。 +// +// ★ 为何需要专方法,而不是复用 GraphData() 再过滤(2026-10-08 实测): +// +// /memory/graph/pulse 本应是**轻量**端点,原实现却先调 GraphData() +// (全量 3190 块 + 2692 边)→ json.Marshal 约 3.3MB → 再反序列化 → 才按时间 +// 过滤。实测它与全量端点**同价**(145ms vs 135ms),而它每 10s 被轮询一次。 +// +// 这里直接用 SQL 在库侧过滤,响应体只含窗口内变动的块(几百字节~几 KB, +// 与全量差两个数量级)。 +// +// limit 上限保护:窗口内也可能有大量块(如一次批量迁移), +// 而星图轮询只需知道“有新东西”,不需要全部。0 表示用默认上限。 +func (g *GraphDB) BlocksChangedSince(since time.Time, limit int) ([]MemoryBlock, error) { + if limit <= 0 { + limit = 200 + } + g.mu.RLock() + defer g.mu.RUnlock() + rows, err := g.db.Query(`SELECT `+blockColumns+` + FROM memory_blocks + WHERE created_at >= ? OR updated_at >= ? + ORDER BY updated_at DESC LIMIT ?`, since, since, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var blocks []MemoryBlock + for rows.Next() { + block, err := scanBlockRow(rows) + if err != nil { + return nil, err + } + blocks = append(blocks, block) + } + return blocks, rows.Err() +} + func (g *GraphDB) MemoryBlocks() ([]MemoryBlock, error) { g.mu.RLock() defer g.mu.RUnlock() diff --git a/internal/memory/blocks_changed_test.go b/internal/memory/blocks_changed_test.go new file mode 100644 index 00000000..76a74217 --- /dev/null +++ b/internal/memory/blocks_changed_test.go @@ -0,0 +1,65 @@ +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)) + } +} diff --git a/internal/plugins/webui/handler_graph_pulse_test.go b/internal/plugins/webui/handler_graph_pulse_test.go index e62c1cb7..02d7af7c 100644 --- a/internal/plugins/webui/handler_graph_pulse_test.go +++ b/internal/plugins/webui/handler_graph_pulse_test.go @@ -9,6 +9,7 @@ import ( "time" "github.com/JianFeeeee/HomeAgent/internal/memory" + sdk "github.com/JianFeeeee/HomeAgent/internal/sdk" pubsdk "github.com/JianFeeeee/homeagentsdk/sdk" ) @@ -30,6 +31,30 @@ func (f *fakeGraphMemory) Purge(map[string]string, string) (int, error) { } func (f *fakeGraphMemory) GraphData() (map[string]interface{}, error) { return f.data, nil } +// BlocksChangedSince 是 pulse 端点现在的数据源(不再走 GraphData 全量 + 过滤)。 +// +// 桩要**忠实反映过滤语义**:只回窗口内的块。若这里偷懒返回全部, +// 就把「窗口过滤」这条判据架空了——测的是桩,不是产品。 +func (f *fakeGraphMemory) BlocksChangedSince(since time.Time, limit int) ([]sdk.MemoryBlockView, error) { + blocks, _ := f.data["memory_blocks"].([]memory.MemoryBlock) + var out []sdk.MemoryBlockView + for _, b := range blocks { + if b.UpdatedAt.Before(since) && b.CreatedAt.Before(since) { + continue + } + out = append(out, sdk.MemoryBlockView{ + ID: b.ID, Modality: string(b.Modality), Text: b.Text, + PayloadDigest: b.PayloadDigest, MIME: b.MIME, + Source: b.Source, Tool: b.Tool, Scene: b.Scene, + CreatedAt: b.CreatedAt, UpdatedAt: b.UpdatedAt, + }) + if limit > 0 && len(out) >= limit { + break + } + } + return out, nil +} + // graphFixture 造一份与生产实例同构的图谱快照: // 节点带 updated_at(pulse 端点按它过滤),memory_blocks 带稠密 vector。 func graphFixture() map[string]interface{} { diff --git a/internal/plugins/webui/handler_memory.go b/internal/plugins/webui/handler_memory.go index 7e2a4b43..b2feb955 100644 --- a/internal/plugins/webui/handler_memory.go +++ b/internal/plugins/webui/handler_memory.go @@ -229,42 +229,23 @@ func (h *Handler) handleMemoryGraphPulse(w http.ResponseWriter, r *http.Request) since = time.Now().Add(-time.Duration(sec) * time.Second) } } - data, err := h.memory.GraphData() + // ★ 直接查「窗口内变动的块」,不再走全量 GraphData()(2026-10-08 修性能)。 + // + // 原实现先 GraphData()(全量 3190 块 + 2692 边)→ Marshal 约 3.3MB → + // 反序列化 → 才按时间过滤。实测与全量端点**同价**(145ms vs 135ms), + // 而本端点每 10s 被星图轮询一次 —— 「轻量端点」名不副实。 + // 现在在库侧过滤,响应体只含窗口内的块(差两个数量级)。 + blocks, err := h.memory.BlocksChangedSince(since, 200) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } - out := graphDataForVisual(data) - // 过滤出窗口内变动过的节点。 - // - // ★★★ 必须按 **memory_blocks** 过滤(2026-10-05 修) - // - // 原实现取 `out["nodes"]`,而 GraphData 的 nodes 是从**旧表 entities** 读的 - // (memory/graph.go:1445 `SELECT ... FROM entities ORDER BY mention_count DESC`)—— - // 旧表在停双写后不再增长,生产里最后更新停在 2026-10-03。 - // 而 pulse 的窗口默认 900s(15 分钟)⇒ **1294 个节点永远落在窗口之外** - // ⇒ 恒返回 {"nodes":[]}。 - // - // ★ 症状形态与本仓那批「静默失效」完全一致:200 + success:true, - // 无任何报错,前端只是「星图不跟着 agent 动」。 - // - // 为什么该按块过滤:星图渲染用的就是 memory_blocks - // (graphDataForVisual 返回的 memory_blocks 是节点来源), - // 且块表是活的(生产最新写入 = 当天)。 - // - // 用块做过滤后仍保持**轻量**响应:不返回整个图谱,只回窗口内变动的块。 - rawBlocks, _ := json.Marshal(out["memory_blocks"]) - var blocks []graphBlockView - _ = json.Unmarshal(rawBlocks, &blocks) - pulse := make([]map[string]interface{}, 0, 8) + pulse := make([]map[string]interface{}, 0, len(blocks)) for _, b := range blocks { - if b.UpdatedAt.Before(since) && b.CreatedAt.Before(since) { - continue - } pulse = append(pulse, map[string]interface{}{ "id": b.ID, "name": b.Text, - "modality": b.Modality, + "modality": string(b.Modality), "payload_digest": b.PayloadDigest, "mime": b.MIME, "tool": b.Tool, @@ -278,6 +259,8 @@ func (h *Handler) handleMemoryGraphPulse(w http.ResponseWriter, r *http.Request) "success": true, "data": map[string]interface{}{"nodes": pulse}, }) + return + } // defaultGraphPulseWindow 是 /memory/graph/pulse 不带 since 时的回看窗口。 diff --git a/internal/sdk/memory.go b/internal/sdk/memory.go index e8b47f27..61678119 100644 --- a/internal/sdk/memory.go +++ b/internal/sdk/memory.go @@ -1,12 +1,43 @@ package sdk -import pubsdk "github.com/JianFeeeee/homeagentsdk/sdk" +import ( + "time" + + pubsdk "github.com/JianFeeeee/homeagentsdk/sdk" +) + +// MemoryBlockView 是块的最小只读视图(供星图轮询端点)。 +// +// ★ 不直接用 memory.MemoryBlock:那是内核内部类型,而本包是**插件边界**。 +// SDK 接口只暴露端点真正要用的字段(原来是先 GraphData 全量再转成 +// graphBlockView 做同样的事,只是绕了一圈)。 +type MemoryBlockView struct { + ID string `json:"id"` + Modality string `json:"modality"` + Text string `json:"text,omitempty"` + PayloadDigest string `json:"payload_digest,omitempty"` + MIME string `json:"mime,omitempty"` + Source string `json:"source,omitempty"` + Tool string `json:"tool,omitempty"` + Scene string `json:"scene,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} // MemoryAPI 是内置插件使用的全量图记忆接口。 type MemoryAPI interface { pubsdk.MemoryAPI // GraphData 返回整个知识图谱的完整快照。 GraphData() (map[string]interface{}, error) + // BlocksChangedSince 返回自 since 之后创建或更新过的块(供星图轻量轮询)。 + // + // ★ 为何单开一个方法而不复用 GraphData:后者是全量快照(生产实测 + // 3190 块 + 2692 边,JSON 约 3.3MB、135ms),而轮询端点只想知「有新东西」。 + // 原先 pulse 端点是先取全量再过滤 —— 实测与全量端点同价(145ms), + // 「轻量端点」名不副实。库侧 SQL 过滤后响应体差两个数量级。 + // + // 属内置接口(非 pubsdk),与 GraphData 同理,不影响外部插件。 + BlocksChangedSince(since time.Time, limit int) ([]MemoryBlockView, error) } type Entity = pubsdk.Entity diff --git a/internal/sdk/memory_impl.go b/internal/sdk/memory_impl.go index cf91c691..5aa5a329 100644 --- a/internal/sdk/memory_impl.go +++ b/internal/sdk/memory_impl.go @@ -264,6 +264,36 @@ func (m *graphMemory) GraphData() (map[string]interface{}, error) { return m.db.GraphData() } +// BlocksChangedSince 实现 MemoryAPI(星图轻量轮询)。 +// +// 直接走库侧 SQL 过滤,不取全量快照后再筛——见接口注释里的实测对比。 +func (m *graphMemory) BlocksChangedSince(since time.Time, limit int) ([]MemoryBlockView, error) { + if m.db == nil { + return nil, nil + } + blocks, err := m.db.BlocksChangedSince(since, limit) + if err != nil { + return nil, err + } + // 映射到边界面视图:只带端点要用的字段,不把内部结构泄出去。 + out := make([]MemoryBlockView, 0, len(blocks)) + for _, b := range blocks { + out = append(out, MemoryBlockView{ + ID: b.ID, + Modality: string(b.Modality), + Text: b.Text, + PayloadDigest: b.PayloadDigest, + MIME: b.MIME, + Source: b.Source, + Tool: b.Tool, + Scene: b.Scene, + CreatedAt: b.CreatedAt, + UpdatedAt: b.UpdatedAt, + }) + } + return out, nil +} + // ---------- 文本记忆 ---------- type textMemoryImpl struct {