feat(memory): P1 长期记忆升级 —— 异步攒批 Consolidate + 软删 + importance/last_seen #1
@@ -58,6 +58,7 @@
|
||||
- [x] Harness 熔断降级中心(真三态状态机 Closed/Open/HalfOpen + 单测含 -race;熔断时回流提示并收尾流,不静默丢弃)
|
||||
- [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] 长期记忆 P2:读路径按 Score(Recency+Importance) 排序+衰减+截断top30(rank单测);桌面端记忆面板可看/改/删(软删);memory_list/delete 工具+端点;🟡 Relevance(Milvus)待P3
|
||||
|
||||
## 第 5 层 · MCP TOOLS
|
||||
|
||||
|
||||
@@ -411,3 +411,28 @@ export async function setMemory(
|
||||
if (!res.ok) throw new Error(data.error ?? `memory failed: ${res.status}`);
|
||||
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";
|
||||
|
||||
// 偏好记忆面板 —— 让用户显式登记/纠正模型对自己的记忆(→ PUT /api/v1/memory)。
|
||||
export function MemoryPanel({ identity }: { identity: Identity }) {
|
||||
export function MemoryPanel({ identity, onSaved }: { identity: Identity; onSaved?: () => void }) {
|
||||
const toast = useToast();
|
||||
const [key, setKey] = useState("回答偏好");
|
||||
const [value, setValue] = useState("简洁、中文、多给要点");
|
||||
@@ -16,6 +16,7 @@ export function MemoryPanel({ identity }: { identity: Identity }) {
|
||||
const m = await setMemory(identity, key.trim(), value.trim());
|
||||
setSaved((s) => [{ key: key.trim(), value: value.trim() }, ...s.filter((x) => x.key !== key.trim())]);
|
||||
toast.push("success", m);
|
||||
onSaved?.();
|
||||
} catch (e) {
|
||||
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 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 }) {
|
||||
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 (
|
||||
<div className="flex h-full">
|
||||
<div className="w-96 border-r border-line bg-ink-900">
|
||||
<MemoryPanel identity={identity} />
|
||||
<div className="flex h-full min-h-0">
|
||||
<div className="w-96 shrink-0 overflow-y-auto border-r border-line bg-ink-900">
|
||||
<MemoryPanel identity={identity} onSaved={load} />
|
||||
</div>
|
||||
<div className="flex-1 p-6 text-xs leading-relaxed text-slate-500">
|
||||
<div className="mb-1 text-sm font-semibold text-slate-300">模型记得我什么 / 会话历史</div>
|
||||
规划:长期画像逐条列出可改/删(含来源),会话列表 + 多轮历史浏览与清空,注入开关。
|
||||
<br />
|
||||
当前左侧可登记偏好(→ memory_upsert → sundynix_user_profile)。
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-5">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Brain className="h-4 w-4 text-brand-400" />
|
||||
<h2 className="text-sm font-semibold text-slate-200">模型记得我什么({items.length})</h2>
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -200,6 +200,39 @@ func (h *Handler) SetMemory(c *gin.Context) {
|
||||
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)。
|
||||
// 受保护路由有 RequireAuth 兜底,此处理论上不会返回 anonymous。
|
||||
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.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.POST("/kb/create", h.KbCreate) // 新建知识库
|
||||
p.POST("/kb/ingest", h.KbIngest) // 文本入库
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
sharedbus "github.com/sundynix/sundynix-shared/bus"
|
||||
"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)
|
||||
case "memory_delete":
|
||||
return g.memoryDelete(ctx, call)
|
||||
case "memory_list":
|
||||
return g.memoryList(ctx, call)
|
||||
case "history_get":
|
||||
return g.historyGet(ctx, call)
|
||||
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)}
|
||||
}
|
||||
|
||||
// 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 判定过时/矛盾时调用。
|
||||
func (g *Gateway) memoryDelete(ctx context.Context, call *contract.ToolCall) *contract.ToolResult {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -108,7 +109,17 @@ func migrateLegacyProfile(db *gorm.DB) {
|
||||
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) {
|
||||
if s.db == nil || userID == "" {
|
||||
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 {
|
||||
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
|
||||
for _, r := range rows {
|
||||
for _, r := range ranked {
|
||||
fmt.Fprintf(&b, "- %s:%s\n", r.Key, r.Value)
|
||||
}
|
||||
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-N(topN<=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;
|
||||
// 置 last_seen=now 作"印证")。importance<=0 时不覆盖旧值(NOOP 印证场景只 bump 时间)。
|
||||
func (s *Store) Upsert(ctx context.Context, userID, key, value string, importance float64) error {
|
||||
|
||||
Reference in New Issue
Block a user