gogo-agent

上下文工程——长期记忆

长期记忆让 Agent 跨会话记住"你是谁、你喜欢什么"——用户说过一次"我出差只坐高铁一等座、不订红眼航班",下个月再来规划时 Agent 自动带上这些偏好,无需重复交代。在 gogo agent 里,它由 AgentScope …

TL;DR

长期记忆让 Agent 跨会话记住"你是谁、你喜欢什么"——用户说过一次"我出差只坐高铁一等座、不订红眼航班",下个月再来规划时 Agent 自动带上这些偏好,无需重复交代。在 gogo agent 里,它由 AgentScope …

长期记忆让 Agent 跨会话记住"你是谁、你喜欢什么"——用户说过一次"我出差只坐高铁一等座、不订红眼航班",下个月再来规划时 Agent 自动带上这些偏好,无需重复交代。在 gogo-agent 里,它由 AgentScope 框架的 record_to_memory / retrieve_from_memory 两个工具驱动,底层落到阿里百炼记忆库做语义存储与召回,前面加一层 Redis 缓存扛住慢调用。

长期记忆的三层架构

gogo-agent 的长期记忆是一个三层结构,从上到下:

第一层:框架自动注册的两个工具

关键点:gogo-agent 里没有手写 @Tool 实现 record/retrieve。这两个工具是 AgentScope 框架在检测到longTermMemoryMode(LongTermMemoryMode.AGENT_CONTROL) 时自动注册的。

// 各 Agent 的 build() 里统一这样接线(以 ItineraryPlanAgent 为例)
ReActAgent.builder()
    .memory(memory)                                       // 短期记忆
    .longTermMemory(longTermMemory)                       // 长期记忆实现
    .longTermMemoryMode(LongTermMemoryMode.AGENT_CONTROL) // 关键:Agent 自主控制
    ...

AGENT_CONTROL 模式的含义:框架把 record_to_memory / retrieve_from_memory 暴露成可调用工具,由 LLM 自主决定何时存、何时取。这与"框架每轮自动注入/自动持久化"的模式相对——后者不需要 Agent 显式调用,但也无法精细控制。选 AGENT_CONTROL 是因为差旅偏好的读写时机需要判断(不是每句话都值得记),交给 LLM + 提示词规则来把控更合适。

第二层:TravelPreferenceLongTermMemory

这是 gogo-agent 唯一自己实现的一层,核心价值是用 Redis 缓存挡住百炼的慢网络调用。

// TravelPreferenceLongTermMemory.java implements LongTermMemory
public class TravelPreferenceLongTermMemory implements LongTermMemory {

    private final BailianLongTermMemory baiLianMemory;   // 远程记忆库
    private final TravelPreferenceMemoryCache cache;     // Redis 缓存
    private final String userId;                         // 用户隔离

    // ===== 写路径:写百炼 → 成功后失效缓存 =====
    @Override
    public Mono<Void> record(List<Msg> msgs) {
        if (msgs == null || msgs.isEmpty()) return Mono.empty();
        return baiLianMemory.record(msgs)
                .doOnSuccess(v -> {
                    // 偏好已更新,主动失效缓存,避免读到旧画像
                    cache.evict(userId);
                })
                .doOnError(e -> logger.warn("保存失败: {}", e.getMessage()));
    }

    // ===== 读路径:Redis 优先,miss 才查百炼并回填 =====
    @Override
    public Mono<String> retrieve(Msg msg) {
        if (msg == null) return Mono.just("");
        return Mono.fromCallable(() -> {
                    String cached = cache.get(userId);
                    return cached != null ? cached : "";
                })
                .subscribeOn(Schedulers.boundedElastic())   // 阻塞的 Redis 调用移出 reactor 线程
                .flatMap(cached -> {
                    if (!cached.isEmpty()) {
                        return Mono.just(cached);            // 缓存命中 → 跳过百炼
                    }
                    return baiLianMemory.retrieve(msg)       // miss → 语义召回
                            .doOnNext(result -> cache.put(userId, result))  // 回填缓存
                            .onErrorReturn("");              // 召回失败降级为空串,不阻断主流程
                });
    }
}

这里有三个精巧的设计细节: 缓存一致性:写(record)成功后立即 cache.evict(userId),保证下次读不会拿到旧画像。这是"写后失效"(write-through invalidation)的经典做法。 空串即未命中:put(userId, "") 不会真正缓存空串,所以读路径里用"空字符串"可靠地表示"缓存未命中",逻辑非常干净。 优雅降级:百炼召回失败时 onErrorReturn("")——记忆是"锦上添花",拿不到不应该让整个规划崩掉,返回空偏好继续走中性分逻辑。

第三层:Redis 缓存与百炼记忆库

TravelPreferenceMemoryCache——30 分钟 TTL 的 Redis 缓存:

// TravelPreferenceMemoryCache.java @Component
private static final String KEY_PREFIX = "ltm:travel-pref:";  // key: ltm:travel-pref:{userId}
// TTL 默认 1800s = 30min,可配 travel.memory.cache.ttl-seconds

public String get(String userId) {
    return redisTemplate.opsForValue().get(KEY_PREFIX + userId);  // miss/error 返回 null
}
public void put(String userId, String value) {
    if (!StringUtils.hasText(value)) return;   // 空值不缓存 → 空串可靠表示"未命中"
    redisTemplate.opsForValue().set(KEY_PREFIX + userId, value, ttl);
}
public void evict(String userId) {
    redisTemplate.delete(KEY_PREFIX + userId); // record 成功后调用
}

为什么缓存偏好? 用户的差旅偏好是高度稳定的数据——常飞航司、酒店品牌、座位选择半年都不会变。而百炼 retrieve 是一次跨网络的语义检索调用,几百毫秒起步。同一个用户一次会话里可能召回多次,30 分钟缓存能把绝大多数召回变成 Redis 读,延时从几百毫秒降到毫秒级。 BailianLongTermMemory——真正的存储与语义召回后端:

// TravelPreferenceLongTermMemoryFactory.create(userId)
BailianLongTermMemory bailianMemory = BailianLongTermMemory.builder()
        .apiKey(apiKey)
        .userId(userId)                    // 按用户隔离
        .memoryLibraryId(memoryLibraryId)  // 百炼记忆库 ID
        .projectId(projectId)
        .profileSchema(profileSchema)
        .metadata(Map.of("source", "gogo-travel-agent"))
        .build();
return new TravelPreferenceLongTermMemory(bailianMemory, cache, userId);

gogo-agent 本地没有向量数据库、没有 embedding 模型、没有相似度计算。语义存储(embedding)与语义召回(向量检索)全部由百炼记忆库在云端完成。本地代码只负责"调用 + 缓存 + 用户隔离"。这是一种"把复杂的记忆基础设施外包给托管服务"的架构选择。

语义召回 vs 关键词匹配

长期记忆的召回是语义的,不是关键词匹配。这一点很重要: 用户输入"按我平时的习惯规划去广州",Agent 调 retrieve_from_memory,百炼会用向量相似度找出与"差旅偏好"语义相关的历史记忆,返回一段自然语言摘要,例如: "用户避开红眼航班;偏好东航;酒店要含早餐;预算敏感。" 注意返回的是自然语言文本,不是结构化 JSON。这段文本会作为 preferences 参数透传给 plan_itinerary,也作为 LLM 打偏好分的依据。之所以用自然语言而非结构化字段,是因为偏好本身是开放的、难以穷举 schema 的("喜欢靠窗""不喜欢转机超过一次""酒店要有健身房"……),自由文本 + LLM 理解比硬 schema 更灵活。

记忆的读写时机

AGENT_CONTROL 模式下,"何时读写"由系统提示词规则约束。以行程规划提示词为例: 何时记录(record_to_memory): - 用户表达差旅偏好时——"以后都选高铁一等座""我出差只住华住会""给我避开红眼航班" - 用户分享差旅经历与感受时 - ⚠️ 不记录:闲聊、天气、一次性事实查询、非旅行话题 何时召回(retrieve_from_memory): - 用户说"按我往常偏好""你记得我喜欢什么"时 - 制定差旅方案前主动召回 何时不操作: - 对话与差旅无关时 - 用户只是陈述事实而非表达偏好时 - 不确定是否为偏好时——宁可不记 安全红线: 不记录敏感信息(身份证号、密码、具体金额),只记录与旅行选择相关的偏好。 一个完整的读写闭环示例:

第一次会话(周一)
  用户:"我出差从来不坐红眼航班,太累了"
  Agent 判断:这是明确偏好 → tool_call: record_to_memory("用户避开红眼航班")
             → 写百炼成功 → evict Redis 缓存

第二次会话(下个月)
  用户:"帮我规划去成都出差"
  Agent 规划前:tool_call: retrieve_from_memory("差旅偏好")
             → Redis miss → 查百炼 → 返回"用户避开红眼航班" → 回填 Redis
  打分时:红眼航班候选给低分,basis 写"红眼航班,用户明确避开"
  → 用户无需重复交代,体验连贯

手动管理个人偏好

除了 Agent 在对话中自主读写,gogo-agent 还提供了一条手动通道——用户在设置页勾选偏好:

PreferenceController (/api/preferences)
  saveUserPreferences  → 把勾选项拼成自然语言 → memory.record(...).block()
  getUserPreferences   → memory.retrieve(...) → 用 fastModel(qwen-flash) 把自然语言摘要反解析成结构化 JSON 回填表单

两条通道写的是同一个百炼记忆库(同一 userId),所以设置页填的偏好和对话中说的偏好会融合——设置页提供显式入口,对话提供隐式积累,互为补充。

版本提示

模型、框架与接口会持续变化。涉及版本号、参数与生产配置时,请在实践前对照对应官方文档。

LLMentor系统化学习大模型应用工程

内容来自个人课程知识库备份,并经过结构化整理。技术版本持续演进,生产使用前请结合官方文档验证。