AI 面试相同请求为什么会重复调用,如何解决
发布时间:2026/8/6 17:04:09
分类:文化教育
浏览:1234

AI 面试相同请求为什么会重复调用 AI用 ConcurrentHashMap CompletableFuture 实现 Single-flight一、先看我们遇到的问题在 AI 面试项目中用户提交答案后后端需要调用 AI 对答案进行评分。正常流程应该是提交答案 → 调用一次 AI → 返回评分结果但在真实环境中同一个逻辑请求可能在很短时间内到达多次。例如用户连续点击提交前端超时后自动重试网关重试两个线程几乎同时处理同一份答案。如果后端没有并发合并机制就会变成请求 A同一会话、同一题、同一答案→ 调用 AI 请求 B同一会话、同一题、同一答案→ 调用 AI 请求 C同一会话、同一题、同一答案→ 调用 AI结果是同一份答案被评分三次重复消耗模型 Token、线程和 AI 接口并发额度而且三次生成结果还可能略有不同。为了判断两个请求是不是同一次计算可以使用业务阶段 会话 ID 题号 答案摘要组成请求 Key。我们想要的效果是请求 A → 真正调用 AI ───────→ 返回 result 请求 B → 不再调用 AI → 等待 A → 返回同一个 result 请求 C → 不再调用 AI → 等待 A → 返回同一个 result这就是 Single-flight同一个 Key 同时到达时只让一个请求执行真实任务其他请求等待并共享它的结果。二、Single-flight 和 Redis 缓存有什么不同这里说的 Redis是指常见的“先读缓存未命中再计算并写回”的结果缓存用法。Stringresultredis.get(key);if(result!null){returnresult;}resultcallAi();redis.set(key,result);returnresult;如果 Redis 已经有结果后来的请求直接读取缓存确实不会重复调用 AI。问题发生在多个请求同时缓存未命中请求 A查询 Redis → miss → 调用 AI 请求 B查询 Redis → miss → 调用 AIA 调用 AI 时结果还没有写入 Redis。B 只知道“缓存中没有结果”并不知道“A 已经在计算”。因此普通结果缓存仍可能在首次缓存 miss 时重复调用 AI。Single-flight 解决的是另一个时间段的问题请求 A发现当前没人计算 → 成为 owner → 调用 AI 请求 B发现 A 正在计算 → 成为 follower → 等待 A可以这样记机制回答的问题复用的对象Redis 结果缓存这个结果以前算出来了吗已经完成的结果Single-flight这个结果现在有人在算吗正在进行的任务Redis 和 Single-flight 不是互相替代的关系它们可以组合使用。但本文只讨论 JVM 本地 Single-flight 的实现原理。三、Single-flight 是现成方法吗Single-flight 是一种并发设计模式不是 Java 标准库里一个名为SingleFlight的固定类。在 Java 中我们可以使用下面两个并发工具自己实现ConcurrentHashMap CompletableFuture它们分别解决两个问题ConcurrentHashMap相同 Key 的请求应该找到哪个任务 CompletableFuture任务结束后所有等待者怎样拿到同一个结果核心容器可以这样定义privatefinalConcurrentMapString,FlightEntryflightsnewConcurrentHashMap();Map 中保存的不是 AI 最终结果本身而是“一次正在执行或刚完成的 Flight”。每个FlightEntry内部可以包含一个CompletableFuture和过期时间。四、ConcurrentHashMap保证相同 Key 找到同一个任务1. 为什么不能使用普通 HashMap假设使用普通 Map并写成“先查询再创建”FlightEntryentryflights.get(key);if(entrynull){entrynewFlightEntry(newCompletableFuture());flights.put(key,entry);}两个线程可能同时执行线程 Aget(key) → null 线程 Bget(key) → null 线程 A创建 Future-A 线程 B创建 Future-B最终 A 和 B 各自认为自己是执行者仍然会调用两次 AI。问题不仅是普通HashMap线程不安全更重要的是“查询 判断 创建”这三个动作不是一个原子操作。2. 为什么使用 ConcurrentHashMap.compute可以通过compute原子地创建或复用 FlightAtomicBooleannewFlightnewAtomicBoolean(false);FlightEntryentryflights.compute(key,(ignored,existing)-{if(existingnull||existing.expireAtMillisnow){newFlight.set(true);returnnewFlightEntry(newCompletableFuture(),nowttlMillis);}returnexisting;});对于同一个 Keycompute中的这段更新逻辑会被原子协调。因此第一个进入的线程发现没有 Flight创建新 Entry后续线程发现 Entry 已存在直接复用只有创建 Entry 的线程把newFlight设置为true。执行结果如下线程 Acompute(k1) → 创建 Future-1 → newFlighttrue → owner 线程 Bcompute(k1) → 复用 Future-1 → newFlightfalse → follower 线程 Ccompute(k1) → 复用 Future-1 → newFlightfalse → follower3. AtomicBoolean 是做什么的compute返回的只是FlightEntry。无论创建还是复用三个线程最后都拿到同一个 Entry所以还要知道“这个 Entry 是不是我创建的”。newFlight就是当前线程的身份标记if(newFlight.get()){// owner真正执行 supplier}else{// follower等待 Future}这里使用AtomicBoolean是因为 Java Lambda 内不能修改普通局部布尔变量。它不是用来在多个请求之间共享 owner 状态每次调用execute都会创建自己的newFlight。4. 为什么不在 compute 里面调用 AIcompute只负责快速地创建或复用 Entry。耗时的 AI 调用放在compute外执行FlightEntryentryflights.compute(...);if(newFlight.get()){Tvaluesupplier.get();}这样 Map 的原子更新部分很短不会把漫长的网络请求放进 Map 的计算逻辑中。五、CompletableFuture让所有请求等待同一个结果1. 它在这里不是异步线程池很多人看到CompletableFuture会先想到异步执行但这里最重要的作用不是启动线程而是充当“将来才会有值的结果容器”。CompletableFutureObjectfuturenewCompletableFuture();这行代码不会自动创建线程也不会自动执行 AI。它只是创建了一个尚未完成的 Future初始状态未完成 owner 成功完成并保存结果 owner 失败异常完成并保存异常2. owner 如何发布成功结果只有newFlighttrue的 owner 执行真实任务Tvaluesupplier.get();entry.resultFuture.complete(value);returnvalue;supplier.get()在 AI 面试场景中就是那次昂贵的 AI 调用。当 owner 得到结果后调用complete(value)。同一个 Future 上正在等待的 follower 都会被唤醒并拿到这个 value。3. follower 如何等待follower 不执行自己的 Supplier而是等待 owner 对应的 FutureTreused(T)entry.resultFuture.get(waitTimeoutMillis,TimeUnit.MILLISECONDS);returnreused;所以即使 follower 传入了另一个 Supplier它也不会执行。它只关心 owner 最终发布的结果。这里使用带超时的get是为了避免 follower 因 owner 卡死而无限等待。需要注意follower 等待超时只代表“我不再等了”不会自动终止 owner 正在执行的 AI 调用。4. owner 失败时怎么办owner 调用 AI 可能抛出异常。这时不能只让 owner 自己失败否则 follower 会一直等待一个永远不会完成的 Future。这时需要让 Future 异常完成catch(Throwableex){entry.resultFuture.completeExceptionally(ex);flights.remove(key,entry);throwex;}completeExceptionally(ex)会唤醒所有 follower让它们知道这次共享任务失败了。remove(key, entry)是条件删除只有 Map 中仍然是这个 Entry 时才删除避免误删后来新建的 Flight。如果等待线程被中断还应该恢复线程的中断标记然后向上抛出异常。六、两个类组合后的完整流程假设 A、B、C 使用相同 Key 同时请求 AI 评分1. A 调用 compute(k1) Map 中没有 k1 A 创建 Future-1成为 owner 2. B 调用 compute(k1) Map 中已有 k1 → Future-1 B 成为 follower 3. C 调用 compute(k1) Map 中已有 k1 → Future-1 C 成为 follower 4. A 执行 supplier.get() 只有这里真正调用 AI 5. B、C 调用 Future-1.get(timeout) 它们等待不调用 AI 6. A 得到 result调用 Future-1.complete(result) 7. B、C 被唤醒也拿到 result两者的职责可以浓缩成一句话ConcurrentHashMap 让相同请求找到同一个任务CompletableFuture 让这些请求共享同一次执行的结果或异常。把上面的逻辑组合起来可以得到下面这段简化代码publicTTexecute(Stringkey,SupplierTsupplier){AtomicBooleannewFlightnewAtomicBoolean(false);FlightEntryentryflights.compute(key,(k,existing)-{if(existingnull||existing.expired()){newFlight.set(true);returnnewFlightEntry(newCompletableFuture(),expireAt);}returnexisting;});if(newFlight.get()){try{Tvaluesupplier.get();entry.resultFuture.complete(value);returnvalue;}catch(Throwableex){entry.resultFuture.completeExceptionally(ex);flights.remove(key,entry);throwex;}}return(T)entry.resultFuture.get(waitTimeout,MILLISECONDS);}七、可选的 TTL 和实现边界除了 FutureFlightEntry还可以带有expireAtMillis。这样成功完成的 Future 能在一个很短的 TTL 内保留稍晚到达的相同 Key 可以立即拿到已完成结果。这种设计除了合并“正在执行”的请求还提供了一个很短的结果复用窗口。过期 Entry 应当在同 Key 再次进入时被替换并通过定期清理避免 Map 持续增长。使用时还要注意三个边界Key 必须准确。Key 太粗会错误合并不同答案Key 太细则无法合并相同请求。等待超时不会取消 owner。follower 超时离开后旧 owner 可能仍在调用 AI。这个 Map 只存在于当前 JVM。它只能直接合并进入同一个应用实例的请求。八、总结AI 面试中的问题是相同评分请求同时到达导致后端重复调用 AI浪费 Token 和并发资源。普通 Redis 结果缓存复用的是“已经算完的结果”Single-flight 合并的是“现在正在进行的相同计算”。Java 中可以使用ConcurrentHashMap CompletableFuture构建本地 Single-flightConcurrentHashMap.compute() → 原子创建或复用同一个 Flight → 选出唯一 owner CompletableFuture → owner 发布结果或异常 → follower 等待并复用最终同一个 Key 即使同时到达多次也只有 owner 真正调用一次 AI其他请求只等待并共享这一次调用的结果。