降SpringAI阿里第5掌-潜龙勿用-藏忆流式

降SpringAI阿里第5掌-潜龙勿用-藏忆流式

两个浏览器一刷新就串线,Memory 被当成产品聊天记录。

[读者] 正在给数字人接 Web 会话的后端工程师
[痛点] 流式不会推、Memory 会串、历史查不全
[现在读] 数字人基线已能对话,下一步必须可控
[读完] 能按三层 ID 隔离 Memory 并打通 SSE
[旧方案] 单 ChatClient 直连模型,前端等全文
    |
    v
[新需求] 数字人项目要短期记忆、流式交互、独立历史
    |
    v
[冲突] Memory 不是产品库,流式不是一次返回
    |
    v
[后果] 串线、超时堆积、断线后上下文错乱

我是老李,在做数字人项目的第 5 掌。前面已经有了注册登录、项目 CRUD、Agent 模型与 System Prompt,Web 运行页也能把一句话发给模型。任务是让这个数字人拥有可控的短期会话能力,同时别把模型记忆和产品历史混成一锅。

当前基线很朴素:Controller 调 ChatClient,call() 拿到全文再返回。单次演示没问题,一放到真实浏览器会话就暴露:两个人同时聊,回答会混;页面刷新,上一轮上下文丢;用户关闭页签,后端还在生成。

冲突问句:如果 Memory 只用一个全局 conversationId,两个浏览器会话凭什么不串线?

选择问句:是把完整聊天记录塞进 Memory,还是让 Memory 只服务模型上下文,产品历史另走业务库?

选错后的后果问句:如果混在一起,下一次查询历史、做审计、做回放时,你还能分清哪条是模型看到的、哪条是产品要展示的吗?

01、故事

现场是一个数字人 Web 运行页:左侧项目设定,右侧对话窗。后端有注册登录、项目 CRUD、标题主题色、开场白结束语、Agent 模型和 System Prompt。任务是把它从“能问一句”推进到“能持续聊、能流式看、能按项目隔离”。

V1 的代码很短:一个 ChatClient,一个 /chat 接口,前端 fetch 后等整段 JSON。测试时只有我一个人,问题不明显。产品和前端同事加入后,两个浏览器同时打开,A 项目问“你好”,B 项目也问“你好”,后端日志里 conversationId 是默认值,Memory 把两段对话拼在一起。

更麻烦的是历史页。产品经理问:我刚聊的内容为什么历史列表没有?我打开数据库,发现只有项目表、用户表,没有聊天消息表。原来大家默认“Memory 里有”,但 Memory 是给模型看的窗口,不是产品完整记录。

于是本掌要同时处理三条线:Prompt 与 Message 的组织、Memory 的隔离与持久化边界、响应式流到 Web 的桥接。分支定为 chapter/05-memory-streaming,提交信息按 feat(ch05): add memory prompt and streaming interaction。

现场先复现
证据再说话
边界要写清
流式要可控

02、问题

旧方案失效在三处。第一,Prompt 拼在 Controller 字符串里,System、User、历史消息没有分层,项目 System Prompt 一改,所有会话都受影响。第二,Memory 没有按 userId/projectId/sessionId 隔离,conversationId 取默认值或全局值,两个浏览器会话互相污染。第三,接口用 call(),前端必须等完整响应,取消、超时、断线重连没有定义行为。

业务影响是直接的:数字人项目不能多租户演示,历史页无法独立查询,前端体验像“转圈等全文”。技术表现是 Memory key 重复、SSE 没有事件流、客户端取消后上游仍可能继续计算、同一会话并发写导致上下文顺序错乱。

可验证的完成标准有三条:两个浏览器会话不串线;Web 能流式显示增量;产品历史可独立查询。验收不靠感觉,靠两个独立 sessionId 同时请求,观察各自 Memory key;靠浏览器 Network 面板看到 text/event-stream 分片;靠业务库消息表能按 projectId/sessionId 查回记录。

旧法能跑通
多租就露馅
验收要三条
缺一不算完

03、原理

Spring AI 的 ChatMemory 是模型上下文窗口,不是数据库。它通过 Advisor 在请求前后读写消息,典型实现是 MessageWindowChatMemory,按 conversationId 维护最近 N 条消息。ChatMemoryRepository 决定这些消息存哪里:内存、JDBC、Redis 或其他。Advisor 负责把 Memory 织入 ChatClient 调用链,聊天调用只带一个 conversationId。

反直觉判断:Memory 越“完整”,模型未必越好。窗口塞满旧消息会挤占 token,也会让当前指令被稀释。短期会话能力的关键不是记住一切,而是按会话隔离、按窗口裁剪、按业务库留全量。

响应式流是另一个边界。stream().content() 返回 Flux<String>,它不是“把全文拆开”这么简单,而是上游 token 到达即向下游推送。客户端取消会沿着 Reactor 链向上传播,但模型供应商是否立即停止,取决于适配层;业务侧不能假设取消等于零成本。超时、断线、重连都必须显式设计。

因此本掌的核心模型是:Prompt Template 管“怎么说”,System/User Message 管“角色与输入”,ChatMemory/Repository 管“模型短期看什么”,Advisor 管“何时读写”,响应式流管“怎么到 Web”。产品聊天记录另走业务库,绝不能只靠 Memory。

记忆非全量
窗口要裁剪
流式可取消
历史另落库

04、架构

[输入] userId/projectId/sessionId + 用户问题
    |
    v
[模块] ChatClient + Prompt Template + Memory Advisor
    |
    v
[数据/状态] conversationId -> ChatMemoryRepository
    |
    v
[处理] 取窗口消息 -> 调模型 -> 增量 Flux
    |
    v
[输出] SSE 到 Web;业务库异步写产品历史

边界:Memory Advisor 只服务模型上下文;业务库写入只服务产品查询与审计。Prompt Template 负责把项目 System Prompt、开场白、用户输入组装成消息,不负责持久化。SSE Controller 只做桥接,不把业务事务塞进流里。

收益是隔离清晰:换 sessionId 就换上下文;查历史走业务库;模型调用链可复用。代价是两套存储需要一致性策略,流式取消时业务库可能只落到半截消息,需要标记状态。适用条件是单会话消息量不大、窗口可裁剪、业务可接受最终一致。

如果项目要跨实例部署,ChatMemoryRepository 必须外置到 JDBC/Redis,否则多副本各记各的,会话漂移。若仍用内存 Repository,只能单实例演示。

上下文归记忆
历史归业务
流式归桥接
边界要分层

05、实战一次

环境按系列固定:JDK 21 LTS,alibaba/spring-ai-alibaba v1.1.2.2,教程仓库 https://github.com/lifuchun522/springaialibabapractice,分支 chapter/05-memory-streaming。以下代码按 Spring AI 1.x ChatMemory API 形态编写,SAA v1.1.2.2 的精确依赖坐标需按当前官方文档核验。未在当前环境实测,以下为预期结果。

依赖示意,按官方 examples 和当前版本核验:

<dependency>
  <groupId>com.alibaba.cloud.ai</groupId>
  <artifactId>spring-ai-alibaba-starter-dashscope</artifactId>
  <version>1.1.2.2</version>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-webflux</artifactId>
</dependency>

配置只放非敏感项,密钥走环境变量:

spring:
  ai:
    dashscope:
      api-key: ${DASHSCOPE_API_KEY}
server:
  port: 8080

Memory 与 ChatClient:

@Configuration
class MemoryConfig {
    @Bean
    ChatMemoryRepository chatMemoryRepository() {
        return new InMemoryChatMemoryRepository();
    }

    @Bean
    ChatMemory chatMemory(ChatMemoryRepository repository) {
        return MessageWindowChatMemory.builder()
                .chatMemoryRepository(repository)
                .maxMessages(20)
                .build();
    }

    @Bean
    ChatClient chatClient(ChatClient.Builder builder, ChatMemory memory) {
        return builder
                .defaultAdvisors(MessageChatMemoryAdvisor.builder(memory).build())
                .build();
    }
}

流式 Controller:

@RestController
class ChatController {
    private final ChatClient chatClient;

    ChatController(ChatClient chatClient) {
        this.chatClient = chatClient;
    }

    @GetMapping(value = "/api/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    Flux<ServerSentEvent<String>> stream(@RequestParam String userId,
                                         @RequestParam String projectId,
                                         @RequestParam String sessionId,
                                         @RequestParam String q,
                                         @RequestParam String systemPrompt) {
        String conversationId = userId + ':' + projectId + ':' + sessionId;
        Flux<String> content = chatClient.prompt()
                .system(systemPrompt)
                .user(q)
                .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId))
                .stream()
                .content();
        return content.map(c -> ServerSentEvent.builder(c).event("delta").build());
    }
}

启动与请求:

export DASHSCOPE_API_KEY=your-key
./mvnw spring-boot:run
curl -N "http://localhost:8080/api/chat/stream?userId=u1&projectId=p1&sessionId=s1&q=你好&systemPrompt=你是数字人"

示例输出按 SSE 形态,不是实测数据:

event:delta
data:你

event:delta
data:好

验证三条:两个浏览器用不同 sessionId 同时请求,观察 conversationId 不同;Network 面板看到事件分片;业务库按 projectId/sessionId 能查到独立历史。V1 到此先跑通,但 Memory 仍是内存、取消未保存、并发未锁,留给后续章节。

先按最小跑
再留问题口
三条验收线
一条不能少

06、排查

现象一:两个浏览器会话串线。怀疑 conversationId 没有按 userId/projectId/sessionId 组合。检查 Controller 参数、Advisor 参数、日志中的 conversationId。证据是日志里两次请求都打印默认值或相同 key。根因是 Advisor 未拿到 ChatMemory.CONVERSATION_ID,或前端没有传 sessionId。修复是强制组合键,并把 conversationId 打入结构化日志。

错误尝试: 一开始我把 sessionId 只放在前端 localStorage,后端用 projectId 当 conversationId。为什么错?同一项目下两个用户的会话仍然共享 Memory,项目隔离不等于会话隔离。后来改成三层 ID 拼接,问题才定位清楚。

现象二:Web 不流式,等很久后一次显示。怀疑接口仍用 call() 或中间层缓冲。检查 Controller 返回类型、produces、网关配置。证据是响应头不是 text/event-stream,或返回 Mono<String> 而非 Flux。根因是误用阻塞式调用,或 Nginx 缓冲未关。修复是 .stream().content(),设置 TEXT_EVENT_STREAM_VALUE,必要时关闭代理缓冲。

现象三:产品历史查不到。怀疑 Memory 被当成业务库。检查数据库是否有消息表,检查 MemoryRepository 是否只存模型窗口。证据是 Memory 里有消息但业务表为空。根因是架构边界混淆。修复是新增业务消息表,Advisor 之外独立写产品历史,Memory 只做短期上下文。

串线查键值
不流查返回
历史查落库
证据定根因

07、优化

V2 基于第 06 章证据改三处。第一,conversationId 固定为 userId + ':' + projectId + ':' + sessionId,并在 Advisor 参数中显式传入;ChatMemoryRepository 从内存换成可按需替换的外置实现,单机演示仍可先用内存。原因是一个组合键才能同时隔离用户、项目、会话。新行为是不同 sessionId 的窗口互不可见。验证方式是并发请求两个 sessionId,检查 Memory key 与响应上下文。

第二,流式接口增加超时、心跳与取消信号。用 Reactor 的 timeout 控制单次生成上限,用 doOnCancel 标记业务消息状态,SSE 周期发送注释或心跳事件。原因是模型供应商延迟不可控,浏览器关闭页签必须让后端知道。新行为是取消后上游尽量停止,业务历史标记为 CANCELLED。验证方式是中途关闭页签,观察服务端取消日志与消息状态。

第三,产品历史独立落库。业务侧在请求开始、增量结束、异常结束时写消息表,字段含 userId、projectId、sessionId、role、content、status、createdAt。Memory 只保留窗口。原因是产品要查询、审计、回放,模型只需要短期上下文。验证方式是按 projectId/sessionId 查历史,与 Memory 窗口解耦。

同一会话并发还要串行化。简单做法是按 conversationId 加锁或进入单消费者队列,避免两个请求同时读写同一 Memory 窗口导致顺序错乱。验证方式是同一 sessionId 快速发两条,确认上下文顺序与落库顺序一致。

组合键隔离
取消要留痕
历史独立存
并发要串行

08、演进

[同一输入]
    |
    +--[V1] 全局 Memory + call() + 无业务库
    |      代价:串线、等待全文、历史缺失
    |
    +--[V2] 三层 conversationId + stream() + 业务消息表
    |      代价:两套存储、取消状态、并发控制
    |
[Trade-off]
得到隔离与流式体验;失去简单单存储;边界是单会话窗口可裁剪

正确性上,V2 把模型上下文和产品历史分开,两个浏览器会话不串线,历史可独立查询。稳定性上,V2 增加了超时、取消、心跳和并发串行,异常不再静默堆积。复杂度上,V2 多了一套业务消息表和状态机,需要处理最终一致。成本上,流式连接占用更长,需要外置 Memory 时还要引入 JDBC/Redis。适用范围是单机演示到多实例部署之间,遗留问题是精确的 token 预算、消息表分页、断线续传的事件 ID 设计。

需要强调:Memory 窗口仍不是完整聊天记录。产品历史表可以全量,但模型只取最近窗口。这个 Trade-off 是有意为之,不是缺陷。

旧简新可控
正确换复杂
成本要看见
边界要写清

09、洞见

9.1 会话隔离先于智能优化

反直觉判断:数字人第一版最该写的不是更花的 Prompt,而是 conversationId 的组合规则。因为一旦串线,所有体验、评测和历史查询都失去意义。本掌的证据是日志中重复 key 直接导致两个浏览器污染,修组合键后隔离才成立。

9.2 Memory 是投影,不是账本

反直觉判断:把完整聊天记录塞进 Memory,短期看似“记得多”,长期会把窗口、成本和审计搅乱。Memory 是给模型看的投影,业务库才是账本。第 06 章“Memory 有消息、业务表为空”就是边界混淆的证据。

9.3 流式取消必须显式建模

Flux 让增量输出变简单,但取消不是自动的业务语义。反直觉判断:客户端断开后,服务端最该做的是留下状态,而不是假装什么都没发生。用 doOnCancel 与业务状态字段把取消纳入模型,才能解释历史里的半截消息。

9.4 Advisor 是织入点,不是业务层

Advisor 适合做 Memory 读写、观测、审计增强,但不该承担产品历史持久化。它处在模型调用链里,失败策略、事务边界、分页查询都不适合塞进去。把 Advisor 当业务层,最后会把一次聊天调用变成不可测试的泥团。

隔离先于巧
记忆是投影
取消要留痕
织入别越界

10、系统落地

原来有什么:数字人项目已有注册登录、项目 CRUD、标题主题色、背景图、开场白结束语、Agent 模型、System Prompt、Web 运行页。能完成单次问答,但没有可控短期会话、没有流式桥接、没有独立产品历史。

本篇新增什么:Prompt Template 与 System/User Message 分层;ChatMemory/Repository 与 Advisor;按 userId/projectId/sessionId 隔离 conversationId;Flux<String> 到 SSE 的桥接;取消、超时、断线重连、并发串行的处理点;业务消息表独立落库。分支 chapter/05-memory-streaming,提交 feat(ch05): add memory prompt and streaming interaction。

现在能做什么:两个浏览器会话不串线;Web 能流式显示增量;产品历史可独立查询。数字人项目从“能答一句”变成“能按项目、按会话持续聊”,并且模型上下文与产品记录各归其位。

还缺什么:外置 ChatMemoryRepository 的正式选型、断线续传的事件 ID 与游标、消息表分页与归档、token 预算策略、多实例下的并发锁或队列、观测指标与告警。下一步是在保持 conversationId 规则不变的前提下,把 Memory 外置并接入观测,让流式链路的取消率和超时率可观测。

底座已成形
会话可隔离
流式可显示
历史可查询

11、小结

Q1 → 用 userId/projectId/sessionId 组合成 conversationId,并显式传入 Advisor
Q2 → Memory 只做模型短期窗口,产品完整历史另写业务库
Q3 → 用 Flux+SSE 桥接,显式处理取消、超时、重连与并发
状态 → 数字人具备可控短期会话与流式交互,两条存储边界清晰

本篇最终可交付能力:在 chapter/05-memory-streaming 分支上,数字人 Web 能用 SSE 看到增量输出;两个浏览器会话用不同 sessionId 不串线;业务库按 projectId/sessionId 查询产品历史;Memory 窗口与业务账本分离。

一问定隔离
二问定边界
三问定流式
状态可验收

12、作业

12.1 理解题:为什么 Memory 不能等于产品完整聊天记录?

参考答案:Memory 是送给模型的上下文窗口,受 token、成本和裁剪策略限制;产品完整聊天记录要支持查询、审计、回放和分页,必须由业务库持久化。二者生命周期、查询方式和一致性要求不同。

12.2 实战题:给数字人项目增加按 userId/projectId/sessionId 隔离的流式接口。

参考答案:组合 conversationId,传入 ChatMemory.CONVERSATION_ID;Controller 返回 Flux<ServerSentEvent<String>>,produces 为 text/event-stream;业务侧在请求开始、完成、取消时写消息表。验收时用两个浏览器会话并发请求,确认上下文不串。

12.3 排障题:Web 端一直不流式,最后一次性出现全文,如何定位?

参考答案:先看接口返回类型是否为 Flux,再看是否误用 call();检查响应头是否为 text/event-stream;检查网关/Nginx 是否开启缓冲;检查客户端是否用了普通 fetch 而不是读取 ReadableStream 或 EventSource。逐层排除后修复桥接与缓冲。

12.4 架构判断题:同一 sessionId 并发两条消息,应该允许并行写 Memory 吗?

参考答案:不应该默认并行。同一会话的 Memory 窗口是有序状态,并发写会导致上下文顺序错乱。应按 conversationId 串行化,或进入单消费者队列,并保证业务库落库顺序与模型看到顺序一致。

理解看边界
实战看隔离
排障看链路
判断看顺序

13、思考

回到本篇核心冲突:数字人要短期记忆,但 Memory 不是产品账本;Web 要流式体验,但流式不是一次返回。工程判断是先把 conversationId 规则定死,再把 Memory 当投影、业务库当账本,最后用响应式流桥接并显式建模取消、超时、重连和并发。

只要这三层边界清楚,后续换外置 Repository、加观测、做多实例,都不需要推翻会话模型。反过来,如果一开始把 Memory 当历史、把 stream 当 call,后面每加一个功能都会放大串线和不可解释。

一念辨边界
一证定根因
一改知取舍
一役见真章

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