Spring AI Alibaba 1.x 系列【66】Graph 长期记忆

文章目录

  • [1. 实现方案](#1. 实现方案)
  • [2. 记忆存储](#2. 记忆存储)
    • [2.1 存储内容](#2.1 存储内容)
    • [2.2 StoreItem](#2.2 StoreItem)
    • [2.3 用户画像摘要](#2.3 用户画像摘要)
    • [2.4 结构化事实](#2.4 结构化事实)
    • [2.5 长期记忆数据](#2.5 长期记忆数据)
  • [3. 记忆更新](#3. 记忆更新)
    • [3.1 消息过滤器](#3.1 消息过滤器)
    • [3.2 信号检测器](#3.2 信号检测器)
    • [3.3 LLM 响应解析结果](#3.3 LLM 响应解析结果)
    • [3.4 Prompt 构建器](#3.4 Prompt 构建器)
    • [3.5 防抖实现](#3.5 防抖实现)
    • [3.6 记忆更新器](#3.6 记忆更新器)
  • [4. 记忆处理节点](#4. 记忆处理节点)
    • [4.1 记忆准备节点](#4.1 记忆准备节点)
    • [4.2 长期记忆更新入队节点](#4.2 长期记忆更新入队节点)
  • [5. 项目集成](#5. 项目集成)
    • [5.1 长期记忆配置类](#5.1 长期记忆配置类)
    • [5.2 构建状态图](#5.2 构建状态图)
    • [5.3 测试](#5.3 测试)

1. 实现方案

长期记忆不是简单的聊天记录,而是结构化的事实上下文摘要 存储,能够在不同会话间持久化,并影响智能体在未来对话中的行为。

极简落地链路(工程视角)

  1. 用户发消息 → 存入短期记忆
  2. 轮次结束 → LLM 抽取信息 → Record 写入长期记忆
  3. 下一轮新请求到来 → 检索长期记忆 → Retrieve 注入短期上下文
  4. 短期记忆 (当前对话) + 召回记忆送入模型 → 生成回复,循环往复

这里,我们参考 DeerFlow 相关实现,记忆由记忆处理节点自行管理,在智能体每一轮执行时运行:

  • 注入 :每次对话开始时,将当前记忆按 Token 预算注入系统提示词
  • 学习:对话结束后,后台提取新事实并更新对应记忆分类
  • 防抖合并:短时间内的多次更新会合并处理,避免频繁写入

演示案例在之前 Graph 短期记忆实现的基础上进行改造:


2. 记忆存储

最终的 JOSN 内容如下:

json 复制代码
{
  "namespace": [
    "longmemory",
    "user_123456"
  ],
  "key": "memory_123456",
  "value": {
    "user_id": "user_123456",
    "updated_at": "2026-05-29T09:23:23.207253Z",
    "summary": {
      "identity": "姓名为张三"
    },
    "facts": [
      {
        "content": "用户姓名是张三",
        "confidence": 0.95,
        "source": "stated",
        "category": "identity",
        "recorded_at": "2026-05-29T09:17:35.406329700Z"
      }
    ]
  },
  "createdAt": 1780046603208,
  "updatedAt": 1780046603208
}

2.1 存储内容

记忆库保存以下几类信息,这些内容会随着对话持续更新:

  • 工作上下文:用户正在进行的项目、目标和常用主题的摘要
  • 个人上下文:用户偏好、沟通风格等个性化信息
  • 当前重点:最近关注的领域和活跃任务
  • 历史记录:数月内的上下文、长期背景信息
  • 事实信息:从对话中提取的明确事实(如常用工具、团队名称、项目约束)

2.2 StoreItem

StoreItemSpring AI Alibaba 框架中用于长期记忆存储的核心数据模型类,作用是定义存储在持久化 / 内存存储中的结构化记忆数据项,支持分层命名空间、唯一键、值存储和时间戳管理。

每个字段的真实含义:

字段 数据类型 长期记忆中的作用
namespace List<String> 记忆的分类 / 目录 例:"user", "zhangsan", "preference"(用户张三的偏好记忆)
key String 记忆的唯一名称 例:"eating_habits"(饮食习惯)
value Map<String, Object> 记忆的具体内容,结构化 Map,存任意记忆数据
createdAt long 记忆第一次形成的时间
updatedAt long 记忆被修改 / 强化的最新时间

一条【用户长期偏好】记忆:

java 复制代码
StoreItem userPreference = StoreItem.of(
    List.of("user", "zhangsan", "preference"), // 记忆目录
    "eating_habits",                           // 记忆唯一键
    Map.of(
        "like_food", "spicy", // 喜欢的水果
        "allergic", "peanut",
        "frequency", "eat_at_home"
    )
);

2.3 用户画像摘要

用户画像摘要,包含 6 个维度,每个维度都是自由文本,由 LLM 在对话中逐轮提取和合并:

java 复制代码
@JsonInclude(JsonInclude.Include.NON_NULL)
public class MemorySummary {

    /** 用户身份背景:姓名、角色、经验等 */
    @JsonProperty("identity")
    private String identity;

    /** 当前工作内容:正在做的项目、任务等 */
    @JsonProperty("current_work")
    private String currentWork;

    /** 技术栈:编程语言、框架、工具等 */
    @JsonProperty("tech_stack")
    private String techStack;

    /** 编码偏好:代码风格、命名规范、架构偏好等 */
    @JsonProperty("code_preferences")
    private String codePreferences;

    /** 沟通风格:语言、语气、回复详细程度等 */
    @JsonProperty("communication_style")
    private String communicationStyle;

    /** 项目上下文:当前项目的背景、目标、约束等 */
    @JsonProperty("project_context")
    private String projectContext;

    public String getIdentity() { return identity; }
    public void setIdentity(String identity) { this.identity = identity; }
    public String getCurrentWork() { return currentWork; }
    public void setCurrentWork(String currentWork) { this.currentWork = currentWork; }
    public String getTechStack() { return techStack; }
    public void setTechStack(String techStack) { this.techStack = techStack; }
    public String getCodePreferences() { return codePreferences; }
    public void setCodePreferences(String codePreferences) { this.codePreferences = codePreferences; }
    public String getCommunicationStyle() { return communicationStyle; }
    public void setCommunicationStyle(String communicationStyle) { this.communicationStyle = communicationStyle; }
    public String getProjectContext() { return projectContext; }
    public void setProjectContext(String projectContext) { this.projectContext = projectContext; }

    public Map<String, String> toMap() {
        Map<String, String> map = new LinkedHashMap<>();
        if (identity != null && !identity.isEmpty()) map.put("identity", identity);
        if (currentWork != null && !currentWork.isEmpty()) map.put("current_work", currentWork);
        if (techStack != null && !techStack.isEmpty()) map.put("tech_stack", techStack);
        if (codePreferences != null && !codePreferences.isEmpty()) map.put("code_preferences", codePreferences);
        if (communicationStyle != null && !communicationStyle.isEmpty()) map.put("communication_style", communicationStyle);
        if (projectContext != null && !projectContext.isEmpty()) map.put("project_context", projectContext);
        return map;
    }

    @SuppressWarnings("unchecked")
    public static MemorySummary fromMap(Map<String, Object> map) {
        if (map == null) return new MemorySummary();
        MemorySummary s = new MemorySummary();
        s.identity = (String) map.get("identity");
        s.currentWork = (String) map.get("current_work");
        s.techStack = (String) map.get("tech_stack");
        s.codePreferences = (String) map.get("code_preferences");
        s.communicationStyle = (String) map.get("communication_style");
        s.projectContext = (String) map.get("project_context");
        return s;
    }

    /** 合并单个分区,仅当新值非空时才覆盖 */
    public void mergeSection(String section, String value) {
        if (value == null || value.isBlank()) return;
        switch (section) {
            case "identity": identity = value; break;
            case "current_work": currentWork = value; break;
            case "tech_stack": techStack = value; break;
            case "code_preferences": codePreferences = value; break;
            case "communication_style": communicationStyle = value; break;
            case "project_context": projectContext = value; break;
        }
    }

    public void forEachNonEmpty(BiConsumer<String, String> consumer) {
        if (identity != null && !identity.isBlank()) consumer.accept("identity", identity);
        if (currentWork != null && !currentWork.isBlank()) consumer.accept("current_work", currentWork);
        if (techStack != null && !techStack.isBlank()) consumer.accept("tech_stack", techStack);
        if (codePreferences != null && !codePreferences.isBlank()) consumer.accept("code_preferences", codePreferences);
        if (communicationStyle != null && !communicationStyle.isBlank()) consumer.accept("communication_style", communicationStyle);
        if (projectContext != null && !projectContext.isBlank()) consumer.accept("project_context", projectContext);
    }

    public boolean isEmpty() {
        return (identity == null || identity.isBlank())
                && (currentWork == null || currentWork.isBlank())
                && (techStack == null || techStack.isBlank())
                && (codePreferences == null || codePreferences.isBlank())
                && (communicationStyle == null || communicationStyle.isBlank())
                && (projectContext == null || projectContext.isBlank());
    }
}

2.4 结构化事实

单条结构化事实。按置信度排序,按 content casefold 去重:

java 复制代码
public class MemoryFact implements Comparable<MemoryFact> {

    /** 事实内容:关于用户的明确陈述 */
    @JsonProperty("content")
    private String content;

    /** 置信度 0-1:stated(用户明确说出)=0.9+,inferred(推断)=0.6-0.8 */
    @JsonProperty("confidence")
    private double confidence;

    /** 来源类型:stated / inferred / corrected */
    @JsonProperty("source")
    private String source;

    /** 所属分区:对应 MemorySummary 的 6 个维度之一 */
    @JsonProperty("category")
    private String category;

    /** 记录时间 */
    @JsonProperty("recorded_at")
    private Instant recordedAt;

    public MemoryFact() {
        this.recordedAt = Instant.now();
    }

    public MemoryFact(String content, double confidence, String source, String category) {
        this.content = content;
        this.confidence = confidence;
        this.source = source;
        this.category = category;
        this.recordedAt = Instant.now();
    }

    public String getContent() { return content; }
    public void setContent(String content) { this.content = content; }
    public double getConfidence() { return confidence; }
    public void setConfidence(double confidence) { this.confidence = confidence; }
    public String getSource() { return source; }
    public void setSource(String source) { this.source = source; }
    public String getCategory() { return category; }
    public void setCategory(String category) { this.category = category; }
    public Instant getRecordedAt() { return recordedAt; }
    public void setRecordedAt(Instant recordedAt) { this.recordedAt = recordedAt; }

    /** 去重键 = content 忽略大小写,用于判断两条事实是否重复 */
    @JsonIgnore
    public String dedupKey() {
        return content != null ? content.toLowerCase(Locale.ROOT).trim() : "";
    }

    @Override
    public int compareTo(MemoryFact other) {
        return Double.compare(other.confidence, this.confidence);
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof MemoryFact that)) return false;
        return dedupKey().equals(that.dedupKey());
    }

    @Override
    public int hashCode() {
        return dedupKey().hashCode();
    }

    public Map<String, Object> toMap() {
        Map<String, Object> map = new LinkedHashMap<>();
        map.put("content", content);
        map.put("confidence", confidence);
        map.put("source", source);
        map.put("category", category);
        map.put("recorded_at", recordedAt != null ? recordedAt.toString() : Instant.now().toString());
        return map;
    }

    @SuppressWarnings("unchecked")
    public static MemoryFact fromMap(Map<String, Object> map) {
        MemoryFact f = new MemoryFact();
        f.content = (String) map.get("content");
        Object conf = map.get("confidence");
        f.confidence = conf instanceof Number ? ((Number) conf).doubleValue() : 0.5;
        f.source = (String) map.getOrDefault("source", "inferred");
        f.category = (String) map.getOrDefault("category", "tech_stack");
        String recorded = (String) map.get("recorded_at");
        f.recordedAt = recorded != null ? Instant.parse(recorded) : Instant.now();
        return f;
    }
}

2.5 长期记忆数据

长期记忆核心数据容器。包含用户画像摘要(6 个分区)和结构化事实列表,支持与 Store 接口互转(toMap / fromMap):

java 复制代码
@JsonIgnoreProperties(ignoreUnknown = true)
public class LongMemory {

    /** 用户标识,对应 Store namespace 第二层 */
    @JsonProperty("user_id")
    private String userId;

    /** 最后更新时间 */
    @JsonProperty("updated_at")
    private Instant updatedAt;

    /** 用户画像摘要(6 个维度) */
    @JsonProperty("summary")
    private MemorySummary summary;

    /** 结构化事实列表,按置信度降序排列 */
    @JsonProperty("facts")
    private List<MemoryFact> facts;

    public LongMemory() {
        this.summary = new MemorySummary();
        this.facts = new ArrayList<>();
        this.updatedAt = Instant.now();
    }

    public static LongMemory createDefault(String userId) {
        LongMemory memory = new LongMemory();
        memory.userId = userId;
        return memory;
    }

    public String getUserId() { return userId; }
    public void setUserId(String userId) { this.userId = userId; }
    public Instant getUpdatedAt() { return updatedAt; }
    public void setUpdatedAt(Instant updatedAt) { this.updatedAt = updatedAt; }
    public MemorySummary getSummary() { return summary; }
    public void setSummary(MemorySummary summary) { this.summary = summary; }
    public List<MemoryFact> getFacts() { return facts; }
    public void setFacts(List<MemoryFact> facts) { this.facts = facts; }

    /** 将 LLM 提取结果合并到当前记忆 */
    @SuppressWarnings("unchecked")
    public void applyUpdate(MemoryUpdateResult update, int maxFacts) {
        if (update.getSummaryUpdates() != null) {
            for (Map.Entry<String, String> entry : update.getSummaryUpdates().entrySet()) {
                summary.mergeSection(entry.getKey(), entry.getValue());
            }
        }

        if (update.getFactsToRemove() != null && !update.getFactsToRemove().isEmpty()) {
            Set<String> toRemove = new HashSet<>(update.getFactsToRemove());
            facts.removeIf(f -> toRemove.contains(f.dedupKey()));
        }

        if (update.getFactsToReplace() != null && !update.getFactsToReplace().isEmpty()) {
            Set<String> toReplace = new HashSet<>(update.getFactsToReplace());
            facts.removeIf(f -> toReplace.contains(f.dedupKey()));
        }

        if (update.getFactsToAdd() != null && !update.getFactsToAdd().isEmpty()) {
            Set<String> existing = facts.stream().map(MemoryFact::dedupKey).collect(Collectors.toSet());
            for (MemoryFact fact : update.getFactsToAdd()) {
                if (!existing.contains(fact.dedupKey())) {
                    facts.add(fact);
                    existing.add(fact.dedupKey());
                }
            }
        }

        facts.sort(MemoryFact::compareTo);
        if (facts.size() > maxFacts) {
            facts = new ArrayList<>(facts.subList(0, maxFacts));
        }

        stripUploadMentions();
        updatedAt = Instant.now();
    }

    /** 清除文件上传等会话级临时信息的痕迹 */
    public void stripUploadMentions() {
        if (facts != null) {
            facts.removeIf(f -> f.getContent() != null &&
                    (f.getContent().contains("uploaded") || f.getContent().contains("上传了文件")));
        }
        if (summary != null) {
            String[] patterns = {"uploaded", "上传了文件", "file upload"};
            summary.forEachNonEmpty((key, value) -> {
                for (String p : patterns) {
                    if (value.toLowerCase().contains(p)) {
                        summary.mergeSection(key, "");
                        break;
                    }
                }
            });
        }
    }

    public boolean isEmpty() {
        return summary.isEmpty() && (facts == null || facts.isEmpty());
    }

    /** 转为 Map 用于写入 Store */
    public Map<String, Object> toMap() {
        Map<String, Object> map = new LinkedHashMap<>();
        map.put("user_id", userId);
        map.put("updated_at", updatedAt != null ? updatedAt.toString() : Instant.now().toString());
        map.put("summary", summary.toMap());
        map.put("facts", facts != null ? facts.stream().map(MemoryFact::toMap).collect(Collectors.toList()) : List.of());
        return map;
    }

    /** 从 Store 读取的 Map 还原 */
    @SuppressWarnings("unchecked")
    public static LongMemory fromMap(Map<String, Object> map) {
        if (map == null) return new LongMemory();
        LongMemory m = new LongMemory();
        m.userId = (String) map.get("user_id");
        String updated = (String) map.get("updated_at");
        m.updatedAt = updated != null ? Instant.parse(updated) : Instant.now();
        m.summary = MemorySummary.fromMap((Map<String, Object>) map.get("summary"));
        List<Map<String, Object>> factMaps = (List<Map<String, Object>>) map.get("facts");
        if (factMaps != null) {
            m.facts = factMaps.stream().map(MemoryFact::fromMap).collect(Collectors.toList());
        }
        return m;
    }
}

3. 记忆更新

3.1 消息过滤器

只保留 UserMessage + 最后一条 AssistantMessage,超长截断,减少送入 LLM 提取器的上下文大小,降低 token 消耗。


java 复制代码
public class MessageFilter {

    private final int maxMessageChars;
    private final boolean filterSystemMessages;

    public MessageFilter(int maxMessageChars, boolean filterSystemMessages) {
        this.maxMessageChars = maxMessageChars;
        this.filterSystemMessages = filterSystemMessages;
    }

    public List<Message> filterForMemory(List<Message> messages) {
        List<Message> filtered = new ArrayList<>();
        AssistantMessage lastAssistant = null;

        for (Message msg : messages) {
            if (filterSystemMessages &&
                    !(msg instanceof UserMessage) &&
                    !(msg instanceof AssistantMessage)) {
                continue;
            }
            if (msg instanceof UserMessage) {
                filtered.add(truncate(msg));
            } else if (msg instanceof AssistantMessage) {
                lastAssistant = (AssistantMessage) msg;
            }
        }

        if (lastAssistant != null) {
            filtered.add(truncate(lastAssistant));
        }

        return filtered;
    }

    private Message truncate(Message msg) {
        String text = msg.getText();
        if (text != null && text.length() > maxMessageChars) {
            String truncated = text.substring(0, maxMessageChars) + "[...truncated]";
            if (msg instanceof UserMessage) {
                return new UserMessage(truncated);
            } else if (msg instanceof AssistantMessage) {
                return new AssistantMessage(truncated);
            }
        }
        return msg;
    }
}

3.2 信号检测器

基于正则表达式的信号检测器(零 LLM 成本),检测用户纠正信号和肯定信号,引导 LLM 提取器提高记忆更新精度。

java 复制代码
public class SignalDetector {

    private static final Pattern[] CORRECTION_PATTERNS = {
            Pattern.compile("不对[,,。.]?我?(其实|实际|原本)"),
            Pattern.compile("纠正[一下]?[::]"),
            Pattern.compile("更正[一下]?[::]"),
            Pattern.compile("(不是|不对|错了)[,,].*应该"),
            Pattern.compile("(?i)(no[,.\\s]?.*(actually|i mean)|correction[\\s:])"),
            Pattern.compile("(?i)(that'?s not (right|correct|what i))"),
            Pattern.compile("(?i)(i actually|let me correct)"),
            Pattern.compile("说错了[,,]"),
            Pattern.compile("搞错了[,,]"),
            Pattern.compile("不是这样的[,,]")
    };

    private static final Pattern[] REINFORCEMENT_PATTERNS = {
            Pattern.compile("(?i)(yes[,.\\s]?(that'?s? (right|correct|exactly|true)|exactly|indeed))"),
            Pattern.compile("没错[,,。.!!]"),
            Pattern.compile("(?i)(exactly[!]?|precisely[!]?)"),
            Pattern.compile("正是[这样如此]"),
            Pattern.compile("对的?[,,。.]"),
            Pattern.compile("(?i)(good[,.]?\\s*(but|and|additionally|also))"),
            Pattern.compile("(?i)(i agree[,.]?\\s*(but|and|additionally|also))"),
            Pattern.compile("(?i)(right[,.]?\\s*(but|and|also|additionally))"),
            Pattern.compile("说(得|的)对[,,。.!!]"),
            Pattern.compile("正确[,,。.!!]"),
    };

    public List<String> detectCorrection(String userMessage) {
        if (userMessage == null) return List.of();
        List<String> signals = new ArrayList<>();
        for (Pattern p : CORRECTION_PATTERNS) {
            if (p.matcher(userMessage).find()) {
                signals.add(userMessage);
                break;
            }
        }
        return signals;
    }

    public List<String> detectReinforcement(String userMessage) {
        if (userMessage == null) return List.of();
        List<String> signals = new ArrayList<>();
        for (Pattern p : REINFORCEMENT_PATTERNS) {
            if (p.matcher(userMessage).find()) {
                signals.add(userMessage);
                break;
            }
        }
        return signals;
    }
}

3.3 LLM 响应解析结果

LLM 响应解析结果,包含摘要更新和事实的增删改。

java 复制代码
public class MemoryUpdateResult {

    private Map<String, String> summaryUpdates;
    private List<MemoryFact> factsToAdd;
    private List<String> factsToReplace;
    private List<String> factsToRemove;

    public Map<String, String> getSummaryUpdates() { return summaryUpdates; }
    public void setSummaryUpdates(Map<String, String> summaryUpdates) { this.summaryUpdates = summaryUpdates; }
    public List<MemoryFact> getFactsToAdd() { return factsToAdd; }
    public void setFactsToAdd(List<MemoryFact> factsToAdd) { this.factsToAdd = factsToAdd; }
    public List<String> getFactsToReplace() { return factsToReplace; }
    public void setFactsToReplace(List<String> factsToReplace) { this.factsToReplace = factsToReplace; }
    public List<String> getFactsToRemove() { return factsToRemove; }
    public void setFactsToRemove(List<String> factsToRemove) { this.factsToRemove = factsToRemove; }
}

3.4 Prompt 构建器

将当前记忆 JSON + 过滤后对话 + 纠正/肯定信号提示拼装成提取 Prompt

java 复制代码
public class UpdatePromptBuilder {

    private static final Logger logger = LoggerFactory.getLogger(UpdatePromptBuilder.class);

    private final ObjectMapper objectMapper;
    private final boolean signalsEnabled;

    private static final String DEFAULT_PROMPT = """
            你是一个长期记忆管理器。请根据以下对话更新用户画像。

            ## 当前记忆
            ```json
            {current_memory}
            ```

            ## 最近的对话
            {conversation}

            ## 指令
            1. 更新以下 6 个摘要分区(identity 身份背景、current_work 当前工作、tech_stack 技术栈、code_preferences 编码偏好、communication_style 沟通风格、project_context 项目背景)。只有当对话中明确提到或强烈暗示了新信息时才更新,未涉及的分区保持为 null。
            2. 提取事实:
               - content:关于用户的明确事实陈述
               - confidence:置信度 0.0-1.0(用户明确说出的用 0.9+,推论得出的用 0.6-0.8)
               - source:"stated"(用户明确说出)、"inferred"(推断得出)、"corrected"(用户纠正了之前的信息)
               - category:该事实所属的摘要分区
            3. 只添加新事实或替换过时的事实,不要重复已有事实。
            4. 所有内容使用中文。
            {signal_hints}

            ## 响应格式
            只返回合法的 JSON(不要 markdown 代码块,不要多余文字):
            {"summary_updates": {"identity": "新内容或null", ...}, "facts_to_add": [{"content": "事实内容", "confidence": 0.9, "source": "stated", "category": "tech_stack"}], "facts_to_replace": ["要替换的事实的去重key"], "facts_to_remove": ["要删除的事实的去重key"]}
            """;

    public UpdatePromptBuilder(boolean signalsEnabled) {
        this.objectMapper = new ObjectMapper().registerModule(new JavaTimeModule());
        this.signalsEnabled = signalsEnabled;
    }

    public String buildPrompt(LongMemory currentMemory, List<Message> messages,
                              List<String> correctionSignals, List<String> reinforcementSignals) {
        String currentMemoryJson;
        try {
            currentMemoryJson = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(currentMemory);
        } catch (Exception e) {
            currentMemoryJson = "{}";
        }

        StringBuilder conversation = new StringBuilder();
        for (Message msg : messages) {
            String role = msg.getMessageType().getValue();
            String text = msg.getText();
            if (text != null && !text.isBlank()) {
                conversation.append("[").append(role).append("]: ").append(text).append("\n\n");
            }
        }

        String signalHints = "";
        if (signalsEnabled) {
            if (correctionSignals != null && !correctionSignals.isEmpty()) {
                signalHints = """
                        **重要**:用户在本次对话中纠正了之前的信息。
                        请更新记忆以反映纠正后的信息,并删除或更新与纠正内容冲突的事实。
                        """;
            } else if (reinforcementSignals != null && !reinforcementSignals.isEmpty()) {
                signalHints = """
                        **注意**:用户在本次对话中肯定或强化了之前的信息。
                        可以适当提高相关事实的置信度,但不要修改已有的准确信息。
                        """;
            }
        }

        return DEFAULT_PROMPT
                .replace("{current_memory}", currentMemoryJson)
                .replace("{conversation}", conversation.toString())
                .replace("{signal_hints}", signalHints);
    }
}

3.5 防抖实现

同一 Thread 内,用户可能在 30 秒内连续发多轮消息,如果不防抖,每一轮都立即调用 LLM 提取记忆,会产生三个问题:

  1. 重复调用浪费3 轮对话调用 3 次记忆 LLM,但合并成一次就够了
  2. 信息不完整 :第 1 轮只看到 1 条对话,第 2 轮只看到 2 条,不如把 3 轮攒一起提取
  3. LLM 频率限制 :短时间内多次调用可能触发 API 限流

设计一个队列条目,包含过滤后的对话消息和检测到的纠正/肯定信号,同一 key = userId 的旧条目会被覆盖以实现防抖批处理。

java 复制代码
public record MemoryQueueEntry(
        String userId,
        String threadId,
        List<Message> messages,
        List<String> correctionSignals,
        List<String> reinforcementSignals,
        Instant queuedAt
) {
    public String key() { return userId; }
}

设计一个异步防抖队列,同一 userId 的对话会覆盖旧条目并重置倒计时,计时器到期后调用 MemoryUpdater 批量提取记忆。

java 复制代码
public class MemoryUpdateQueue {

    private static final Logger logger = LoggerFactory.getLogger(MemoryUpdateQueue.class);

    private final ConcurrentHashMap<String, MemoryQueueEntry> entries;
    private final ConcurrentHashMap<String, ScheduledFuture<?>> timers;
    private final ScheduledExecutorService scheduler;
    private final MemoryUpdater memoryUpdater;
    private final Duration debounceDuration;

    public MemoryUpdateQueue(MemoryUpdater memoryUpdater, Duration debounceDuration, int threadPoolSize) {
        this.memoryUpdater = memoryUpdater;
        this.debounceDuration = debounceDuration;
        this.entries = new ConcurrentHashMap<>();
        this.timers = new ConcurrentHashMap<>();
        this.scheduler = Executors.newScheduledThreadPool(threadPoolSize, r -> {
            Thread t = new Thread(r, "longmemory-updater");
            t.setDaemon(true);
            return t;
        });
    }

    /**
     * 入队。同一 key 覆盖旧条目,取消旧计时器并重新开始防抖倒计时。
     */
    public CompletableFuture<Void> enqueue(MemoryQueueEntry entry) {
        entries.put(entry.key(), entry);

        ScheduledFuture<?> existing = timers.get(entry.key());
        if (existing != null && !existing.isDone()) {
            existing.cancel(false); // 取消旧计时器,实现防抖
        }

        ScheduledFuture<?> timer = scheduler.schedule(
                () -> processQueue(entry.key()),
                debounceDuration.toMillis(),
                TimeUnit.MILLISECONDS
        );
        timers.put(entry.key(), timer);

        return CompletableFuture.completedFuture(null);
    }

    /** 计时器到期回调:取出条目,调用 LLM 提取记忆,异常不影响主流程 */
    private void processQueue(String key) {
        MemoryQueueEntry entry = entries.remove(key);
        timers.remove(key);
        if (entry == null) return;

        try {
            memoryUpdater.update(entry);
        } catch (Exception e) {
            logger.warn("记忆更新失败 key={}: {}", key, e.getMessage());
        }
    }

    @PreDestroy
    public void shutdown() {
        logger.info("正在关闭 MemoryUpdateQueue...");
        timers.values().forEach(f -> f.cancel(false));
        scheduler.shutdown();
        try {
            if (!scheduler.awaitTermination(10, TimeUnit.SECONDS)) {
                scheduler.shutdownNow();
            }
        } catch (InterruptedException e) {
            scheduler.shutdownNow();
            Thread.currentThread().interrupt();
        }
    }
}

3.6 记忆更新器

LLM 驱动的记忆提取的核心编排器,用 Store 接口持久化,运行在队列线程池中。所有异常被静默捕获,绝不传播到主对话流程。

java 复制代码
public class MemoryUpdater {

    private static final Logger logger = LoggerFactory.getLogger(MemoryUpdater.class);
    /** Store 命名空间前缀,key 固定为 "memory" */
    private static final List<String> NAMESPACE_PREFIX = List.of("longmemory");
    private static final String STORE_KEY = "memory";

    private final ChatModel chatModel;
    private final Store store;
    private final UpdatePromptBuilder promptBuilder;
    private final ObjectMapper objectMapper;
    private final int maxFacts;
    private final MessageFilter messageFilter;
    private final SignalDetector signalDetector;
    private final boolean signalsEnabled;

    public MemoryUpdater(ChatModel chatModel, Store store,
                         UpdatePromptBuilder promptBuilder,
                         MessageFilter messageFilter, SignalDetector signalDetector,
                         int maxFacts, boolean signalsEnabled) {
        this.chatModel = chatModel;
        this.store = store;
        this.promptBuilder = promptBuilder;
        this.messageFilter = messageFilter;
        this.signalDetector = signalDetector;
        this.maxFacts = maxFacts;
        this.signalsEnabled = signalsEnabled;
        this.objectMapper = new ObjectMapper();
    }

    /**
     * 对单个队列条目执行完整更新流程(同步,运行在队列线程中)。
     */
    public void update(MemoryQueueEntry entry) {
        try {
            // 1. 从 Store 加载当前记忆
            LongMemory current = loadFromStore(entry.userId());

            // 2. 过滤消息
            List<Message> filtered = messageFilter.filterForMemory(entry.messages());

            // 3. 检测纠正/肯定信号
            List<String> correctionSignals = new ArrayList<>(entry.correctionSignals());
            List<String> reinforcementSignals = new ArrayList<>(entry.reinforcementSignals());
            if (signalsEnabled && correctionSignals.isEmpty() && reinforcementSignals.isEmpty()) {
                for (Message msg : filtered) {
                    if (msg instanceof UserMessage) {
                        correctionSignals.addAll(signalDetector.detectCorrection(msg.getText()));
                        reinforcementSignals.addAll(signalDetector.detectReinforcement(msg.getText()));
                    }
                }
            }

            // 4. 构建 Prompt
            String prompt = promptBuilder.buildPrompt(current, filtered, correctionSignals, reinforcementSignals);

            // 5. 调用 LLM
            String response = chatModel.call(new Prompt(new UserMessage(prompt)))
                    .getResult().getOutput().getText();

            // 6. 解析 JSON 响应
            MemoryUpdateResult updateResult = parseResponse(response);

            // 7. 应用更新
            if (updateResult != null) {
                current.applyUpdate(updateResult, maxFacts);
            }

            // 8. 写入 Store
            saveToStore(current);
            logger.debug("Memory updated for {}", entry.userId());

        } catch (Exception e) {
            logger.warn("Memory update failed for {}: {}", entry.userId(), e.getMessage());
        }
    }

    /** 从 Store 加载 LongMemory */
    public LongMemory loadFromStore(String userId) {
        List<String> namespace = new ArrayList<>(NAMESPACE_PREFIX);
        namespace.add(userId);
        Optional<StoreItem> item = store.getItem(namespace, STORE_KEY);
        if (item.isPresent()) {
            Map<String, Object> value = item.get().getValue();
            if (value != null) {
                return LongMemory.fromMap(value);
            }
        }
        return LongMemory.createDefault(userId);
    }

    /** 写入 Store */
    public void saveToStore(LongMemory memory) {
        List<String> namespace = new ArrayList<>(NAMESPACE_PREFIX);
        namespace.add(memory.getUserId());
        StoreItem item = StoreItem.of(namespace, STORE_KEY, memory.toMap());
        store.putItem(item);
    }

    /** 从 LLM 原始响应中解析 JSON */
    private MemoryUpdateResult parseResponse(String response) {
        try {
            String json = extractJson(response);
            if (json == null) return null;

            Map<String, Object> raw = objectMapper.readValue(json,
                    new TypeReference<Map<String, Object>>() {});

            MemoryUpdateResult result = new MemoryUpdateResult();

            // 解析摘要更新
            @SuppressWarnings("unchecked")
            Map<String, String> summaryUpdates = (Map<String, String>) raw.get("summary_updates");
            if (summaryUpdates != null) {
                Map<String, String> cleaned = new HashMap<>();
                for (Map.Entry<String, String> e : summaryUpdates.entrySet()) {
                    if (e.getValue() != null && !e.getValue().isBlank() && !"null".equalsIgnoreCase(e.getValue())) {
                        cleaned.put(e.getKey(), e.getValue());
                    }
                }
                result.setSummaryUpdates(cleaned);
            }

            // 解析新增事实
            @SuppressWarnings("unchecked")
            List<Map<String, Object>> factsRaw = (List<Map<String, Object>>) raw.get("facts_to_add");
            if (factsRaw != null) {
                List<MemoryFact> facts = new ArrayList<>();
                for (Map<String, Object> f : factsRaw) {
                    MemoryFact fact = new MemoryFact();
                    fact.setContent((String) f.get("content"));
                    Object conf = f.get("confidence");
                    fact.setConfidence(conf instanceof Number ? ((Number) conf).doubleValue() : 0.5);
                    fact.setSource((String) f.getOrDefault("source", "inferred"));
                    fact.setCategory((String) f.getOrDefault("category", "tech_stack"));
                    facts.add(fact);
                }
                result.setFactsToAdd(facts);
            }

            // 解析替换/删除
            @SuppressWarnings("unchecked")
            List<String> factsToReplace = (List<String>) raw.get("facts_to_replace");
            @SuppressWarnings("unchecked")
            List<String> factsToRemove = (List<String>) raw.get("facts_to_remove");
            result.setFactsToReplace(factsToReplace != null ? factsToReplace : List.of());
            result.setFactsToRemove(factsToRemove != null ? factsToRemove : List.of());

            return result;
        } catch (Exception e) {
            logger.warn("Failed to parse LLM response: {}", e.getMessage());
            return null;
        }
    }

    /** 从响应文本中提取 JSON 对象 */
    private String extractJson(String response) {
        if (response == null) return null;
        String trimmed = response.trim();
        int start = trimmed.indexOf('{');
        int end = trimmed.lastIndexOf('}');
        if (start >= 0 && end > start) {
            return trimmed.substring(start, end + 1);
        }
        return null;
    }
}

4. 记忆处理节点

4.1 记忆准备节点

记忆准备节点放在 START 之后第一个,负责加载长期记忆写入 long_memory_context + 将 query 写入短期记忆。

java 复制代码
public class MemoryPrepareNode implements NodeAction {

    private static final Logger logger = LoggerFactory.getLogger(MemoryPrepareNode.class);

    private final MemoryUpdater memoryUpdater;
    private final MessageWindowChatMemory chatMemory;

    public MemoryPrepareNode(MemoryUpdater memoryUpdater, MessageWindowChatMemory chatMemory) {
        this.memoryUpdater = memoryUpdater;
        this.chatMemory = chatMemory;
    }

    @Override
    public Map<String, Object> apply(OverAllState state) throws Exception {
        String threadId = state.value(THREAD_ID, "default").toString();
        String userId = state.value("user_id", "default").toString();
        String query = state.value(QUERY, "").toString();

        // 1. 写入短期记忆
        if (query != null && !query.isBlank()) {
            chatMemory.add(threadId, List.of(new UserMessage(query)));
        }

        // 2. 加载长期记忆
        LongMemory memory = memoryUpdater.loadFromStore(userId);
        String context = "";
        if (!memory.isEmpty()) {
            context = buildMemoryContext(memory);
        }

        logger.debug("MemoryPrepare - threadId={}, query={}, longMemoryLen={}",
                threadId, query, context.length());

        return Map.of("long_memory_context", context);
    }

    private String buildMemoryContext(LongMemory memory) {
        StringBuilder sb = new StringBuilder();
        sb.append("<system-reminder>\n");
        sb.append("以下是关于用户的长期记忆信息,请在回答时参考:\n\n");

        sb.append("## 用户画像\n");
        memory.getSummary().forEachNonEmpty((key, value) -> {
            String label = switch (key) {
                case "identity" -> "身份背景";
                case "current_work" -> "当前工作";
                case "tech_stack" -> "技术栈";
                case "code_preferences" -> "编码偏好";
                case "communication_style" -> "沟通风格";
                case "project_context" -> "项目背景";
                default -> key;
            };
            sb.append("- **").append(label).append("**: ").append(value).append("\n");
        });

        if (memory.getFacts() != null && !memory.getFacts().isEmpty()) {
            sb.append("\n## 已知事实\n");
            int count = 0;
            for (MemoryFact fact : memory.getFacts()) {
                if (count >= 10) break;
                sb.append("- ").append(fact.getContent())
                        .append(" (置信度: ").append(String.format("%.0f%%", fact.getConfidence() * 100))
                        .append(")\n");
                count++;
            }
        }

        sb.append("\n</system-reminder>");
        return sb.toString();
    }
}

4.2 长期记忆更新入队节点

放在 StateGraphEND 之前,负责从 MessageWindowChatMemory 读取对话 → 过滤 → 检测信号 → 异步入队,不阻塞主流程。

java 复制代码
public class MemoryUpdateQueueNode implements NodeAction {

    private static final Logger logger = LoggerFactory.getLogger(MemoryUpdateQueueNode.class);

    private final MemoryUpdateQueue queue;
    private final MessageFilter messageFilter;
    private final SignalDetector signalDetector;
    private final boolean signalsEnabled;
    private final MessageWindowChatMemory chatMemory;

    public MemoryUpdateQueueNode(MemoryUpdateQueue queue, MessageFilter messageFilter,
                                 SignalDetector signalDetector, boolean signalsEnabled,
                                 MessageWindowChatMemory chatMemory) {
        this.queue = queue;
        this.messageFilter = messageFilter;
        this.signalDetector = signalDetector;
        this.signalsEnabled = signalsEnabled;
        this.chatMemory = chatMemory;
    }

    @Override
    public Map<String, Object> apply(OverAllState state) throws Exception {
        String userId = state.value("user_id", "default").toString();
        String threadId = state.value("thread_id", "default").toString();

        // 从 MessageWindowChatMemory 读取对话历史
        List<Message> allMessages = chatMemory.get(threadId);
        if (allMessages == null || allMessages.isEmpty()) {
            logger.debug("No messages in chatMemory for thread {}", threadId);
            return Map.of();
        }

        // 过滤
        List<Message> filtered = messageFilter.filterForMemory(allMessages);

        // 检测信号
        List<String> correctionSignals = new ArrayList<>();
        List<String> reinforcementSignals = new ArrayList<>();
        if (signalsEnabled) {
            for (Message msg : filtered) {
                if (msg instanceof UserMessage) {
                    correctionSignals.addAll(signalDetector.detectCorrection(msg.getText()));
                    reinforcementSignals.addAll(signalDetector.detectReinforcement(msg.getText()));
                }
            }
        }

        // 异步入队
        MemoryQueueEntry entry = new MemoryQueueEntry(
                userId, threadId,
                filtered, correctionSignals, reinforcementSignals, Instant.now()
        );
        queue.enqueue(entry);

        logger.debug("Queued memory update for user={}, {} messages",
                userId, filtered.size());

        return Map.of();
    }
}

5. 项目集成

5.1 长期记忆配置类

基于 Store 接口的 FileSystemStore 实现磁盘持久化(目录:.longmemory)。

java 复制代码
@Configuration
public class LongMemoryConfig {

    /** 防抖时间,同一用户的多轮对话在此时间内只触发一次 LLM 提取 */
    private static final Duration DEBOUNCE = Duration.ofSeconds(30);
    /** 最大事实数量 */
    private static final int MAX_FACTS = 20;
    /** 单条消息最大字符数 */
    private static final int MAX_MESSAGE_CHARS = 1000;
    /** 队列线程数 */
    private static final int QUEUE_THREADS = 2;

    /** FileSystemStore 存储根目录 */
    @Bean
    public Store longMemoryStore() {
        String path = System.getProperty("user.dir") + "/longmemory-store";
        System.out.println("[LongMemory] Store 路径: " + path);
        return new FileSystemStore(path);
    }

    @Bean
    public MessageFilter messageFilter() {
        return new MessageFilter(MAX_MESSAGE_CHARS, true);
    }

    @Bean
    public SignalDetector signalDetector() {
        return new SignalDetector();
    }

    @Bean
    public UpdatePromptBuilder updatePromptBuilder() {
        return new UpdatePromptBuilder(true);
    }

    @Bean
    public MemoryUpdater memoryUpdater(
            @Qualifier("dashScopeChatModel") ChatModel chatModel,
            Store longMemoryStore,
            UpdatePromptBuilder promptBuilder,
            MessageFilter messageFilter,
            SignalDetector signalDetector) {
        return new MemoryUpdater(chatModel, longMemoryStore, promptBuilder,
                messageFilter, signalDetector, MAX_FACTS, true);
    }

    @Bean
    public MemoryUpdateQueue memoryUpdateQueue(MemoryUpdater updater) {
        return new MemoryUpdateQueue(updater, DEBOUNCE, QUEUE_THREADS);
    }

}

5.2 构建状态图

java 复制代码
@Configuration
public class OrderGraphConfig2 {

    private static final Logger log = LoggerFactory.getLogger(OrderGraphConfig2.class);

    @Bean("order2CompiledGraph")
    public CompiledGraph orderCompiledGraph(
            @Qualifier("dashScopeChatModel") ChatModel chatModel,
            MessageWindowChatMemory chatMemory,
            MemoryUpdater memoryUpdater,
            MemoryUpdateQueue memoryUpdateQueue,
            MessageFilter messageFilter,
            SignalDetector signalDetector) throws GraphStateException {

        KeyStrategyFactory keyStrategyFactory = () -> {
            HashMap<String, KeyStrategy> strategies = new HashMap<>();
            strategies.put(QUERY, new ReplaceStrategy());
            strategies.put(THREAD_ID, new ReplaceStrategy());
            strategies.put(RESPONSE, new ReplaceStrategy());
            strategies.put(USER_INTENT, new ReplaceStrategy());
            strategies.put(ORDER_ID, new ReplaceStrategy());
            strategies.put(LOGISTICS_INFO, new ReplaceStrategy());
            strategies.put(URGE_RESULT, new ReplaceStrategy());
            strategies.put(PROCESS_STATUS, new ReplaceStrategy());
            strategies.put(LONG_MEMORY_CONTEXT, new ReplaceStrategy());
            return strategies;
        };

        ChatClient chatClient = ChatClient.builder(chatModel).build();

        StateGraph graph = new StateGraph(keyStrategyFactory);

        // 记忆准备(长期记忆注入 + 短期记忆写入,合并为一个节点)
        graph.addNode("memory_prepare", node_async(
                new MemoryPrepareNode(memoryUpdater, chatMemory)));
        graph.addNode("memory_update", node_async(new MemoryUpdateQueueNode(
                memoryUpdateQueue, messageFilter, signalDetector, true, chatMemory)));

        graph.addNode("intent_recognition", node_async(new IntentRecognitionNode(chatClient, chatMemory)));
        graph.addNode("logistics_query", node_async(new LogisticsQueryNode()));
        graph.addNode("urge_order", node_async(new UrgeOrderNode()));
        graph.addNode("llm_response", node_async(new OrderLlmNode(chatClient, chatMemory)));

        // START → 记忆准备 → 意图识别
        graph.addEdge(START, "memory_prepare");
        graph.addEdge("memory_prepare", "intent_recognition");

        graph.addConditionalEdges("intent_recognition",
                edge_async(state -> {
                    String intent = state.value(USER_INTENT).map(Object::toString).orElse("");
                    if ("物流查询".equals(intent)) return "物流查询";
                    if ("催单".equals(intent)) return "催单";
                    return "llm_response";
                }),
                Map.of("物流查询", "logistics_query",
                       "催单", "urge_order",
                       "llm_response", "llm_response"));

        graph.addEdge("logistics_query", "llm_response");
        graph.addEdge("urge_order", "llm_response");
        // LLM 响应 → 记忆更新 → END
        graph.addEdge("llm_response", "memory_update");
        graph.addEdge("memory_update", END);

        GraphRepresentation mermaid = graph.getGraph(
                GraphRepresentation.Type.MERMAID, "电商订单咨询工作流", true);
        log.info("Order2 StateGraph Mermaid:\n{}", mermaid.content());

        SaverConfig saverConfig = SaverConfig.builder().register(new MemorySaver()).build();
        CompiledGraph compiledGraph = graph.compile(
                CompileConfig.builder().saverConfig(saverConfig).build());

        log.info("Order2 CompiledGraph ready");
        return compiledGraph;
    }
}

5.3 测试

发起对话:

自动生成了一个记忆文件:

文件内容包含了个人喜好等内容:

重启项目,自动加载了长期记忆

相关推荐
春日见1 小时前
五分钟入门 强化学习---Q-Learning算法与实现
人工智能·python·深度学习·算法·机器学习·计算机视觉
卡次卡次11 小时前
vibecoding起步之Claude Code的skills是什么,里面有什么文件,以ppt的一个skills举例
人工智能·opencv·powerpoint
AI服务老曹1 小时前
解耦异构算力:基于 Docker 与 GB28181/RTSP 的边缘计算 AI 视频管理平台架构设计与源码交付实践
人工智能·docker·边缘计算
Javatutouhouduan1 小时前
Java面试大厂真题汇总!
java·java面试·java面试题·后端开发·java编程·java架构师·java八股文
小饕1 小时前
RAG 实战:文本切块(Text Chunking)从入门到精通
人工智能
多年小白1 小时前
【周末消息】2026年5月30日-6月1日
大数据·人工智能·深度学习·机器学习·金融
AI导出鸭PC端1 小时前
智谱清言清除符号:当LLM输出遭遇“结构性失序”,一份关于AI导出鸭的工程化测评
人工智能
maomao大哥闯天下1 小时前
K8s对象deployment、job、service应用详解
java·容器·kubernetes
闪电悠米2 小时前
黑马点评-优惠券秒杀-05_local_lock_cluster_problem
java·spring boot·redis·缓存