斗破Google ADK Java苍穹-第6境斗王-化翼藏忆-Session与Memory

斗王化翼:Session、Memory 与 Artifact,让数字人从一轮对话走向持续存在

刷新就失忆、跨用户串话、Prompt 膨胀,斗王要赢的是持续存在

[读者] 已跑通 Runner 与 Event、想把数字人做成产品的 ADK Java 开发者
[痛点] 一刷新就失忆、多用户互相串话、历史越堆越长
[现在读] 执行上下文已经能跑,但还没有“存在感”,再叠功能只会放大故障面
[读完] 能落地三重隔离、记忆策略与上下文压缩,并说清三层存储的边界
[旧方案] 一个进程内 Map 装对话历史,每轮把全部历史回灌 Prompt
    |
    v
[新需求] 数字人要跨请求、跨重启、跨用户持续存在,还要能用项目资料
    |
    v
[冲突] 运行状态、长期记忆、大内容被塞进同一个桶,会话键还只有一维
    |
    v
[后果] 重启丢数据、用户串话、Token 与延迟随轮次上涨、故障无法归因

我是老李,带一个小团队做企业内部数字人。前面几章我们把 Runner、Event、InvocationContext 都跑通了,数字人已经能“可靠地执行一次”,但它只有一轮的寿命。

真正上项目之后,反馈来得很快:用户刷新页面,数字人问“你是谁”;两个同事先后打开同一个项目助手,B 看到了 A 的对话;聊到后面响应肉眼可见地变慢,而且它开始把很久之前的无关内容当成当前事实。

这一章要做的事很明确:让数字人从“一轮对话”展翼成“持续存在”——能续聊、能记住用户、能用项目资料与文件,同时不能破坏前面几章建立的 Harness 治理边界。

那就得先想清楚三件事。

为什么“把历史全塞进 Prompt”这条最直觉的路,反而最先崩?

State、Memory、Artifact 到底谁装什么,边界划错了会以什么形式暴露?

如果不把执行上下文收口到 Session,用户与项目的隔离靠什么守住?

选错任何一个,代价都不是“不好用”,而是把一次演示事故升级成一次数据串号事故。

01、故事

现场是一个企业内部的项目助手,跑在两个团队共用的测试环境上。入口很简单:一个网页输入框,加一个项目选择器。后端是我们前几章攒出来的 ADK Java 工程——LlmAgent 有项目身份和人格,Runner 驱动执行,Event 一路外放,Harness 做统一治理。

那天的任务是做一个跨会话演示:上午用账号 A 聊一轮需求,下午用账号 B 登录,让数字人接着上午的上下文继续追问细节。

演示开始三分钟后,问题全出来了。

第一个问题:A 上午说的“预算按三期结算”不见了,数字人像第一次见到这个人,从头问项目背景。

第二个问题:B 打开页面,第一句话是“你上午说的三期结算,我补充一下”,而 B 从没说过这句话。

第三个问题:为了让“记忆”看起来正常,负责演示的同事把历史全部拼回 Prompt。聊到后面,输入框还没发出去,等待就已经很长。

三个问题背后是同一件事:我们的“会话”从来没有被当成一个实体来管理。进程内一个 Map 装历史,键是前端随手生成的字符串;State 和“记忆”被当成同义词;文件和大内容直接塞进对话文本。

任务不是加一个“记忆功能”,而是给数字人装上一对能持续扇动的翅膀:Session 承载运行态,Memory 承载长期记忆,Artifact 承载大内容与文件,并且每次访问都被 userId、projectId、sessionId 三重锁定。

任务描述是对的,但我们对“记”的理解是错的。

一刷新就空白
一对话就串人
一长聊就变慢
一问才知无根

02、问题

旧方案失效得很彻底。它由两件事组成:一个进程内的 Map<String, List<Message>>,加一句“把全部历史拼进 Prompt”。在单用户、单进程、短对话的演示里它跑得通,一旦进入真实项目就同时踩中四个坑。

无持久化:进程结束,Session 消失。无隔离:会话键只有一维,没有用户与项目的概念。无分层:运行状态、长期记忆、文件内容被压成一个字符串。无上限:events 线性增长,每轮全量回灌。

业务影响是三个层次递进的:演示当天就出事故;多用户无法共用一个助手实例;一旦有真实客户数据,串话就从“体验问题”变成“数据问题”,审计上也说不清楚谁能看到什么。

技术表现更容易观察:重启前后 get 拿不到同一份 events;不同用户的请求命中同一个会话;单轮请求的 Prompt 长度只增不减;出问题时没有任何一层可以说清“这个值是谁在什么时候写进去的”。

所以这一境需要一个可验证的完成标准,而不是“感觉记住了”。

第一,同一个 sessionId 在进程重启后仍能读取到同一份 state 与 events。第二,不同 userId 或不同 projectId 的会话在 get / list 结果中互不可见。第三,events 数量在压缩后不随轮次线性增长,且被折叠的内容有摘要可追溯。第四,长期记忆只在符合策略时写入,并支持按 key 删除。第五,以上四条都以自动化测试的形式存在,而不是靠人肉点页面。

验收口径定下来之后,剩下的工作就是选对抽象。

无状态难续聊
无隔离必串话
无分层终膨胀
无标准难验收

03、原理

先立第一条:Event 是唯一真相,State 只是事件流的投影。

在 ADK 的运行模型里,一次执行产生的是 Event。appendEvent 把 Event 追加进 Session 的 events 列表,同时把 Event 携带的 stateDelta 合并进 session.state。这意味着你不能“直接改状态”,只能通过追加事件去改变状态。第一次看到这个设计会觉得绕,但它是可恢复性的来源:只要有完整事件流,状态永远能重算。

反直觉判断:State 才是那个可以丢的东西,Event 才是那个不能丢的东西。很多团队为了“省事”只持久化最终状态,结果一旦状态被写坏,现场无法复盘,只能删库重来。

第二条更关键:上下文不是记忆。

上下文是“这一次执行需要看到的窗口”,它的生命周期由本轮请求决定;记忆是“跨会话可被检索的结论”,它的生命周期由用户和项目决定。把两者混为一谈,就会得到“越记越慢、越记越乱”的结果——因为你把长期数据的读取成本挂到了每一轮请求上。

反直觉判断:把更多历史塞进 Prompt,不会让数字人更“记得住”,只会让它更贵、更慢、更容易把旧指令当成当前事实。真正决定它记得住记不住的,是存储分层与写入策略,而不是窗口长度。

第三条是生命周期差异,它直接决定代码放哪一层。

State 属于会话级运行态,可以随时重建,允许丢失;Memory 属于用户级或项目级的长期语义,写入要有门槛,删除要有出口;Artifact 是大内容与文件,按引用访问,不进上下文。三者共用一套 Session 抽象,但绝不共用一张表、一个键空间。

第四条是隔离的本质。隔离不是“过滤”,而是“键空间”。一个一维的 sessionId 无论怎么校验,都无法表达“这份数据属于谁、属于哪个项目”。只有当键本身携带归属,越权访问才变成不可能,而不是被拦住。

第五条是熵增。会话不主动压缩就一定膨胀,这不是实现问题,是系统属性。所以压缩必须是一个显式的、可测试的策略,而不是“以后再说”。

事件才是真相
状态只是投影
记忆需经取舍
文件另辟他处

04、架构

[输入] 用户消息 + (userId, projectId, sessionId)
    |
    v
[模块] Runner -> BaseSessionService -> Session(State + Events)
    |
    v
[数据/状态] State(运行态) / Memory(长期语义) / Artifact(大内容)
    |
    v
[处理] create/get -> appendEvent(stateDelta) -> 投影State -> compaction -> 检索Memory
    |
    v
[输出] 带历史的续聊响应 + 可恢复会话视图 + 可引用文件

边界是这样的:Session 负责“这一次执行看到什么”,Memory 负责“跨会话能想起什么”,Artifact 负责“大内容与文件在哪里”。Runner 与 Harness 不直接碰存储,只通过 BaseSessionService 这一层抽象访问。

收益有三个。第一,执行上下文可恢复:只要有 events,状态可以重算,故障可以复盘。第二,隔离变成结构性的:会话键天然携带归属,越权不是被拦截,而是根本查不到。第三,演进成本被摊薄:内存实现与持久化实现可以互换,上层代码不动。

代价也很明确。第一,多了一层抽象,本地调试时要多写一点胶水。第二,持久化会带来一致性话题:写内存成功、写库失败怎么办。第三,压缩会损失细节,必须接受“摘要不等价于原文”。第四,记忆策略需要人来做判断,代码没法替你想清楚“什么值得记”。

适用条件是:只要你的数字人需要跨请求存活,这套分层就成立。如果只是一次性问答、无用户概念、无重启需求,它反而增加复杂度。

所以这一境的架构重点不是“加存储”,而是把三种不同寿命的数据放进三个正确的位置,并让键空间替你做隔离。

一层管运行态
一层存长期忆
一层放大内容
三层不可混用

05、实战一次

环境与版本基线:JDK 21、Maven 3.9+、Google ADK Java 1.11.0。

依赖如下,坐标与版本需按当前官方文档核验:

<dependency>
  <groupId>com.google.adk</groupId>
  <artifactId>google-adk</artifactId>
  <version>1.11.0</version>
</dependency>

配置上只有一个决定:把 projectId 映射到 ADK 的 appName 维度,把 userId、sessionId 各自独立开,三者不做任何拼接复用。

核心实现从三重隔离键开始。

package com.example.dh.session;

public record SessionKey(String userId, String projectId, String sessionId) {

    public SessionKey {
        requireNonBlank(userId, "userId");
        requireNonBlank(projectId, "projectId");
        requireNonBlank(sessionId, "sessionId");
    }

    private static void requireNonBlank(String value, String name) {
        if (value == null || value.isBlank()) {
            throw new IllegalArgumentException(name + " must not be blank");
        }
    }

    /** appName 承载项目维度,userId / sessionId 各归其位 */
    public String appName() {
        return projectId;
    }
}

然后是记忆策略的最小可用版本。注意这里只做“判断”,不做“存储”,职责单一。

package com.example.dh.memory;

public interface DigitalHumanMemoryPolicy {

    enum MemoryKind { PREFERENCE, FACT, TASK_CONTEXT }

    record Candidate(String key, String value, MemoryKind kind, double confidence) {}

    /** 哪些信息值得记 */
    boolean shouldPersist(Candidate candidate);

    /** 什么时候删:过期、被否定、或用户要求遗忘 */
    boolean shouldForget(Candidate candidate, long ageMillis);

    /** 注入上下文时最多带多少条 */
    default int maxInject() {
        return 8;
    }

    /** 默认策略:只记高置信度的偏好与事实,任务上下文不跨会话 */
    static DigitalHumanMemoryPolicy strict() {
        long day = 24L * 3600 * 1000;
        return new DigitalHumanMemoryPolicy() {
            @Override
            public boolean shouldPersist(Candidate candidate) {
                return candidate.confidence() >= 0.8 && candidate.kind() != MemoryKind.TASK_CONTEXT;
            }

            @Override
            public boolean shouldForget(Candidate candidate, long ageMillis) {
                long ttl = candidate.kind() == MemoryKind.PREFERENCE ? 90 * day : 30 * day;
                return ageMillis > ttl;
            }
        };
    }
}

接着用 InMemorySessionService 把链路跑通。下面调用的参数形态与 Optional 语义需按当前官方文档核验;关键点是先 get 后续聊,不存在才 create,顺序反过来会覆盖已有状态。

package com.example.dh.session;

import com.google.adk.events.Event;
import com.google.adk.sessions.BaseSessionService;
import com.google.adk.sessions.Session;
import java.util.Map;
import java.util.Optional;

public class SessionBootstrap {

    private final BaseSessionService sessionService;

    public SessionBootstrap(BaseSessionService sessionService) {
        this.sessionService = sessionService;
    }

    public Session open(SessionKey key) {
        Optional<Session> existing = sessionService.getSession(
                key.appName(), key.userId(), key.sessionId(), Optional.empty());
        if (existing.isPresent()) {
            return existing.get();
        }
        return sessionService.createSession(
                key.appName(), key.userId(), Map.of(), key.sessionId());
    }

    /** 状态不直接改,只通过 Event 的 stateDelta 追加 */
    public void writeState(Session session, Map<String, Object> delta) {
        Event event = newStateEvent(delta);
        sessionService.appendEvent(session, event);
    }

    /** Event 构造方式需按当前官方文档核验 */
    private Event newStateEvent(Map<String, Object> delta) {
        return Event.builder()
                .author("system")
                .actions(com.google.adk.events.EventActions.builder()
                        .stateDelta(delta)
                        .build())
                .build();
    }
}

启动入口:

package com.example.dh;

import com.example.dh.session.SessionBootstrap;
import com.example.dh.session.SessionKey;
import com.google.adk.sessions.BaseSessionService;
import com.google.adk.sessions.InMemorySessionService;
import com.google.adk.sessions.Session;
import java.util.Map;

public final class DemoApp {
    public static void main(String[] args) {
        BaseSessionService sessions = new InMemorySessionService();
        SessionBootstrap bootstrap = new SessionBootstrap(sessions);

        SessionKey key = new SessionKey("u-a", "proj-1", "s-1");
        Session session = bootstrap.open(key);
        bootstrap.writeState(session, Map.of("preference.settlement", "three-phase"));

        System.out.println("opened session for " + key.userId() + " @ " + key.projectId());
    }
}

运行:

mvn -q compile exec:java -Dexec.mainClass=com.example.dh.DemoApp

验证用测试而不是肉眼。listSessions 的返回类型与参数需按当前官方文档核验,断言意图是:同一个 projectId 下,两个 userId 各自只看到自己的会话。

package com.example.dh.session;

import static org.junit.jupiter.api.Assertions.assertEquals;

import com.google.adk.sessions.InMemorySessionService;
import java.util.Optional;
import org.junit.jupiter.api.Test;

class SessionIsolationTest {

    @Test
    void 不同用户在同一项目下互不可见() {
        InMemorySessionService sessions = new InMemorySessionService();
        SessionBootstrap bootstrap = new SessionBootstrap(sessions);

        bootstrap.open(new SessionKey("u-a", "proj-1", "s-1"));
        bootstrap.open(new SessionKey("u-b", "proj-1", "s-1"));

        int visibleToA = sessions.listSessions("proj-1", "u-a", Optional.empty()).size();
        int visibleToB = sessions.listSessions("proj-1", "u-b", Optional.empty()).size();

        assertEquals(1, visibleToA);
        assertEquals(1, visibleToB);
    }
}

未在当前环境实测,以下为预期结果(示例输出):

$ mvn -q test -Dtest=SessionIsolationTest
opened session for u-a @ proj-1
BUILD SUCCESS

V1 到这里就结束了。它能跑、能测、能证明三重隔离的键是对的,但它有两个明显遗留:进程一停数据就没了;events 只增不减。这两件事留给后面两章。

先跑内存实现
再验三重隔离
断言必须可复现
输出须留证据

06、排查

V1 交出去不到一天,两条故障就回来了。

第一条:服务重启后数字人失忆。

现象:重启后同一个 sessionId 的 get 返回空 events,state 也回到初始值。怀疑点有两个:记忆策略没生效,或者是持久化没接上。检查方式很直接——在启动日志里打印 sessionService.getClass().getName(),并在重启前后各调一次 listSessions 做对比。证据很清楚:实现类是 InMemorySessionService,进程内 Map 随进程销毁,list 结果为空。

根因不是策略,而是 V1 从来没有实现持久化适配层——用内存实现验证隔离是对的,但把它当成生产实现是错的。

修复方向是引入 Session 存储层并在适配器里先落库、再回填内存。

错误尝试: 有人提议“先用一个 static final Map<String, Session> 顶一下”。为什么错:静态容器只在单进程内看起来持久,进程一停还是丢;更糟的是它是一个全局共享点,键少一维就等于把串话面直接放大。静态缓存解决的是“访问速度”,不是“持久化”,拿它顶持久化是把故障从 A 挪到了 B。

第二条:跨用户串话。

现象:用户 B 打开页面,看到了用户 A 的对话历史。怀疑点先是“Prompt 拼接串了”,因为两边的历史确实进了同一个上下文。检查方式是把每次请求的 userId、projectId、sessionId 三个值原样打出来,再对照 listSessions 的返回。证据是两条不同 userId 的请求拿到了同一个 sessionId——前端把 sessionId 存在浏览器里复用,后端在没有 sessionId 时又 fallback 到一个固定的默认值。

根因是会话键只有一维:只要两个用户落到同一个 sessionId,隔离就不存在,跟 Prompt 怎么写没关系。

修复方向是把 SessionKey(userId, projectId, sessionId) 作为唯一入口,并在服务层拒绝任何跨 key 的读取。

顺带排查出第三条:Prompt 膨胀。现象是单轮请求的历史部分只增不减。检查方式是统计每次请求回灌的 events 条数。证据显示它随轮次线性增长。根因是“上下文”和“记忆”没有分开——所有历史都当成当前请求的必需输入。这条留到下一章处理。

三条链里,最值钱的一条认识是:串话的根因几乎总在键,不在 Prompt。

疑在内存易失
证据指向键值
误修加重串话
根因只在分层

07、优化

V2 围绕第 06 章的三条证据做三件事,每一件都对应一条已确认的根因。

第一件:持久化 Session 适配层。根因是内存实现不落盘,所以引入一个存储接口,在 BaseSessionService 之外补一层持久化。修改如下(表结构按“事件是真相”设计,state 只是可重建的投影):

CREATE TABLE dh_session (
  project_id  VARCHAR(64)  NOT NULL,
  user_id     VARCHAR(64)  NOT NULL,
  session_id  VARCHAR(128) NOT NULL,
  state_json  TEXT         NOT NULL,
  updated_at  BIGINT       NOT NULL,
  PRIMARY KEY (project_id, user_id, session_id)
);

CREATE TABLE dh_session_event (
  project_id  VARCHAR(64)  NOT NULL,
  user_id     VARCHAR(64)  NOT NULL,
  session_id  VARCHAR(128) NOT NULL,
  seq         BIGINT       NOT NULL,
  event_json  TEXT         NOT NULL,
  PRIMARY KEY (project_id, user_id, session_id, seq)
);

注意主键就是三重键,这不是为了好看——它让越权查询在 SQL 层面就查不出结果。适配层的关键顺序是“先追加事件,再更新 state 投影”,因为 state 可以丢,事件不能丢。

第二件:把三重隔离做成唯一入口。根因是会话键一维,所以在服务层把 SessionKey 作为唯一参数类型,任何直接传 sessionId 的调用都不提供重载。新行为是:拿不到完整三重键就抛异常,宁可报错也不 fallback 到默认会话。验证方式是补一条测试:用只带 sessionId 的请求调用服务层,断言抛出 IllegalArgumentException,而不是返回别人的会话。

第三件:记忆策略与上下文压缩。根因是上下文与记忆没分开,所以这里做两件独立的事。

记忆侧:DigitalHumanMemoryPolicy 在一轮结束时统一求值 shouldPersist,通过才写 Memory,shouldForget 命中就删除。写入时机放在轮末而不是轮中,避免半轮脏写入。

压缩侧:把“保留窗口”做成显式策略,而不是靠截断。下面的实现只做策略计算,返回保留列表与折叠摘要,真正的写回仍由 Session 服务负责——这样压缩逻辑可以单独测试。

package com.example.dh.session;

import com.google.adk.events.Event;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;

public final class EventCompactor {

    public static final int KEEP_RECENT = 20;
    public static final String SUMMARY_KEY = "dh.compacted.summary";

    public record Result(List<Event> retained, String foldedSummary) {}

    public static Result plan(List<Event> events, int keepRecent) {
        if (events.size() <= keepRecent) {
            return new Result(List.copyOf(events), null);
        }
        int cut = events.size() - keepRecent;
        List<Event> older = events.subList(0, cut);
        List<Event> retained = new ArrayList<>(events.subList(cut, events.size()));
        String summary = older.stream()
                .map(EventCompactor::oneLine)
                .filter(s -> !s.isBlank())
                .collect(Collectors.joining(" | "));
        return new Result(retained, summary);
    }

    private static String oneLine(Event event) {
        if (event.content() == null) {
            return "";
        }
        return String.valueOf(event.content()).replace('\n', ' ');
    }
}

新行为与验证:压缩后 retained 的长度上界是 KEEP_RECENT,不随总轮次增长;折叠摘要以字符串形式进入 state 的 SUMMARY_KEY,可追溯、可重算。验证方式是两条断言——轮次从 30 增到 300,retained.size() 始终不超过 20;折叠摘要非空且长度随被折叠内容单调增长。这里只声明验证方案与断言口径,具体数值取决于真实运行结果。

这一章没有引入任何新概念,三件事全部来自第 06 章的三条证据。

落库方得久存
策略方定取舍
折叠方控膨胀
隔离方保边界

08、演进

[同一输入] 用户 B 在 proj-1 打开数字人并说“继续之前的方案”
    |
    +--[V1] 命中进程内 Map,可能读到 A 的历史 / 代价:串话 + 重启即失忆
    |
    +--[V2] 命中 (proj-1, u-b, s-*) 三重键,读不到就新建 / 代价:多一次存储往返与压缩开销
    |
[Trade-off]
得到:可恢复、可隔离、可压缩的持续存在;失去:一点延迟与“摘要不等价原文”的精度

正确性上,V1 只能说“在单用户单进程下看起来对”,V2 的正确性由三重键与事件流保证,可以写成断言。稳定性上,V1 的失败是静默的(读到错的数据不报错),V2 把越权变成异常,失败是显式的。

复杂度上,V1 只有一个 Map,V2 多了一个存储接口、一个适配器、一个策略接口和一个压缩器——这是真实的成本,不能假装没有。成本上,V1 每轮读全量历史,单轮成本随轮次上涨;V2 每轮只读保留窗口,成本有上界,但每次请求多了一次存储 I/O。

适用范围上,V1 适合一次性问答或纯演示;V2 适合任何需要跨请求、跨用户、跨重启的场景。

遗留问题还有三个:Memory 现在只有写入与删除规则,还没有说清检索怎么排序;压缩摘要目前是拼接式,语义质量有限;持久化适配层的一致性策略(写库成功、写内存失败)还没有定稿。

所以这一境的演进不是“V2 全面优于 V1”,而是:V1 用最小的抽象验证了键与流的方向,V2 付了真实的复杂度代价把它变成能上项目的形态。

旧法简而失忆
新法全而增负
取舍各有边界
权衡不在一时

09、洞见

9.1 上下文不是记忆

反直觉判断:让数字人更“记得住”的办法,通常不是把窗口开得更大,而是把不该进窗口的东西挪出去。上下文窗口是给“本轮决策”用的稀缺资源,记忆是给“跨会话结论”用的长期资产。二者一旦混用,就会出现越记越慢、越记越乱的结果——成本花在读取上,准确率却在下降。

9.2 串话的根因几乎总在键

反直觉判断:跨用户串话看起来像 Prompt 拼接问题,但绝大多数情况下它是键空间问题。只要会话键是一维的,无论你在 Prompt 里加多少“你只代表当前用户”的约束,隔离都不存在。三重键的价值不在于校验得更严,而在于让“查不到别人的数据”成为默认行为。

9.3 分层不是洁癖,是成本结构

State、Memory、Artifact 三种数据的读频率、写频率、寿命、体积完全不同。把它们塞进一张表,等于用最贵的那个的读写模式去服务所有数据:State 的读频率被 Artifact 的体积拖累,Memory 的寿命被 State 的重建策略打乱。分层的收益不在代码整洁,而在每一层的成本都落在自己的量级上。

9.4 删得掉,才敢记

记忆策略里最容易被忽略的是删除。只写不删的系统,长期来看一定会退化成噪音库:写入越多,检索越差,最终用户会主动要求“忘掉这个”。可删除、可过期、可被否定,是记忆可以被信任的前提。

上下文非记忆
记忆非日志
文件非状态
边界即架构

10、系统落地

原来有的:一个能可靠执行一轮的 ADK Java 数字人,Agent 有身份和人格,Runner 驱动执行,Event 外放,Harness 做统一治理。

本篇新增的:三重隔离键与访问入口;Session 存储适配层(内存实现用于验证,持久化实现用于落地);DigitalHumanMemoryPolicy 写入与淘汰策略;EventCompactor 上下文保留策略;以及对应的隔离与压缩断言。

现在能做的:数字人可以跨请求续聊,跨进程重启后恢复,跨用户与跨项目不串话,长期记忆只在符合策略时写入,会话长度受控。

还缺的:记忆的检索与排序策略;摘要压缩的语义质量;持久化适配层的一致性定稿;以及记忆内容的可观测性——现在能查“存了什么”,还不能回答“为什么检索到这一条”。

下一步的演进方向,是把这一层已经收口的存储能力纳入 Harness 治理:让写入、删除、压缩都成为可审计、可回放的动作,而不是散落在各处的调用。

原有单轮问答
今有跨会话身
所缺长期语义
下一步待治理

11、小结

Q1 → 全量回灌先崩,是因为把长期数据挂到了每轮请求上,成本随轮次上涨且旧内容会污染当前事实
Q2 → State 装运行态、Memory 装长期语义、Artifact 装大内容,边界错了先表现为“慢”,再表现为“串”
Q3 → 靠三重键 + 策略层 + 持久化适配层守住,键定归属,策略定增删,适配层定存续
状态 → 数字人具备跨请求、跨重启、跨用户的持续存在能力,并配有可复现的隔离与压缩测试

这一境的可交付能力不是“加了记忆”,而是:运行态可恢复、记忆可增删、上下文有上界、隔离可断言。四件事里任何一件缺失,前面那对翅膀都扇不起来。

三问皆有回应
三键定住边界
一策管住增删
一测守住行为

12、作业

12.1 理解题:为什么 ADK 要求通过 appendEvent 携带 stateDelta,而不是直接改 session.state?

参考答案:因为事件是唯一真相,状态只是事件流的投影。直接改状态会让状态成为无来源的数据,一旦写坏就无法复盘、无法重算。通过 stateDelta 追加,保证任何状态变化都有对应的事件可追溯,也让持久化时可以只保证“事件不丢”,状态随时能从事件重建。具体的方法签名与合并语义需按当前官方文档核验。

12.2 实战题:用 InMemorySessionService 写一条测试,证明同一个 projectId 下两个不同 userId 的会话互不可见。

参考答案:参考第 05 章的 SessionIsolationTest。要点有三:用 SessionKey(userId, projectId, sessionId) 构造请求;对两个不同 userId 分别 create 或 open;然后用 listSessions 分别按 userId 查询,断言各自只看到一条。不要用同一个 userId 换 sessionId 来测,那验证的是会话维度,不是用户维度。listSessions 的参数形态需按当前官方文档核验。

12.3 排障题:演示时用户 B 看到了用户 A 的历史,你打印出的 userId 与 sessionId 都是对的,最可能的问题在哪?

参考答案:先看 projectId(appName)维度是否被复用成了全局默认值。三个维度里只要有一个退化成常量,隔离就被削弱;如果 userId 与 sessionId 都对,很可能是 projectId 被写成固定值,或者查询时用默认 appName 回退。其次是检查是否存在静态缓存或单例 Holder 绕过了服务层。修复方式是让服务层只接受完整 SessionKey,取消一切 fallback。

12.4 架构判断题:有同事提议把 Memory 直接存进 session.state 里,理由是“少一层、少一次查询”,你怎么判断?

参考答案:不成立,但原因不是“不优雅”,而是生命周期错配。State 是会话级运行态,可丢可重建;Memory 是用户级或项目级长期语义,需要独立写入门槛和删除出口。放在 State 里会带来三个后果:会话清理时误删长期记忆;重建 State 时无法区分哪些是记忆;检索被迫读全量会话。如果确实需要跨会话共享,应该走独立的 Memory 层,而不是扩大 State 的语义。

理解需对文档
实战须留证据
排障要看数据
判断要讲边界

13、思考

这一境真正的分水岭,不是“有没有加持久化”,而是你有没有承认三种数据是不同的东西。刷新失忆、跨用户串话、Prompt 膨胀,表面上是三个故障,实际是同一个错误的三个症状:把运行状态、长期记忆和大内容塞进同一个桶,并且只用一个一维键去描述归属。

分层之后,很多以前说不清的问题会变得可断言:会话该不该恢复,看事件流;记忆该不该写,看策略;上下文有没有失控,看压缩后的窗口大小;有没有串话,看键空间能不能查到别人的数据。

这套判断可以迁移到任何有状态的 Agent 系统上:先把数据的寿命和归属写清楚,再决定它存在哪里;先让键携带归属,再谈校验;先给增长设一个上界,再谈优化。翅膀不是靠给它加更多记忆长出来的,是靠把不该背的东西放下之后才扇起来的。

一刷新便失忆
一错键即串话
一分层即解围
一验证方落地

posted @ 2026-10-10 22:23  李福春  阅读(2)  评论(0)    收藏  举报