在 ReActAgent「推理→调工具→再推理」的循环里,重复调用几乎是必然的: - 同一轮多步:规划一次差旅,模型可能在校验餐标、校验酒店标、生成方案时各查一次差旅政策——其实是同一份数据。 - 跨会话高频:用户每开一轮新对话,Agent 都可能召回一次「他的出行偏好」,而偏好画像几小时内根本不变。 - 跨工具接力:规划工具算出的方案,紧接着要交给审核工具/子 Agent,如果不落地,审核方就得把整个规划重跑一遍。 - 模型的不确定性:LLM 有时会「忘了自己刚查过」,再调一次同样的工具。 重复的代价分三档,越靠后越该缓存:查库(便宜但累积可观)→ 跨网络慢调用(百炼召回、外部 API,几百 ms 起)→ 外部配额(按次计费的第三方接口,重复=烧钱)。gogo-agent 的缓存点正是沿着这条代价梯度铺开的。
会话级缓存——同一轮会话内只查一次
最轻量的一层:把结果缓存在会话上下文对象里,生命周期就是这一轮会话。它靠 AgentScope 的 ToolExecutionContext 机制,把 AgentSessionContext 透明注入到工具方法参数中(对 LLM 不可见,无需 @ToolParam)。 AgentSessionContext 里有三个缓存字段,注意它们各自的并发策略和一致性策略并不相同:
public class AgentSessionContext {
private final String userId;
private final String sessionId;
// ① 差旅政策:政策按城市分层(一线/新一线/其他)而不同,必须以【城市】为键;
// 工具可能并发执行,用 ConcurrentHashMap 保证线程安全
private final Map<String, String> travelPolicyByCity = new ConcurrentHashMap<>();
// ② 用户联系/乘机人信息(JSON):会话内准静态,但一旦被 update 就必须失效
private volatile String userContactInfo;
// ③ 用户常驻/办公城市(JSON):会话内【无写入口】,可安全长缓存
private volatile String userBaseLocation;
public String getTravelPolicy(String city) { // city 为 null 直接返回 null(不走缓存)
return city != null ? travelPolicyByCity.get(city.trim()) : null;
}
public void setTravelPolicy(String city, String travelPolicy) {
if (city != null && travelPolicy != null) {
travelPolicyByCity.put(city.trim(), travelPolicy);
}
}
public void invalidateUserContactInfo() { // 联系信息被更新后失效,下次查询回源 DB
this.userContactInfo = null;
}
// ... getUserBaseLocation / setUserBaseLocation ...
}
关键设计点:政策缓存以「城市」为键,而不是整个会话一个值。 一次差旅可能同时涉及多个目的城市(如「北京+上海两地跑」),而政策按城市分层不同,用 Map
@Tool(name = "query_travel_policy", description = "查询指定用户和目的城市的差旅政策标准…")
public String queryTravelPolicy(AgentSessionContext sessionCtx,
@ToolParam(name = "city") String city) {
String cached = sessionCtx.getTravelPolicy(city);
if (cached != null) {
logger.info("[TOOL][query_travel_policy] hit cache, city={}", city);
return cached; // 命中:直接返回,零查库
}
// 未命中:回源 → 序列化 → 回填缓存
TravelPolicy policy = travelPolicyService.getPolicy(sessionCtx.getUserId(), city);
String result = JSON.toJSONString(policy);
sessionCtx.setTravelPolicy(city, result);
return result;
}
用户信息类工具(QueryUserInfoTools)用的是同一套模式,但多了「写后失效」的闭环:
// 读:query_user_contact_info —— 命中会话缓存直接返回,否则回源 DB 并回填
String cached = sessionCtx.getUserContactInfo();
if (cached != null) { return cached; }
// ... 查 userProfileRepository、拼 JSON ...
sessionCtx.setUserContactInfo(json);
// 写:update_user_contact_info —— 存库成功后,主动失效会话缓存,防止读到旧值
userProfileRepository.save(profile);
sessionCtx.invalidateUserContactInfo();
而 query_user_base_location(常驻城市)因为会话内根本没有写入口,缓存后无需任何失效逻辑,是三者里最省心的。
跨工具 / 跨会话的 Redis 缓存
当「结果要跨工具接力」或「慢依赖要跨会话复用」时,会话对象就装不下了,得落 Redis。gogo-agent 有四处 Redis 缓存,Key 与 TTL 都按业务寿命精心设计: | 组件 | Key 结构 | TTL | 解决的重复 | | --- | --- | --- | --- | | ItineraryPlanStore | planner:result:{userId} | 24 小时 | 规划工具算完,审核工具/子 Agent 直接读,不重算 | | TravelPreferenceMemoryCache | ltm:travel-pref:{userId} | 30 分钟 | 跨会话反复打百炼长期记忆召回(慢) | | ApiKeyService | apikey:{provider}:{userId} | 7 天 | 反复查库解密 API Key | | RghTokenStore | rgh:token:{userId} | 30 天 | 反复获取/校验 rgh CLI 登录 token |
(1)跨工具结果复用:ItineraryPlanStore 这是最能体现「一次计算、多方复用」的缓存。规划工具 ItineraryPlannerTool 把往返方案排序结果算完后写 Redis;下游的审核工具 ItineraryReviewTools 直接读,而不是把整个规划重跑一遍:
@Component
public class ItineraryPlanStore {
private static final String KEY_PREFIX = "planner:result:";
private static final Duration RESULT_TTL = Duration.ofHours(24); // 短期中间数据,超时自动清
public static String keyOf(String userId) { // userId 做安全字符过滤,空则归 default
String uid = (userId == null || userId.trim().isEmpty()) ? "default"
: userId.trim().replaceAll("[^a-zA-Z0-9_.-]", "_");
return KEY_PREFIX + uid;
}
public void save(String userId, String resultJson) {
redisTemplate.opsForValue().set(keyOf(userId), resultJson, RESULT_TTL);
}
public String load(String userId) {
String content = redisTemplate.opsForValue().get(keyOf(userId));
return (content == null || content.isBlank()) ? null : content;
}
}
用 Redis 而非会话字段,是因为审核可能由独立的子 Agent(itinerary_review_agent,另一个 prototype 实例、另一份 memory)完成,两者不共享同一个会话对象,只能靠外部存储接力。 (2)跨会话慢调用缓存:TravelPreferenceMemoryCache + 长期记忆 百炼长期记忆的 retrieve 是跨网络慢调用,而用户偏好画像几十分钟内基本不变。缓存层做三件事:读命中即返回、写空串不缓存(空=未命中的哨兵)、异常降级为未命中:
public String get(String userId) {
try {
return redisTemplate.opsForValue().get(KEY_PREFIX + userId);
} catch (Exception e) {
logger.warn("...读取偏好缓存失败: {}", e.getMessage());
return null; // Redis 故障 → 当作未命中,回源,绝不阻断主流程
}
}
public void put(String userId, String value) {
if (userId == null || userId.isBlank() || value == null || value.isBlank()) {
return; // 空内容不缓存,保证「空串==未命中」可靠成立
}
redisTemplate.opsForValue().set(KEY_PREFIX + userId, value, ttl); // 默认 1800s
}
在 TravelPreferenceLongTermMemory.retrieve() 里,读缓存这个阻塞操作被隔离到 boundedElastic 线程,命中就跳过百炼,未命中才回源并回填:
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)
.doOnNext(result -> cache.put(userId, result)) // 未命中:回源并回填
.onErrorReturn("");
});
ApiKeyService(7 天)和 RghTokenStore(30 天)是同一套 cache-aside 思路,只是 TTL 按数据寿命拉长:API Key、登录 token 变更极少,缓存越久越省。