From 57d2c14fca39aa0cd06d7f14fb5311435be75375 Mon Sep 17 00:00:00 2001 From: john Date: Wed, 22 Jul 2026 11:43:45 +0800 Subject: [PATCH] fix(memory): honor empty extraction results --- conversation-memory.mjs | 5 +- conversation-memory.test.mjs | 63 +++++++++++++++++++ .../memory-v2-candidate-and-lifecycle.md | 8 ++- 3 files changed, 74 insertions(+), 2 deletions(-) diff --git a/conversation-memory.mjs b/conversation-memory.mjs index 9c67a34..b522208 100644 --- a/conversation-memory.mjs +++ b/conversation-memory.mjs @@ -109,6 +109,7 @@ function buildMemoryPrompt(messages) { '你是 TKMind 的用户长期记忆提取器。', '请从下面的用户对话中提取可以长期复用的个人记忆。', '只记录用户明确表达或强证据支持的信息,不要猜测,不要记录临时闲聊。', + '不要把问题、请求、指令或待办本身当作用户事实;问句没有提供答案时不要记录。', '不要记录密码、密钥、身份证、手机号、银行卡等敏感信息。', '只返回 JSON 对象,不要 Markdown,不要解释。', '格式:{"memories":[{"label":"preference|habit|interest|goal|fact|experience|knowledge","text":"一句完整中文记忆","confidence":0.0-1.0}]}', @@ -382,7 +383,9 @@ export function createConversationMemoryService(pool, options = {}) { warnLlmExtractionFailed(err); } } - if (!memories?.length) memories = fallbackMemoriesFromMessages(messages); + // An empty array is an authoritative LLM decision that the batch contains no + // durable memory. Only fall back when extraction was unavailable (`null`). + if (memories == null) memories = fallbackMemoriesFromMessages(messages); const stored = await storeMemories(userId, messages, memories); await markAnalyzed(messages.map((message) => message.id)); return { ok: true, analyzed: messages.length, memories: stored }; diff --git a/conversation-memory.test.mjs b/conversation-memory.test.mjs index 8ec532a..7046c74 100644 --- a/conversation-memory.test.mjs +++ b/conversation-memory.test.mjs @@ -208,6 +208,35 @@ test('saveAndAnalyze marks messages analyzed when llm extraction fails and no me else process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = previous; }); +test('saveAndAnalyze still uses fallback when llm extraction is unavailable', async () => { + const previous = process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED; + process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = '1'; + const pool = createPool(); + const service = createConversationMemoryService(pool, { + now: () => 2250, + llmProviderService: { + async createChatCompletion() { + throw new Error('upstream unavailable'); + }, + }, + }); + + const result = await service.saveAndAnalyze('session-fallback', 'user-fallback', [ + { + id: 'm-fallback', + role: 'user', + content: [{ type: 'text', text: '我喜欢简洁的回答。' }], + metadata: { userVisible: true }, + }, + ]); + + assert.equal(result.memories, 1); + assert.match(pool.state.memories[0].memory_text, /简洁/); + + if (previous == null) delete process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED; + else process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = previous; +}); + test('saveAndAnalyze uses admin effective env for memory extraction model', async () => { const previous = process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED; process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = '1'; @@ -284,6 +313,40 @@ test('saveAndAnalyze extracts memories through llmProviderService', async () => else process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = previous; }); +test('saveAndAnalyze respects an empty llm result instead of storing a question through fallback', async () => { + const previous = process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED; + process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = '1'; + const pool = createPool(); + const service = createConversationMemoryService(pool, { + now: () => 2750, + llmProviderService: { + async createChatCompletion({ messages }) { + assert.match(String(messages?.[0]?.content ?? ''), /问句没有提供答案时不要记录/); + return { + ok: true, + reply: JSON.stringify({ memories: [] }), + }; + }, + }, + }); + + const result = await service.saveAndAnalyze('session-question', 'user-question', [ + { + id: 'm-question', + role: 'user', + content: [{ type: 'text', text: '用户记住的记忆召回灰度测试代号是什么?' }], + metadata: { userVisible: true }, + }, + ]); + + assert.equal(result.analyzed, 1); + assert.equal(result.memories, 0); + assert.equal(pool.state.memories.length, 0); + + if (previous == null) delete process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED; + else process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = previous; +}); + test('saveAndAnalyze throttles repeated llm extraction warnings', async () => { const previous = process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED; process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = '1'; diff --git a/docs/regression-guards/memory-v2-candidate-and-lifecycle.md b/docs/regression-guards/memory-v2-candidate-and-lifecycle.md index b8f97a0..a0a34b8 100644 --- a/docs/regression-guards/memory-v2-candidate-and-lifecycle.md +++ b/docs/regression-guards/memory-v2-candidate-and-lifecycle.md @@ -17,6 +17,10 @@ 包含已注入的 `[Memory Context]`。提取器因此可能把旧记忆再次沉淀,而忽略用户本轮明确 要求保存的内容,形成旧记忆自我复制。 +提取器返回 `{"memories":[]}` 时,旧逻辑仍会触发规则回退,可能把“……是什么”一类 +问句误存为事实。空数组必须视为成功的“无需保存”判断;只有提取不可用并返回 `null` +时才允许规则回退。 + 当前生产使用的本地 hash embedding 只有 3 维,且连续中文可能被视为单个 token。即使正确 记忆已进入 pgvector,它也可能排在向量 Top-K 之外。因此 pgvector 读取必须合并有界的 向量候选与最近候选,再以中文字符 n-gram 查询覆盖率重排;无词法重合时保持向量排序。 @@ -41,11 +45,13 @@ 原始消息查询失败时必须 fail-open 到现有可见会话,不能阻塞保存接口。 8. pgvector 召回必须同时覆盖有界向量候选和有界最近候选,去重后只返回请求的 limit; 中文词法重排用于弥补低维本地 hash 的排序缺陷,且候选池上限不得超过 100。 +9. LLM 提取明确返回空数组时不得再走规则回退;提取提示必须明确排除没有提供答案的 + 问题、请求、指令和待办,避免把问句本身沉淀为长期记忆。 ## 回归检查 ```bash -node --test conversation-repair.test.mjs memory-v2-personal-store.test.mjs memory-v2-lifecycle.test.mjs \ +node --test conversation-memory.test.mjs conversation-repair.test.mjs memory-v2-personal-store.test.mjs memory-v2-lifecycle.test.mjs \ memory-v2-pgvector-backfill.test.mjs memory-v2-runtime.test.mjs npm test ```