长期记忆与知识图谱:实体关系抽取、偏好沉淀与按需召回
本节目标
- 区分人类认知科学与 Agent 工程中的三类记忆:工作记忆(Working Memory)、情景记忆(Episodic Memory)与语义记忆(Semantic / Graph Memory)
- 理解为什么跨会话的用户偏好与项目架构关系适合用实体-关系三元组
(Subject, Predicate, Object)存储 - 用原生 JavaScript 手写一个支持三元组 upsert、冲突覆盖与多跳邻域子图检索(Multi-hop Subgraph Retrieval)的轻量级知识图谱记忆库
为什么向量检索搞不定「结构化关系与状态更新」?
假设用户在第 1 周告诉 Agent:「我们的主数据库用的是 MySQL 8.0」,到了第 3 周又说:「我们已经把主数据库从 MySQL 迁移到了 PostgreSQL 16」。
如果只用第 11 课的向量库把两句话都存进去,当第 4 周检索「主数据库是什么」时,向量库会把「MySQL 8.0」和「PostgreSQL 16」两条冲突的记录同时召回,导致模型产生精神分裂。
而基于实体-关系三元组(Knowledge Graph Triples)的语义记忆天然支持按主谓键(Subject + Predicate)精准更新状态与多跳关联推理(Multi-hop Reasoning):
[User: Wen] ──(维护项目)──► [Project: Study] ──(部署平台)──► [Cloudflare Pages]
│
(存储绑定)
▼
[KV: GUESTBOOK]当询问「Wen 维护的项目使用了什么存储?」时,哪怕对话里从未在同一句话里同时出现过 Wen 和 KV: GUESTBOOK,通过从实体 Wen 出发进行 2 跳广度优先遍历(2-Hop BFS),也能精准召回整条关系链。
手写支持状态覆盖与多跳 BFS 检索的知识图谱记忆引擎
export class GraphMemoryStore {
constructor() {
// 复合主键 "${subject}::${predicate}" -> { subject, predicate, object, updatedAt, confidence }
this.triples = new Map()
}
// 写入或更新事实三元组(自动覆盖同一主语+谓语的过期事实)
upsertTriple({ subject, predicate, object, confidence = 1.0 }) {
const s = String(subject).trim()
const p = String(predicate).trim()
const o = String(object).trim()
const key = `${s.toLowerCase()}::${p.toLowerCase()}`
const record = {
subject: s,
predicate: p,
object: o,
confidence,
updatedAt: new Date().toISOString()
}
this.triples.set(key, record)
return record
}
// 批量从 LLM 抽取出的结构化事实更新图谱
ingestFacts(facts = []) {
return facts.map((f) => this.upsertTriple(f))
}
// 多跳子图检索(Multi-hop BFS):从种子实体出发,沿出边与入边向外探索 maxHops 层关联事实
querySubgraph(seedEntities = [], { maxHops = 2, maxFacts = 20 } = {}) {
const queue = seedEntities.map((e) => ({ entity: String(e).toLowerCase(), hop: 0 }))
const visitedEntities = new Set(queue.map((q) => q.entity))
const matchedTriples = new Map()
while (queue.length > 0 && matchedTriples.size < maxFacts) {
const { entity, hop } = queue.shift()
if (hop >= maxHops) continue
for (const [key, t] of this.triples.entries()) {
const sLow = t.subject.toLowerCase()
const oLow = t.object.toLowerCase()
if (sLow.includes(entity) || oLow.includes(entity)) {
matchedTriples.set(key, t)
const neighbor = sLow.includes(entity) ? oLow : sLow
if (!visitedEntities.has(neighbor)) {
visitedEntities.add(neighbor)
queue.push({ entity: neighbor, hop: hop + 1 })
}
}
}
}
return [...matchedTriples.values()]
}
// 将召回的子图格式化为紧凑的自然语言上下文块注入 System Prompt
formatForPrompt(seedEntities, opts) {
const facts = this.querySubgraph(seedEntities, opts)
if (facts.length === 0) return ''
const lines = facts.map((f) => `- (${f.subject}) --[${f.predicate}]--> (${f.object})`)
return `【长期知识图谱召回事实】\n${lines.join('\n')}`
}
}课后练习
练习 1:为什么在 GraphMemoryStore 中,我们将 Map 的主键设计为 ${subject}::${predicate}(主语 + 谓语),而不是 ${subject}::${predicate}::${object}?
答案:对于具有唯一状态的事实属性(例如 (项目 Study, 部署分支, main) 或 (用户, 偏好的包管理器, pnpm)),当状态发生变更时,以 subject::predicate 作为主键能自动用新宾语(object)原地覆盖旧值,从根源上消除新旧记忆并存导致的上下文矛盾。
练习 2:相比把整个知识图谱的几千条三元组全量塞进 System Prompt,为什么要用 querySubgraph(seedEntities, { maxHops: 2 }) 做局部子图召回?
答案:全量塞入不仅浪费大量 Token,还会引入与当前问题毫不相干的噪声实体干扰模型注意力;以当前用户问题中的核心实体为种子做 2 跳 BFS 扩展,能以几十个 Token 的极小代价精准提取出直接相关与间接关联的事实链。