Agent 聊天:记忆候选判断实现方案
长期记忆是 AI 电子伴侣体验里很关键的一环。用户会期待 Agent 记住自己的偏好、边界、关系目标和重要事实。
但长期记忆也有一个很现实的问题:不是每一句聊天都值得保存。
如果每轮对话都直接进入记忆抽取,系统很快就会变得不好维护。普通寒暄可能被保存下来,比如哈哈、好、晚安;一次性情绪也可能被长期化,比如我今天有点烦;重复事实会反复进入记忆库,让记忆变脏。除此之外,每次回复后都调用 LLM 抽取,也会让成本变高;如果敏感内容被错误长期化,用户隐私风险也会变大。
所以这次新增了记忆候选判断层。它位于长期记忆抽取之前,先判断这一轮是否值得进入抽取流程。
原来的链路是:
1assistant 回复完成2-> 保存 assistant 消息3-> 如果安全边界允许4-> 直接调用长期记忆抽取器5-> 写入 agent_memories
现在改成:
1assistant 回复完成2-> 保存 assistant 消息3-> 如果安全边界允许4-> 读取已有长期记忆5-> 记忆候选判断6-> 如果不值得保存,直接跳过7-> 如果值得保存,调用长期记忆抽取器8-> 去重9-> 写入 agent_memories
这样长期记忆系统多了一层闸门。抽取器不再处理所有聊天,只处理有长期价值的候选对话。
记忆候选判断不负责抽取最终记忆。
它只回答一个问题:
1这一轮对话值不值得进入长期记忆抽取?
它会判断这轮对话里是否有稳定信息,未来多轮对话是否仍然用得上;也会排除临时情绪、普通寒暄、已有记忆的重复表达,以及不应该保存的敏感内容。
只有判断通过,才进入后续抽取器。
后端新增了 AgentMemoryCandidateSchema:
01const AgentMemoryCandidateSchema = z.object({02shouldExtract: z.boolean(),03confidence: z.number().min(0).max(1),04category: z.enum([05'preference',06'boundary',07'relationship_goal',08'conversation_style',09'important_fact',10'identity_profile',11'temporary_emotion',12'small_talk',13'assistant_generated',14'duplicate',15'unsafe',16'unclear',17]),18stability: z.enum(['stable', 'likely_stable', 'temporary', 'unclear']),19importance: z.number().int().min(0).max(5),20reason: z.string().trim().max(300),21candidateFacts: z.array(z.string().trim().min(1).max(120)).max(3),22})
这些字段各有分工。shouldExtract 表示是否进入长期记忆抽取,confidence 表示判断置信度,category 表示候选类型,stability 表示信息稳定性,importance 表示重要度,reason 保存判断原因,candidateFacts 则保存可能值得抽取的候选事实。
注意:这里的 candidateFacts 不是最终记忆,只是给下一步抽取器的参考。
适合进入长期记忆候选的内容,通常是未来很多轮对话都会继续用到的信息。
不适合进入长期记忆候选的内容,通常是临时的、重复的,或者不应该被长期保存的。
长期记忆不是聊天记录备份,而是高价值用户画像和关系上下文。
为了降低成本,候选判断不是一上来就调用 LLM。
第一步是本地快速拒绝,也就是 shouldSkipMemoryCandidateFast。
它会处理几类明显不需要记忆的情况。
1if (!userText || !assistantText) {2return {3shouldExtract: false,4category: 'unclear',5reason: '用户消息或 Agent 回复为空,不进入长期记忆候选。',6}7}
没有完整的一轮用户消息和 Agent 回复时,不进入长期记忆。
1if (userText.length < 6 && !/(记住|以后|喜欢|讨厌|不要|别再)/.test(userText)) {2return {3shouldExtract: false,4category: 'small_talk',5reason: '用户消息过短且没有明确记忆信号,判断为寒暄或临时互动。',6}7}
短消息如果没有明显记忆信号,大概率只是即时互动。
1if (/^(好|嗯|哦|啊|哈+|哈哈+|谢谢|谢啦|好的|可以|行|ok|OK|晚安|早安|拜拜|再见)[。!!~~\s]*$/.test(userText)) {2return {3shouldExtract: false,4category: 'small_talk',5reason: '本轮是普通寒暄、确认或结束语,不适合作为长期记忆。',6}7}
这类内容即使出现很多次,也不应该污染长期记忆库。
1const normalizedUserText = normalizeMemoryContent(userText)23if (params.existingMemories.some((memory) => normalizeMemoryContent(memory.content) === normalizedUserText)) {4return {5shouldExtract: false,6category: 'duplicate',7reason: '用户消息与已有长期记忆完全重复,不需要再次抽取。',8}9}
重复内容直接跳过,避免同一条记忆被保存多次。
1if (/(密码|验证码|身份证|银行卡|住址|手机号|电话|token|api key|apikey|secret|密钥)/i.test(userText)) {2return {3shouldExtract: false,4category: 'unsafe',5reason: '本轮疑似包含敏感隐私或凭证信息,不进入长期记忆。',6}7}
这些内容即使看起来重要,也不应该被长期保存。
如果本地规则没有快速拒绝,就进入 LangChain 结构化判断。
Prompt 如下:
01const agentMemoryCandidatePrompt = ChatPromptTemplate.fromMessages([02[03'system',04[05'你是 AI 电子伴侣聊天产品的长期记忆候选判断器。',06'你的任务不是抽取记忆,也不是回复用户,而是判断本轮对话是否值得进入长期记忆抽取流程。',07'只有稳定、未来多轮对话仍然有用的信息才应该进入抽取:用户偏好、边界禁忌、关系目标、对 Agent 的互动风格要求、重要事实、稳定身份资料。',08'以下内容通常不要进入抽取:普通寒暄、一次性情绪、临时状态、纯粹感谢、表情语气、Agent 自己编造的信息、已经存在的重复记忆、危险或不应保存的信息。',09'如果用户明确要求“记住/以后/不要/别再/我喜欢/我不喜欢/我的习惯/我的边界”,通常应判断为候选。',10'如果只是用户当下难过、生气、累,除非它表达了稳定偏好、重要事件或长期边界,否则不要进入长期记忆。',11'输出必须是可被 LangChain 结构化解析的 JSON 对象。',12].join('\n'),13],14])
调用方式:
01const structuredModel = model.withStructuredOutput(AgentMemoryCandidateSchema, {02name: 'agent_memory_candidate_judgement',03method: params.method,04})0506const chain = agentMemoryCandidatePrompt.pipe(structuredModel)0708const result = await chain.invoke({09agentName: params.agentName || '未命名 Agent',10existingMemories: formatExistingMemories(params.existingMemories),11conversationSummary: params.conversationSummary || '暂无',12userText: params.userText,13assistantText: params.assistantText.slice(0, 3000),14})
这里传入了已有长期记忆,目的是让模型判断重复内容时更谨慎。
项目里用户可以配置不同的 OpenAI 兼容接口,所以结构化输出不能只依赖一种方式。
当前复用了已有的 structured output 方法选择:
1function getStructuredOutputMethods(providerConfig: ChatProviderConfig) {2return providerConfig.wireApi === 'responses'3? ['jsonSchema', 'functionCalling', 'jsonMode'] as const4: ['functionCalling', 'jsonSchema', 'jsonMode'] as const5}
候选判断会依次尝试:
01for (const method of getStructuredOutputMethods(params.providerConfig)) {02try {03return await invokeLangChainMemoryCandidateJudgement({04...params,05method,06userText,07assistantText,08})09} catch (error) {10lastError = error11}12}
如果全部失败,会使用本地关键词兜底。
当 LangChain 候选判断失败时,会调用 buildFallbackMemoryCandidate。
它根据关键词做保守判断:
1const hasExplicitMemorySignal = /(记住|记一下|以后|下次|别再|不要再|我喜欢|我不喜欢|我讨厌|我习惯|我的习惯|我的边界|我的偏好|我希望你|你以后)/.test(text)2const hasBoundarySignal = /(不要|别|拒绝|不接受|底线|边界|禁忌|雷点|不舒服)/.test(text)3const hasPreferenceSignal = /(喜欢|不喜欢|偏好|习惯|更想|更希望|受不了|讨厌|想要你|希望你)/.test(text)4const hasImportantFactSignal = /(生日|工作|学校|家人|朋友|伴侣|前任|城市|搬家|生病|考试|面试|项目|目标|计划)/.test(text)
如果命中这些信号,才允许进入抽取。
这不是完美语义判断,但在模型不可用时,它比全部抽取更安全。
模型返回后还会经过 normalizeMemoryCandidate。
关键规则:
1if (2next.category === 'small_talk' ||3next.category === 'temporary_emotion' ||4next.category === 'assistant_generated' ||5next.category === 'duplicate' ||6next.category === 'unsafe'7) {8next.shouldExtract = false9}
这些类别无论模型是否设置 shouldExtract = true,都会被强制跳过。
临时信息和低重要度内容也会被跳过:
1if (next.stability === 'temporary' || next.importance <= 0) {2next.shouldExtract = false3}
低置信度且重要度不高的内容也跳过:
1if (next.confidence < 0.55 && next.importance < 4) {2next.shouldExtract = false3}
这一步保证模型判断不会直接越过产品规则。
原来的 persistAgentMemoriesFromTurn 是直接抽取。
现在变成:
01const existingMemories = await listActiveAgentMemories({02db: params.db,03userId: params.userId,04agentId: params.agentId,05limit: 50,06})0708const memoryCandidate = await judgeAgentMemoryCandidateWithLangChain({09providerConfig: params.providerConfig,10agentName: params.agentName,11existingMemories,12conversationSummary: params.previousSummary,13userText: params.userText,14assistantText: params.assistantText,15signal: params.signal,16})1718if (!memoryCandidate.shouldExtract) {19console.info('Agent memory extraction skipped by candidate judgement', {20userId: params.userId,21agentId: params.agentId,22sourceMessageId: params.sourceMessageId,23category: memoryCandidate.category,24confidence: memoryCandidate.confidence,25reason: memoryCandidate.reason,26})27return28}
只有通过候选判断,才继续调用抽取器:
01const candidateMemories = await extractAgentMemoriesWithLangChain({02providerConfig: params.providerConfig,03agentName: params.agentName,04existingMemories,05memoryCandidate,06conversationSummary: params.previousSummary,07userText: params.userText,08assistantText: params.assistantText,09signal: params.signal,10})
候选判断结果不只是一个开关,也会传给抽取器:
01function formatMemoryCandidateForPrompt(candidate: AgentMemoryCandidate) {02return [03`是否进入抽取:${candidate.shouldExtract ? '是' : '否'}`,04`类别:${candidate.category}`,05`置信度:${candidate.confidence.toFixed(2)}`,06`稳定性:${candidate.stability}`,07`重要度:${candidate.importance}`,08`判断原因:${candidate.reason}`,09candidate.candidateFacts.length > 010? `候选事实:${candidate.candidateFacts.map((fact, index) => `${index + 1}. ${fact}`).join(';')}`11: '候选事实:暂无',12].join('\n')13}
抽取器 Prompt 中新增:
1本轮记忆候选判断:2{memoryCandidate}
这样抽取器不是从整轮对话里盲抽,而是围绕候选判断给出的方向做精抽。
本次没有新增表,也没有新增字段。
原因是记忆候选判断是抽取前的运行时决策,不是一个必须长期保存的业务实体。
真正需要长期保存的仍然是 agent_memories 表中的记忆结果。
如果后续要分析候选命中率、跳过原因、不同模型的候选质量,可以再新增审计表,例如:
1agent_memory_candidate_logs
第一版先不加表,避免过早扩大数据模型。
候选判断不是安全边界的替代品。
当前流程仍然是:
1Safety Boundary2-> allowMemoryExtraction3-> Memory Candidate Judgement4-> Memory Extraction
也就是说,如果安全边界已经判断本轮不允许记忆抽取,候选判断不会执行。
候选判断只负责在安全允许的前提下,进一步判断是否值得长期保存。
后续可以保存每次候选判断结果,用来观察哪些内容最常被跳过,哪些 Agent 容易产生脏记忆,以及哪些模型的候选判断质量更好。
对于高敏感或高重要度候选,可以让用户确认:
1我可以记住这一点吗?
不过这要谨慎设计,不能频繁打断聊天。
当前候选类别和最终记忆类型还不是一一强绑定。
后续可以建立映射,例如:
preference -> 偏好boundary -> 边界relationship_goal -> 关系目标conversation_style -> 对话风格important_fact / identity_profile -> 重要事实这样抽取器会更稳定。
候选判断中的 importance 和 confidence 可以用于长期记忆排序,决定哪些记忆更应该优先注入 Prompt。
记忆候选判断是长期记忆系统的第一道过滤层。
它不负责保存记忆,也不负责生成最终记忆内容,而是决定本轮对话是否值得进入抽取器。
第一版采用本地 fast reject + LangChain 结构化判断 + 关键词兜底的组合:简单内容快速跳过,有语义价值的内容交给模型判断,模型失败时仍有保守兜底。
这样可以降低成本、减少脏记忆、保护敏感信息,也让 AI 电子伴侣的长期记忆更像真正重要的理解,而不是聊天记录垃圾桶。