AgentScope第14式·分神状态

AgentScope第14式·状态隔离

同一份 Agent 配置,两个用户对话却在互相串词

[读者] 已经把单用户数字人跑通的 Java 后端工程师
[痛点] 一并发就串数据,一重启就丢上下文
[现在读] RuntimeContext 与 AgentState 的边界不划清,后面每一步都会踩
[读完] 会用 (userId, sessionId) 隔离状态,并选对状态存储后端
[旧方案] 单例 Agent + 进程内全局 Memory
    |
    v
[新需求] 多用户、多会话,同一 session 重启后还要能恢复
    |
    v
[冲突] Agent 配置与 Agent 状态被当成同一个东西
    |
    v
[后果] 串词、丢上下文、被迫加锁把并发改回串行

我是老李,做 Java 后端十几年,这两年带着一个小团队把数字人产品从 Demo 往生产推。前 13 式我们一路把最小交互数字人从选型、环境、Agent 内核、Tool/MCP、权限、Harness、Memory、Skill、Plan 做到了可运行的对话链路,代码都落在教程仓库 https://github.com/lifuchun522/agentscopractice 的 chapter 分支上,基线是 AgentScope Java v2.0.3。

问题是这一路上,我们始终只有一个人在用。管理端建一个项目,运行页 /run/{projectId} 打开就是那个数字人,谁来都是同一个 Agent、同一份 Memory。演示阶段这没什么,反正演示的人只有一个。

可现在市场部说要拿去做内测,二十个人同时开同一个项目,各自的语气、各自说过的话、各自的自我介绍,全都不许串。那一刻我才意识到,前面十三式堆起来的能力,全都建在一个隐式假设上:全局只有一份会话。

两份不同 sessionId 的请求,为什么会在同一个进程里互相看见对方的对话?

会话状态到底该放在内存里、写成 JSON 文件,还是塞进 Redis 或 MySQL?

同一 session 重启后还能把之前说过的话捞回来吗,还是只能从头开始?

如果这三个问题答不上来,那么后面无论加多少 Tool、多少 Skill、多少 Sandbox,都只是在一个漏水的桶里加水。

配置如衣可共
状态如身须独
先说清这两样
再谈多用户

01、故事

故事发生在一次内测前的联调。产品经理拿着两个浏览器窗口坐在我旁边,一个是同事 A,一个是同事 B。A 先跟数字人说自己叫老李,喜欢红烧肉;然后 B 打开同一个项目,问数字人一句「我叫什么」。

数字人回答:你叫老李,你喜欢红烧肉。

B 愣了两秒,笑着说这数字人也太会认亲戚了。我当时脸有点热,因为我心里清楚这不是模型幻觉,这是我们的代码把两个人的会话喂进了同一份上下文。

这次联调的任务是:在不改动前 13 式已有能力的前提下,让同一个数字人项目支持多用户、多会话并发,并且同一 session 在进程重启后能恢复上下文。听起来只是加一个 userId 参数,实际上要动的是整个状态归属。

我们当时的做法很朴素:Spring Boot 里注册一个单例的 Agent,Memory 挂在 Agent 内部,运行页通过 LiveKit 建立实时会话,Realtime Bridge 把消息转给 AgentScope Java 推理。单用户时这套链路是通的,因为它根本不需要区分「谁」。

变化是从「谁」开始的。一旦请求带上了两个不同的 userId,链路里所有「只有一份」的东西都变成了隐患:Agent 实例、Memory、会话历史、正在进行的 ReAct 循环。

有意思的是,AgentScope Java 这一类框架其实早就把这个边界想清楚了,问题在我们没读进去。

单例一配到底
多户各说各话
边界先划清楚
后面少打补丁

02、问题

先说旧方案为什么失效。我们原来的 Session 管理是这样的:应用启动时 new 一个 Agent,把 systemPrompt、model、agentName 这些项目配置灌进去,然后所有请求共用。Memory 也是进程内的一份,谁说了什么全写进去。

这套东西在单用户下没有任何毛病,因为「共享」就等于「唯一」。但内测场景下它至少碎了三次。

第一次是数据串。B 能看到 A 的历史,这不是小概率异常,这是必然——同一份 Memory 被两个用户读写,模型自然把 A 的信息当成了对话上下文。

第二次是并发崩。为了不串数据,我们第一反应是给对话入口加 synchronized。结果是串数据变成了排队,A 不等 B 结束就轮不到,实时语音对话直接卡成对讲机。

第三次是重启丢。部署一次,所有人的会话历史归零,用户刚建立的人设、刚说过的偏好全没了。这不是 bug,这是我们把状态放在了一个生命周期只有「一个进程」的地方。

从业务影响看,这三次其实是一件事:数字人这个产品的核心资产是会话状态,而我们把这份资产存在了最不该存的地方。

从技术表现看,可以拆成三条可验证的判断:

  1. 两个不同 userId + sessionId 的请求并发执行,任何一方的回复里不得出现另一方的独有信息。
  2. 同一个 sessionId 内部,请求必须串行,顺序不能乱,否则 ReAct 的中间步骤会互相覆盖。
  3. 进程重启后,用同一个 (userId, sessionId) 发问,应能恢复之前的会话上下文。

这三条就是本篇的完成标准。它们都不涉及新功能,全是「归属」问题。

需要说明的是,本篇讨论的状态是「可变状态」。项目配置(agentName、model、systemPrompt、主题色、开场白)是只读配置,它天然可以共享、可以缓存、可以随进程复制;会话状态不行,它必须按 (userId, sessionId) 归属。这两者的处理方式完全不同,而我们过去把它们写在了同一个对象里。

拉通不等于共享
隔离才是本事
状态若无归属
并发必出乱账

03、原理

先把本篇真正要用的三个概念分清。

RuntimeContext,按 v2.0.3 的源码位置 agentscope-core/src/main/java/io/agentscope/core/agent/RuntimeContext.java,它是「这一次运行」的上下文载体——谁在问、哪个会话、什么环境。它描述的是运行期身份,不是业务配置。

AgentState,位于 io/agentscope/core/state/AgentState.java,它承载的是会话里会变的那部分:对话历史、中间状态、后续章节会加入的 plan / memory 等。它的特征是可序列化、可搬迁、可恢复。

AgentStateStore,位于 io/agentscope/core/state/AgentStateStore.java,它定义状态读写的契约。真正落盘到哪里,由存储实现决定,框架不关心你是内存还是 Redis。

这里有个反直觉判断,也是本篇后面所有设计和排查的地基:

反直觉判断:Agent 越是「无状态」,它才越能支撑有状态的多会话。

大多数人直觉是反过来的——既然每个用户有自己的会话,那我给每个用户 new 一个 Agent 不就隔离了吗?短期看是,长期看是灾难。因为 Agent 里装着 model、systemPrompt、tool 注册表这些昂贵且只读的配置,按用户复制等于按用户复制一份配置,配置一改就要遍历所有实例,内存也顶不住。

正确的切法是:配置共享、状态私有。 Agent 作为「配置 + 推理逻辑」的载体可以共享;状态通过 RuntimeContext 标识身份、通过 AgentStateStore 按 key 取出自己的那一份,用完写回。Agent 在整条推理链上只做无状态变换:读状态 → 推理 → 写状态。

这个判断直接解释了为什么「同一 Session 串行、不同 Session 并行」是自然的:不同 Session 的状态本来就是两份数据,并行天经地义;同一 Session 的状态只有一份,两端同时读改写必然丢更新,所以必须串行。串行的粒度是 key,不是整个应用。

同时也解释了存储选择的本质:内存、JSON、Redis、JDBC 这四种实现之间,差别首先不是性能,而是生命周期的边界。

  • 内存:生命周期 = 一个 JVM 进程。重启即失,多实例不一致。
  • JSON 文件:生命周期 = 一台机器上的一个文件。单机重启可恢复,多实例共享盘会打架。
  • Redis:生命周期 = 集群。多实例一致,但要考虑过期策略和序列化。
  • JDBC:生命周期 = 数据库。多实例一致,可审计可查,代价是写放大和延迟。

选哪个不是技术品味问题,是你对「会话能活多久、在几个实例之间活」这个业务问题的回答。内测阶段我们要「重启后不丢」,那就已经排除了纯内存。

配置可以复刻
状态必须私有
谁把两者混谈
谁就等着串词

04、架构

[输入] /api/dialog { userId, sessionId, projectId, text }
    |
    v
[模块] SessionRuntimeRegistry(按 key 组装 RuntimeContext)
    |
    v
[数据/状态] AgentStateStore 适配层(内存 / JSON / Redis / JDBC)
    |
    v
[处理] 同 session 串行、跨 session 并行的 Agent 推理
    |
    v
[输出] 该会话自己的回复 + 该会话自己的状态快照

边界。 这一段只解决「状态的归属与生命周期」。它不解决推理质量、不解决工具调用、不解决多 Agent 协作,那些是前后章节的事。LiveKit 仍然只负责 WebRTC Room 与实时音视频/数据传输,Realtime Bridge 仍然保持最薄:它接收音频/消息,补上 userId 与 sessionId,转给 AgentScope Java,再把结果回推,自己绝不持有会话状态。

收益。 会话状态从「进程的附属品」变成「可定位、可搬迁、可恢复的业务数据」。这一步做完,水平扩容才有意义;在此之前,扩两个实例只会把串词概率翻倍。

代价。 每次对话前后多一次状态读取与写回,多一次序列化;同一 session 引入了锁,极端情况下会有排队;锁注册表本身需要清理,否则是个缓慢泄漏。

适用条件。 只要你的 Agent 会跑在多于一个用户、或者多于一个进程上,这段架构就是必须的。哪怕现在只有单机,只要业务预期里存在「重启后用户还认得我」,也该上持久化实现。

不适用条件。 纯演示、一次性对话、明确不要求恢复上下文、并且确定永远单实例的场景,硬套这套会让链路复杂一倍而收益为零。判断方法很简单:如果会话丢了业务方不会投诉,那就不必上 JDBC。

相关官方位置。 概念说明见官方文档 docs/v2/zh/docs/building-blocks/context.md(v2.0.3 tag);三个核心类型的源码分别在 agentscope-core 的 io/agentscope/core/agent/RuntimeContext.java、io/agentscope/core/state/AgentState.java、io/agentscope/core/state/AgentStateStore.java。本篇只依赖一个结论:框架把「身份」和「状态」拆成了两个概念,业务层要做的是把身份传对、把状态存对。 具体方法签名请以固定 tag 源码为准,本文不假设接口方法名。

入口只认身份
中台只管路由
存储兜住变化
出口各归各家

05、实战一次

本章分支:chapter/14-state-context,从第 13 式的 chapter 分支继续创建。建议提交:feat(ch14): isolate runtime context and agent state。

环境与版本。 JDK 17+,Maven,Spring Boot 3.x,MySQL 8(或先用文件/内存跑通)。AgentScope Java 固定 v2.0.3。

依赖。 在原有工程中加入 AgentScope Java 的核心依赖,版本与教程基线保持一致:

<dependency>
  <groupId>io.agentscope</groupId>
  <artifactId>agentscope-core</artifactId>
  <version>2.0.3</version>
</dependency>

groupId / artifactId 以 v2.0.3 tag 的 pom 或官方 README 为准,若你本地用的是聚合依赖,按其结构替换即可。

数据模型。 会话状态落到 MySQL 时,主键就是归属键:

CREATE TABLE agent_session_state (
  user_id     VARCHAR(64)  NOT NULL,
  session_id  VARCHAR(64)  NOT NULL,
  project_id  VARCHAR(64)  NOT NULL,
  state_json  MEDIUMTEXT   NOT NULL,
  revision    BIGINT       NOT NULL DEFAULT 0,
  updated_at  DATETIME(3)  NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
              ON UPDATE CURRENT_TIMESTAMP(3),
  PRIMARY KEY (user_id, session_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

核心实现之一:归属键。 用一个不可变的 record 表示会话身份,避免把两个字符串参数到处传错顺序:

public record SessionKey(String userId, String sessionId) {
    public SessionKey {
        if (userId == null || userId.isBlank()) {
            throw new IllegalArgumentException("userId 不能为空");
        }
        if (sessionId == null || sessionId.isBlank()) {
            throw new IllegalArgumentException("sessionId 不能为空");
        }
    }
}

核心实现之二:状态读写契约。 业务侧先定义一个薄接口,官方 AgentStateStore 的适配实现挂在它后面。这样存储换实现时,业务代码不动:

public interface SessionStateRepository {
    Optional<String> load(String userId, String sessionId);
    void save(String userId, String sessionId, String projectId, String stateJson);
    void delete(String userId, String sessionId);
}

内存实现用于本地冒烟:

public class InMemorySessionStateRepository implements SessionStateRepository {

    private final Map<SessionKey, String> store = new ConcurrentHashMap<>();

    @Override
    public Optional<String> load(String userId, String sessionId) {
        return Optional.ofNullable(store.get(new SessionKey(userId, sessionId)));
    }

    @Override
    public void save(String userId, String sessionId, String projectId, String stateJson) {
        store.put(new SessionKey(userId, sessionId), stateJson);
    }

    @Override
    public void delete(String userId, String sessionId) {
        store.remove(new SessionKey(userId, sessionId));
    }
}

JDBC 实现用 upsert + 乐观锁,避免同一会话被两个实例同时写坏:

@Repository
public class JdbcSessionStateRepository implements SessionStateRepository {

    private final JdbcTemplate jdbc;

    public JdbcSessionStateRepository(JdbcTemplate jdbc) {
        this.jdbc = jdbc;
    }

    @Override
    public Optional<String> load(String userId, String sessionId) {
        List<String> rows = jdbc.queryForList(
                "SELECT state_json FROM agent_session_state WHERE user_id = ? AND session_id = ?",
                String.class, userId, sessionId);
        return rows.isEmpty() ? Optional.empty() : Optional.of(rows.get(0));
    }

    @Override
    public void save(String userId, String sessionId, String projectId, String stateJson) {
        jdbc.update("""
                INSERT INTO agent_session_state (user_id, session_id, project_id, state_json, revision)
                VALUES (?, ?, ?, ?, 0)
                ON DUPLICATE KEY UPDATE
                  state_json = VALUES(state_json),
                  project_id = VALUES(project_id),
                  revision   = revision + 1
                """, userId, sessionId, projectId, stateJson);
    }

    @Override
    public void delete(String userId, String sessionId) {
        jdbc.update("DELETE FROM agent_session_state WHERE user_id = ? AND session_id = ?",
                userId, sessionId);
    }
}

核心实现之三:按 key 组装 RuntimeContext。 这一步是整篇的关键——加载状态、构造上下文、把身份喂给 Agent,都在这里完成:

@Component
public class SessionRuntimeRegistry {

    private final SessionStateRepository repository;

    public SessionRuntimeRegistry(SessionStateRepository repository) {
        this.repository = repository;
    }

    /**
     * 每次请求都重新构造上下文,绝不缓存到 Agent 实例字段上。
     * RuntimeContext 的构造方式以 v2.0.3 的 RuntimeContext.java 为准,
     * 这里只固定语义:身份 = (userId, sessionId),状态来自 repository。
     */
    public RuntimeContext contextOf(SessionKey key, String projectId) {
        String stateJson = repository.load(key.userId(), key.sessionId()).orElse(null);
        return RuntimeContext.builder()
                .userId(key.userId())
                .sessionId(key.sessionId())
                .attribute("projectId", projectId)
                .attribute("stateJson", stateJson)
                .build();
    }

    public void persist(SessionKey key, String projectId, String stateJson) {
        repository.save(key.userId(), key.sessionId(), projectId, stateJson);
    }
}

核心实现之四:同 session 串行、跨 session 并行。 锁的粒度是 SessionKey,不是方法:

@Component
public class SessionLockRegistry {

    private final ConcurrentHashMap<SessionKey, ReentrantLock> locks = new ConcurrentHashMap<>();

    public ReentrantLock lockFor(SessionKey key) {
        return locks.computeIfAbsent(key, k -> new ReentrantLock());
    }

    /** 会话结束后调用,避免锁对象无限增长。 */
    public void release(SessionKey key) {
        ReentrantLock lock = locks.get(key);
        if (lock != null && !lock.isLocked() && !lock.hasQueuedThreads()) {
            locks.remove(key, lock);
        }
    }
}

对话入口把四件事串起来:

@PostMapping("/api/dialog")
public ResponseEntity<DialogResponse> dialog(@RequestBody DialogRequest request) {
    SessionKey key = new SessionKey(request.userId(), request.sessionId());
    ReentrantLock lock = lockRegistry.lockFor(key);
    lock.lock();
    try {
        RuntimeContext ctx = registry.contextOf(key, request.projectId());
        String reply = digitalHumanService.chat(ctx, request.text());
        registry.persist(key, request.projectId(), digitalHumanService.snapshot(ctx));
        return ResponseEntity.ok(new DialogResponse(key.sessionId(), reply));
    } finally {
        lock.unlock();
        lockRegistry.release(key);
    }
}

启动与请求。 默认用 InMemorySessionStateRepository 冒烟,需要重启恢复时把 Spring 配置切到 JdbcSessionStateRepository。用两个用户同时打:

curl -s -X POST localhost:8080/api/dialog \
  -H 'Content-Type: application/json' \
  -d '{"userId":"u1","sessionId":"s1","projectId":"p1","text":"我叫老李"}'

curl -s -X POST localhost:8080/api/dialog \
  -H 'Content-Type: application/json' \
  -d '{"userId":"u2","sessionId":"s2","projectId":"p1","text":"我叫小王"}'

curl -s -X POST localhost:8080/api/dialog \
  -H 'Content-Type: application/json' \
  -d '{"userId":"u1","sessionId":"s1","projectId":"p1","text":"我叫什么"}'

验证。 未在当前环境实测,以下为预期结果:u1 的最后一次请求返回包含「老李」,u2 的会话里不会出现「老李」;把服务停掉再用 u1/s1 发问,用 JDBC 实现时应仍能答出老李,用内存实现时应答不出来——这个对比本身就是最好的教材。示例输出略去了具体文案,因为模型回复不稳定,验收要看的是「有没有串」,不是「说了什么」。

先跑通一条链
再谈规模化
键若定得明白
后患自然少

06、排查

证据链一:并发串数据。

现象:u1 与 u2 交替对话后,u2 的回复里出现 u1 说过的信息。

怀疑:模型幻觉。

检查:把两次请求的 sessionId 打日志,确认不同;再打印每次传给 Agent 的上下文里包含的历史条数,发现两次都是「全局累计条数」。

证据:官方文档 docs/v2/zh/docs/building-blocks/context.md(v2.0.3)明确指出运行期上下文承载身份与状态这类数据;源码 io/agentscope/core/agent/RuntimeContext.java 的定位是「单次运行的上下文」,而不是「应用的全局单例」。当业务层把一份 Memory 挂在单例 Agent 上,框架根本没有可用来区分的维度——它连你是谁都无从得知。

根因:状态挂在进程级对象上,而不是挂在 (userId, sessionId) 这个 key 上。

修复:引入 SessionRuntimeRegistry,每次请求按 key 取状态、构造 RuntimeContext,用完写回。

证据链二:重启丢上下文。

现象:重新部署后,同一个用户接着聊,数字人完全不记得之前说过什么。

怀疑:框架没做持久化。

检查:看当前注册的存储实现是哪一个,发现是内存实现;再查 AgentStateStore 这个抽象的存在意义——它把「状态存哪里」交给实现,框架本身不替你选。

证据:io/agentscope/core/state/AgentState.java 与 AgentStateStore.java(v2.0.3)的存在,本身就说明状态是被设计成「可被外部接管」的;如果框架认定状态只能在内存里,就不需要这一层抽象。所以「重启丢」不是框架的锅,是存储实现的生命周期只有一个 JVM。

根因:存储实现的生命周期与应用要求的生命周期不匹配。

修复:换成 JDBC 或 Redis 实现,键仍然是 (userId, sessionId)。

错误尝试: 第一反应是给对话方法加 synchronized,或者用 ThreadLocal 存 Memory。

这两个都很典型,也都错。synchronized 解决的是「同时写」,不解决「写到哪儿」——串数据的根因是两份会话共用一份状态,加锁只是让它们排队去污染同一份数据,顺便把实时语音体验毁掉。ThreadLocal 看起来更聪明,但它把状态绑定到了「承载请求的线程」上,而实时链路的推理回调、流式输出、异步事件很可能不在同一个线程上执行,一会儿取得到一会儿取不到;更致命的是线程池复用,上一个请求的残留会被下一个请求读到,等于把串数据从「并发可见」变成了「随机偶发」,排查难度翻倍。

正确的写法是把身份显式传下去,让状态跟着 key 走,而不是跟着线程走、更不是跟着进程走。

上游贡献判断: 本章在最小数字人 Demo 中没有复现出 AgentScope Java v2.0.3 的明确缺陷。多会话串数据属于业务层把可变状态放在错误位置,不是框架行为异常;重启丢失来自我们选择的内存实现,属于预期行为。若后续在对比 v2.0.3 tag 与 main 时,发现官方文档对「RuntimeContext 与 AgentState 的职责边界」描述不足、容易让使用者把状态挂到 Agent 实例上,那么可以按文档缺口方向整理一份 Issue 候选,但前提是先在官方仓库 Issues / Discussions 中检索是否已有同类讨论,且必须带上最小复现与具体文档位置,不能只凭感觉提。

现象指向并发
根因藏在单例
别急着加锁
先问状态归谁

07、优化

基于第 06 章的两条证据链,V2 做三件事。

第一,把存储实现变成可配置。 定义一个配置项决定装载哪个 SessionStateRepository:

agentscope:
  state:
    store: jdbc      # memory | json | redis | jdbc
    json:
      root: ./data/sessions
    redis:
      key-prefix: dh:state:

这样做不是为了炫技,而是为了让「会话能活多久」变成一个可以被 review 的显式决策,而不是藏在某一行 new 里。

第二,把写回做成幂等 + 带版本。 前面的 SQL 已经带上了 revision 自增。如果将来引入多实例 + 分布式锁缺失的场景,可以在读取时一并取出 revision,写回时用 WHERE revision = ? 做乐观校验,失败则重试一次。这样即使同一会话意外被两个实例同时处理,也不会静默覆盖。

第三,锁注册表要能回收。 前面 release 只做了「没人排队就移除」,这在高并发下会存在竞态(可能移除瞬间有新请求拿到旧锁)。更稳妥的做法是引用计数:

public final class RefCountedLock {
    private final ReentrantLock lock = new ReentrantLock();
    private final AtomicInteger refs = new AtomicInteger(1);

    public void acquire() { lock.lock(); }
    public void release() { lock.unlock(); }
    public boolean tryRetire() { return refs.decrementAndGet() == 0; }
    public void retain() { refs.incrementAndGet(); }
}

lockFor 在取锁时先 retain,再用 tryRetire 判断是否可以从 map 中移除。这不是本篇的重点,但如果不做,跑一周之后 map 会随着会话数缓慢膨胀——这是很典型的「状态泄漏」,和我们本篇讨论的状态归属是同一类问题。

新行为。 应用启动时根据配置装载存储实现;每个请求进入时按 (userId, sessionId) 加载状态、加会话锁、推理、写回、解锁;重启后用同一 key 发问能恢复上下文。

验证方法。 三条:并发双用户不串(对照观察 u1/u2 独有信息的可见性)、同 session 顺序不乱(并发发 10 条,检查回复顺序与状态最终一致)、重启后可恢复(重启进程后再问一次)。注意这三条都是行为验证,不需要性能数字;如果你手上没有压测环境,不要凭空写 QPS 提升。

改一处存法
换整套秩序
代价是序列化
收益是可迁移

08、演进

[同一输入] {userId:u1, sessionId:s1, text:"我叫老李"}
    |
    +--[V1] 单例 Agent + 进程内全局 Memory
    |      代价:串词、重启丢、水平扩容即错
    |
    +--[V2] RuntimeContext + AgentStateStore(key)
    |      代价:每轮多一次读写 + 序列化,引入会话锁与锁注册表管理
    |
[Trade-off]
得到会话隔离与可恢复;失去一点吞吐,以及「随手在 Agent 上挂个字段」的方便

正确性。 V1 在单用户单实例下是正确的,V2 在多用户多实例下才是正确的。这不是升级关系,是适用范围不同。判断自己该用哪个,只需要问一句:这个会话丢了,业务方会不会来找我?

稳定性。 V1 的稳定性随并发数下降;V2 的稳定性取决于存储后端,JDBC/Redis 明显优于 JSON 文件优于内存。JSON 文件在多实例共享目录的情况下会出现写冲突,这不是理论问题,是必现问题。

复杂度。 V1 几乎零复杂度;V2 增加了一个接口、四个实现、一份建表语句、一个锁注册表。这部分复杂度是可控的,因为它被收敛在「状态访问」这一个横切面里,没有渗透到业务逻辑。

成本。 主要成本是每轮对话的序列化与一次数据库/Redis 往返。会话状态越大,序列化越贵。这里的工程建议是:别把整份 Memory 无脑序列化,只序列化真正需要跨进程恢复的部分——比如对话历史与关键槽位,临时中间结果不必落盘。

适用范围。 只要产品有「用户」这个概念,V2 就是必须的。反过来,如果是一个内部一次性工具、单实例、用完即弃,V1 更省事。

遗留问题。 三个:一是锁注册表的回收策略还比较粗糙;二是状态 JSON 的体积没有上限控制,长会话会越来越大,需要截断或摘要策略;三是多实例部署时进程内的 ReentrantLock 已经失效,需要换成基于 Redis 的分布式锁或粘性会话。第三点是本篇明确的边界,不能假装已经解决。

旧法快而脆弱
新法慢而稳固
取舍看场景
不必迷信一端

09、洞见

9.1 配置无状态、状态有归属,是两件事

本篇最值钱的一句话:Agent 的配置(agentName、model、systemPrompt、工具注册表)应该尽可能无状态,会话的可变状态必须按 (userId, sessionId) 归属。把这两样塞进同一个对象,是所有串数据问题的起点。反过来,把它们拆开之后,你会突然发现水平扩容变得简单了——实例之间不需要同步配置,只需要共享状态存储。

9.2 反直觉判断:状态存储的选择,本质是生命周期选择

大多数人在选 Redis 还是 MySQL 时想的是性能。在本篇的场景里,这个思路是错的。内存 vs JSON vs Redis vs JDBC 的第一差异是「这份状态能活多久、能在几个实例之间活」,性能只是第二位的。如果你的业务要求是「重启后不丢」,那么无论内存实现多快,它都是错的;如果你的业务是单机一次性工具,无论 Redis 多能扩,它都是多余的。先回答生命周期,再谈性能。

9.3 反直觉判断:串行是手段,不是目标

听到「同一 Session 必须串行」时,很容易顺手把整个对话入口加上全局锁,觉得反正是串行。但这会把「同一会话内有序」错做成「所有会话一起排队」,A 用户的长思考会阻塞 B 用户的实时语音。正确的粒度是 SessionKey:同一个 key 串行,不同 key 并行。这也是本篇把锁放在 SessionLockRegistry 而不是 Service 方法上的原因。

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

如果你做的是单实例、单人使用、会话丢失无所谓的场景——比如一个内部调试用的对话页、一次性的 Demo 演示、评测脚本里的 Agent 调用——那么这套 (userId, sessionId) + 存储 + 锁的组合是纯负担,会让你多写一堆代码却没有任何业务收益。判断标准只有一个:会话状态是不是业务资产。 不是,就别上。

9.5 会话状态必须可序列化,这条约束会反推你的设计

一旦状态要跨进程恢复,它就必须是可序列化的。这条约束会反过来影响你后面的设计:不能把多态对象、文件句柄、非可序列化的中间结构直接塞进状态;后面章节要加的 plan、memory、skill 状态,也都得考虑这一点。早一点接受这个约束,比写到第十二式时被迫重构要便宜得多。

越无状态越好扩
越有归属越安全
边界之外莫强求
范围内求极致

10、系统落地

原来有什么。 前 13 式留下的是一个可以跑通的最小交互数字人:Spring Boot 管理端 + 运行页 /run/{projectId},LiveKit 负责实时会话,Realtime Bridge 做媒体适配,AgentScope Java v2.0.3 负责推理,MySQL 存项目配置,但会话状态是全局一份。

本篇新增什么。 在 chapter/14-state-context 分支上,新增了 SessionKey、SessionStateRepository(内存/JSON/Redis/JDBC 四种实现可选)、SessionRuntimeRegistry、SessionLockRegistry,以及一张 agent_session_state 表。运行页与 LiveKit 链路没有改动——这是刻意的,状态归属应该是一个横切关注点,不该污染实时链路。

现在能做什么。 一个数字人项目可以被多个用户同时使用,各自的会话互不可见;同一用户同一会话的多条消息按顺序处理,不会互相覆盖;用 JDBC 或 Redis 实现时,进程重启后同一 (userId, sessionId) 能恢复上下文。第 02 章给出的三条完成标准到这里都有对应的实现与验证路径。

还缺什么。 第一,多实例部署下的分布式锁还没做,当前锁只在单 JVM 内有效。第二,状态体积没有上限与清理策略,长会话会持续膨胀。第三,会话的主动结束与回收(用户关掉页面后多长时间清状态)没有实现。第四,前面章节的 Memory / Skill / Plan 等能力在本篇中还没有全部纳入状态归属检查,需要后续逐项对齐。

下一步如何演进。 自然的顺序是先把状态体积与清理做完(否则越往后越难改),再把分布式锁补上(否则多实例部署始终是个隐患),然后把前面章节的能力逐个接进这套状态模型。这个顺序不是随意排的:状态模型是地基,地基没稳之前往上叠的东西都要返工。

开源参与点: 本章未发现明确上游问题。多会话串数据与重启丢状态都已定位到业务侧的状态归属与存储实现选择,与 AgentScope Java v2.0.3 的框架行为无关。后续如果在对比 v2.0.3 tag 与 main 分支、或核对官方文档 docs/v2/zh/docs/building-blocks/context.md 时发现文档层面的表述缺口,且官方 Issues / Discussions 中没有同类讨论,可以作为 Issue/PR 候选记录,届时再按「先复现、再对比版本、再检索已有讨论」的流程走。

底座已能分家
下一步谈持久
能力长在骨上
不在文档页里

11、小结

Q1 → 串数据的根因是状态没有按 (userId, sessionId) 归属,而是挂在进程级单例上;加锁只能串行化,不能隔离。

Q2 → 存哪儿取决于「会话要活多久、在几个实例之间活」,不取决于性能数字;内测要求重启可恢复,纯内存就已经出局。

Q3 → 能恢复,前提是把 AgentStateStore 的实现从内存换成 JDBC 或 Redis,键仍然是 (userId, sessionId),业务代码不需要改。

状态 → 多用户、多会话隔离的数字人底座已在 chapter/14-state-context 上跑通,可提交 feat(ch14): isolate runtime context and agent state,验收点是并发不串数据与重启可恢复。

Q1 串数据   → 状态没按 (userId, sessionId) 归属
Q2 存储选择 → 看进程生命周期,不看性能数字
Q3 重启恢复 → 换存储实现即可,业务接口不变
状态        → 多用户多会话隔离的最小数字人底座

问题有了答案
分支可以合流
状态各归其主
会话方能长久

12、作业

12.1 理解题:RuntimeContext 与 AgentState 的职责边界在哪里?

参考答案:RuntimeContext 描述的是「这次运行是谁、在哪个会话、什么环境」,属于运行期身份;AgentState 承载的是会话里会变化的那部分数据,特点是可序列化、可搬迁。两者都不应该被当作应用级单例使用。判断自己有没有理解,可以问一句:如果我把 AgentState 缓存成 Spring 单例 Bean,会出现什么现象——答案是所有会话看到同一份状态,也就是本篇开头那个「你叫老李」的 bug。具体类型定义请打开 v2.0.3 tag 下 io/agentscope/core/agent/RuntimeContext.java 与 io/agentscope/core/state/AgentState.java 自行核对,不要只背本文结论。

12.2 实战题:把内存实现换成 JDBC 实现,并验证重启后可恢复。

参考答案:按第 05 章的建表语句建表,实现 SessionStateRepository 的 JDBC 版本,把 Spring 装配从内存实现切到 JDBC 实现;用 u1/s1 说一句带个人信息的话,重启进程,再用同一 key 发问,观察是否还能答出。重点不是会不会写 JDBC,而是能否保持「业务代码零改动」——如果换存储要改 Service,说明你的抽象层位置放错了。

12.3 排障题:用户反馈「两个浏览器窗口内容一样」,如何用两条证据链定位?

参考答案:第一条证据链查归属,打印每次请求的 (userId, sessionId) 与加载到的历史条数,如果两条不同的 key 加载到相同的累计条数,说明状态没按 key 走;第二条证据链查存储实现,确认当前生效的是内存还是持久化实现,看它能否区分 key。注意不要一上来加锁,也不要一上来怀疑模型——串数据是归属问题,不是并发问题,这两者很容易被混为一谈。

12.4 架构判断题:多实例部署时,本节的 ReentrantLock 还有效吗?该如何处理?

参考答案:无效。ReentrantLock 只在单个 JVM 内可见,两个实例各自持有一把「看起来一样」的锁,同一会话仍然会被同时处理,最终写回时互相覆盖。三种处理方向:一是引入基于 Redis 等的分布式锁;二是把同一会话的请求路由到同一实例(粘性会话),本质上是用路由代替锁;三是用乐观锁(revision 校验)加冲突重试,接受偶发重算。选哪种取决于你对延迟与一致性的容忍度,但必须选一个——把单机锁留在多实例部署里,是最危险的一种「看起来没问题」。

动手胜过空想
源码胜过转述
一题验一判断
一证换一安心

13、思考

回到本篇的核心冲突:我们把「Agent 配置」和「Agent 会话状态」写在了同一个对象里,于是在第一次遇到两个用户时,整个链路就散了。解决它的过程并不复杂——把身份显式传下去,把状态按 key 取出来,把存储实现做成可替换的——但每一步都需要你先想清楚边界在哪里。

这套判断可以复用到很多地方:任何「共享一个昂贵对象 + 每个请求有私有数据」的系统,都要问一句私有数据挂在哪儿。挂在对象字段上、挂在 ThreadLocal 上、挂在进程里,都是在赌未来不会有第二个用户、第二个实例、第二次重启。

真正拉开差距的,是你有没有能力沿着官方源码和文档去确认「框架把哪部分设计成了可替换的」。A

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