feat(memory): P2 读路径打分 + 桌面端记忆面板(看/改/删)

读路径(Generative Agents 公式的 Recency+Importance 两项,Relevance 待 P3):
- memory.Get 改为 rankProfiles:Score=0.4·Recency+0.6·Importance,按分降序、截断 top-30。
  Recency=0.98^天 指数衰减;未评分行用兜底 importance=5(不被不公平遗忘)。纯函数 + 单测。
- 新增 Store.List(结构化、不截断)+ memory_list 工具。

桌面端记忆面板:
- gateway GET /memory(列表) + DELETE /memory?key=(软删),受保护组。
- api listMemory/deleteMemory;MemoryView 右侧从占位 → 真列表:按分排序展示
  key/value/重要度/最近时间,可内联编辑(PUT)与删除(软删);左侧登记后自动刷新。

实测:PUT 两条 → GET 返回带 importance/last_seen 的有序列表(较新者靠前)→ DELETE 一条
(软删)→ 再 GET 已消失。rank/recency/兜底 importance 单测过;前端 tsc+构建通过。

至此记忆:召回(打分) + 历史 + 自动对账(P1) + 用户可管控(面板) 闭环。Relevance(Milvus) 留 P3。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-06-22 14:48:11 +08:00
parent 1674252d81
commit 0edfc948ba
9 changed files with 316 additions and 14 deletions
+1
View File
@@ -58,6 +58,7 @@
- [x] Harness 熔断降级中心(真三态状态机 Closed/Open/HalfOpen + 单测含 -race;熔断时回流提示并收尾流,不静默丢弃) - [x] Harness 熔断降级中心(真三态状态机 Closed/Open/HalfOpen + 单测含 -race;熔断时回流提示并收尾流,不静默丢弃)
- [x] Harness LLM 自动化评测(规则检查 + LLM-as-judge,异步 off 热路径评分记录 + 单测) - [x] Harness LLM 自动化评测(规则检查 + LLM-as-judge,异步 off 热路径评分记录 + 单测)
- [x] 长期记忆 P1:异步攒批 Consolidate(每3轮1次 LLM 对账→ADD/UPDATE/DELETE/NOOP+ 软删 + Profile 加 importance/last_seen(为 Generative Agents 打分铺路);纯逻辑+store集成单测 - [x] 长期记忆 P1:异步攒批 Consolidate(每3轮1次 LLM 对账→ADD/UPDATE/DELETE/NOOP+ 软删 + Profile 加 importance/last_seen(为 Generative Agents 打分铺路);纯逻辑+store集成单测
- [x] 长期记忆 P2:读路径按 Score(Recency+Importance) 排序+衰减+截断top30(rank单测);桌面端记忆面板可看/改/删(软删)memory_list/delete 工具+端点;🟡 Relevance(Milvus)待P3
## 第 5 层 · MCP TOOLS ## 第 5 层 · MCP TOOLS
+25
View File
@@ -411,3 +411,28 @@ export async function setMemory(
if (!res.ok) throw new Error(data.error ?? `memory failed: ${res.status}`); if (!res.ok) throw new Error(data.error ?? `memory failed: ${res.status}`);
return data.message ?? "ok"; return data.message ?? "ok";
} }
// 一条长期偏好(含读路径打分维度)。
export interface MemoryItem {
key: string;
value: string;
importance: number;
last_seen: string;
}
// listMemory: GET /api/v1/memory —— 列出当前用户偏好(已按 Score 降序)。
export async function listMemory(id: Identity): Promise<MemoryItem[]> {
const res = guard401(await fetch(`${GATEWAY}/api/v1/memory`, { headers: idHeaders(id) }));
const data = (await res.json()) as { memories?: MemoryItem[]; error?: string };
if (!res.ok) throw new Error(data.error ?? `list memory failed: ${res.status}`);
return data.memories ?? [];
}
// deleteMemory: DELETE /api/v1/memory?key= —— 软删一条偏好。
export async function deleteMemory(id: Identity, key: string): Promise<void> {
const res = guard401(await fetch(`${GATEWAY}/api/v1/memory?key=${encodeURIComponent(key)}`, { method: "DELETE", headers: idHeaders(id) }));
if (!res.ok) {
const d = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(d.error ?? `delete memory failed: ${res.status}`);
}
}
@@ -4,7 +4,7 @@ import { setMemory, type Identity } from "../lib/api";
import { Button, Input, Textarea, Field, useToast } from "../ui"; import { Button, Input, Textarea, Field, useToast } from "../ui";
// 偏好记忆面板 —— 让用户显式登记/纠正模型对自己的记忆(→ PUT /api/v1/memory)。 // 偏好记忆面板 —— 让用户显式登记/纠正模型对自己的记忆(→ PUT /api/v1/memory)。
export function MemoryPanel({ identity }: { identity: Identity }) { export function MemoryPanel({ identity, onSaved }: { identity: Identity; onSaved?: () => void }) {
const toast = useToast(); const toast = useToast();
const [key, setKey] = useState("回答偏好"); const [key, setKey] = useState("回答偏好");
const [value, setValue] = useState("简洁、中文、多给要点"); const [value, setValue] = useState("简洁、中文、多给要点");
@@ -16,6 +16,7 @@ export function MemoryPanel({ identity }: { identity: Identity }) {
const m = await setMemory(identity, key.trim(), value.trim()); const m = await setMemory(identity, key.trim(), value.trim());
setSaved((s) => [{ key: key.trim(), value: value.trim() }, ...s.filter((x) => x.key !== key.trim())]); setSaved((s) => [{ key: key.trim(), value: value.trim() }, ...s.filter((x) => x.key !== key.trim())]);
toast.push("success", m); toast.push("success", m);
onSaved?.();
} catch (e) { } catch (e) {
toast.push("error", (e as Error).message); toast.push("error", (e as Error).message);
} }
@@ -1,18 +1,123 @@
import { useCallback, useEffect, useState } from "react";
import { Trash2, RefreshCw, Save, X, Pencil, Brain } from "lucide-react";
import { MemoryPanel } from "../panels/MemoryPanel"; import { MemoryPanel } from "../panels/MemoryPanel";
import type { Identity } from "../lib/api"; import { listMemory, setMemory, deleteMemory, type Identity, type MemoryItem } from "../lib/api";
import { Textarea, EmptyState, useToast, cn } from "../ui";
// 记忆模块:当前以偏好登记为主;规划做厚为 画像看/改/删 + 会话历史浏览 // 记忆模块:左侧登记偏好;右侧"模型记得我什么"——按 Score 排序列出,可改/删(软删)
export function MemoryView({ identity }: { identity: Identity }) { export function MemoryView({ identity }: { identity: Identity }) {
const toast = useToast();
const [items, setItems] = useState<MemoryItem[]>([]);
const [loading, setLoading] = useState(false);
const [editKey, setEditKey] = useState<string | null>(null);
const [draft, setDraft] = useState("");
const load = useCallback(async () => {
setLoading(true);
try {
setItems(await listMemory(identity));
} catch {
setItems([]);
} finally {
setLoading(false);
}
}, [identity]);
useEffect(() => {
void load();
}, [load]);
const saveEdit = async (key: string) => {
if (!draft.trim()) return;
try {
await setMemory(identity, key, draft.trim());
setEditKey(null);
toast.push("success", "已更新");
void load();
} catch (e) {
toast.push("error", (e as Error).message);
}
};
const remove = async (key: string) => {
try {
await deleteMemory(identity, key);
toast.push("success", `已删除「${key}`);
void load();
} catch (e) {
toast.push("error", (e as Error).message);
}
};
const fmtDate = (s: string) => {
const d = new Date(s);
return isNaN(d.getTime()) || d.getFullYear() < 2000 ? "—" : d.toLocaleDateString();
};
return ( return (
<div className="flex h-full"> <div className="flex h-full min-h-0">
<div className="w-96 border-r border-line bg-ink-900"> <div className="w-96 shrink-0 overflow-y-auto border-r border-line bg-ink-900">
<MemoryPanel identity={identity} /> <MemoryPanel identity={identity} onSaved={load} />
</div> </div>
<div className="flex-1 p-6 text-xs leading-relaxed text-slate-500"> <div className="min-h-0 flex-1 overflow-y-auto p-5">
<div className="mb-1 text-sm font-semibold text-slate-300"> / </div> <div className="mb-3 flex items-center gap-2">
/ + <Brain className="h-4 w-4 text-brand-400" />
<br /> <h2 className="text-sm font-semibold text-slate-200">{items.length}</h2>
memory_upsert sundynix_user_profile <span className="text-[11px] text-slate-600"> × </span>
<button onClick={load} className="ml-auto text-slate-600 hover:text-slate-300" title="刷新">
<RefreshCw className={cn("h-3.5 w-3.5", loading && "animate-spin")} />
</button>
</div>
{items.length === 0 ? (
<EmptyState icon={Brain} title="还没有长期记忆" desc="左侧登记偏好,或多聊几轮后系统会自动提炼(每 3 轮对账一次)。" />
) : (
<ul className="space-y-2">
{items.map((m) => (
<li key={m.key} className="rounded-lg border border-line bg-ink-850 p-3">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-slate-200">{m.key}</span>
<span className="rounded bg-brand/15 px-1.5 py-0.5 text-[10px] text-brand-400" title="重要度">
{m.importance || "—"}
</span>
<span className="text-[10px] text-slate-600"> {fmtDate(m.last_seen)}</span>
<div className="ml-auto flex items-center gap-1">
{editKey === m.key ? (
<>
<button onClick={() => saveEdit(m.key)} className="text-success hover:opacity-80" title="保存">
<Save className="h-3.5 w-3.5" />
</button>
<button onClick={() => setEditKey(null)} className="text-slate-500 hover:text-slate-300" title="取消">
<X className="h-3.5 w-3.5" />
</button>
</>
) : (
<>
<button
onClick={() => {
setEditKey(m.key);
setDraft(m.value);
}}
className="text-slate-500 hover:text-brand-400"
title="编辑"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button onClick={() => remove(m.key)} className="text-slate-600 hover:text-danger" title="删除(软删)">
<Trash2 className="h-3.5 w-3.5" />
</button>
</>
)}
</div>
</div>
{editKey === m.key ? (
<Textarea className="mt-2 h-16 w-full resize-none text-xs" value={draft} onChange={(e) => setDraft(e.target.value)} autoFocus />
) : (
<div className="mt-1 text-xs text-slate-400">{m.value}</div>
)}
</li>
))}
</ul>
)}
</div> </div>
</div> </div>
); );
@@ -200,6 +200,39 @@ func (h *Handler) SetMemory(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok", "message": res.Content}) c.JSON(http.StatusOK, gin.H{"status": "ok", "message": res.Content})
} }
// ListMemory: GET /api/v1/memory —— 列出当前用户的全部偏好(结构化,供记忆面板)。
func (h *Handler) ListMemory(c *gin.Context) {
res, err := h.bus.CallTool(c.Request.Context(), contract.ToolSubjectGo("memory_list"),
&contract.ToolCall{Tool: "memory_list", Args: map[string]any{"user_id": userID(c)}})
if err != nil || res == nil || !res.OK {
msg := "记忆列举失败"
if res != nil && res.Error != "" {
msg = res.Error
}
c.JSON(http.StatusBadGateway, gin.H{"error": msg})
return
}
var items []map[string]any
_ = json.Unmarshal([]byte(res.Content), &items)
c.JSON(http.StatusOK, gin.H{"memories": items})
}
// DeleteMemory: DELETE /api/v1/memory?key= —— 软删当前用户的一条偏好。
func (h *Handler) DeleteMemory(c *gin.Context) {
key := c.Query("key")
if key == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "key required"})
return
}
res, err := h.bus.CallTool(c.Request.Context(), contract.ToolSubjectGo("memory_delete"),
&contract.ToolCall{Tool: "memory_delete", Args: map[string]any{"user_id": userID(c), "key": key}})
if err != nil || res == nil || !res.OK {
c.JSON(http.StatusBadGateway, gin.H{"error": "删除失败"})
return
}
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
// userID 取当前用户标识 —— 仅信任 JWT 鉴权中间件注入的已验证 uid(不再认 header)。 // userID 取当前用户标识 —— 仅信任 JWT 鉴权中间件注入的已验证 uid(不再认 header)。
// 受保护路由有 RequireAuth 兜底,此处理论上不会返回 anonymous。 // 受保护路由有 RequireAuth 兜底,此处理论上不会返回 anonymous。
func userID(c *gin.Context) string { func userID(c *gin.Context) string {
@@ -50,6 +50,8 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob.
{ {
p.POST("/tasks", h.SubmitTask) // 解析 DSL 并 Publish 到 NATS(带已验证 uid p.POST("/tasks", h.SubmitTask) // 解析 DSL 并 Publish 到 NATS(带已验证 uid
p.PUT("/memory", h.SetMemory) // 偏好记忆登记(→ mcp-go memory_upsert p.PUT("/memory", h.SetMemory) // 偏好记忆登记(→ mcp-go memory_upsert
p.GET("/memory", h.ListMemory) // 列出当前用户偏好(记忆面板)
p.DELETE("/memory", h.DeleteMemory) // 软删一条偏好(?key=
p.GET("/kb/list", h.KbList) // 当前用户的知识库列表(owner 隔离) p.GET("/kb/list", h.KbList) // 当前用户的知识库列表(owner 隔离)
p.POST("/kb/create", h.KbCreate) // 新建知识库 p.POST("/kb/create", h.KbCreate) // 新建知识库
p.POST("/kb/ingest", h.KbIngest) // 文本入库 p.POST("/kb/ingest", h.KbIngest) // 文本入库
+24
View File
@@ -9,6 +9,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"time"
sharedbus "github.com/sundynix/sundynix-shared/bus" sharedbus "github.com/sundynix/sundynix-shared/bus"
"github.com/sundynix/sundynix-shared/contract" "github.com/sundynix/sundynix-shared/contract"
@@ -75,6 +76,8 @@ func (g *Gateway) dispatch(ctx context.Context, call *contract.ToolCall) *contra
return g.memoryUpsert(ctx, call) return g.memoryUpsert(ctx, call)
case "memory_delete": case "memory_delete":
return g.memoryDelete(ctx, call) return g.memoryDelete(ctx, call)
case "memory_list":
return g.memoryList(ctx, call)
case "history_get": case "history_get":
return g.historyGet(ctx, call) return g.historyGet(ctx, call)
case "history_append": case "history_append":
@@ -136,6 +139,27 @@ func (g *Gateway) memoryUpsert(ctx context.Context, call *contract.ToolCall) *co
return &contract.ToolResult{OK: true, Content: fmt.Sprintf("已记住 %s 的「%s」", uid, key)} return &contract.ToolResult{OK: true, Content: fmt.Sprintf("已记住 %s 的「%s」", uid, key)}
} }
// memoryList 返回某用户全部 active 偏好(结构化 JSON,供管理面板查看/编辑)。
func (g *Gateway) memoryList(ctx context.Context, call *contract.ToolCall) *contract.ToolResult {
uid, _ := call.Args["user_id"].(string)
rows, err := g.memory.List(ctx, uid)
if err != nil {
return &contract.ToolResult{OK: false, Error: "memory_list: " + err.Error()}
}
type item struct {
Key string `json:"key"`
Value string `json:"value"`
Importance float64 `json:"importance"`
LastSeen string `json:"last_seen"`
}
out := make([]item, 0, len(rows))
for _, r := range rows {
out = append(out, item{Key: r.Key, Value: r.Value, Importance: r.Importance, LastSeen: r.LastSeenAt.Format(time.RFC3339)})
}
data, _ := json.Marshal(out)
return &contract.ToolResult{OK: true, Content: string(data)}
}
// memoryDelete 软删一条画像偏好(user_id + key)—— consolidate 判定过时/矛盾时调用。 // memoryDelete 软删一条画像偏好(user_id + key)—— consolidate 判定过时/矛盾时调用。
func (g *Gateway) memoryDelete(ctx context.Context, call *contract.ToolCall) *contract.ToolResult { func (g *Gateway) memoryDelete(ctx context.Context, call *contract.ToolCall) *contract.ToolResult {
uid, _ := call.Args["user_id"].(string) uid, _ := call.Args["user_id"].(string)
@@ -0,0 +1,50 @@
package memory
import (
"testing"
"time"
)
func TestRankProfiles(t *testing.T) {
now := time.Date(2026, 6, 22, 12, 0, 0, 0, time.UTC)
rows := []Profile{
{Key: "高重要近期", Value: "a", Importance: 9, LastSeenAt: now.AddDate(0, 0, -1)},
{Key: "低重要久远", Value: "b", Importance: 2, LastSeenAt: now.AddDate(0, 0, -60)},
{Key: "中等", Value: "c", Importance: 5, LastSeenAt: now.AddDate(0, 0, -10)},
}
ranked := rankProfiles(rows, now, 0)
if ranked[0].Key != "高重要近期" || ranked[2].Key != "低重要久远" {
t.Errorf("应按 Score 降序:高重要近期 > 中等 > 低重要久远,得 %s/%s/%s", ranked[0].Key, ranked[1].Key, ranked[2].Key)
}
// 截断 top-N
if got := rankProfiles(rows, now, 2); len(got) != 2 || got[0].Key != "高重要近期" {
t.Errorf("top-2 截断错: %d 条 首=%s", len(got), got[0].Key)
}
// 原切片不被改动(rankProfiles 应 copy
if rows[0].Key != "高重要近期" {
t.Error("rankProfiles 不应修改入参顺序")
}
}
func TestRecencyDecay(t *testing.T) {
now := time.Date(2026, 6, 22, 12, 0, 0, 0, time.UTC)
if recencyScore(now, time.Time{}) != 1.0 {
t.Error("无 last_seen 应视为新鲜=1")
}
fresh := recencyScore(now, now.AddDate(0, 0, -1))
old := recencyScore(now, now.AddDate(0, 0, -30))
if !(fresh > old && old > 0) {
t.Errorf("越久越低且 >0: fresh=%v old=%v", fresh, old)
}
}
func TestProfileScore_DefaultImportance(t *testing.T) {
now := time.Now()
// importance=0(旧/未评分)应按兜底 5 计,而不是 0(否则被不公平遗忘)。
p := Profile{Importance: 0, LastSeenAt: now}
got := profileScore(p, now)
want := wRecency*1.0 + wImportance*(defaultImportance/10)
if got != want {
t.Errorf("未评分行应用兜底 importance: got %v want %v", got, want)
}
}
+64 -3
View File
@@ -6,6 +6,7 @@ import (
"context" "context"
"fmt" "fmt"
"log" "log"
"math"
"sort" "sort"
"strings" "strings"
"time" "time"
@@ -108,7 +109,17 @@ func migrateLegacyProfile(db *gorm.DB) {
log.Printf("[memory] 已回灌 %d 条偏好(新雪花 id)", len(saved)) log.Printf("[memory] 已回灌 %d 条偏好(新雪花 id)", len(saved))
} }
// Get 返回某用户的画像,渲染为可直接注入 prompt 的多行文本(按 key 排序,稳定输出)。 // 读路径打分参数(Generative Agents 公式的 Recency + Importance 两项;Relevance 待接 Milvus)。
const (
memTopN = 30 // 注入上限(截断,控 context)
wRecency = 0.4 // 最近性权重
wImportance = 0.6 // 重要度权重
recencyDecayPerDay = 0.98 // 每天衰减因子(指数)
defaultImportance = 5.0 // 旧/未评分行的兜底重要度(避免被不公平遗忘)
)
// Get 返回某用户画像,渲染为可注入 prompt 的多行文本。
// 按 Score = wRecency·Recency + wImportance·Importance 降序,截断 top-N(控 context + 自然遗忘)。
func (s *Store) Get(ctx context.Context, userID string) (string, error) { func (s *Store) Get(ctx context.Context, userID string) (string, error) {
if s.db == nil || userID == "" { if s.db == nil || userID == "" {
return "", nil return "", nil
@@ -117,14 +128,64 @@ func (s *Store) Get(ctx context.Context, userID string) (string, error) {
if err := s.db.WithContext(ctx).Where("user_id = ?", userID).Find(&rows).Error; err != nil { if err := s.db.WithContext(ctx).Where("user_id = ?", userID).Find(&rows).Error; err != nil {
return "", err return "", err
} }
sort.Slice(rows, func(i, j int) bool { return rows[i].Key < rows[j].Key }) ranked := rankProfiles(rows, time.Now(), memTopN)
var b strings.Builder var b strings.Builder
for _, r := range rows { for _, r := range ranked {
fmt.Fprintf(&b, "- %s%s\n", r.Key, r.Value) fmt.Fprintf(&b, "- %s%s\n", r.Key, r.Value)
} }
return strings.TrimRight(b.String(), "\n"), nil return strings.TrimRight(b.String(), "\n"), nil
} }
// List 返回某用户全部 active 偏好(结构化,供管理面板查看/编辑),按 Score 降序、不截断。
func (s *Store) List(ctx context.Context, userID string) ([]Profile, error) {
if s.db == nil || userID == "" {
return nil, nil
}
var rows []Profile
if err := s.db.WithContext(ctx).Where("user_id = ?", userID).Find(&rows).Error; err != nil {
return nil, err
}
return rankProfiles(rows, time.Now(), 0), nil
}
// rankProfiles 纯函数:按 Score 降序排序(同分 key 升序稳定),截断 top-NtopN<=0 不截断)。
func rankProfiles(rows []Profile, now time.Time, topN int) []Profile {
out := make([]Profile, len(rows))
copy(out, rows)
sort.SliceStable(out, func(i, j int) bool {
si, sj := profileScore(out[i], now), profileScore(out[j], now)
if si != sj {
return si > sj
}
return out[i].Key < out[j].Key
})
if topN > 0 && len(out) > topN {
out = out[:topN]
}
return out
}
// profileScore 计算一条偏好的 Recency+Importance 综合分(各归一到 [0,1])。
func profileScore(p Profile, now time.Time) float64 {
imp := p.Importance
if imp <= 0 {
imp = defaultImportance
}
return wRecency*recencyScore(now, p.LastSeenAt) + wImportance*(imp/10)
}
// recencyScore 指数衰减的最近性分:last_seen 越久越低;未记时间视为新鲜(1)。
func recencyScore(now, last time.Time) float64 {
if last.IsZero() {
return 1.0
}
days := now.Sub(last).Hours() / 24
if days < 0 {
days = 0
}
return math.Pow(recencyDecayPerDay, days)
}
// Upsert 写入/更新一条画像偏好((user_id,key) 冲突即覆盖 value/importance,保留原 id // Upsert 写入/更新一条画像偏好((user_id,key) 冲突即覆盖 value/importance,保留原 id
// 置 last_seen=now 作"印证")。importance<=0 时不覆盖旧值(NOOP 印证场景只 bump 时间)。 // 置 last_seen=now 作"印证")。importance<=0 时不覆盖旧值(NOOP 印证场景只 bump 时间)。
func (s *Store) Upsert(ctx context.Context, userID, key, value string, importance float64) error { func (s *Store) Upsert(ctx context.Context, userID, key, value string, importance float64) error {