Skip to main content

同一用户并发提问的互斥控制

上一篇讲了两份数据的分工:t_agent_message保存页面上的对话记录,t_agent_state保存下一轮接着运行需要的框架状态。状态每轮整行读出,结束时整行写回,写回时没有版本号校验。

以比特严选的一次选购为例。用户在电脑上打开一个已有会话,发了一句:

想买台MacBook,平时写代码,偶尔剪一下视频,预算一万五,帮我看看选哪款、什么配置合适。

Agent还在查商品配置和价格,用户又在手机上打开同一个会话,补了一句:

对了,我每天都得背着它通勤,太重的就不考虑了。

两个请求带着相同的conversationId到达服务端。开两个标签页也是一样的情况。

本篇要解决的是:同一会话能不能同时处理两次提问,一个用户能同时运行多少个会话,以及连接断开后,后台那一轮怎么结束、锁怎么释放。

同一个会话为什么不能同时跑两轮​

1. 两轮可能覆盖彼此新增的状态​

本文把一次请求触发的完整Agent执行称为一轮。一轮内部可以多次调用模型和工具;用户确认操作后发起的继续执行,也算新的一轮。

如果没有会话互斥,两轮同时运行时,都可能在对方保存之前读到同一份旧状态,再各自追加消息。AgentStateMapper.upsert()会覆盖整份payload,不会合并两轮各自新增的内容。下面按代码推演一次可能的执行顺序。

这里被覆盖的是t_agent_state中供Agent下一轮加载的状态。页面上的聊天记录存在另一张表t_agent_message中,每条问题和回复单独保存,所以可能仍然完整。

这就会出现:用户在页面上明明能看到电脑那一轮的选购问题和回答,Agent下一轮读取状态时,却已经找不到这部分内容。所以同一会话的下一轮,必须等上一轮把状态写回之后才能开始。

2. 前端的限制只在当前页面有效​

同一页面上连续点两次发送,只要前一次请求还没结束,前端就不会发送第二个请求。agentChatStore.ts在准备发送时就把当前会话的isStreaming设为true,不用等第一段回复到达。

sendMessage()开头就检查这个状态。下文源码中的解释性行注释为本文添加。

sendMessage: async (question) => {
const trimmed = question.trim();
if (!trimmed) return;
// 当前会话的请求尚未结束时,不发第二个请求
if (get().isStreaming) return;
// 省略待确认检查与发送逻辑
},

这个状态存在当前页面里。另一个标签页、另一台设备读不到它,直接调用接口更不会经过这个判断,所以服务端还要再做互斥控制。

按会话加锁,再限制每个用户的会话数​

1. 三种加锁范围的区别​

不同会话的状态分别保存,可以同时运行。但一个用户启动太多会话,又会同时占用大量模型调用和工具执行资源。因此,还需要限制单用户的并发会话数。

三种方案都能阻止同一会话同时处理两次提问。区别在于:一个用户已经有会话在运行时,还能不能在其他空闲会话中发起提问。按本项目拿不到锁就拒绝请求的处理方式,对比如下:

方案一个用户最多同时运行几个会话在其他空闲会话中提问时
按用户互斥1个只要已有会话在运行,就拒绝新请求
只按会话互斥不限制总数可以继续启动其他会话,用户占用的资源可能不断增加
会话互斥,再限制用户并发数配置的上限,项目默认为5个未满5个时允许启动,达到5个时拒绝新请求

Ragent用的是最后一种。application.yaml里的相关配置如下,其余agent配置省略:

agent:
# 省略模型、MCP、迭代与超时配置
max-concurrent-runs-per-user: 5
# 省略记忆配置

默认一个用户最多同时运行5个会话,历史会话总数不受这个配置限制。

2. 会话锁和名额锁​

这两项限制由AgentRunGate负责,分别用两种锁实现:

锁Redis key数量限制什么
会话锁ragent:agent:run-lock:{userId}:{conversationId}每个会话一把同一会话同时只能有一轮运行,状态不会被两轮互相覆盖
名额锁ragent:agent:run-permit:{userId}:{slot}每个用户若干把,slot从0到上限减1,默认是0~4每一轮占用其中任意一把,全部被占时拒绝,限制单用户同时运行的会话数

一轮开始时先取得会话锁,再取得一把名额锁,两把都拿到才启动Agent,这一轮结束后一起归还。任何一把拿不到,请求直接被拒绝,不排队等待。

名额为什么用几把锁表示,而不是存一个运行数量?计数器要在开始时加一、结束时减一,持有任务的进程如果中途退出,就没有人执行减一,这个名额会一直被占着。每把名额锁都有自己的过期时间,进程退出后会自动过期,名额就能被其他运行使用。计数器也可以另加恢复机制,但当前实现选择复用分布式锁的过期能力。

3. 为什么用Redisson的RLock​

同一会话的两个请求可能落在不同服务节点上。Java的本地锁只在一个JVM里有效,所以两种锁都放在各节点共用的Redis里,通过Redisson客户端的RLock加锁和解锁。

先看一轮运行期间,这两把锁在Redis里实际是什么样。下面是在一个临时Redis上,用AgentRunGate.acquire()为用户2001的一个会话加锁后,用redis-cli读出来的内容:

# 会话锁
> TYPE ragent:agent:run-lock:2001:2104420977583990001
hash
> HGETALL ragent:agent:run-lock:2001:2104420977583990001
1) "03afcac9-ba8a-42f9-80db-5826c39ee30e:2104420977583996928"
2) "1"
> PTTL ragent:agent:run-lock:2001:2104420977583990001
(integer) 29596

# 名额锁,占的是 0 号
> HGETALL ragent:agent:run-permit:2001:0
1) "03afcac9-ba8a-42f9-80db-5826c39ee30e:2104420977583996928"
2) "1"
> PTTL ragent:agent:run-permit:2001:0
(integer) 29587

每把锁是一个Hash,里面只有一个字段:

  • 字段名是持有者,由两段用冒号拼成。前一段03afcac9-...是Redisson客户端ID:每个服务进程创建RedissonClient时随机生成的一个UUID,同一个进程里所有锁共用它,用来区分锁是哪个服务节点加的。后一段2104420977583996928是持有者编号,默认取当前线程ID;不同节点上的线程ID可能重复,所以要拼上客户端ID才唯一。Ragent在这里传的不是线程ID,原因下一节讲。
  • 字段值 1是这个持有者的加锁次数,同一持有者再次加锁会加一,解锁减一,减到0才删除这个key。
  • PTTL 是key剩余的有效时间,单位毫秒。这段有效时间称为租约,默认30秒。

下述为任意请求示例效果:

这一轮结束、释放锁之后,这两个key都从Redis中删除了。Redisson 4.6.1加锁与解锁源码

Ragent用到它的三项能力,每一项对应Agent运行的一个特点:

Agent运行的特点用到的RLock能力
加锁在请求线程,释放在收尾线程;上一轮迟到的释放不能删掉下一轮的锁加锁、解锁都能显式传入持有者编号;解锁时先校验持有者再删除,两步在Redis里用一个Lua脚本原子执行
一轮跑多久事先不知道,可能只查一次商品,也可能连续调用多个工具不指定固定租期时启用看门狗,持锁进程活着就定期续期
持锁的节点宕机后,没有人执行释放进程退出后不再续期,锁在租约到期后被Redis删除

持有者编号具体用什么,在后面讲加锁和释放时展开;续期和过期的细节,在讲节点宕机时展开。

4. 先加锁,再检查待确认​

请求进来后,AgentChatServiceImpl.streamChat()第一步就加锁,之后才检查这个会话有没有等待确认的操作:

String userId = UserContext.getUserId();
// 新会话先生成 ID,已有会话沿用请求中的 ID
String actualConversationId = StrUtil.isBlank(conversationId)
? IdUtil.getSnowflakeNextIdStr()
: conversationId;
String taskId = IdUtil.getSnowflakeNextIdStr();

// 先取得会话锁和用户名额
Runnable releaseGate = runGate.acquire(userId, taskId, actualConversationId);
try {
// 已有会话再检查归属和待确认状态
if (StrUtil.isNotBlank(conversationId)
&& conversationService.hasPendingConfirm(actualConversationId, userId)) {
throw new ClientException("上一步操作还在等你确认,请先确认或取消");
}
startRun(question, userId, actualConversationId, taskId, emitter, releaseGate);
} catch (RuntimeException | Error e) {
// 启动失败,归还已经取得的锁和名额
cleanupFailedStart(taskId, releaseGate);
throw e;
}

会话在等用户确认时,弹出确认卡的那一轮已经结束,会话锁也释放了。这时用户再发新问题,锁拦不住,要靠hasPendingConfirm()去消息表查有没有待确认的卡片。

这项检查要放在加锁之后。确认卡是上一轮收尾时才写进消息表的,如果先检查、后加锁,可能出现下面的顺序:

  1. 新请求检查消息表,上一轮还没收尾,查到没有待确认;
  2. 上一轮收尾,写入确认卡,释放会话锁;
  3. 新请求拿到锁开始运行,新问题被送进了一个正在等确认的会话。

先加锁就不会这样:上一轮没结束,新请求拿不到锁;能拿到锁,说明上一轮已经收尾,确认卡已经写进消息表,检查一定能查到。

用户点确认或拒绝走的是confirmPendingTool(),入口申请的也是这把会话锁,所以确认后继续执行的那一轮,同样不会和新提问同时运行。

解锁付费内容,👉戳​