這一篇把 藍圖 轉成可以直接照抄的定義。先給語言中立的介面契約(可以照著在任何語言實作),再給一份 TypeScript 的具體版本與對應的 SQL schema。設計目標只有一個:讓底層框架成為 adapter,換掉它不需要改上層程式碼。

這是設計草案,不是產品程式碼。 它刻意省略了錯誤處理、重試、批次最佳化與觀測埋點,那些應該按專案的既有慣例補上。凡是我的設計主張而非取自某個框架的部分,都會標明。


一、核心資料型別(語言中立)

五個型別。這是最小完整集合——少任何一個都會導致某類記憶無處可放。

MemoryUnit(記憶單元)——所有可召回記憶的基本單位。

MemoryUnit:
  id            : 唯一識別碼
  kind          : "semantic" | "episodic" | "procedural"
  scope         : Scope                  // 主體歸屬,見下
  content       : 文字                    // 注入 context 的內容
  summary       : 文字(可選)             // 用於粗篩,通常較短
  content_hash  : 文字                    // 內容雜湊,去重用
  source        : SourceRef               // provenance,指回原始事件
  time          : TemporalMeta            // 四個時間戳,見下
  confidence    : 0.0–1.0                 // 來源可信度
  labels        : 文字列表                 // 自由標記,用於過濾
  embedding     : 向量(可選,衍生物)

Scope(範圍)——決定誰能讀、誰能寫、刪除時刪哪些。

Scope:
  subject_type  : "user" | "agent" | "shared" | "session"
  subject_id    : 文字                    // user_id / agent_id / session_id
  project_id    : 文字(可選)             // 多專案隔離

取自 Mem0 的 scope keysLangMem 的 namespace,並補上 subject_type 這個明確的型別欄位。加這個欄位的理由是它讓「刪除某使用者的全部資料」與「共享知識不被誤刪」變成一個 WHERE 條件,而不是一段需要小心維護的邏輯。

TemporalMeta(時間中介資料)——雙時間軸,取自 Graphiti

TemporalMeta:
  created_at    : 時間戳                  // 系統何時知道(必填)
  expired_at    : 時間戳(可選)           // 系統何時判定不再成立
  valid_at      : 時間戳(可選)           // 現實中何時開始為真
  invalid_at    : 時間戳(可選)           // 現實中何時停止為真
  last_recalled : 時間戳(可選)           // 最後一次被召回,歸檔判斷用
  recall_count  : 整數                    // 召回次數,重要性訊號

後兩個欄位不在 Graphiti 的設計裡,是我加的:召回統計是決定歸檔優先序最便宜的訊號,而且它不需要任何 LLM 判斷。

MemoryBlock(記憶分區)——常駐層的單位,取自 Letta

MemoryBlock:
  id            : 唯一識別碼
  label         : 文字                    // "rules" | "user_profile" | "persona" ...
  value         : 文字
  token_budget  : 整數                    // 這個分區的 context 配額
  writable      : 布林                    // false = 只有開發者能改
  description   : 文字(可選)             // 告訴 agent 這裡該放什麼

Episode(情節)——不可變的原始事件。

Episode:
  id            : 唯一識別碼
  scope         : Scope
  occurred_at   : 時間戳
  raw           : 文字或結構化內容          // 原始內容,不做任何處理
  channel       : 文字                    // "chat" | "tool_result" | "document" ...
  derived_ids   : 識別碼列表               // 從這個 episode 抽出的 MemoryUnit

Episode 只 append、永不修改。 這是整份 schema 裡最重要的約束——它讓 provenance、重跑抽取、以及「區分刪除事實與刪除歷史」三件事同時成立。


二、服務介面(語言中立)

三個介面,對應藍圖的三條路徑。上層程式碼只依賴這三個介面,框架實作藏在它們後面。

MemoryStore:                              // 真源的讀寫
  put_episode(Episode) -> id
  put_unit(MemoryUnit) -> id
  update_unit(id, patch) -> void
  invalidate_unit(id, at: 時間戳) -> void   // 標記失效,不刪除
  delete_units(scope, filter) -> 數量       // 物理刪除,僅供刪除請求使用
  get_unit(id) -> MemoryUnit
  list_units(scope, filter, 分頁) -> MemoryUnit 列表

MemoryIndex:                              // 衍生索引,可從真源重建
  upsert(MemoryUnit) -> void
  remove(id) -> void
  search_semantic(query, scope, k) -> (id, 分數) 列表
  search_lexical(query, scope, k) -> (id, 分數) 列表
  rebuild(scope) -> void                  // 換 embedding 模型時用

MemoryPolicy:                             // 所有「該不該」的決策集中在這裡
  should_write(candidate, context) -> 布林 + 理由
  reconcile(candidate, neighbors) -> Decision
  should_archive(unit, now) -> 布林
  budget_for(block_label) -> 整數

MemoryPolicy 抽成獨立介面是這份草案最重要的一個設計主張。 六個框架的差異幾乎全部落在這個介面的實作裡:Letta 把它交給 agent 推理、Mem0 交給一條 LLM 管線、LangMem 交給開發者。把它獨立出來,你就可以在不改儲存與檢索的情況下換掉整套記憶治理策略——包括從「LLM 判斷」換成「規則判斷」來降低成本,或反過來。

Decision:
  op            : "ADD" | "UPDATE" | "DELETE" | "NOOP"
  target_id     : 識別碼(UPDATE/DELETE 時必填)
  reason        : 文字                    // 必填,不可省略
  confidence    : 0.0–1.0

reason 必填不是形式主義。 記憶系統的錯誤是靜默的,而在沒有理由紀錄的情況下,「為什麼這條記憶被覆寫了」在事後幾乎無法回答。這一欄的成本是幾十個 token,價值是整個系統的可稽核性。


三、TypeScript 具體實作

// ---------- 型別 ----------
 
export type MemoryKind = "semantic" | "episodic" | "procedural"
export type SubjectType = "user" | "agent" | "shared" | "session"
 
export interface Scope {
  subjectType: SubjectType
  subjectId: string
  projectId?: string
}
 
export interface TemporalMeta {
  createdAt: string          // ISO 8601
  expiredAt?: string
  validAt?: string
  invalidAt?: string
  lastRecalledAt?: string
  recallCount: number
}
 
export interface SourceRef {
  episodeId: string
  channel: string
  extractorVersion: string   // 抽取邏輯的版本,重跑時用來識別
}
 
export interface MemoryUnit {
  id: string
  kind: MemoryKind
  scope: Scope
  content: string
  summary?: string
  contentHash: string
  source: SourceRef
  time: TemporalMeta
  confidence: number
  labels: string[]
}
 
export interface MemoryBlock {
  id: string
  label: string
  value: string
  tokenBudget: number
  writable: boolean
  description?: string
}
 
export interface Episode {
  id: string
  scope: Scope
  occurredAt: string
  raw: string
  channel: string
  derivedIds: string[]
}
 
export type Decision =
  | { op: "NOOP"; reason: string; confidence: number }
  | { op: "ADD"; reason: string; confidence: number }
  | { op: "UPDATE"; targetId: string; reason: string; confidence: number }
  | { op: "DELETE"; targetId: string; reason: string; confidence: number }
 
// ---------- 介面 ----------
 
export interface MemoryStore {
  putEpisode(e: Episode): Promise<string>
  putUnit(u: MemoryUnit): Promise<string>
  updateUnit(id: string, patch: Partial<MemoryUnit>): Promise<void>
  invalidateUnit(id: string, at: string): Promise<void>
  deleteUnits(scope: Scope, filter?: UnitFilter): Promise<number>
  getUnit(id: string): Promise<MemoryUnit | null>
  listUnits(scope: Scope, filter?: UnitFilter): Promise<MemoryUnit[]>
}
 
export interface UnitFilter {
  kind?: MemoryKind
  labels?: string[]
  validAt?: string           // 只要在這個時點有效的
  includeExpired?: boolean   // 預設 false
}
 
export interface MemoryIndex {
  upsert(u: MemoryUnit): Promise<void>
  remove(id: string): Promise<void>
  searchSemantic(q: string, scope: Scope, k: number): Promise<Hit[]>
  searchLexical(q: string, scope: Scope, k: number): Promise<Hit[]>
  rebuild(scope: Scope): Promise<void>
}
 
export interface Hit { id: string; score: number }
 
export interface MemoryPolicy {
  shouldWrite(c: Candidate, ctx: WriteContext): Promise<{ ok: boolean; reason: string }>
  reconcile(c: Candidate, neighbors: MemoryUnit[]): Promise<Decision>
  shouldArchive(u: MemoryUnit, now: string): boolean
  budgetFor(blockLabel: string): number
}
 
export interface Candidate {
  kind: MemoryKind
  scope: Scope
  content: string
  validAt?: string
  labels: string[]
  confidence: number
}
 
export interface WriteContext {
  episode: Episode
  isResidentTarget: boolean  // 目標是常駐層嗎?門檻要更嚴
}

寫入管線的骨架——藍圖的五個階段一一對應,可以直接當成實作起點

export async function writePipeline(
  episode: Episode,
  deps: { store: MemoryStore; index: MemoryIndex; policy: MemoryPolicy;
          extract: (e: Episode) => Promise<Candidate[]>;
          hash: (s: string) => string },
): Promise<Decision[]> {
  // 階段 0:真源先落地。抽取失敗也不能丟掉原始事件。
  await deps.store.putEpisode(episode)
 
  // 階段 1:抽取候選
  const candidates = await deps.extract(episode)
  const decisions: Decision[] = []
 
  for (const c of candidates) {
    // 階段 0':把關。常駐層目標用更嚴的門檻。
    const gate = await deps.policy.shouldWrite(c, {
      episode,
      isResidentTarget: c.kind === "procedural",
    })
    if (!gate.ok) {
      decisions.push({ op: "NOOP", reason: gate.reason, confidence: 1 })
      continue
    }
 
    // 階段 2:雜湊去重。零成本,放在 LLM 對帳之前。
    const contentHash = deps.hash(c.content)
    const existing = await deps.store.listUnits(c.scope, { kind: c.kind })
    if (existing.some((u) => u.contentHash === contentHash)) {
      decisions.push({ op: "NOOP", reason: "exact duplicate", confidence: 1 })
      continue
    }
 
    // 階段 3:對帳。只在同 scope、同 kind 的鄰域內比對。
    const hits = await deps.index.searchSemantic(c.content, c.scope, 5)
    const neighbors = (
      await Promise.all(hits.map((h) => deps.store.getUnit(h.id)))
    ).filter((u): u is MemoryUnit => u !== null && u.kind === c.kind)
 
    const decision = await deps.policy.reconcile(c, neighbors)
    decisions.push(decision)
 
    // 階段 4:寫入真源,再更新索引。順序不可顛倒。
    const now = new Date().toISOString()
    switch (decision.op) {
      case "ADD": {
        const unit: MemoryUnit = {
          id: crypto.randomUUID(),
          kind: c.kind,
          scope: c.scope,
          content: c.content,
          contentHash,
          source: { episodeId: episode.id, channel: episode.channel,
                    extractorVersion: "v1" },
          time: { createdAt: now, validAt: c.validAt, recallCount: 0 },
          confidence: c.confidence,
          labels: c.labels,
        }
        await deps.store.putUnit(unit)
        await deps.index.upsert(unit)
        break
      }
      case "UPDATE": {
        // 舊值標記失效、寫入新值:保留歷史而非覆寫。
        await deps.store.invalidateUnit(decision.targetId, now)
        await deps.index.remove(decision.targetId)
        // 接著同 ADD 的流程寫入新 unit(略)
        break
      }
      case "DELETE":
        // 預設是失效而非物理刪除。物理刪除只走 deleteUnits()。
        await deps.store.invalidateUnit(decision.targetId, now)
        await deps.index.remove(decision.targetId)
        break
      case "NOOP":
        break
    }
  }
  return decisions
}

召回與組裝——注意回傳值裡的 trace,它是可觀測性的資料來源

export async function assembleContext(
  query: string,
  scope: Scope,
  deps: { store: MemoryStore; index: MemoryIndex; policy: MemoryPolicy;
          blocks: MemoryBlock[]; countTokens: (s: string) => number },
): Promise<{ text: string; trace: AssembleTrace }> {
  const parts: string[] = []
  const trace: AssembleTrace = { residentIds: [], recalledIds: [], tokens: 0 }
 
  // 階段 1:常駐層。無條件載入,按各自配額截斷。不經過檢索。
  for (const b of deps.blocks) {
    const budget = deps.policy.budgetFor(b.label)
    const value = truncateToTokens(b.value, budget, deps.countTokens)
    parts.push(`## ${b.label}\n${value}`)
    trace.residentIds.push(b.id)
  }
 
  // 階段 2:候選生成。語意+詞彙兩路,之後合併。
  const [sem, lex] = await Promise.all([
    deps.index.searchSemantic(query, scope, 20),
    deps.index.searchLexical(query, scope, 20),
  ])
  const merged = reciprocalRankFusion([sem, lex])
 
  // 階段 3:時效過濾。只要目前有效的事實。
  const now = new Date().toISOString()
  const units = (
    await Promise.all(merged.map((h) => deps.store.getUnit(h.id)))
  ).filter((u): u is MemoryUnit =>
    u !== null && !u.time.expiredAt && (!u.time.invalidAt || u.time.invalidAt > now),
  )
 
  // 階段 4:按召回層自己的配額截斷(與常駐層的配額互不侵蝕)。
  let used = 0
  const recallBudget = deps.policy.budgetFor("__recall__")
  for (const u of units) {
    const t = deps.countTokens(u.summary ?? u.content)
    if (used + t > recallBudget) break
    parts.push(u.summary ?? u.content)
    trace.recalledIds.push(u.id)
    used += t
  }
 
  const text = parts.join("\n\n")
  trace.tokens = deps.countTokens(text)
  return { text, trace }   // trace 必須被記錄下來
}
 
export interface AssembleTrace {
  residentIds: string[]
  recalledIds: string[]
  tokens: number
}

四、SQL schema

單一資料庫就能實作藍圖的全部結構,不需要圖資料庫(除了 Q6 的多跳需求)。

CREATE TABLE episodes (
  id           TEXT PRIMARY KEY,
  subject_type TEXT NOT NULL,
  subject_id   TEXT NOT NULL,
  project_id   TEXT,
  occurred_at  TIMESTAMPTZ NOT NULL,
  channel      TEXT NOT NULL,
  raw          TEXT NOT NULL
);
-- episodes 只 INSERT,不 UPDATE、不 DELETE(刪除請求除外)
 
CREATE TABLE memory_units (
  id                TEXT PRIMARY KEY,
  kind              TEXT NOT NULL CHECK (kind IN ('semantic','episodic','procedural')),
  subject_type      TEXT NOT NULL CHECK (subject_type IN ('user','agent','shared','session')),
  subject_id        TEXT NOT NULL,
  project_id        TEXT,
  content           TEXT NOT NULL,
  summary           TEXT,
  content_hash      TEXT NOT NULL,
  episode_id        TEXT NOT NULL REFERENCES episodes(id),
  extractor_version TEXT NOT NULL,
  created_at        TIMESTAMPTZ NOT NULL,   -- 系統何時知道
  expired_at        TIMESTAMPTZ,            -- 系統何時判定失效
  valid_at          TIMESTAMPTZ,            -- 現實中何時開始為真
  invalid_at        TIMESTAMPTZ,            -- 現實中何時停止為真
  last_recalled_at  TIMESTAMPTZ,
  recall_count      INTEGER NOT NULL DEFAULT 0,
  confidence        REAL NOT NULL DEFAULT 1.0,
  labels            TEXT[] NOT NULL DEFAULT '{}'
);
 
-- 主查詢路徑:範圍 + 型別 + 只要有效的
CREATE INDEX idx_units_scope ON memory_units
  (subject_type, subject_id, kind) WHERE expired_at IS NULL;
 
-- 去重路徑
CREATE UNIQUE INDEX idx_units_hash ON memory_units
  (subject_type, subject_id, kind, content_hash) WHERE expired_at IS NULL;
 
-- 歸檔候選:失效已久、或久未召回
CREATE INDEX idx_units_archive ON memory_units (expired_at, last_recalled_at);
 
CREATE TABLE memory_blocks (
  id           TEXT PRIMARY KEY,
  subject_type TEXT NOT NULL,
  subject_id   TEXT NOT NULL,
  label        TEXT NOT NULL,
  value        TEXT NOT NULL,
  token_budget INTEGER NOT NULL,
  writable     BOOLEAN NOT NULL DEFAULT TRUE,
  description  TEXT,
  UNIQUE (subject_type, subject_id, label)
);
 
-- 決策紀錄:稽核與 debug 的唯一依據
CREATE TABLE memory_decisions (
  id          BIGSERIAL PRIMARY KEY,
  episode_id  TEXT NOT NULL REFERENCES episodes(id),
  op          TEXT NOT NULL,
  target_id   TEXT,
  reason      TEXT NOT NULL,
  confidence  REAL NOT NULL,
  decided_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);
 
-- 組裝紀錄:可觀測性與 token 效率量測的來源
CREATE TABLE assemble_traces (
  id           BIGSERIAL PRIMARY KEY,
  turn_id      TEXT NOT NULL,
  resident_ids TEXT[] NOT NULL,
  recalled_ids TEXT[] NOT NULL,
  tokens       INTEGER NOT NULL,
  created_at   TIMESTAMPTZ NOT NULL DEFAULT now()
);

向量索引可以放在同一個資料庫(pgvector)或外部向量庫;詞彙檢索用 Postgres 的全文檢索或 SQLite 的 FTS5 都可以。關鍵是這兩者都是衍生物——MemoryIndex.rebuild() 應該能從 memory_units 完整重建它們。

最後兩張表(memory_decisionsassemble_traces)在多數實作裡不存在,而它們的缺席正是「不知道 agent 為什麼記住/召回了什麼」的直接原因。加上它們的成本是兩張 append-only 表,收益是整個系統從黑盒變成可觀測。


五、Adapter 範例:把框架接到這組介面

// 用 Mem0 實作寫入管線的抽取與對帳,其餘自己控制
export class Mem0Policy implements MemoryPolicy {
  constructor(private client: Mem0Client) {}
 
  async shouldWrite(c: Candidate, ctx: WriteContext) {
    // 常駐層(規則)不交給 LLM 判斷,走人工審核佇列
    if (ctx.isResidentTarget) {
      return { ok: false, reason: "resident writes require human review" }
    }
    return { ok: true, reason: "non-resident, delegate to reconcile" }
  }
 
  async reconcile(c: Candidate, neighbors: MemoryUnit[]): Promise<Decision> {
    const r = await this.client.decide(c.content, neighbors.map((n) => n.content))
    return { op: r.op, targetId: r.targetId, reason: r.reason ?? "mem0",
             confidence: r.score ?? 0.5 } as Decision
  }
 
  shouldArchive(u: MemoryUnit, now: string) {
    if (u.time.expiredAt && daysBetween(u.time.expiredAt, now) > 90) return true
    if (u.time.recallCount === 0 && daysBetween(u.time.createdAt, now) > 180) return true
    return false
  }
 
  budgetFor(label: string) {
    return { rules: 1200, user_profile: 600, persona: 300, __recall__: 1500 }[label] ?? 400
  }
}

這個 adapter 示範了藍圖的實際價值shouldWrite 對常駐層加了一道框架本身沒有的護欄(規則變更需人工審核),shouldArchivebudgetFor 補上了 Mem0 沒有的容量控制——而這些都不需要修改 Mem0,也不需要在換掉 Mem0 時重寫。


相關