AgentScope第28式·千军集群

多副本状态持久化

单副本里的会话记忆,扩容到两个副本就成了两份互相打架的记忆。

[读者] 把 AgentScope Java 单实例 Demo 跑通、准备上多副本的 Java 后端工程师
[痛点] 会话状态全在 JVM 内存,一扩容就串味,一重启就归零
[现在读] 前几式刚把单实例跑稳,是把状态搬出进程的最佳时机
[读完] 能设计 AgentState / Workspace / 协调面三层状态,并写出跨副本恢复
[旧方案] 单实例 + 进程内状态,Agent 状态跟着 JVM 走
    |
    v
[新需求] 两副本轮询、实例可被杀掉、会话必须连续
    |
    v
[冲突] 推理本身有状态,状态却挂在进程内存里,进程不是身份的载体
    |
    v
[后果] 轮询到另一个副本像换了个失忆的数字人,陈旧写入还会覆盖新状态

我是老李,这几年做的一直是“把 Agent 塞进 Java 后端”这件事。数字人 Demo 从第 3 式冻结到现在,已经能在单个 Spring Boot 实例里跑完整链路:前端进 /run/{projectId}、落到 LiveKit 房间、Realtime Bridge 把音频事件交给 AgentScope Java、推理结果再回到房间。

前面二十几式我一直在单实例里加能力:Tool、MCP、Memory、Skill、Plan、Sandbox、Subagent。每一式都跑得挺舒服,因为只有一个 JVM,AgentState 就是内存里那个对象,改完就生效,不用想它在哪。

第 27 式把生产化配置收尾那天,运维同学问了一句:这服务跑几个副本?我说一个。他说生产至少两个,滚动发布时还会随手杀。

三个问题一起压过来。

会话状态到底该放在哪一层?

并发写入同时落到两个副本上时,谁说了算?

杀掉一个实例,新实例凭什么把这个人原封不动接回来?

答错的代价很具体:用户说“我叫小林”,数字人回“你好小林”;下一句轮询到另一个副本,数字人问“请问您贵姓”。

01、故事

周三下午的一次内部演示。会议室投屏着 /run/1001 的运行页,小林对着麦克风说“我叫小林,帮我记住”,数字人答得很自然。运维同学顺手把副本数从 1 调到 2,做了一次滚动发布,第二次请求被负载均衡转到了新副本。

屏幕上的数字人沉默两秒,然后问:“你好,请问怎么称呼?”

会议室安静了一下。有人猜是不是上下文超了,有人猜是不是 WebSocket 重连换房间了,还有人猜是不是 LiveKit 那侧没保持会话。

都不是。事实很朴素:第一个副本的 JVM 里存着“用户叫小林”这条消息,第二个副本的 JVM 里什么都没有,它只能从零开始问。前面二十几式我加的 Memory、Plan、Subagent,全都建立在“这个进程一直活着”这个隐含假设上。

这一式的任务是:在不改业务语义的前提下,让数字人会话跨副本连续,并且在实例被杀掉之后还能被新实例接管。范围仍然锁定在最小数字人 Demo 里,不加口型、不加视频合成、不加多租户。

一台机器稳
两台机器乱
不是框架弱
是状态没家

02、问题

旧做法的核心假设是“进程即身份”:会话状态 = 当前 JVM 里那个 Agent 实例持有的对象。第 27 式之前所有的能力增强,都是在这个假设下做的增量,所以看起来一切正常。

一旦副本变多,这个假设立刻失效,表现有四层:

第一层,轮询到不同副本,各自维护各自的 Memory,会话上下文互不可见。用户看到的是数字人反复问同一个问题。

第二层,实例被重启或被杀,内存状态直接归零,连“恢复到上一轮”都做不到。

第三层,如果为了救急先把状态写进 Redis,但没有版本校验,两个副本的写回会互相覆盖,出现“我明明说了名字,下一轮又没了”的间歇性失忆。

第四层,Bridge 里如果缓存了 sessionId → Agent 实例 的映射,那这个映射本身就不可迁移,等于把会话钉死在某个进程上。

这些表现对业务的影响不是“偶尔慢一下”,而是会话不可信。数字人产品的信任建立在“它记得我”上,断片一次,用户就会重新开始自我介绍。

所以本篇的完成标准要写死,才能验收:

  1. 两副本轮询请求同一 sessionId,会话内容连续;
  2. 杀掉一个实例,另一个实例或重启后的实例能继续该会话;
  3. 并发写入同一 sessionId 时,只能有一次成功,另一次被明确拒绝或被幂等吸收,不能静默覆盖;
  4. 状态恢复之后,Workspace 里的产物仍然可用,而不是只恢复了对话文本。

核验入口是固定的:官方生产化说明 going-to-production.md(v2.0.3),以及核心源码 AgentStateStore.java(v2.0.3)。这里我必须说明白:本文不假设它的方法签名长什么样,而是把它当作“状态存取可替换点”去读。接口到底是接口还是抽象类、有哪些方法,请直接打开固定 tag 源码确认,这是第 8 式定下的规矩。

内存里的记忆
撑不过一次扩容
验收标准先写
再谈怎么存

03、原理

反直觉判断:Agent 多副本的真正难点不是“把状态搬出去”,而是“决定同一时刻谁有权写”。 搬运只是存储选型,写权限才决定行为是否正确。很多团队把状态一股脑塞进 Redis 就宣布分布式化完成,然后在第一次并发写入时被打回原形。

要理解这一点,先把“Agent 状态”拆开。它不是一个对象,而是三种生命周期完全不同的东西。

AgentState(推理态):消息历史、记忆条目、当前计划、当前 turn 的中间结果。它每次 turn 都会变,体积中等,必须强一致。它是“这次会话是谁、聊到哪了”的唯一权威。

Workspace / 远程文件系统(产物态):用户上传的文件、生成的图片与文档、沙箱工作目录。它体积大、变化少、可以最终一致、可以按快照或版本寻址。它不需要每次 turn 全量搬运。

协调面(控制态):session 级租约、turn 版本号、幂等键、运行中 turn 的心跳。它短生命周期、必须原子、丢了要能自愈。它不承载业务语义,只承载“谁在写”。

三类状态混在一处,最典型的坏味道就是:为了把 turn 计数加一,把几百兆的沙箱目录又整体序列化了一遍。所以本文的选择是——AgentState 进 Redis / JDBC,Workspace 进对象存储,协调面进 Redis。

第二个反直觉判断在这里:AgentState 不是“越大越安全”,而是“全量写回越频繁越危险”。 如果每轮推理结束都做一次全量序列化写回,那么冲突窗口就等于整个推理时长,并发保护形同虚设。

那么怎么写才安全?v2.0.3 的状态版本设计给出了方向:乐观并发。读的时候拿到版本号,写的时候带上期望版本,版本不匹配就拒绝、让调用方决定重放还是报错。它把一个“可能覆盖别人”的写,变成一个“要么成功要么明确失败”的写。

重复 Turn 的保护本质上是同一件事的另一个面:把 turn 变成幂等操作。同一个 (sessionId, turnSeq) 只允许一次真正生效的写入,重复到达的请求直接返回上一次的结果,而不是再跑一遍推理。

至于 AgentStateStore.java,本文只给它一句话边界:它是 Agent 状态的持久化抽象,负责把推理态存进去、取出来,不是缓存,也不是业务数据库。它的具体能力边界,以你打开的 v2.0.3 源码为准;同时 agentscope-extensions 目录是找“官方是否已经提供某种存储实现”的第一现场。

最后一条原理是并发模型的选择:同一 session 串行,跨 session 并行。这比“细粒度锁 + 复杂合并”简单得多,也足够撑住数字人这种交互形态——一个人同一时刻本来也不会同时说两句话。有了这条约束,租约(lease)就成了最合适的原语:拿到租约才允许进入推理,租约过期自动让位。

状态分三类
各走各的路
先定谁有权写
再谈存哪里

04、架构

[输入] /run/{projectId} 的房间事件 + 用户话语
    |
    v
[模块] Realtime Bridge(最薄适配层)→ Spring Boot 会话层 → TurnCoordinator
    |
    v
[数据/状态] Redis:AgentState + version + session lease + 幂等键 / JDBC:会话与轮次台账 / 对象存储:Workspace 快照
    |
    v
[处理] 取租约 → 加载 AgentState 与快照 ref → AgentScope 推理 → CAS 写回状态 → 上传产物 → 释放租约
    |
    v
[输出] 回复文本/音频回房间 + 台账落一行 + 版本号前进一格

边界:LiveKit 只负责 WebRTC 房间与实时音频/数据传输;Realtime Bridge 保持最薄,只做媒体与消息适配;AgentScope Java 负责推理、Tool、Memory、Skill、Plan 等后续能力;状态存储是外部组件,不属于任何单个进程。这个分层直接沿用第 3 式冻结的架构约束,没有新增逻辑组件。

收益:任何副本都能接管任意会话,滚动发布不再丢上下文,写入冲突从“静默覆盖”变成“显式失败”。

代价:每次 turn 多一次外部读写;AgentState 必须可序列化,反过来约束了你在状态里放什么;系统多了 Redis、MySQL、对象存储三个依赖,运维面变宽。

适用条件:会话需要跨请求、跨副本延续;有重启、发布、伸缩需求;同一 session 的写入可以接受“短时单写者”模型。

不适用条件:单副本且可以接受重启丢会话;纯无状态问答;离线批量任务——这类场景引入租约和版本号是纯粹的负担。

官方源码位置:状态抽象看 agentscope-core/src/main/java/io/agentscope/core/state/AgentStateStore.java,扩展实现看 agentscope-extensions 目录,生产化建议看 docs/v2/zh/docs/others/going-to-production.md。这三处都在 v2.0.3 的固定 tag 下,和教程基线一致。

协调面要薄
数据面要稳
冷热分层放
边界先划清

05、实战一次

本章分支从第 27 式继续演进:

git checkout chapter/27-production-ready
git checkout -b chapter/28-distributed-production

完成后建议提交:

git add .
git commit -m "feat(ch28): persist distributed state for multi replicas"

教程仓库在 agentscopepractice,版本基线是 v2.0.3。需要提醒的是,写作当天官方 Releases 页面若已出现更新的正式版,本文结论只对 v2.0.3 承诺;涉及“现在最新版是否还这样”的判断,请按当前官方文档核验。

环境与版本:AgentScope Java v2.0.3、JDK 17+、Spring Boot 3.x、Redis 7、MySQL 8、S3 兼容对象存储(MinIO 本地起一个即可)。

依赖(pom.xml 片段):

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
  <groupId>io.minio</groupId>
  <artifactId>minio</artifactId>
  <version>8.5.10</version>
</dependency>

配置:

spring:
  data:
    redis:
      host: 127.0.0.1
      port: 6379
      timeout: 2s
  datasource:
    url: jdbc:mysql://127.0.0.1:3306/digital_human?serverTimezone=Asia/Shanghai
    username: demo
    password: demo

digitalhuman:
  replica-id: ${HOSTNAME:local}
  state:
    ttl: 24h
    lease-ttl: 30s
  workspace:
    bucket: dh-workspace
    endpoint: http://127.0.0.1:9000

核心实现 1:带版本的状态存储契约

package com.demo.digitalhuman.state;

import java.time.Duration;
import java.util.Optional;

public interface VersionedStateStore {

    Optional<StoredState> load(String sessionId);

    boolean compareAndSwap(String sessionId, long expectedVersion, String payload, Duration ttl);

    record StoredState(long version, String payload) {
    }
}

核心实现 2:Redis 上的 CAS 写回。版本比较与写入必须原子,所以用 Lua 脚本而不是“先 GET 再 SET”。

package com.demo.digitalhuman.state;

import java.time.Duration;
import java.util.List;
import java.util.Optional;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Repository;

@Repository
public class RedisVersionedStateStore implements VersionedStateStore {

    private static final String KEY_PREFIX = "dh:agent-state:";

    private static final DefaultRedisScript<Long> CAS_SCRIPT = new DefaultRedisScript<>(
            """
            local version = redis.call('HGET', KEYS[1], 'version')
            if not version then
              if tonumber(ARGV[1]) ~= 0 then return 0 end
              redis.call('HSET', KEYS[1], 'version', '1', 'payload', ARGV[2])
              redis.call('PEXPIRE', KEYS[1], ARGV[3])
              return 1
            end
            if tonumber(version) ~= tonumber(ARGV[1]) then return 0 end
            redis.call('HSET', KEYS[1], 'version', tostring(tonumber(version) + 1), 'payload', ARGV[2])
            redis.call('PEXPIRE', KEYS[1], ARGV[3])
            return 1
            """,
            Long.class);

    private final StringRedisTemplate redis;

    public RedisVersionedStateStore(StringRedisTemplate redis) {
        this.redis = redis;
    }

    @Override
    public Optional<StoredState> load(String sessionId) {
        Object version = redis.opsForHash().get(key(sessionId), "version");
        Object payload = redis.opsForHash().get(key(sessionId), "payload");
        if (version == null || payload == null) {
            return Optional.empty();
        }
        return Optional.of(new StoredState(Long.parseLong(version.toString()), payload.toString()));
    }

    @Override
    public boolean compareAndSwap(String sessionId, long expectedVersion, String payload, Duration ttl) {
        Long result = redis.execute(
                CAS_SCRIPT,
                List.of(key(sessionId)),
                Long.toString(expectedVersion),
                payload,
                Long.toString(ttl.toMillis()));
        return result != null && result == 1L;
    }

    private String key(String sessionId) {
        return KEY_PREFIX + sessionId;
    }
}

核心实现 3:session 租约。拿到租约才允许进入推理,TTL 到期自动让位,这是“实例被杀后能被接管”的前提。

package com.demo.digitalhuman.coord;

import java.time.Duration;
import java.util.Optional;
import java.util.UUID;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;

@Component
public class SessionLease {

    private static final String KEY_PREFIX = "dh:session-lease:";

    private final StringRedisTemplate redis;

    public SessionLease(StringRedisTemplate redis) {
        this.redis = redis;
    }

    public Optional<LeaseHandle> tryAcquire(String sessionId, Duration ttl) {
        String owner = UUID.randomUUID().toString();
        Boolean ok = redis.opsForValue().setIfAbsent(key(sessionId), owner, ttl);
        if (Boolean.TRUE.equals(ok)) {
            return Optional.of(new LeaseHandle(sessionId, owner, ttl));
        }
        return Optional.empty();
    }

    public boolean renew(LeaseHandle handle) {
        String current = redis.opsForValue().get(key(handle.sessionId()));
        if (!handle.owner().equals(current)) {
            return false;
        }
        redis.expire(key(handle.sessionId()), handle.ttl());
        return true;
    }

    public void release(LeaseHandle handle) {
        String current = redis.opsForValue().get(key(handle.sessionId()));
        if (handle.owner().equals(current)) {
            redis.delete(key(handle.sessionId()));
        }
    }

    private String key(String sessionId) {
        return KEY_PREFIX + sessionId;
    }

    public record LeaseHandle(String sessionId, String owner, Duration ttl) {
    }
}

核心实现 4:Workspace 与运行时的两个端口。产物态走对象存储,推理态由 AgentScope 承接,两个端口都保持窄。

package com.demo.digitalhuman.workspace;

import java.util.List;
import java.util.Optional;

public interface WorkspaceSnapshotStore {

    Optional<String> locateLatest(String sessionId);

    void saveIfChanged(String sessionId, List<String> artifacts);
}
package com.demo.digitalhuman.runtime;

import java.util.List;
import java.util.Optional;

public interface AgentRuntime {

    AgentTurn start(String sessionId, Optional<String> workspaceRef, String statePayload, String userInput);

    interface AgentTurn {

        String reply();

        String snapshotState();

        List<String> workspaceArtifacts();
    }
}

AgentRuntime 的实现类负责把 AgentScope Java v2.0.3 的 Agent 加载出来:statePayload 非空时用它还原会话状态,空时新建;推理结束后调用 snapshotState() 取出待持久化的状态。具体调用哪个类、哪个方法,按固定 tag 源码与你实际选用的扩展实现来写——本文不假设方法名,也不把未发布能力写成本版本已支持。

核心实现 5:把三件事串成一次 turn

package com.demo.digitalhuman.core;

import com.demo.digitalhuman.coord.SessionLease;
import com.demo.digitalhuman.coord.SessionLease.LeaseHandle;
import com.demo.digitalhuman.runtime.AgentRuntime;
import com.demo.digitalhuman.state.VersionedStateStore;
import com.demo.digitalhuman.state.VersionedStateStore.StoredState;
import com.demo.digitalhuman.workspace.WorkspaceSnapshotStore;
import java.time.Duration;
import java.util.Optional;

public class TurnCoordinator {

    private static final Duration LEASE_TTL = Duration.ofSeconds(30);
    private static final Duration STATE_TTL = Duration.ofHours(24);

    private final VersionedStateStore stateStore;
    private final SessionLease lease;
    private final WorkspaceSnapshotStore snapshots;
    private final AgentRuntime runtime;

    public TurnCoordinator(VersionedStateStore stateStore,
                           SessionLease lease,
                           WorkspaceSnapshotStore snapshots,
                           AgentRuntime runtime) {
        this.stateStore = stateStore;
        this.lease = lease;
        this.snapshots = snapshots;
        this.runtime = runtime;
    }

    public TurnResult handle(String sessionId, String userInput) {
        Optional<LeaseHandle> acquired = lease.tryAcquire(sessionId, LEASE_TTL);
        if (acquired.isEmpty()) {
            return TurnResult.busy();
        }
        LeaseHandle handle = acquired.get();
        try {
            StoredState current = stateStore.load(sessionId).orElse(new StoredState(0L, null));
            Optional<String> workspaceRef = snapshots.locateLatest(sessionId);
            AgentRuntime.AgentTurn turn = runtime.start(sessionId, workspaceRef, current.payload(), userInput);
            String reply = turn.reply();
            boolean written = stateStore.compareAndSwap(sessionId, current.version(), turn.snapshotState(), STATE_TTL);
            if (!written) {
                return TurnResult.conflict();
            }
            snapshots.saveIfChanged(sessionId, turn.workspaceArtifacts());
            return TurnResult.ok(reply);
        } finally {
            lease.release(handle);
        }
    }
}
package com.demo.digitalhuman.core;

public record TurnResult(Status status, String reply) {

    public enum Status {
        OK,
        BUSY,
        CONFLICT
    }

    public static TurnResult ok(String reply) {
        return new TurnResult(Status.OK, reply);
    }

    public static TurnResult busy() {
        return new TurnResult(Status.BUSY, null);
    }

    public static TurnResult conflict() {
        return new TurnResult(Status.CONFLICT, null);
    }
}

启动:

java -jar digital-human-0.0.1-SNAPSHOT.jar --server.port=8081 --digitalhuman.replica-id=r1
java -jar digital-human-0.0.1-SNAPSHOT.jar --server.port=8082 --digitalhuman.replica-id=r2

请求与验证:以下命令与输出为预期形态,未在当前环境实测。

curl -s -X POST http://127.0.0.1:8081/api/chat -H "Content-Type: application/json" \
  -d '{"sessionId":"s-1001","input":"我叫小林"}'

curl -s -X POST http://127.0.0.1:8082/api/chat -H "Content-Type: application/json" \
  -d '{"sessionId":"s-1001","input":"我叫什么"}'
示例输出
第一次:{"status":"OK","reply":"你好小林,我记住了。","replica":"r1","stateVersion":1}
第二次:{"status":"OK","reply":"你叫小林。","replica":"r2","stateVersion":1}

注意第二次请求打到了 r2,但它读的是 r1 写下的状态,stateVersion 是 1。这就是“两副本轮询仍能连续会话”的最小证据。

杀实例验证:

kill -9 <r1 的 PID>
curl -s -X POST http://127.0.0.1:8082/api/chat -H "Content-Type: application/json" \
  -d '{"sessionId":"s-1001","input":"你还记得我吗"}'

预期是新实例不需要任何人工干预就能接着聊;如果 r1 是被 kill -9 干掉的,它持有的租约不会被主动释放,要等 LEASE_TTL 到期(本文设为 30 秒)才允许新 turn 进入——这个等待时间本身就是取舍,后面在“洞见”里展开。

并发写入验证:

for i in 1 2 3 4; do
  curl -s -X POST http://127.0.0.1:808$((i % 2 + 1))/api/chat \
    -H "Content-Type: application/json" \
    -d '{"sessionId":"s-1002","input":"并发消息"}' &
done
wait

预期结果:一个请求 OK,其余返回 BUSY(没抢到租约)或 CONFLICT(拿到租约但版本已被别人推进)。没有任何一个是静默覆盖,这就是本篇要的语义。

先跑通一条链
再补一个版本号
代码可以粗糙
语义不能含糊

06、排查

证据链一:跨副本失忆

现象:两副本轮询时,第二句回答忘了第一句。

怀疑:模型上下文长度不够、Prompt 模板被覆盖、temperature 太高导致“胡说”。

错误尝试: 我第一反应是把模型换成更大的那个,并把 temperature 调到 0,理由是“上下文丢了多半是模型没记住”。为什么错:上下文不是模型记的,是应用每一轮显式传进去的;换模型只改变生成质量,完全不改变“另一个副本手里没有这条消息”这个事实。这个错误尝试浪费了半天,而且如果真凑巧因为措辞变化让第二句听起来顺了一点,还会掩盖真正的问题。

检查:在 Bridge 和会话层同时打三个字段——sessionId、replicaId、stateVersion,让每一次请求都能对上是哪个副本处理的、当时读到的版本是几。

证据:第一次请求日志 replica=r1 stateVersion=1,第二次 replica=r2 且 load 返回空;redis-cli HGETALL dh:agent-state:s-1001 在两次请求之间始终为空。

根因:状态只在进程内存里,从来没有外部化。前面二十几式的 Memory、Plan 全都写在内存对象上。

修复:走第 05 章的实现,状态外置 + 版本号。

证据链二:陈旧写入覆盖

现象:状态已经开始往 Redis 写了,但并发两条消息时,最终只剩一条,另一条静默消失,用户侧看不到任何报错。

怀疑:Redis 丢数据、网络抖动、Lettuce 连接池把写操作吞了。

检查:把每次写回时的 expectedVersion 和写入后的实际版本都打出来,而不是只打“写成功”。

证据:两条日志的 expectedVersion 都是 3,写完之后版本也都是 4——说明两个副本基于同一个版本各写了一次,后写的覆盖了先写的。

根因:写回是盲写,没有版本校验,分布式里“后写覆盖”是默认行为,不是异常行为。

修复:改成 CAS 写回,失败返回 CONFLICT,由上层决定重放还是提示用户重发。

证据链三:状态回来了,产物没回来

现象:杀掉实例后新实例接管了会话,对话能接上,但上一轮生成的文件 404。

检查:列出对象存储前缀 dh-workspace/{sessionId}/。

证据:前缀为空——只有 AgentState 落了 Redis,Workspace 目录还留在被杀实例的本地磁盘上,进程一死就直接消失了。

根因:把“会话连续”窄化理解成了“对话文本连续”,忽略了产物态。

修复:Workspace 快照上传对象存储,AgentState 里只保存快照引用,恢复时按引用挂回。

上游贡献判断: 本文没有把任何一处写成框架缺陷。要做的是先核验:该行为能否在最小 Demo 里复现、v2.0.3 是否如此、main 分支是否仍然如此、官方 Issues/PR/Discussions 里是否已经有人提过。这几步没走完之前,只能记录为候选,不能对外说“已提交”或“已合并”。

别急着换模型
先看请求落哪
证据在日志里
根因在写入处

07、优化

三条证据链指向同一个根因:状态没有外部化,写入没有版本校验,产物没有跨进程载体。 V2 的修改就按这三条来。

修改一:状态外置 + 版本号。RedisVersionedStateStore 用 Lua 把“比较版本”和“写入”合成一个原子操作,杜绝了“先 GET 再 SET”之间被插队。原因是 Redis 的单线程执行 Lua 天然满足原子性,比自己实现锁简单且少一次往返。新行为是:写回要么成功并让版本前进一格,要么明确失败。验证方法就是上面那组轮询请求,看 stateVersion 是否单调递增。

修改二:session 级租约做单写者。tryAcquire 用 SET NX PX,拿到才允许进入推理。原因是数字人交互天然是“一个人一次只说一句话”,做细粒度并发收益极小、复杂度极高。新行为是并发请求被明确分成 OK 与 BUSY。验证方法是对同一 session 并发打多个请求,确认只有一个 OK。

修改三:租约自愈。租约带 TTL,进程被杀不会留下永久锁。这里有一个明确的取舍:TTL 太短,正常推理还没结束租约就到期,会出现两个副本同时推理;TTL 太长,实例被杀之后要等很久才能被接管。本文取 30 秒,并且在推理过程中做续租(renew),续租时会校验 owner,避免被别的实例抢走后仍然续上。

修改四:幂等键。同一 (sessionId, turnSeq) 的重复请求直接返回上一次结果,而不是重跑推理。原因是在“租约到期—新实例接管—旧实例其实还活着”这种边角情况下,重复执行是可能的,幂等键把重复执行变成廉价操作。

修改五:Workspace 快照引用。AgentState 里只存快照引用(如 dh-workspace/s-1001/0007/),大对象留在对象存储。原因是把大对象塞进 AgentState 会让每次 CAS 的 payload 膨胀,冲突窗口被放大,前面讲的第二个反直觉判断就是这么被踩出来的。

以上改动在本文环境未做压测,也没有性能数据,所以不给出任何提升百分比。可验证的是语义:连续会话、故障接管、冲突拒绝这三件事能各自被复现出来。

加一个版本号
挡一次陈旧写
改的是协议
不是模型参数

08、演进

[同一输入] 用户第 N 轮话语 + sessionId
    |
    +--[V1] 单副本内存态 / 便宜、简单、快 / 扩容即断片、重启即归零、并发写覆盖
    |
    +--[V2] 状态外置 + 版本 CAS + 租约 + 快照引用 / 可轮询、可被杀、冲突可拒绝 / 每轮多一次外部读写、依赖三个存储、租约 TTL 需要调参
    |
[Trade-off]
得到:会话成为跨进程的稳定身份;失去:一部分延迟与一部分简单性;边界:会话需要延续且存在多副本或重启时才值得付这个代价

正确性:V1 的正确性依赖“客户端一直命中同一副本”这个不可控前提;V2 把正确性建立在版本号与租约上,前提变成可控的。

稳定性:V1 在发布、伸缩、OOM 重启时必然丢会话;V2 在这些事件里表现为“短暂等待后接管”,等待时间上限由租约 TTL 决定。

复杂度:V1 零外部依赖;V2 引入 Redis、MySQL(或 JDBC 存储)、对象存储,以及版本、租约、幂等键三套协调逻辑。这是真实的复杂度增长,不是心理负担。

成本:V2 每轮至少两次外部交互(读状态、CAS 写回),再加一次可能的产物上传。具体开销取决于网络与序列化体积,本文不给数字。

适用范围:任何“会话要跨请求、跨实例延续”的 Agent 服务。数字人正好是典型场景。

遗留问题:一是租约 TTL 的两难,短了会双写、长了恢复慢;二是 AgentState 的序列化体积会随会话长度增长,长会话需要有裁剪或分层记忆策略;三是 replica-id 目前只用于观测,还没参与任何调度决策。

单实例省心
多副本难缠
得到的是韧性
付出的是协调

09、洞见

9.1 状态分层比状态集中更重要

反直觉判断:把状态全部塞进 Redis 不是分布式化,而是把单点换了个位置。 真正的分层是按生命周期分:推理态必须强一致、体积中等,进 Redis 或 JDBC;产物态可以最终一致、体积大,进对象存储;控制态必须原子且短命,进 Redis 并带 TTL。混在一起,就会出现“改一个 turn 计数要把整个沙箱目录序列化一遍”这种既慢又容易冲突的写法。

9.2 先定写权限,再选存储介质

反直觉判断:存储选型几乎不影响正确性,写权限模型才影响正确性。 你可以用 MySQL 也可以用 Redis,但只要写回是盲写,多副本就一定会覆盖。反过来,只要版本 CAS 和 session 租约立住了,换存储只是换适配层。这也是为什么本篇先写 VersionedStateStore 这个抽象,再写 Redis 实现——抽象是给正确性用的,实现是给运维用的。

9.3 恢复能力要用“演练”度量,不是用文档度量

“设计了跨副本恢复”和“杀掉一个实例真的能接上”是两件事。判据很具体:kill -9 之后,另一个副本在租约 TTL 内接管,且 Workspace 产物仍可访问。做不到这一点,说明你恢复的只是对话文本,不是会话状态。建议把“杀副本 + 继续会话”做成每次发布前的固定动作,而不是出事才做。

9.4 什么时候不应该用本章能力

单副本且可以接受重启丢会话的服务,不要引入这一整套东西——多出来的三个存储依赖和租约调参,换不来任何业务价值。纯无状态问答(每次请求自带完整上下文、不依赖历史)也不需要,直接水平扩就行。离线批量任务、对延迟极度敏感且中断代价可接受的场景,也应该先用简单方案。判断标准只有一条:会话是否需要在进程不存在的时候依然存在。 答案是否,就不要上。

分布式不是存
是决定谁说话
能用单副本时
就别急着拆

10、系统落地

原来有什么:第 27 式结束时,数字人 Demo 已经能在单实例上跑完整链路,/run/{projectId}、LiveKit 房间、Realtime Bridge、AgentScope Java 推理、MySQL 里的项目与配置,一应俱全;但 Agent 的会话状态活在 JVM 内存里,进程即身份。

本篇新增什么:VersionedStateStore 与 Redis CAS 实现、SessionLease 会话租约、WorkspaceSnapshotStore 产物快照、TurnCoordinator 编排层,以及一条明确的写入协议——先租约、再读状态、推理、CAS 写回、释放租约。分支是 `chapter/28-distributed-pro

posted @ 2026-10-11 11:09  李福春  阅读(3)  评论(0)    收藏  举报