做 AI 应用,最容易被忽视、又最致命的问题,就是“状态”这回事。很多团队把模型能力、Prompt 工程调得风生水起,一上线生产环境,用户量稍微上来一点,各种莫名其妙的问题就出现了:实例重启一下,登录态丢了;扩容三个节点,用户被甩到不同机器上,对话上下文对不上;发版到一半,正在流式生成回答的用户直接断掉,回来还得从头问一遍。排查到最后,几乎都指向同一个根因——服务是有状态的,Session 还赖在本地内存里。
无状态化,就是 AI 应用高可用最底层的那块地基。地基不打牢,上层模型调得再烂其实都没事(不是),上层设计得再花哨也白搭。这篇文章不聊虚的,直接讲透:Session 到底该存哪、无状态化改造怎么一步步落地、以及我在实际项目中踩过的那些坑。
1. AI 应用为什么比传统应用更需要无状态化
1.1 传统应用可以容忍掉线,AI 应用不行
先想一个问题:为什么电商网站、内容管理系统这些传统应用,对“有状态”没那么敏感?因为它们的交互模式是“短请求”:用户点一个按钮,服务端处理完返回结果,这个请求就结束了。就算服务重启了,用户刷新一下页面,再点一次,损失很小。
AI 应用完全不是这个逻辑。用户跟一个智能体对话,本质上是多轮、长程、有记忆的交互。中间断了,不只是“再点一次”的问题。用户可能已经跟助手聊了十几轮,给了很多背景信息,助手也基于这些信息做了大量分析和推理。你让他从头再来?你的产品在用户心里直接就“不可用”了。我见过一个企业内部知识库问答系统,就因为发版时 Session 失效,被业务部门投诉到差点下架——业务人员正在用 AI 助手写一份基于三个月数据的分析报告,写到一半,全部上下文没了。
1.2 GPU 实例的昂贵决定了它不能浪费
AI 应用和普通 Web 应用最大的不同,是后面的算力成本。一个带 GPU 的推理实例,一小时的成本可能是普通 CPU 实例的几十倍。这种实例的调度、重启、扩容,频率远高于普通业务。
问题在于,如果 Session 存在实例本地,那实例一销毁,整个用户上下文就没了。为了保证用户不丢上下文,你就不能随便杀实例。这会造成两个后果:第一,实例利用率上不去,流量低峰期也得硬撑着那么多机器,因为状态都绑死在上面;第二,故障恢复特别慢,实例挂了,你要么等它自动拉起(但数据已经没了),要么手动处理用户投诉,根本谈不上高可用。无状态化之后,实例就是一个“随时可以杀、随时可以重建”的廉价计算单元,配上一个弹性伸缩策略,成本能降下来一大截。
1.3 AI 应用的“状态”比传统应用多得多
传统 Web 应用的 Session,基本就是用户登录态。AI 应用里,状态无处不在:
- 登录态:用户是谁、有什么权限。
- 对话上下文:多轮会话的历史消息、当前正在处理的主题。
- 临时业务状态:比如用户上传了一份文档,正在解析;或者任务队列里的一个 job(比如“帮我生成100张图片”)。
- 流式输出的进度:模型正在一个字一个字地吐,吐到一半断了,怎么续上?
这些状态如果全部塞在进程内存里,那这个服务基本上就跟“高可用”三个字无缘了。所以无状态化不是锦上添花,是 AI 应用真正要规模化运行的前提。
2. 先分清概念:Session、Cookie、Token,别再混为一谈
2.1 Cookie 和 Session 的经典关系
先说最容易混淆的一组:Cookie 和 Session。
打个比方,Session 是服务端开给用户的“档案袋”,里面装着用户的登录状态、偏好、临时数据。Cookie 是发给用户的“凭证纸条”,上面写着“我的档案袋编号是 xxx”。
用户第一次访问,服务端创建一个 Session,把 Session ID 通过 Set-Cookie 塞给浏览器。浏览器下次请求时带上这个 Cookie,服务端拿到 Session ID,去自己的“档案柜”(内存或者存储)里翻出对应用户的档案。
传统 Java Web、PHP 应用默认的 Session 方案,就是把档案柜放在应用进程自己的内存里。这在单机时代没问题,一到分布式环境就炸了——你部署了三个实例,用户的请求第一次打到 A 实例,Session 建在 A 的内存里。第二次负载均衡把请求转发到 B 实例,B 的内存里根本没有这个 Session,用户就被当成“未登录”了。
2.2 Token 模式:把状态装进票据里
Token 的思路跟 Session 完全不同。Session 是服务端记状态,客户端只拿一个 ID;Token 是状态本身就在票据里,服务端不存或者尽量少存。
最典型的是 JWT,把用户 ID、过期时间、自定义字段经过签名后打包成一串很长的字符串,发给客户端。客户端每次请求带上它,服务端验签通过就相信里面的内容。
Token 模式天然适合无状态化,因为服务端不用维护 Session 档案柜。但它的坑也不少:Token 一旦签发,在过期之前无法主动作废;要修改 Token 里的某个字段,得重新签发;如果 Token 里塞了大量数据,请求头会变得很大。所以后面聊方案选型时,我会说清楚它适合哪些场景,不适合哪些场景。
2.3 AI 应用里的“状态”远不止登录态
在 AI 应用里,如果只把登录态处理好了,你以为就无状态了,其实还差得远。
想象一个智能客服机器人,用户进来说“我要退货”,机器人问“订单号是多少?”,用户发了订单号,机器人又问“什么原因?”,用户回答了……每一轮都有前文依赖。这个对话上下文放哪?如果放在进程内存里,跟前面说的问题一模一样:实例一换,上下文没了。
再比如一个“帮我把这份 PDF 里所有图表提取成 Excel”的任务。用户上传了 PDF(这个文件临时候存在哪?),任务开始执行(这个任务状态在哪记录?),执行到一半实例重启了(怎么恢复?重新从第一个 PDF 开始?还是可以断点续跑?)。
我一直跟团队说一句话:在 AI 应用里,状态设计不是登录态的问题,是整个数据流的问题。每个可能在中间步骤失败的操作,都要问一句:如果当前实例立刻死掉,这个操作能从哪一步被重建?把这些都回答清楚了,才叫真的无状态化。
3. Session 到底存哪?主流方案逐一拆解
3.1 方案一:Redis 外部存储(最推荐,AI 应用首选)
既然不能存本地内存,那就把 Session 抽出来,放到一个所有实例都能访问的地方。最常用的就是 Redis。
Redis 做 Session 存储有几个天然优势。第一是快,Session 读写是高频操作,不能每次去查数据库,Redis 是纯内存操作,亚毫秒级响应。第二是自带过期机制,Session 一定是有生命周期的,Redis 的 TTL(Time To Live)直接对应 Session 过期时间,一条EXPIRE命令搞定,不用自己起定时任务清理。第三是数据结构灵活,用 Hash 存用户属性、字段随时增减很方便;用 List 存对话历史消息也顺手。
具体落到代码上,大致是这样的思路:
# 伪代码:Redis Session 存储示例 import redis import uuid import json r = redis.Redis(host='redis-service', port=6379, db=0) def create_session(user_id): session_id = uuid.uuid4().hex session_data = { "user_id": user_id, "login_time": int(time.time()), "permissions": ["chat", "file_upload", "report"] } r.hset(f"session:{session_id}", mapping=session_data) r.expire(f"session:{session_id}", 3600) # 1小时过期 return session_id def get_session(session_id): data = r.hgetall(f"session:{session_id}") return data if data else None def update_session(session_id, field, value): r.hset(f"session:{session_id}", field, value) r.expire(f"session:{session_id}", 3600) # 续期注意一个细节:expire最好在每次访问时都重新设置一次,这叫滑动过期。用户一直在用,就不应该因为固定时间到了被踢出去;用户半小时没动,Session 就该失效,这是个安全与体验的平衡。
不过 Redis 方案也有几个坑需要提前知道。
Redis 的持久化配置不能省。如果 Redis 只开默认的内存模式,进程一重启,所有 Session 全丢,这就是把问题从应用层挪到了缓存层。生产环境至少开启 AOF(Append Only File)持久化,并且配置appendfsync everysec,保证最多丢一秒的数据。Session 丢了可以重登,但对话上下文丢了,用户的体感是“这 AI 怎么失忆了”,非常糟糕。
Redis 的高可用不能省。如果 Redis 是个单点,那它就是整个系统最脆弱的一环。生产环境至少要做主从 + 哨兵模式,或者直接上 Redis Cluster。这个不是过度设计,而是“无状态化”本身的前提——你为了高可用把状态搬出去了,结果存状态的地方比原来还容易挂,那就本末倒置了。
3.2 方案二:数据库存储(适合低频、强一致场景)
数据库存储 Session 也是常见的路子,尤其是当你对数据一致性要求极高的时候。
这里说的数据库,可以是 PostgreSQL、MySQL 这类关系型数据库。表结构大概长这样:
CREATE TABLE sessions ( session_id VARCHAR(64) PRIMARY KEY, user_id VARCHAR(64) NOT NULL, session_data JSONB, -- 存序列化后的状态数据 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, expires_at TIMESTAMP NOT NULL ); CREATE INDEX idx_sessions_user_id ON sessions(user_id); CREATE INDEX idx_sessions_expires_at ON sessions(expires_at);注意几个细节:session_data用 JSONB 而不是一行一个字段,是因为 Session 数据本身结构不固定,经常要加字段,JSONB 灵活得多;expires_at一定要建索引,因为清理过期 Session 的定时任务需要用这个字段查;session_id是主键,查询走主键索引,速度能接受。
数据库方案最大的优点:跟业务数据在同一个事务里。举个例子,用户在一个表单里填写了很长的内容,点击提交时需要“校验 Session + 写业务数据”,这两个操作如果都在同一个数据库事务里,就不会出现 Session 更新成功、业务数据写失败这种尴尬状态。
但它的缺点也很明显:慢。Session 是每次请求都要读的,数据库每次都走一次磁盘 IO,跟 Redis 比慢一个量级。而且数据库的连接数是有限的,Redis 可以扛几万个连接,数据库几百个连接就告警了。所以这个方案适合低频场景,比如管理后台、内部工具,不适合面向大规模用户的 AI 应用。
3.3 方案三:客户端 Token(JWT 等)
第三个思路:直接把状态交给客户端,服务端保持“无状态”。最典型的就是 JWT。
# 伪代码:JWT 签发与校验示例 import jwt import time SECRET_KEY = "your-256-bit-secret" def create_jwt(user_id, permissions): payload = { "user_id": user_id, "permissions": permissions, "iat": int(time.time()), "exp": int(time.time()) + 3600 # 1小时过期 } token = jwt.encode(payload, SECRET_KEY, algorithm="HS256") return token def verify_jwt(token): try: # 会自动检查 exp,过期会抛异常 payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"]) return payload except jwt.ExpiredSignatureError: return NoneJWT 的好处是,服务端完全不用存 Session,天然无状态,非常适合水平扩容。但用 JWT 在 AI 应用里有几个问题要提前注意。
JWT 无法主动作废。比如管理员把一个用户踢下线,或者检测到异常登录需要冻结会话——对不起,做不到,你只能等它自己过期。如果 JWT 过期时间设很长,这个窗口期就非常危险。解决办法是引入黑名单机制,踢下线时把 JWT 的jti(JWT ID)加入 Redis 黑名单,每次校验时先查一下黑名单。但一旦引入黑名单,你又依赖 Redis 了,无状态化就打折扣了。
JWT 不适合放大体积的状态。JWT 里塞的东西越多,请求头就越大。而 AI 应用的对话上下文是非常大的,几轮对话就可能几十 KB。如果真的把上下文塞进 JWT,每次请求光传 Token 就是几万字节,HTTP 头可能直接爆掉。我见过一个团队试图把对话摘要放进 JWT,结果请求头超过负载均衡器的限制,直接 400 报错。所以 JWT 只适合存轻量的身份信息,不适合存业务状态。
3.4 三种方案横向对比,到底怎么选
| 对比维度 | Redis 存储 | 数据库存储 | 客户端 JWT |
|---|---|---|---|
| 读写速度 | 极快(内存级) | 慢(磁盘 IO) | 快(本机验签) |
| 存储容量 | 有限(受内存限制) | 几乎无限 | 极小(请求头限制) |
| 主动作废 | 支持(删除 key) | 支持(删除行) | 需要额外黑名单 |
| 数据一致性 | 可能短暂不一致 | 强一致(事务) | 无服务端状态 |
| 框架复杂度 | 中(需引入 Redis) | 低(可能已有数据库) | 低(只需密钥) |
| 适合场景 | 高频、大规模用户 | 低频、强一致、审计需求 | 轻量身份认证 |
我个人在 AI 应用里的建议是组合使用:登录态用 JWT 或 Redis 都行(各有取舍),但对话上下文、临时文件状态这类重量级状态,一定要放到 Redis 或数据库里。别为了“无状态”三个字,把什么都往 JWT 里怼,最后得不偿失。
4. 无状态化改造实操:从“有状态”到“无状态”的完整路径
4.1 改造第一步:先盘清楚状态在哪
不夸张地说,有 80% 的团队在改造前根本说不清自己的状态都存在哪里。所以第一步不是改代码,是体检。
我对团队的要求是:打开代码仓库,全局搜索以下几类东西:
- 静态字段:
static修饰的 Map、List、变量,这些是藏在代码里的全局状态,最坑。 - 进程内缓存:比如用一个 ConcurrentHashMap 当缓存用的,里面存了任何跟用户相关的数据,就是状态。
- 内存 Session:Spring Boot 默认的
HttpSession、Python Flask 默认的客户端 Session、Django 默认的 Session 表(如果用的是内存后端)。 - 临时文件:上传的文件存在本地磁盘路径,比如
/tmp/upload/xxx.pdf、./temp/,这是文件系统级别的状态。
整理一个表格,每一行是一个“状态项”,列上:当前存放位置、所属用户、生命周期、更新频率。盘完一遍你会很惊讶:原来有这么多状态藏在角落里。
4.2 改造第二步:为每类状态找新家
盘完之后,开始分类处理。我习惯分成三类:
第一类:纯登录态。这类最简单,按照上一节的方案选型,要么 Redis、要么 JWT。记住原则:只存身份信息,不存业务数据。
第二类:对话上下文和业务临时状态。这类是 AI 应用的特色。一般的做法是:
# 伪代码:对话历史 Redis 存储 def save_message(session_id, role, content): message = { "role": role, # "user" / "assistant" "content": content, "time": int(time.time()) } r.rpush(f"conversation:{session_id}", json.dumps(message)) r.expire(f"conversation:{session_id}", 1800) # 30分钟无操作清理 def get_recent_messages(session_id, limit=10): raw_list = r.lrange(f"conversation:{session_id}", -limit, -1) return [json.loads(item) for item in raw_list]这里有个关键,我踩过大坑:对话列表不能无限增长。ChatGPT 之所以会用一段时间变慢,除了模型输入长度限制,还有一个原因是上下文太长。Redis 存几十轮对话很容易,但每次请求时把这些历史打包发给模型,Token 数量爆炸,延迟和成本都飙升。所以正确姿势是:存全量,但「取用」时只取最近 N 轮,再配上一些截断策略(比如超长摘要)。
第三类:临时文件和中间产物。上传的文件不要写本地磁盘,要写到对象存储(例如 S3、阿里云 OSS 这类)。即使文件不大,也别图省事写本地。实例的磁盘是“本地盘”,随时可能跟着实例一起消失。对象存储才有持久化保障。
4.3 改造第三步:关闭会话亲和性
这一步是很多人改完代码之后还会踩的坑,其实代码层面已经完全无状态了,但负载均衡上还开着sticky session(会话黏滞)。这种配置会让负载均衡把同一个用户的请求总是转发到同一个后端实例,原因是想保证 Session 一致——在改造前这确实是必要的。改造之后反而成了障碍:你想让实例 A 重启时流量平滑切到实例 B,但 sticky session 会坚持把请求打到 A 上,直到 A 彻底没响应了才切换,用户体验就会是“卡了一下”,甚至“直接超时”。
所以在改造完成后,需要去负载均衡配置里把 sticky session 关掉。这是一个很容易被忽略的步骤,但效果立竿见影:实例的上下线对用户完全透明。
体检清单里加一条:检查负载均衡配置,确认没有开启会话保持。
4.4 改造第四步:容器环境下的配套改造
如果你的服务已经容器化(跑在 Kubernetes 上,或者类似平台),无状态化改造还有一个重要的配套工作:优雅下线。
容器平台的滚动发版流程大致是:新实例先启动,等它通过了健康检查,再把老实例停掉。老实例停掉之前,如果正在处理一个长请求(比如一个 AI 对话生成请求),是直接杀掉,还是等它处理完?
答案是:都不应该这样。直接杀掉,这个用户就断了;等它处理完,一个流式输出最长可能要几十秒甚至几分钟,滚动发版就被拖死了。
正确做法是实现 preStop 钩子。在容器被终止前,先告诉负载均衡“我不再接收新请求了”,然后给正在处理的请求一个宽限期(比如 30 秒),如果宽限期到了还没处理完,再强制终止。这样既保证了用户不被打断,又不会让发版卡死。
# Kubernetes 示例:优雅下线配置 lifecycle: preStop: exec: command: - /bin/sh - -c - "sleep 5 && wget -q -O /dev/null http://localhost:8080/health/standby || true"这段配置的意思是:容器要终止时,先等待 5 秒(等待负载均衡摘除流量),然后把当前实例置为“待机”模式,不再接收新请求。处理完正在进行的请求后,容器才会真正退出。
4.5 改造第五步:验证无状态化是否达标
代码改完了,怎么确认真的无状态了?我有一个自测的方法,叫“随便杀”。
挑一台正在跑流量的实例,直接kill -9。然后看两点:
- 正在用这个实例的用户,是否无感?重新刷新后,登录态是否还在?
- 整个集群的流量是否很快重新分散到其他实例?有没有局部热点?
如果你执行完这个操作,发现用户掉线了,或者某个用户因为被切到新实例而上下文丢失——恭喜你,找到了最后一个没有改干净的状态点。重复这个过程,直到“随便杀任意一台实例”都不影响用户,这个服务就真正具备高可用的地基了。
我在一个真实项目里做过这个验证,第一次kill -9之后测出两个问题:一个是临时上传的文件写到了本地盘,另一个是一个 AI 任务队列的状态存了内存的 Map 里。都修完之后再杀,完全无感。后来线上真的发生过一次实例级故障,用户除了那几秒的网络延迟,业务一点没受影响,当时真是松了一大口气。
5. 常见问题与排查技巧实录
5.1 现象:实例重启后用户全部掉线,需要重新登录
这是最典型的“Session 存在本地内存”的信号。排查思路:
- 看登录态的存储位置。搜索代码里有没有用本地内存存 Session 的痕迹。
- 看负载均衡是否开了 sticky session。如果开了,先考虑业务是不是真的需要。
- 看 Redis/数据库里的 Session key 在重启后还在不在。如果 Redis 里根本没有这个 Session,说明写入逻辑有问题,或者 Redis 持久化没配好。
提示:有一种隐蔽的情况——“配置了 Redis,但只在登录那一刻写了一次,后面每次请求是从进程内缓存读的”。这种“半无状态化”特别容易漏掉,查的时候要注意看读路径,而不仅仅是写路径。
5.2 现象:Redis 一抖动,整个应用全崩
状态搬到 Redis 之后,Redis 就成了全系统的“心脏”。如果 Redis 崩了,所有请求都可能失败,这时候有状态化反而显得“稳定”——至少单机不会互相拖累。
解决方案:
- 给 Redis 加高可用:主从 + 哨兵,或者 Cluster 模式。
- 在应用层做降级:如果 Redis 短暂不可用,可以允许用户在降级模式下继续使用,比如只读历史、不能发起新会话。
- 对 Session 读取加本地短暂缓存(注意:这里要讲清楚,本地缓存跟“有状态”的区别是,超过很短的时间后就会失效,不会影响跨实例)。
- 使用带渐进式的缓存策略。Redis 的故障往往持续几秒到几十秒,如果请求能承受“稍微慢一点”,可以先查 Redis,失败后放行“只读不写”的请求,而不是直接抛异常。
5.3 现象:JWT 过期之后,用户被踢下线的时间点非常奇怪
JWT 的exp是签发时定的,如果用户在有效期还剩 10 秒时发了一个请求,那这个请求可能成功也可能失败——因为网络延迟,服务端收到时可能已经过期了。
这个问题在 AI 应用里被放大了:用户的对话可能持续几分钟甚至更长,比如一个 AI 生成报告的任务,可能每 10 秒发一次心跳,如果 JWT 刚好在这 10 秒内过期,任务就断了。
好的做法是:不要把 JWT 的过期时间设得太短(比如 15 分钟),而是设置成一个相对安全的范围(比如 2 小时),同时服务端结合 Redis 做“活跃会话续期”——只要用户在这个会话里持续活跃,就自动刷新 JWT,但用一个滑动过期窗口控制总时长,防止无限续期导致安全问题。
5.4 现象:流式输出到一半,实例重启,用户的回答断了
这是 AI 应用特有的问题。传统 Web 应用不会“流式输出到一半”,因为它们的响应通常是完整结果一次性返回。而 AI 的流式输出是持续几十秒的事情。
排查思路:流式输出期间,用户的请求一直挂在某个实例的长连接上。就算状态都无状态化了,长连接本身还是有状态的——它绑定在特定的实例上。所以:
- 如果实例要重启,preStop 钩子要给它足够时间完成当前响应。
- 如果确实需要跨实例续传(比如用户从手机上退出再进,想继续看之前的回答),就要把“流式输出进度”记录到 Redis。比如记录到第几个 Token 了,断线后从断点重新请求模型续写。这个功能实现起来有难度,但这是解决“AI 失忆”的核心手段。
- 对绝大多数应用来说,核心目标是减少实例异常重启的概率,而不是在断点续传上做文章。先把基础打好:已经完成的内容都存了,下次同样的问题不会从头再来。
5.5 实战心得:Redis Key 怎么设计不容易乱
Redis 存 Session、对话上下文、临时任务状态,key 一多就容易乱。我习惯的命名规范:
| 业务对象 | Key 格式 | 过期时间 |
|---|---|---|
| 用户登录态 | session:{session_id} | 2 小时,滑动续期 |
| 对话历史 | conversation:{conversation_id} | 1 小时无访问清理 |
| 临时文件 | file:{file_id}:metadata | 24 小时 |
| 任务状态 | task:{task_id} | 24 小时 |
| 用户限流计数 | ratelimit:{user_id}:{api_name} | 1 分钟 |
统一用冒号分隔层级,后端查问题时一眼就能认出来。还有两个细节:
第一,所有 key 都必须带过期时间。如果有一个 key 没有设置 TTL,那它就是一个潜在的内存泄漏点。我见过线上 Redis 内存涨到 90% 才发现,有个任务的 session 状态忘了设置过期时间,越积越多。
第二,业务逻辑里续期要在读的时候做。用户访问一次就续一次 TTL,逻辑上只有“活跃用户”才值得在 Redis 里占着内存。这个一定要形成习惯,写进代码规范。
6. 写在最后:一次实战后的体会
项目上线跑了一个季度,这份东西里讲的每一个坑,我们几乎都踩过一遍。最大的体会是:无状态化不是一次性的“改造项目”,而是一种需要持续维护的架构纪律。
你这次把 Session 从内存搬出来了,下次新同事写了个static Map存点“临时数据”,你不拦住,过两个月线上一定出问题。所以在日常的 code review 里,我一定会盯死这几件事:有没有新增的静态可变状态?有没有往本地磁盘写东西?新加的接口有没有依赖上一次请求留下的数据?
另一个体会是关于“度”的。无状态化不等于“所有状态都不能碰”,而是把状态放到正确的层次:能放客户端的放客户端,放不下的放 Redis,有强一致要求的放数据库。哪一层放什么东西,取决于它的生命周期、一致性要求、读写频率。想清楚这个,架构自然就清晰了。
最后想说的是,做 AI 应用这几年的感受:模型能力决定了体验的上限,但架构的稳定性决定了这个上限能兑现多少。一次对话因实例重启而中断,用户不会怪 GPU 不稳定,他只会记住“这个产品不可靠”。无状态化这件事,值得从一开始就认真对待,不要等踩到线上的事故再回头补课。