AI 面试相同请求为什么会重复调用,如何解决

AI 面试相同请求为什么会重复调用 AI:用 ConcurrentHashMap + CompletableFuture 实现 Single-flight

一、先看我们遇到的问题

在 AI 面试项目中,用户提交答案后,后端需要调用 AI 对答案进行评分。

正常流程应该是:

text 复制代码
提交答案 → 调用一次 AI → 返回评分结果

但在真实环境中,同一个逻辑请求可能在很短时间内到达多次。例如:

  • 用户连续点击提交;
  • 前端超时后自动重试;
  • 网关重试;
  • 两个线程几乎同时处理同一份答案。

如果后端没有并发合并机制,就会变成:

text 复制代码
请求 A(同一会话、同一题、同一答案)→ 调用 AI
请求 B(同一会话、同一题、同一答案)→ 调用 AI
请求 C(同一会话、同一题、同一答案)→ 调用 AI

结果是同一份答案被评分三次,重复消耗模型 Token、线程和 AI 接口并发额度,而且三次生成结果还可能略有不同。

为了判断两个请求是不是同一次计算,可以使用 业务阶段 + 会话 ID + 题号 + 答案摘要 组成请求 Key。

我们想要的效果是:

text 复制代码
请求 A → 真正调用 AI ───────→ 返回 result
请求 B → 不再调用 AI → 等待 A → 返回同一个 result
请求 C → 不再调用 AI → 等待 A → 返回同一个 result

这就是 Single-flight:同一个 Key 同时到达时,只让一个请求执行真实任务,其他请求等待并共享它的结果。


二、Single-flight 和 Redis 缓存有什么不同

这里说的 Redis,是指常见的"先读缓存,未命中再计算并写回"的结果缓存用法。

java 复制代码
String result = redis.get(key);
if (result != null) {
    return result;
}

result = callAi();
redis.set(key, result);
return result;

如果 Redis 已经有结果,后来的请求直接读取缓存,确实不会重复调用 AI。

问题发生在多个请求同时缓存未命中:

text 复制代码
请求 A:查询 Redis → miss → 调用 AI
请求 B:查询 Redis → miss → 调用 AI

A 调用 AI 时,结果还没有写入 Redis。B 只知道"缓存中没有结果",并不知道"A 已经在计算"。因此,普通结果缓存仍可能在首次缓存 miss 时重复调用 AI。

Single-flight 解决的是另一个时间段的问题:

text 复制代码
请求 A:发现当前没人计算 → 成为 owner → 调用 AI
请求 B:发现 A 正在计算   → 成为 follower → 等待 A

可以这样记:

机制 回答的问题 复用的对象
Redis 结果缓存 这个结果以前算出来了吗? 已经完成的结果
Single-flight 这个结果现在有人在算吗? 正在进行的任务

Redis 和 Single-flight 不是互相替代的关系,它们可以组合使用。但本文只讨论 JVM 本地 Single-flight 的实现原理。


三、Single-flight 是现成方法吗

Single-flight 是一种并发设计模式,不是 Java 标准库里一个名为 SingleFlight 的固定类。

在 Java 中,我们可以使用下面两个并发工具自己实现:

text 复制代码
ConcurrentHashMap + CompletableFuture

它们分别解决两个问题:

text 复制代码
ConcurrentHashMap:相同 Key 的请求应该找到哪个任务?
CompletableFuture:任务结束后,所有等待者怎样拿到同一个结果?

核心容器可以这样定义:

java 复制代码
private final ConcurrentMap<String, FlightEntry> flights =
        new ConcurrentHashMap<>();

Map 中保存的不是 AI 最终结果本身,而是"一次正在执行或刚完成的 Flight"。每个 FlightEntry 内部可以包含一个 CompletableFuture 和过期时间。


四、ConcurrentHashMap:保证相同 Key 找到同一个任务

1. 为什么不能使用普通 HashMap

假设使用普通 Map,并写成"先查询,再创建":

java 复制代码
FlightEntry entry = flights.get(key);
if (entry == null) {
    entry = new FlightEntry(new CompletableFuture<>());
    flights.put(key, entry);
}

两个线程可能同时执行:

text 复制代码
线程 A:get(key) → null
线程 B:get(key) → null
线程 A:创建 Future-A
线程 B:创建 Future-B

最终 A 和 B 各自认为自己是执行者,仍然会调用两次 AI。

问题不仅是普通 HashMap 线程不安全,更重要的是"查询 + 判断 + 创建"这三个动作不是一个原子操作。

2. 为什么使用 ConcurrentHashMap.compute

可以通过 compute 原子地创建或复用 Flight:

java 复制代码
AtomicBoolean newFlight = new AtomicBoolean(false);

FlightEntry entry = flights.compute(key, (ignored, existing) -> {
    if (existing == null || existing.expireAtMillis <= now) {
        newFlight.set(true);
        return new FlightEntry(
                new CompletableFuture<>(),
                now + ttlMillis
        );
    }
    return existing;
});

对于同一个 Key,compute 中的这段更新逻辑会被原子协调。因此:

  • 第一个进入的线程发现没有 Flight,创建新 Entry;
  • 后续线程发现 Entry 已存在,直接复用;
  • 只有创建 Entry 的线程把 newFlight 设置为 true

执行结果如下:

text 复制代码
线程 A:compute(k1) → 创建 Future-1 → newFlight=true  → owner
线程 B:compute(k1) → 复用 Future-1 → newFlight=false → follower
线程 C:compute(k1) → 复用 Future-1 → newFlight=false → follower

3. AtomicBoolean 是做什么的

compute 返回的只是 FlightEntry。无论创建还是复用,三个线程最后都拿到同一个 Entry,所以还要知道"这个 Entry 是不是我创建的"。

newFlight 就是当前线程的身份标记:

java 复制代码
if (newFlight.get()) {
    // owner:真正执行 supplier
} else {
    // follower:等待 Future
}

这里使用 AtomicBoolean,是因为 Java Lambda 内不能修改普通局部布尔变量。它不是用来在多个请求之间共享 owner 状态;每次调用 execute 都会创建自己的 newFlight

4. 为什么不在 compute 里面调用 AI

compute 只负责快速地创建或复用 Entry。耗时的 AI 调用放在 compute 外执行:

java 复制代码
FlightEntry entry = flights.compute(...);

if (newFlight.get()) {
    T value = supplier.get();
}

这样 Map 的原子更新部分很短,不会把漫长的网络请求放进 Map 的计算逻辑中。


五、CompletableFuture:让所有请求等待同一个结果

1. 它在这里不是异步线程池

很多人看到 CompletableFuture 会先想到异步执行,但这里最重要的作用不是启动线程,而是充当"将来才会有值的结果容器"。

java 复制代码
CompletableFuture<Object> future = new CompletableFuture<>();

这行代码不会自动创建线程,也不会自动执行 AI。它只是创建了一个尚未完成的 Future:

text 复制代码
初始状态:未完成
owner 成功:完成,并保存结果
owner 失败:异常完成,并保存异常

2. owner 如何发布成功结果

只有 newFlight=true 的 owner 执行真实任务:

java 复制代码
T value = supplier.get();
entry.resultFuture.complete(value);
return value;

supplier.get() 在 AI 面试场景中就是那次昂贵的 AI 调用。

当 owner 得到结果后,调用 complete(value)。同一个 Future 上正在等待的 follower 都会被唤醒,并拿到这个 value。

3. follower 如何等待

follower 不执行自己的 Supplier,而是等待 owner 对应的 Future:

java 复制代码
T reused = (T) entry.resultFuture.get(
        waitTimeoutMillis,
        TimeUnit.MILLISECONDS
);
return reused;

所以,即使 follower 传入了另一个 Supplier,它也不会执行。它只关心 owner 最终发布的结果。

这里使用带超时的 get,是为了避免 follower 因 owner 卡死而无限等待。需要注意:follower 等待超时只代表"我不再等了",不会自动终止 owner 正在执行的 AI 调用。

4. owner 失败时怎么办

owner 调用 AI 可能抛出异常。这时不能只让 owner 自己失败,否则 follower 会一直等待一个永远不会完成的 Future。

这时需要让 Future 异常完成:

java 复制代码
catch (Throwable ex) {
    entry.resultFuture.completeExceptionally(ex);
    flights.remove(key, entry);
    throw ex;
}

completeExceptionally(ex) 会唤醒所有 follower,让它们知道这次共享任务失败了。remove(key, entry) 是条件删除,只有 Map 中仍然是这个 Entry 时才删除,避免误删后来新建的 Flight。

如果等待线程被中断,还应该恢复线程的中断标记,然后向上抛出异常。


六、两个类组合后的完整流程

假设 A、B、C 使用相同 Key 同时请求 AI 评分:

text 复制代码
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 让这些请求共享同一次执行的结果或异常。

把上面的逻辑组合起来,可以得到下面这段简化代码:

java 复制代码
public <T> T execute(String key, Supplier<T> supplier) {
    AtomicBoolean newFlight = new AtomicBoolean(false);

    FlightEntry entry = flights.compute(key, (k, existing) -> {
        if (existing == null || existing.expired()) {
            newFlight.set(true);
            return new FlightEntry(new CompletableFuture<>(), expireAt);
        }
        return existing;
    });

    if (newFlight.get()) {
        try {
            T value = supplier.get();
            entry.resultFuture.complete(value);
            return value;
        } catch (Throwable ex) {
            entry.resultFuture.completeExceptionally(ex);
            flights.remove(key, entry);
            throw ex;
        }
    }

    return (T) entry.resultFuture.get(waitTimeout, MILLISECONDS);
}

七、可选的 TTL 和实现边界

除了 Future,FlightEntry 还可以带有 expireAtMillis。这样,成功完成的 Future 能在一个很短的 TTL 内保留,稍晚到达的相同 Key 可以立即拿到已完成结果。

这种设计除了合并"正在执行"的请求,还提供了一个很短的结果复用窗口。过期 Entry 应当在同 Key 再次进入时被替换,并通过定期清理避免 Map 持续增长。

使用时还要注意三个边界:

  1. Key 必须准确。 Key 太粗会错误合并不同答案;Key 太细则无法合并相同请求。
  2. 等待超时不会取消 owner。 follower 超时离开后,旧 owner 可能仍在调用 AI。
  3. 这个 Map 只存在于当前 JVM。 它只能直接合并进入同一个应用实例的请求。

八、总结

AI 面试中的问题是:相同评分请求同时到达,导致后端重复调用 AI,浪费 Token 和并发资源。

普通 Redis 结果缓存复用的是"已经算完的结果";Single-flight 合并的是"现在正在进行的相同计算"。

Java 中可以使用 ConcurrentHashMap + CompletableFuture 构建本地 Single-flight:

text 复制代码
ConcurrentHashMap.compute()
    → 原子创建或复用同一个 Flight
    → 选出唯一 owner

CompletableFuture
    → owner 发布结果或异常
    → follower 等待并复用

最终,同一个 Key 即使同时到达多次,也只有 owner 真正调用一次 AI,其他请求只等待并共享这一次调用的结果。

相关推荐
TunerT_TQ1 小时前
Valhalla 静态工程审阅 #022|百度PaddlePaddle 源码证据驱动评测【大厂开源基础设施特辑】
人工智能·百度·开源·paddlepaddle·#深度学习·#飞桨
阿里云大数据AI技术1 小时前
基于阿里云EMR Serverless StarRocks AI Function和混合检索,构建智驾训练数据管理平台
人工智能
evans在进步1 小时前
Spring AI 从入门到实战:用 Java 实现大模型对话与 Tool Calling
java·人工智能·spring
智塑未来1 小时前
发那科又叫法兰克?译名误区拆解,数控自动化龙头全维度解析
大数据·人工智能·自动化
网易云信1 小时前
立即下载!帝王蟹(ClawHive)桌面客户端正式上线!
人工智能·agent
windliang1 小时前
Claude Code 源码分析(六):上下文的发现、注入与压缩
前端·javascript·人工智能
众人皆醒我独醉1 小时前
Ray Serve:把 LLM 推理当"分布式 Actor"调度——不是 Kubernetes,是 Python 原生
面试·llm·ai编程
龍德明宇1 小时前
AI不会疼-龍德明宇
人工智能·算法·大语言模型llm·负主体性·ai存在论
(轻舟已过万重山)1 小时前
第27章 框架实操:用 LangChain/LlamaIndex 搭建完整 RAG 系统
人工智能·ai·langchain