🌟 背景:被浪费的昂贵 Token
在构建 AI Agent 应用时,LLM 的 API 接口无疑是系统中最宝贵、最昂贵的资源。不仅因为按 Token 计费的成本高昂,更因为其响应延迟大、并发吞吐量受限(RPM/TPM 限制)。
在实际生产环境中,我们经常会遇到一种让人头疼的**"并发重复请求"**现象:
- 评分请求:同一个考生的作答,由于前端重试或网关重试,瞬间打出 5 个相同的评分请求。
- 追问生成:同一个上下文,多个 Agent 线程同时决定生成追问,导致 3 个线程同时调用 LLM。
- 简历抽题:同一份简历 ID,短时间内被多个下游服务并发请求抽题。
如果这些请求打到了同一个 Agent 实例,甚至更糟------打到了不同的 Agent 实例(分布式环境),传统的做法是每个线程各自为战,发起独立的 LLM 调用。
后果是什么?
- 💸 成本成倍增加:原本只需 1 次调用的开销,变成了 N 次。
- 🐢 系统被拖垮:并发骤增导致 LLM API 限流,正常请求也被阻塞。
- 🎲 结果不一致:相同输入可能得到不同的 LLM 输出,给业务逻辑带来隐患。
为了解决这个问题,业内有一个成熟的模式------Single-Flight(合并请求/单飞模式)。
💡 核心概念:从单机到分布式
1. 什么是 Single-Flight?
Single-Flight 的核心思想非常简单:当多个相同的请求并发到达时,只让第一个请求真正去执行,其他请求"搭便车"等待第一个请求的结果,然后共享这个结果。
就像多个人同时按电梯,只有第一个人的按电梯动作有效,其他人不需要再按,但大家都能坐上同一部电梯。
2. 单体 Single-Flight vs 分布式 Single-Flight
| 特性 | 单体 Single-Flight | 分布式 Single-Flight |
|---|---|---|
| 作用范围 | 单个 JVM / 单个进程内 | 整个分布式集群 / 多个 Pod 间 |
| 底层存储 | 本地内存 (如 ConcurrentHashMap + CompletableFuture) |
分布式缓存 (如 Redis) |
| 适用场景 | 单机部署,无状态服务的前置过滤 | 微服务架构,K8s 多实例部署 |
| 解决痛点 | 单机内的线程并发重复 | 跨节点的网络请求并发重复 |
⚠️ 为什么单体版不够用?
现代 AI Agent 服务几乎都是无状态、多实例部署的。如果只做单机 Single-Flight,请求 A 打到 Pod 1,请求 B 打到 Pod 2,它们在本地内存中互不可见,依然会发起两次重复的 LLM 调用。因此,对于 Agent 调用 LLM 的场景,必须实现分布式 Single-Flight。
🏗️ 架构设计:分布式 Single-Flight 如何工作?
我们借助 Redis 作为分布式协调中心,实现跨实例的请求合并。核心流程分为三个阶段:请求拦截与标记 、执行与等待 、结果广播与销毁。
关键设计点:
- 请求指纹:如何判断两个请求是否"相同"?必须对业务参数(如:Prompt、模型名、Temperature等)进行一致性 Hash,生成唯一的 Request Key。
- Leader 选举 :利用 Redis 的
SETNX(Set If Not Exists) 指令,谁先设置成功,谁就是真正去调 LLM 的 Leader。 - 结果传递 :Follower 如何拿到结果?有两种主流方案:
- 方案一:Pub/Sub(发布订阅)。Leader 完成后通过 Redis Pub/Sub 广播结果。优点是实时性高;缺点是 Pub/Sub 不持久化,如果 Follower 在广播瞬间断线会丢失结果。
- 方案二:短暂缓存+轮询 。Leader 完成后将结果存入 Redis(TTL 设短,如 10s),Follower 发现 SETNX 失败后,循环短暂休眠去 Redis GET 结果。这种方式更稳定,推荐在工程中使用。
💻 代码实战:基于 Redis 的分布式 Single-Flight
这里采用 Java + Spring Boot + Redisson 演示(基于短暂缓存+等待机制,逻辑更健壮),其他语言原理相通。
1. 定义请求指纹工具类
java
public class LLMRequestKeyUtil {
/**
* 生成 LLM 请求的唯一指纹
* 必须包含所有影响 LLM 输出的核心参数
*/
public static String generateKey(LLMRequest request) {
String rawKey = request.getModel() + ":" +
request.getPrompt() + ":" +
request.getTemperature() + ":" +
request.getTopP();
// 使用 MD5 或 SHA256 缩短 Key 长度
return DigestUtils.md5Hex(rawKey);
}
}
2. 核心拦截器:DistributedSingleFlight
java
@Component
public class DistributedSingleFlight {
@Autowired
private RedissonClient redissonClient;
// 等待 LLM 响应的轮询间隔
private static final long POLLING_INTERVAL_MS = 100;
// 等待超时时间(防止 Leader 异常宕机导致 Follower 死等)
private static final long WAIT_TIMEOUT_MS = 30000;
public LLMResponse execute(LLMRequest request, Supplier<LLMResponse> llmInvoker) {
String key = "llm:flight:" + LLMRequestKeyUtil.generateKey(request);
String resultKey = "llm:result:" + LLMRequestKeyUtil.generateKey(request);
RLock lock = redissonClient.getLock(key);
try {
// 尝试获取分布式锁,非阻塞,立即返回
boolean isLeader = lock.tryLock(0, 60, TimeUnit.SECONDS); // 60s为锁自动过期时间(防死锁)
if (isLeader) {
// ===== 我是 Leader,真正去调 LLM =====
try {
LLMResponse response = llmInvoker.get();
// 将结果短暂缓存到 Redis,供 Follower 读取
redissonClient.getBucket(resultKey).set(response, 10, TimeUnit.SECONDS);
return response;
} catch (Exception e) {
// 调用异常,也要存入异常信息,别让 Follower 死等
redissonClient.getBucket(resultKey).set(e, 10, TimeUnit.SECONDS);
throw e;
} finally {
// 释放锁,允许后续相同请求进来
lock.unlock();
}
} else {
// ===== 我是 Follower,等待 Leader 的结果 =====
long startTime = System.currentTimeMillis();
while (System.currentTimeMillis() - startTime < WAIT_TIMEOUT_MS) {
// 轮询检查结果是否已放入 Redis
Object result = redissonClient.getBucket(resultKey).get();
if (result != null) {
if (result instanceof Exception) {
throw (Exception) result; // 业务异常抛出
}
return (LLMResponse) result; // 拿到结果,搭便车成功!
}
// 稍作休眠,避免 CPU 空转
Thread.sleep(POLLING_INTERVAL_MS);
}
throw new RuntimeException("等待 LLM 结果超时");
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("线程被中断", e);
}
}
}
3.在 Agent 业务逻辑中使用
java
@Service
public class AgentScoringService {
@Autowired
private DistributedSingleFlight singleFlight;
@Autowired
private LLMClient llmClient;
public ScoreResult scoreAnswer(String userId, String question, String answer) {
LLMRequest request = new LLMRequest("gpt-4", buildPrompt(question, answer), 0.1, 1.0);
// 💡 魔法在这里:用 SingleFlight 包装 LLM 调用逻辑
LLMResponse response = singleFlight.execute(request, () -> {
// 只有 Leader 线程会执行到这里
return llmClient.call(request);
});
return parseScore(response);
}
}
🚨 生产环境避坑指南
在将分布式 Single-Flight 推向生产环境时,有几个关键点需要特别注意:
- 指纹必须绝对准确
- 不同的业务意图,哪怕只差一个标点符号,也不能合并。一定要仔细盘点 LLM Request 中的所有参数,千万不要把流式输出的流对象放入指纹计算。
- 死锁与超时控制
- 如果 Leader 节点在调用 LLM 时突然 OOM 或网络断开,必须保证锁能自动释放(Redisson 的看门狗机制或设置合理的 TTL)。
- Follower 不能无限期等待,必须设置
WAIT_TIMEOUT_MS,超时后降级为直接调用 LLM(容错底线)。
- 流式响应(SSE/WebSocket)的复杂性
- 如果你的 Agent 对外提供流式输出(Token 一个个蹦出来),Single-Flight 的实现难度会剧增。Leader 需要将 LLM 的流式结果拆包、存入 Redis 队列(如 Stream),Follower 从队列中消费。如果流式场景并发不高,建议绕开 Single-Flight,仅在非流式场景使用。
- 熔断与降级
- SingleFlight 本质上是让多个请求共享一个出口,如果这个出口(LLM调用)挂了,所有等待的请求都会失败,产生局部雪崩效应。因此,LLM Client 必须配置合理的熔断降级策略。
🎉 总结
在 AI 接口越来越昂贵的今天,分布式 Single-Flight 是一种投入产出比极高的架构模式。
通过 Redis 的分布式锁和短暂缓存机制,我们成功地将并发重复的 LLM 调用合并为一次,不仅为公司省下了大笔 Token 费用,更有效缓解了 LLM API 的并发压力,提升了系统的整体稳定性。
如果你的 AI 业务也面临类似痛点,赶紧把这套方案安排上吧!🚀