gogo-agent

使用缓存减少工具重复调用

在 ReActAgent「推理→调工具→再推理」的循环里,重复调用几乎是必然的: 同一轮多步:规划一次差旅,模型可能在校验餐标、校验酒店标、生成方案时各查一次差旅政策——其实是同一份数据。 跨会话高频:用户每开一轮新对话,Agent…

TL;DR

在 ReActAgent「推理→调工具→再推理」的循环里,重复调用几乎是必然的: 同一轮多步:规划一次差旅,模型可能在校验餐标、校验酒店标、生成方案时各查一次差旅政策——其实是同一份数据。 跨会话高频:用户每开一轮新对话,Agent…

在 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 才不会串城市。这正是相比早期「单值缓存」的一次修正——早期假设「一会话一城市」,多城市场景会读到错的政策。 工具侧就是标准的 cache-aside(旁路缓存)。以差旅政策为例:

@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 变更极少,缓存越久越省。

版本提示

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

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

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