01为什么需要 Actor:AI 后端的三重困境
流式建模、状态管理、错误与超时——传统请求-响应后端在 AI 时代暴露的三处结构性缺失。
demo/session.go:SessionActor传统后端的世界观是请求-响应:一个请求进来,同步算完,一个响应出去,连接关闭。这套模型撑起了过去二十年的 Web——控制的基本单位是「一次请求」,状态活不过一次函数调用,失败被压缩成一个状态码。但 AI 应用,尤其是 Agent、流式对话这一类,骨子里是一个围绕「消息流」运转的有状态系统:服务端吐出的不再是一次性的 response,而是一连串带业务语义的事件——start、token、tool_call、done……控制的基本单位也随之从「一次请求」下沉到了「一条消息」。把这套新形态硬塞进旧世界观,后端会在三个地方同时裂开。
flowchart LR
subgraph rr["请求-响应"]
reqClient["客户端"] -->|"HTTP 请求"| reqHandler{{"无状态 handler"}}
reqHandler <-->|"序列化读写"| reqStore[("外部存储<br/>Redis / DB")]
reqHandler -->|"一次性响应<br/>连接关闭"| reqClient
end
subgraph ap["Actor-per-session"]
sessClient["客户端"] -->|"Tell / Subscribe 消息"| sessMailbox[("Mailbox")]
sessMailbox --> sessActor{{"会话 Actor<br/>私有状态常驻"}}
sessActor -->|"持续推送事件流"| sessClient
end
请求-响应 vs. Actor-per-session:状态放哪儿,谁把它接住
本章接下来要论证的,就是下半部分为什么更适合 AI 后端——从流、状态、错误三个角度,一一拆给你看。
流式建模的缺失
LLM 不是「算完再返回」,而是逐 token 吐流。用户要的是打字机效果:第一个字尽快上屏,后续的字源源不断。请求-响应模型没有「一个请求对应一串持续产出」的原语——你只能用轮询、长轮询或手动攒 SSE 来打补丁,而这些补丁都绕不开一个问题:产出的中间状态放在哪?
三个补丁各有各的代价。续传能力:前端网络抖动、断线重连是常态,要在重连后接着吐,你得把「进度 + 上下文」序列化进 Redis,重连时重新加载、绑定会话、恢复现场——这需要存储组件、会话粘滞(sticky session)、分布式任务协调三者配合,任何一环掉链子,用户看到的就是「从头开始」。事件边界:token、tool_call、done 这些业务语义,传统架构没有原生的事件解析层,得自己写解析器处理拆包粘包,解析失败还要设计重试。流控粒度:传统流控以线程/协程为单位,做不到「单条消息级」的精细节流——客户端读得慢,你没法只优雅地放慢这一路生成,往往得引入额外的限流中间件。
具体想象一个场景:用户正盯着字一个个往外蹦,手机切到弱网,3 秒后重新连上——负载均衡器很可能把这次重连甩给了另一台机器。经典架构要在新机器上补全整段上下文:去 Redis 读进度游标、反查会话归属、重放游标之后的内容,还要祈祷这几秒里没有别的请求并发改过同一个 Redis key。换成会话 Actor,worker 从未停止产出,新 token 一直在会话的私有内存里排队;新连接进来只需一条 Subscribe 消息,Actor 直接从自己的 tokens 切片里把断点之后的内容重放回去——不问 Redis,不需要粘滞路由,状态从来没有离开过它所归属的对象:
func (s *SessionActor) onSubscribe(ctx *actor.Context[SessionMsg], sub *subscriber) { s.sub = sub // 断线续传 / 重放:直接从私有状态里把 fromSeq 之后的 token 补发给新连接。 // 这一步完全不需要外部存储——会话状态本身就是「检查点」。 for seq := sub.fromSeq; seq < len(s.tokens); seq++ { s.emit(SSEEvent{Seq: seq, Name: "token", Data: s.tokens[seq]}) } if s.done { s.emit(SSEEvent{Seq: len(s.tokens), Name: "done", Data: "already finished"}) } ctx.Logger().Info("subscriber attached", "session", s.id, "from_seq", sub.fromSeq, "replayed", max(0, len(s.tokens)-sub.fromSeq))}「流控粒度」这一条,demo 里用一个小设计具体回应:worker 不用 time.Sleep 阻塞着等下一个 token,而是给自己发一条定时消息,吐流间隙依然能响应别的控制消息——流控天然是消息级的,不需要额外中间件:
// tick 是 worker 发给自己的自驱动信号:产出下一个 token。// worker 不用 time.Sleep 阻塞 mailbox,而是「给自己发定时消息」——// 这样在吐流间隙依然能响应其它控制消息,是 Actor 里处理周期性任务的惯用法。type tick struct{}🔑 设计钥匙
把「一次生成」看成一个有状态的实体,而不是一次函数调用——它有自己的进度、自己的 token 历史、自己的订阅者。这正是 Actor 的形状:一个持续存活、按消息推进的对象。
状态管理的缺失
一次多轮对话是有状态的:上下文、已生成的 token、用户是否还连着。传统做法把这些状态推到进程外(Redis、数据库),因为无状态的 handler 之间没法安全共享内存。可一旦状态出了进程,你就得为每一次读写付出序列化 + 网络 + 锁的代价,还要处理缓存一致性。
这套外置状态还有两处更隐蔽的痛。第一,状态与线程强耦合:线程一崩,状态就丢,且无法跨线程、跨节点复用——续传、故障恢复只能靠额外的持久化 + 恢复逻辑硬补,而这套补丁本身又是一套需要单独维护的系统。第二,交互式控流困难:对一个正在跑的生成任务,你经常需要实时「读状态、改状态」——查询它生成到第几个 token 了、暂停它、取消它、往它中间插一条指令。传统架构没有「状态-控流」的原生联动,得自己写状态查询接口,再配一把并发修改锁防止踩踏。
举一个最朴素的例子:给会话加一个访问计数器,前端每次交互都要读一下当前值。传统做法要么把计数器塞进 Redis 用 INCR 保证原子性(多一次网络往返),要么在进程内用一个 sync.Mutex 包住共享变量(多一把锁,多一处死锁风险)。会话 Actor 的答案是:计数器就是它的一个私有字段,Bump 消息和 GetCount 消息经由同一个单线程的 Receive 排队处理——同一时刻只有一条消息在改状态,s.counter++ 天生不会被另一条消息打断,不需要锁,也不需要外部原子操作:
// Bump 让会话的私有计数器加一——用于演示「单线程 Actor 无需锁也无数据竞争」。type Bump struct{}看这个会话 Actor 的私有状态——prompt、tokens、started、done 全是普通字段,只被单线程的 Receive 访问:
// SessionActor 是一个有状态会话。它实现 actor.Receiver[SessionMsg] 与 actor.Lifecycle。//// 所有字段都是私有状态,只被本 Actor 的 Receive 单线程访问——所以【没有一把锁】。type SessionActor struct { id string
// 生成规格与进度(会话的核心状态,worker 崩溃也不会丢)。 prompt string total int crashAt int tokens []string // 已产出的 token,index 即 seq;断线重放/崩溃续传的数据来源 started bool done bool workerStart int // worker 启动次数:>1 即发生过崩溃重启
worker *actor.PID[WorkerMsg] sub *subscriber // 当前连接的订阅者,nil 表示无人连接
counter int // 演示无锁并发的私有计数器
recovered bool // 是否已尝试从快照恢复(懒加载,首条消息时触发)}📝 注意
状态私有不等于放弃持久化。
SessionActor依然会在每次产出 token 后落一次快照(persist),用来扛住进程重启——但这是 Actor 自己选择的节奏,写一次内存字段、顺手落一次盘,而不是架构强加的「每次读写都要跨一次网络」。持久化服务于状态,而不是状态寄生于持久化。
错误与超时建模的缺失
LLM 调用会超时、会限流、会中途失败。请求-响应模型里,一个失败要么冒泡成 500、要么被 try/catch 就地吞掉——没有「让它崩,然后自愈」的一等公民。而流式生成一旦崩在第 5 个 token,你希望的是:换一个干净的执行体重来,从第 5 个续传,用户无感。
这一条最隐蔽,也最致命。设想经典的「主协程委派任务、工作协程执行」模型:工作协程出错了,它自己无从决断该怎么办——重试?放弃?降级?而真正有判定能力的主协程又不在它的上下文里,对这个错误浑然不觉。惯常做法是开一条 side channel(额外的 error channel)把错误回传给主协程,能用,但很生硬——错误处理逻辑散落在业务代码的角角落落;超时(哪怕工作协程一切正常,单纯是外部 LLM 服务慢)还得再单起一套定时器机制来兜底。我们真正想要的,是把「谁为谁的失败负责」变成架构里一条显式的脉络,而不是事后打的补丁。
demo 里这套机制被直接演出来了。StartGen 允许指定 CrashAt:worker 产出到那个序号时故意 panic,模拟一次真实的 LLM 调用中途失败。但这次崩溃不会波及会话——SessionActor 在创建 worker 时已经用 WithSupervisor(actor.RestartStrategy(3)) 声明了监督关系:谁为谁的失败负责,在创建那一刻就写死了,不是运行时才现拼。worker panic 后运行时 recover 住,按策略重启出一个全新实例;新实例启动后上报 workerReady,会话据此算出 fromSeq := len(s.tokens)——因为进度活在会话而不是活在崩溃的 worker 里——接着从第 5 个 token 继续产出。整个过程中,盯着 SSE 流的客户端只会看到 token 一个接一个地来,不会看到任何缝隙:
func (s *SessionActor) onWorkerReady(ctx *actor.Context[SessionMsg]) { s.workerStart++ fromSeq := len(s.tokens) crashAt := s.crashAt if s.workerStart > 1 { crashAt = -1 // 这是崩溃后的重启实例:不再故意崩溃,从已产出处续传 ctx.Logger().Info("worker restarted, resume from seq (state survived crash)", "session", s.id, "restart", s.workerStart, "from_seq", fromSeq) } s.worker.Tell(beginGen{Prompt: s.prompt, Total: s.total, FromSeq: fromSeq, CrashAt: crashAt})}三重困境同出一源:传统架构缺一个能同时承载「流、状态、失败归属」的统一原语。
这三处缺失指向同一个答案:一个按消息驱动、状态私有、可监督自愈的运行时。下一章我们就把这个答案的四条金律立起来。
小结
- AI 后端的三重困境:流式产出缺一个有状态的执行单元、多轮会话被逼到进程外、错误与超时没有显式的责任归属——请求-响应模型在这三处结构性失灵,靠 Redis、锁、error channel 这类补丁只能缓解,治不了根。
- Actor 用「有状态实体 + 消息驱动 + 监督自愈」同时回应这三点:状态活在对象里,免去外部存储的网络损耗;单线程
Receive免去锁;父子监督关系让「谁为谁的失败负责」在创建时就显式写定。 - 通向下一章:Actor 到底是什么——四条金科玉律与「一家公司」的心智模型。