news 2026/9/9 6:13:48

JCache缓存预热实战:从标准API到工程化落地

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
JCache缓存预热实战:从标准API到工程化落地

前几天在技术群里看到有人转这道题,题目本身不长,就一句话:如何通过 JCache API 实现一个简单的缓存预热逻辑。底下跟着一长串讨论,有人说 JCache 是什么,能不用 Redis 吗;有人说预热不就是启动时遍历数据库往缓存里塞数据吗;还有人问 loadAll 是不是异步的,怎么等它执行完。说实话,这问题看着基础,但能答完整的人真不多。

我做 Java 开发这些年,本地缓存这块踩过不少坑,JCache(JSR-107)也在一套供应链系统里实打实落地过。当时做缓存预热就踩过“启动后第一批请求又慢又卡”的坑,后来把 JCache 的 CacheLoader、loadAll、定时刷新这套玩熟了才彻底解决。这篇就以这道面试题为引子,把 JCache 缓存预热的原理、代码、坑一次性说完。不管你是准备高级 Java 岗位面试,还是正在给老系统做性能优化,这篇都值得你花十分钟读完。

1. 先搞清楚 JCache 是干什么的,再谈预热

1.1 JSR-107 规范的由来与边界

JCache 是 Java 官方的缓存标准,准确说是 JSR-107 专家组制定的规范,2014 年发布了 1.0 版本,目前 API 主要落在javax.cache包里。 JCache 的定位和 JDBC 之于数据库类似,JDBC 统一了 Java 操作数据库的接口,JCache 则统一了 Java 应用操作缓存的方式。

这套规范定义了一组核心接口:CachingProvider负责获取缓存管理器,CacheManager负责创建和管理缓存,Cache就是实际的 KV 容器,可以理解为一个线程安全的 Map。除此之外还有CacheLoader负责“缓存未命中时从哪里加载数据”,CacheWriter负责“缓存数据变更时写到哪个外部存储”,ExpiryPolicy负责过期策略,CacheEntryListener负责监听缓存事件。

有一点必须先说清楚:JCache 规范本身不限定缓存是本地还是分布式,它只定义接口,实现方可以是单机内存、也可以是分布式集群。常见实现包括 Ehcache 3、Hazelcast、Infinispan、Oracle Coherence 等。所以你写代码时面对的是接口,底层换成哪个实现,业务代码基本不用动。这道题如果出现在高级 Java 面试里,考官往往不是要你背 API,而是想听你讲明白“你怎么在真实系统里把缓存预热这件事做对”。

1.2 这道面试题考的是规范还是实现

很多候选人看到“JCache(JSR-107)”就开始紧张,觉得这玩意只在八股文里见过,实际项目没几个人用。但面试官问这道题,至少有三层意图:

第一层是考 API 熟练度。你能不能脱口而出Caching.getCachingProvider()getCacheManager()createCache()这套调用链?能不能说出cache.putcache.getcache.loadAll这几个方法的区别?

第二层是考工程经验。预热逻辑放哪里?是 Spring 启动完成后通过ApplicationRunner执行,还是在静态代码块里做,或者用@PostConstruct?启动时一次性加载多少数据才不会把数据库打垮?这些只有真正做过的人才能答出细节。

第三层是考原理理解。比如 JCache 里loadAll是异步的,很多新手以为调用完缓存里就有数据了,实际并非如此。再比如CacheLoaderload方法在缓存未命中时会被自动调用,这个机制如果利用好了,预热逻辑会写得非常优雅。

这道题冠以“基础篇”,不代表它简单,而是提醒你,越是基础的东西越能考察一个工程师的功底。

1.3 预热这件事为什么值得单拎出来问

缓存预热这个词,通俗讲就是在系统冷启动或大规模发布的时候,提前把热点数据从数据库或其他数据源加载到缓存里,避免用户请求一进来就打到数据库,导致响应时间飙升。

我见过不少团队,Redis 用得很熟,但项目里一旦引入本地缓存,就只会在请求进来时cache.get(key),查不到再查库再回填。这种懒加载模式对低频冷数据没问题,对高并发热点数据就是一场灾难。比如秒杀活动,零点刚过,成千上万个请求同时查一个还没进缓存的热点商品,缓存击穿就在一瞬间,数据库直接被打到报警。预热的核心价值就是把这些热点数据在流量到达之前塞进缓存,让缓存永远“备好弹药”。

同一道题在不同的工程阶段有不同的答法,后面几个部分我会把代码和思路完整铺开。

2. 缓存预热的设计思路:从需求到方案

2.1 冷启动的痛:不预热到底会发生什么

先讲一个我真实遇到过的场景。那套供应链系统里有个门店库存查询接口,每次查询需要关联商品、门店、库存流水三张表,数据库算一次要 30 到 80 毫秒。上线初期用的懒加载缓存,第一个用户查某个门店的时候,缓存里没有数据,就得等数据库把三张表 join 完之后再塞缓存。结果每次发版重启后的一两分钟内,请求延迟从平时的 5 毫秒飙升到 200 毫秒以上,用户端表现为页面一直在转圈。

更麻烦的是并发场景。多个线程同时发现同一个 key 不在缓存里,就会同时去查库,形成一个放大效应。缓存里每缺一个热点 key,数据库就要多扛几十倍的查询压力。这就是缓存穿透和击穿的雏形。预热就是为了解决这类问题,当然,它不能完全替代布隆过滤和互斥回填等措施,但能把绝大部分冷启动问题消解掉。

从技术选型角度看,预热方案有三种常见实现路径:第一种是用 JCache 规范里的CacheLoader+loadAll;第二种是应用启动后手动遍历数据源,一条条cache.put进去;第三种是用定时任务做周期性刷新。这道题问的是“通过 JCache API 实现”,所以核心重点在前两种,但定时刷新也必须懂,因为预热数据不会永远“热”下去。

2.2 方案一:用 CacheLoader 实现自动加载与批量预热

JCache 规范设计了一个非常巧妙的东西叫CacheLoader,它有两个核心方法:load(K key)负责根据单个 key 加载数据,loadAll(Iterable<? extends K> keys)负责批量加载。

这里有个容易被忽略的联动机制:当你给一个缓存配置了CacheLoader之后,调用cache.get(key)时如果缓存未命中,JCache 规范规定实现方会自动调用CacheLoader.load(key)去外部数据源取数据,取到后回填到缓存,再返回给调用方。也就是说,缓存查不到时会自动去问数据源,不需要你在业务代码里手写“查不到就去 DB 查再 put”这种样板代码。

loadAll就是我们做缓存预热的核心入口。你可以把需要预热的热点 key 集合传进去,CacheLoader会按 key 去数据源加载数据并写入缓存。这个机制的巧妙之处在于,预热的加载逻辑和运行期的未命中加载逻辑共用同一套CacheLoader,不存在两套数据加载规则不一致的问题。

2.3 方案二:启动时手动批量写入

如果你不想引入CacheLoader,也可以在缓存创建好之后,直接调用cache.putAll(map)把数据库里的数据批量写进缓存。这种方式的优点是非常直观,代码量少,一看就懂。缺点是“加载数据”的逻辑和“缓存写入”的逻辑耦合在业务代码里,不便于复用,尤其当你有多个缓存需要预热时,得写大量重复代码。

我见过一些老项目就是这种风格:写一个XXXCacheWarmUp类,里面先查数据库,再循环cache.put,每加一个缓存就复制一份类似代码。 这种方案在小场景下没有大问题,但一旦你的系统里缓存数量变多、数据源变复杂,就会发现每个缓存的预热逻辑散落各处,没人统一管理。后来我把这些预热逻辑收拢到缓存配置层,配合CacheLoader重写,维护成本才降下来。

2.4 两个方案怎么选

这里直接给出一张对比表,方便你根据项目情况做决策:

维度CacheLoader + loadAll启动时手动 putAll
代码耦合度加载逻辑统一在 Loader,业务代码只关心 key预热逻辑散落在启动类或工具类
未命中兜底自动调用 load,无需额外代码需要自己写查库回填逻辑
批量加载支持 loadAll,符合规范支持 putAll,但缺少加载语义
学习成本需要理解 JSR-107 的异步加载机制低,和操作普通 Map 类似
定时刷新结合可以复用 Loader 做定时 loadAll需要再起逻辑,重复代码较多
推荐场景多个缓存、热点 key 明确、生命周期受控单缓存、一次性启动加载

我的个人建议是:新项目优先用CacheLoader方案,它和 JCache 的设计思路一致,后期想要加定时刷新、加监控、加分布式协调都更容易。老项目如果是临时优化,例外直接用putAll也完全没问题。

3. 手把手实现:JCache 缓存预热完整代码

3.1 工程准备:Maven 依赖和 Provider 选择

先用 Maven 引入 JCache API 和一个具体实现。我比较常用 Ehcache 3 作为本地缓存 Provider,因为它对 JSR-107 的支持比较完整,配置也灵活。先加cache-api,再加ehcache依赖:

<dependency> <groupId>javax.cache</groupId> <artifactId>cache-api</artifactId> <version>1.1.1</version> </dependency> <dependency> <groupId>org.ehcache</groupId> <artifactId>ehcache</artifactId> <version>3.10.8</version> </dependency>

这里注意一点:ehcache这个 dependency 会把 JCache 相关的类也带进来,所以很多项目只加ehcache不加cache-api也能编译。但为了代码层面不依赖具体实现,我们还是建议显式声明cache-api。另外如果你用的是 Hazelcast,方案类似,但依赖包名不同,Provider 是通过Caching.getCachingProvider()的 SPI 机制自动发现的,不需要手动指定类名。

3.2 自定义 CacheLoader,把数据库查询写进去

假设我们有个用户信息表,需要预热用户 ID 1 到 10000 的基础信息。先定义一个用户服务,模拟数据库查询:

public class UserService { public User getUserById(Long userId) { // 模拟数据库查询,实际场景替换成 MyBatis / JPA 等 return new User(userId, "user-" + userId); } }

然后定义UserCacheLoader,实现javax.cache.integration.CacheLoader<Long, User>

public class UserCacheLoader implements CacheLoader<Long, User> { private final UserService userService = new UserService(); @Override public User load(Long key) { return userService.getUserById(key); } @Override public Map<Long, User> loadAll(Iterable<? extends Long> keys) { Map<Long, User> result = new HashMap<>(); for (Long key : keys) { // 实际项目建议批量查询,不要循环单查 result.put(key, load(key)); } return result; } }

这里的重点是loadAll的实现。很多人在面试时只写了load,没写loadAll,这是不完整的。因为预热走的是loadAll,如果你不重写它,默认行为是什么取决于具体实现,部分 Provider 会退化成逐个调用load,性能没法保证。我在实际项目中会把loadAll改成按批次的批量 DB 查询,比如每 500 个 key 一批,用WHERE id IN (...)去查。

3.3 创建缓存并执行启动预热,注意 loadAll 是异步的

先创建一个带CacheLoader配置的缓存:

public class CacheWarmUpDemo { public static void main(String[] args) throws Exception { // 1. 获取 JCache 的 CachingProvider CachingProvider provider = Caching.getCachingProvider(); CacheManager cacheManager = provider.getCacheManager(); // 2. 定义缓存配置,绑定 CacheLoader MutableConfiguration<Long, User> configuration = new MutableConfiguration<>(); configuration.setTypes(Long.class, User.class); configuration.setCacheLoaderFactory(() -> new UserCacheLoader()); // 3. 创建名为 "userCache" 的缓存 Cache<Long, User> userCache = cacheManager.createCache("userCache", configuration); // 4. 准备预热 key 集合 Set<Long> hotKeys = new HashSet<>(); for (long id = 1; id <= 10000; id++) { hotKeys.add(id); } // 5. 执行预热 CountDownLatch latch = new CountDownLatch(1); userCache.loadAll(hotKeys, true, new CompletionListener() { @Override public void onCompletion() { latch.countDown(); } @Override public void onException(Exception e) { e.printStackTrace(); latch.countDown(); } }); // 6. 等待预热完成,超时时间视数据量而定 boolean finished = latch.await(30, TimeUnit.SECONDS); if (finished) { System.out.println("预热完成,缓存中数据量:" + userCache.size()); } else { System.out.println("预热超时,请检查数据源性能"); } } }

这里最关键的一点是loadAll方法签名:

void loadAll(Set<? extends K> keys, boolean replaceExistingValues, CompletionListener completionListener)

第二个参数replaceExistingValues表示是否覆盖缓存中已有的值。预热场景建议传true,否则缓存里已经有脏数据时不会刷新。第三个参数是完成监听器,可以为 null,但只要你希望知道预热什么时候结束,就最好传一个CompletionListener

规范里loadAll是异步的,实现方会在内部线程池里执行批量加载,执行完成后回调onCompletion。这里我用了CountDownLatch把异步转同步,预热完成后才对外提供服务。这一步非常关键,很多生产事故就是“缓存还没热完,流量就放进来了”。

3.4 定时刷新:让预热过的数据不“凉”下去

启动时预热只解决冷启动问题,但热点数据会随业务变化而转移。上午热门的商品,下午可能就不是了。所以除了启动预热,我还用ScheduledExecutorService做周期刷新。逻辑很简单:每隔一段时间,调用一次loadAll,把新一代热点 key 刷进缓存,同时淘汰掉已经不需要的 key。

ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1, r -> { Thread t = new Thread(r, "user-cache-warmup"); t.setDaemon(true); return t; }); scheduler.scheduleAtFixedRate(() -> { Set<Long> latestHotKeys = queryLatestHotUserIds(); userCache.loadAll(latestHotKeys, true, null); }, 1, 10, TimeUnit.MINUTES);

实际项目里,热 key 列表往往由运营后台配置、或者通过大数据平台统计最近 N 分钟的访问量得到。我这里用queryLatestHotUserIds()代替,你只需要把它替换成真实的动态查询即可。

定时刷新有几个细节需要注意:线程池一定要给线程起名字,否则排查问题时发现一堆无名线程会很难受;后台刷新如果失败不能影响主流程;刷新频率要避开业务高峰期,比如凌晨 3 点到 5 点之间做全量重刷就是比较常见的选择。

3.5 单条数据的动态预热与兜底

除了批量预热,还有一种场景是“按需预热”。比如用户第一次登录,我们要缓存他的昵称和头像。这时候走cache.get,如果配置了CacheLoader,JCache 会自动调到load方法,从数据库查出数据然后回填缓存。业务代码不需要关心缓存里有没有,只管拿结果就行:

User user = userCache.get(userId); if (user != null) { return user; } // 返回 null 说明缓存里没有,且 Loader 也没能加载到数据

这是我看好 JCache 的一个原因,它对业务代码的侵入非常低。不过这里有个坑:如果数据源里真的没有这个 userId,load会返回 null,那么缓存里会不会缓存 null?不同实现行为不同,有些实现会把空值也存进去,有些不会。为了业务安全,我的习惯是缓存里只存“能确认存在的对象”,查不到就抛异常或者返回Optional.empty(),不要让 null 在缓存层面传播。

另外还有一种动态预热场景:数据库里的某条记录变更了,需要主动刷新缓存里对应 key。直接用userCache.put(userId, newUser)即可,但要注意,put 操作会不会触发CacheWriter。如果你的缓存配置了CacheWriter,put 会同时写入外部存储,这可能不是你想要的行为。只更新缓存不清数据源时,可以考虑putIfAbsentreplace等方法,仔细读一下 JCache API 的注释再动手。

4. 实战中的坑与排查技巧

4.1 以为 loadAll 是同步的,结果请求来了缓存还是空的

这是我们项目里真实发生过的一次事故。开发同学写了userCache.loadAll(keys, true, null);之后,直接在主线程打印了userCache.size(),结果打出来是 0。他以为是代码写错了,排查了半天才发现loadAll根本没等加载完成就返回了。

这不是实现 bug,JSR-107 规范里明确写了loadAll是异步的。如果你调用时不传CompletionListener,那加载后台默默执行,业务线程拿不到任何通知。所以核心代码里我用CountDownLatch做了阻塞等待,并且设置了超时时间。建议你在面试中也把这一点作为亮点主动说出来,面试官一般都会追问“那你怎么知道异步加载完成了”,此时能答出CompletionListenerCountDownLatch配合使用,会明显加分。

4.2 预热的 Key 跟业务 Key 串了台

有位读者私信问过我一个问题:为什么预热完成了,但接口里cache.get还是查到不到?后来 debug 发现,他预热时用的 key 是Long类型的用户 ID,但接口里从请求参数拿到 ID 后做了一次String.valueOf(userId),再拿String类型当 key 去查。Cache<Long, User>里根本不可能命中String类型的 key,于是每次都走CacheLoader去查库。

这个坑的本质是 key 类型不统一。 JCache 在创建缓存时会通过setTypes(Long.class, User.class)声明 key 和 value 的类型,运行时如果 key 类型不对会直接抛ClassCastException。但如果你同时有多个缓存,且定义得比较随意,就不一定能及时发现。建议在工程上做一个统一的 CacheKey 封装,或者至少保证所有调用方都通过同一个人口类操作同一个缓存。

4.3 并发环境下重复预热打爆数据源

假设你的服务有 10 个节点,每个节点启动时都执行一次全量预热。如果热点 key 集是 10 万个,那么启动瞬间就会有 100 万次数据库查询请求进来。数据源再扛不住,就直接影响正在运行的业务。

解决思路有几个:第一,预热数据源最好走只读从库;第二,控制批量加载的大小,比如loadAll内部按 500 个 key 一批查询;第三,分布式环境可以用分布式锁,比如基于 Redis 或数据库实现的锁,保证同一时刻只有一个节点在执行全量预热,其他节点等锁释放后只做增量拉取;第四,如果允许短暂的冷启动,可以分批预热,先热最核心的 1000 个 key,后续再扩展。

4.4 换了缓存 Provider 之后行为变了

JCache 号称统一缓存 API,但不同 Provider 对等规范的实现细节略有差异。比如同样是createCache,如果缓存名已经存在,Ehcache 3 会抛CacheException,有些实现可能只是打印警告。再比如Cache.get未命中时自动调用CacheLoader的能力,不同 Provider 对“null value”的处理策略也不完全一样。

所以我的建议是:代码里尽量依赖 JCache 标准 API,不要调用任何 Provider 特有的类;测试环境用 Ehcache,生产环境用 Hazelcast 的场景,一定要在预发环境做一轮完整的缓存专项验证。否则等到上线那天才发现行为不一致,排障会很被动。

4.5 数据一致性:预热时数据源刚好在更新

预热本质上是把数据库里的数据做一次快照复制到缓存。但如果预热过程中数据源发生了更新,缓存里放进去的可能是旧数据。比如预热读到用户手机号是旧号码,预热进行到一半时用户改了手机号,那缓存里就会一直是旧号码,直到过期或下次刷新。

解决方式之一是预热完成之后,再结合业务事件做一次增量刷新。比如数据变更时通过消息队列发送一个事件,消费者收到事件后调用cache.put更新。另一个思路是让预热的写操作用putIfAbsent,而正常业务变更用replace,从 API 层面区分初始化写入和更新写入。当然,这些都要结合具体业务场景来选,不要为了用而用。

5. 面试延伸:这一题怎么答才加分

5.1 答题结构建议

如果面试现场遇到这道题,个人建议按“是什么、为什么、怎么做、有哪些坑”四层来答。先一句话点明 JCache 是 JSR-107 定义的缓存标准接口;再讲为什么要缓存预热;然后展开代码层面的实现,从CacheLoaderloadAll,重点强调异步回调;最后主动说出几个坑,比如异步等待、并发预热、key 类型一致性等。

整个回答控制在 3 到 5 分钟比较合适。不要一上来就贴大段代码,面试官想听的是思路。代码可以放到说完思路之后作为补充。主动提及CountDownLatch等待异步完成,以及定时刷新热点 key 这两个点,往往能让面试官觉得你确实在真实场景中写过,而不是只看过八股文。

5.2 几个容易答错的点

第一,CacheLoaderCacheWriter别混淆。CacheLoader是数据加载,解决缓存 miss 后从哪来的问题;CacheWriter是数据写入,解决缓存 put 后同步到哪的问题。两者方向相反。第二,loadAllreplaceExistingValues参数很多人不知道,理解成“是否允许加载”甚至“是否异步”,完全偏了。它的语义是:当缓存里已有某个 key 的值时,要不要用加载的新值覆盖。第三,JCache 缓存默认过期策略是什么,不同的 Provider 默认值可能不同,千万不要假设MutableConfiguration不设置 ExpiryPolicy 就等于永不过期。在 Ehcache 3 里,如果不配置 Duration,缓存对象是永久有效的,但如果底层存储淘汰策略触发了,数据还是可能被提前清掉。所以生产环境始终要明确设定过期策略。

5.3 再把目光拉回这道题本身

“高级 Java 每日一道面试题 2025 年 6 月 16 日基础篇”,虽然是“基础篇”,但背后串联起来的其实是 Java 缓存领域最核心的一条知识线:标准 API、加载器、异步、批量预热、定时刷新、容量管理。真正吃透这条线,你在实际项目中面对任何缓存框架,无论是 JCache 还是 Spring Cache 还是自己封装的内存缓存,都会多一层掌控力。

如果让我说一个在这道题里最有价值的实操细节,我会选“异步加载的等待机制”。工作中很多和缓存相关的事故都不是因为缓存技术本身复杂,而是因为开发同学默认某个操作是同步的,结果它实际是异步的,或者反过来。JCache 的loadAll恰好是个典型。你在代码里加一个CountDownLatch只需要几行,但能在启动瞬间挡住一大批潜在问题。根据我的经验,凡是上线前做过预热验证、并留出等待时间的系统,冷启动阶段的错误率都低得多。希望你下次写缓存预热时,也能保留这个习惯,至少让它成为一个默认动作。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/9 6:12:23

从“虚短虚断”到实战:运放电路原理、反馈与典型应用

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/9 6:09:43

10机39节点Simulink建模与暂态稳定仿真指南

1. 10机39节点到底是什么&#xff1f;为什么教科书和论文都爱用它1.1 从新英格兰测试系统说起如果你做电力系统方向的研究或课程设计&#xff0c;一定绕不开 IEEE 39 节点系统&#xff0c;也就是常说的 10 机 39 节点系统。很多人第一次看到这个名称时会有一种错觉&#xff1a;…

作者头像 李华
网站建设 2026/9/9 6:07:31

Opencode:可解释的AI编程代理与环境感知型开发协作者

1. 项目概述&#xff1a;Opencode 不是“开源代码”的泛称&#xff0c;而是一个真实存在的 AI 编程代理工具最近在开发者社区和 GitHub 趋势榜上频繁刷屏的opencode&#xff0c;很多人第一反应是“哦&#xff0c;不就是 open source code 的缩写&#xff1f;”——这恰恰是它最…

作者头像 李华
网站建设 2026/9/9 6:04:26

Skill不是Prompt!从设计到测试,大模型Agent技能工程化全解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/9 5:56:57

Claude 5.1缓存降价75%:Agent成本优化的工程实践指南

1. 这不是降价&#xff0c;是Anthropic在重新定义Agent经济模型的起点 最近刷到一条消息&#xff1a;Anthropic宣布Claude 5.1将缓存读取价格下调75%&#xff0c;并称Agent任务成本最高可降低45%。朋友圈里有人转发时配文“AI调用终于不肉疼了”&#xff0c;也有人直接截图发问…

作者头像 李华
网站建设 2026/9/9 5:56:55

Code Agent多智能体协作系统实战:RuntimeRunner硬调度设计

1. 这不是又一个“Agent框架”演示&#xff0c;而是一次真实系统生命周期的复盘“Code Agent 解剖”这个系列我写了十八篇&#xff0c;每一篇都聚焦一个具体模块、一次关键迭代、一个踩过的坑。但第十九篇&#xff0c;我想聊点不一样的——不是怎么写好一个Agent&#xff0c;而…

作者头像 李华