
系列说明一个 Java 后端视角的 Spring AI 渐进式实战教程载体为开源项目「劳小司 · 智能法律助手」。序章技术栈全景与 AI 学习指南阶段一 · 流式对话内核篇1 SSE 流式·停止生成·思考可见化 /篇2 会话记忆压缩与流式滚动体验本文阶段二 · 工具调用篇1 Function Calling 与法律计算器 / 篇2 联网搜索与工具预算阶段三 · RAG 知识库篇1 起步与底账化 / 篇2 Agentic RAG 与引用可信度 / 篇3 检索质量与体验阶段四 · 多模型路由篇1 五路级联路由阶段五 · 安全与质量门篇1 安全层与强制检索 / 篇2 质量门与评估门禁 / 篇3 指代消解与阻塞隔离阶段六 · 产品化与用户体系篇1 认证·配额·门禁 / 篇2 前端·移动端·身份 / 篇3 劳动法专精与多模态阶段七 · 存储演进与部署篇1 存储迁移 / 篇2 部署契约本篇涉及memory/PgChatMemory.java、memory/ChatMemoryProperties.java、前端stores/chat.js。一、两个必须同时解决的问题一个多轮对话助手记忆上有两个矛盾的需求不能失忆重启服务、隔天再来历史会话还得能点开看不能无限膨胀聊到几十轮把全部原文塞进 Prompttoken 成本和上下文窗口都会爆。本项目用**原文窗口 早期摘要两级压缩解决第 2 点用落 PostgreSQL**解决第 1 点。前端侧还要治理流式渲染的一堆体验坑一并放在本篇。二、ChatMemory 抽象与 PG 实现Spring AI 把会话记忆抽象成ChatMemory接口三个方法add(conversationId, messages)、get(conversationId)、clear(conversationId)。我们实现一个基于 PostgreSQL 的PgChatMemoryComponentpublicclassPgChatMemoryimplementsChatMemory{Overridepublicvoidadd(StringconversationId,ListMessagemessages){/* 落 chat_message 表 窗口裁剪 */}OverridepublicListMessageget(StringconversationId){/* 摘要置顶 窗口内原文 */}}为什么从 Redis 迁到 PG早期记忆放 Redis配了 24h TTL结果昨天的会话今天点进去是空的——记忆是持久资产不是缓存。迁到 PG 的chat_messageid 自增即时序chat_summary滚动摘要后历史永久可回看Redis 退居配额 / 锁 / 停止信号等易失态。多实例也天然安全状态全在 PG任意实例读写同一份。三、两级压缩原文窗口 异步滚动摘要第一级——原文窗口。只保留最近verbatimRounds轮一轮 用户 助手两条本项目配 4 轮够覆盖一次咨询的连续追问又不至于让 Prompt 过长的逐字原文intmaxRecordsproperties.getVerbatimRounds()*2;longcountmessageMapper.countBySession(conversationId);if(countmaxRecords){intoverflow(int)(count-maxRecords);ListMessageRecorddiscardedmessageMapper.selectOldest(conversationId,overflow);messageMapper.deleteOldest(conversationId,overflow);if(properties.isSummaryEnabled()!discarded.isEmpty()){summarizeAsync(conversationId,discarded);// 第二级}}第二级——滚动摘要。被裁掉的早期消息不是直接丢弃而是异步喂给一个廉价模型压成一段摘要存进chat_summaryprivatevoidsummarizeAsync(StringsessionId,ListMessageRecorddiscarded){Mono.fromRunnable(()-{StringoldSummarysummaryMapper.select(sessionId);// 已有摘要// 新摘要 f(旧摘要 本批裁掉的消息)滚动合并StringsummarymodelRegistry.getRawClient(...).prompt().system(SUMMARY_PROMPT).user(sb.toString()).call().content();summaryMapper.upsert(sessionId,summary);}).subscribeOn(Schedulers.boundedElastic()).subscribe();// 不阻塞主流程}读取时摘要以一条SystemMessage置顶再拼窗口内原文StringsummarysummaryMapper.select(conversationId);if(summary!null!summary.isBlank()){result.add(newSystemMessage(【 Earlier Conversation Summary 】summary));}result.addAll(窗口内原文);成本意识摘要用summary-route: cheap指定的廉价路线且异步执行——用户这一轮的正常回答不等摘要。摘要失败则降级为直接丢弃不影响对话。四、前端流式滚动体验治理前面三节管的是后端怎么把上下文喂给模型这一节管前端怎么把模型的输出喂给用户——同一条流的两端。后端把 token 一条条推来前端渲染同样有一堆坑。以下是stores/chat.js里真实踩过的① Pinia 响应式代理坑——首轮流式结束时才一次性显示。向this.messages[sid]赋一个本地数组后若继续操作赋值表达式返回的原始对象会绕过 Vue 的响应式代理后续 push / 改属性不触发重渲染。正确做法是先赋值再从 state 重读代理引用if(!this.messages[sid])this.messages[sid][]constmsgsthis.messages[sid]// 从 state 重读拿到响应式代理msgs.push({role:assistant,content:,streaming:true,...})constassistantmsgs[msgs.length-1]// 代理引用逐 chunk 改才实时渲染② 多模态突发整包——24ms 平滑排空。部分 provider 对含图请求会一次性下发大段 reasoning/content画面啪地全出来失去逐字观感。解决把到达的 token 先进缓冲pendingTok用setInterval每 24ms 吐出一小撮length/6自适应恢复持续滚动输出正常逐 token 流时缓冲恒小观感无损。收尾done时flushPending()立即把剩余落屏。③ 引用卡去重——避免 v-for key 冲突导致子树渲染冻结。稠密 稀疏双路检索可能同一条文命中两次重复的lawNamearticleNo会让CitationCard的v-for :key冲突Vue patch 错乱、引用卡卡死。前端按 key 去重、保留相关度更高的一条。④ 停止即冻结画面。点停止时discardPending()丢弃未排空的缓冲避免已经停了字还在往外蹦。五、踩坑备忘① 摘要时机先裁剪后摘要别在关键路径上等模型。若同步等摘要返回再回用户首 token 延迟会明显变大。异步 boundedElastic 是关键。② 记忆只存正文不存 citation/stage/retry 标记。后端doOnNext收集 token 落记忆时凡哨兵标记开头的都跳过——否则下轮读到一堆\u0000垃圾。附件也只落[本轮含附件xxx]占位全文不进记忆。③ 摘要读取失败要降级。get()里读摘要包了 try-catch摘要表异常时退回仅近期原文绝不让记忆读取拖垮整个对话。六、小结机制做法持久化记忆落 PGchat_message chat_summary非缓存两级压缩verbatimRounds 原文窗口 异步滚动摘要成本摘要用廉价路线、异步、失败降级前端体验响应式代理引用、平滑排空、引用去重、停止冻结七、下篇预告到这里一个能流式、不失忆的对话内核完成了。但模型只会说还不会做——它能算经济补偿金吗下一篇进入阶段二用 Function Calling 给模型装上法律计算器。源码与体验Gitee国内快https://gitee.com/spaserby/laoxiaosi.git GitHub https://github.com/spaserby/laoxiaosi.git 在线演示https://laoxiaosi.noctisblue.com本系列全套代码皆开源觉得这篇有帮助欢迎顺手点颗 ⭐