长期记忆让 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),所以设置页填的偏好和对话中说的偏好会融合——设置页提供显式入口,对话提供隐式积累,互为补充。