降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,后面每加一个功能都会放大串线和不可解释。
一念辨边界
一证定根因
一改知取舍
一役见真章

浙公网安备 33010602011771号