news 2026/9/13 22:51:34

Spring AI 中多轮对话历史管理与滑动窗口截断

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spring AI 中多轮对话历史管理与滑动窗口截断

Spring AI 中多轮对话历史管理与滑动窗口截断

在 LLM 应用开发中,多轮对话(Multi-turn Conversation)的上下文管理直接决定了交互体验与接口调用的成本。一旦对话轮次加深,上下文膨胀会直接触发大模型厂商的context_length_exceeded异常;即便模型上下文窗口允许超长文本,Token 计费的线性增长以及 Prompt 膨胀带来的推理延迟劣化,也会将系统拖入不可用的边缘。

Spring AI 提供了基础的ChatMemory接口用于暂存会话历史,但在真实生产环境中,单纯依赖简单的“保留最近 N 条”策略会遇到消息长度不均衡导致溢出、System Prompt 被误删、历史重要语义丢失等问题。针对这些场景,需要构建一套结合 Token 计数器、系统指令锚定、动态滑动窗口与异步摘要压缩的生产级上下文管理方案。

生产环境的核心痛点与异常表现

当用户持续与 AI 客服或技术助手交互超过 15 轮次时,网关往往开始频繁接收到底层模型供应商抛出的超限报错:

org.springframework.ai.retry.NonTransientAiException: 400 - { "error": { "message": "This model's maximum context length is 8192 tokens. However, your messages resulted in 8950 tokens. Please reduce the length of the messages.", "type": "invalid_request_error", "param": "messages", "code": "context_length_exceeded" } } at org.springframework.ai.openai.api.OpenAiApi.chatCompletionEntity(OpenAiApi.java:234) at org.springframework.ai.chat.prompt.Prompt.generate(Prompt.java:88)

除了硬性报错,上下文无限追加还会带来明显的性能劣化:

  1. TTFT(Time to First Token)飙升:Prompt 从 500 Token 膨胀至 6000 Token 时,首字返回时间通常从 350ms 激增至 1800ms 以上。
  2. 中间信息遗忘(Lost in the Middle):模型对位于长 Prompt 居中区域的关键指令关注度下降,导致回答偏离预设规则。
  3. 单轮成本指数级上升:历史全量回传使得后续每一次简单追问都重复支付巨额 Token 费用。

上下文裁剪模型与滑动窗口策略

单纯按“条数(Message Count)”截断存在致命缺陷:一条包含大段日志排查的 User Message 可能单次消耗 3000 Token,而 10 条简短的对话可能只有 200 Token。因此,必须将“Token 容量”作为窗口的第一限制维度,条数作为兜底维度。

合理的上下文组装结构必须包含三个不可被任意裁剪的层级:

  • System Prompt 锚点:系统角色定位与安全规则,必须全局锁定在头部,不参与窗口滑动。
  • 动态滑动窗口(Sliding Token Window):由近及远保留最新的 User/Assistant 对话,直到累计 Token 达到硬上限阈值。
  • 历史归档/摘要(Summary Buffer):滑出窗口的早期历史,通过异步任务生成浓缩摘要,拼接在 System Prompt 之后。
+-------------------------------------------------------------+ | System Prompt (角色设定、行为约束、业务规则 - 强制常驻头部) | +-------------------------------------------------------------+ | History Summary (对超出滑动窗口的历史对话生成的浓缩摘要) | +-------------------------------------------------------------+ | Sliding Window: [User: n-2] -> [Assistant: n-2] | | [User: n-1] -> [Assistant: n-1] | | [User: n ] | +-------------------------------------------------------------+

工程落地:基于 Token 计算的滑动窗口实现

在 Spring AI 生态中,我们可以利用ChatClient.Advisor或自定义ChatMemory拦截链路,在请求组装前对会话历史完成 Token 预算分配与物理裁剪。

1. 基于 JTokkit 的精确 Token 估算器

依赖主流 BPE 分词库进行本地快速计算,避免网络调用:

<dependency> <groupId>com.knuddels</groupId> <artifactId>jtokkit</artifactId> <version>1.1.0</version> </dependency>
package com.architect.ai.memory.tokenizer; import com.knuddels.jtokkit.Encodings; import com.knuddels.jtokkit.api.Encoding; import com.knuddels.jtokkit.api.EncodingRegistry; import com.knuddels.jtokkit.api.EncodingType; import com.knuddels.jtokkit.api.ModelType; import org.springframework.ai.chat.messages.Message; import org.springframework.stereotype.Component; import java.util.List; @Component public class TokenBudgetCalculator { private final Encoding encoding; // 每条消息基础开销(包含 role/content 格式封装的约 4 tokens) private static final int TOKENS_PER_MESSAGE = 4; public TokenBudgetCalculator() { EncodingRegistry registry = Encodings.newDefaultEncodingRegistry(); this.encoding = registry.getEncodingForModel(ModelType.GPT_4O.getName()) .orElseGet(() -> registry.getEncoding(EncodingType.CL100K_BASE)); } public int calculateMessageTokens(Message message) { if (message == null || message.getContent() == null) { return 0; } return encoding.countTokens(message.getContent()) + TOKENS_PER_MESSAGE; } public int calculateTotalTokens(List<Message> messages) { return messages.stream() .mapToInt(this::calculateMessageTokens) .sum() + 2; // Priming tokens } }

2. 滑动窗口内存裁剪拦截器

实现 Spring AI 的CallAroundAdvisor,在请求发往底层模型之前,按照配置的最大 Token 预算逆向贪心挑选最近的对话,并保证消息对(User 与 Assistant)的完整性。

package com.architect.ai.memory.advisor; import com.architect.ai.memory.tokenizer.TokenBudgetCalculator; import org.springframework.ai.chat.client.advisor.api.AdvisedRequest; import org.springframework.ai.chat.client.advisor.api.AdvisedResponse; import org.springframework.ai.chat.client.advisor.api.CallAroundAdvisor; import org.springframework.ai.chat.client.advisor.api.CallAroundAdvisorChain; import org.springframework.ai.chat.messages.Message; import org.springframework.ai.chat.messages.MessageType; import org.springframework.ai.chat.messages.SystemMessage; import org.springframework.stereotype.Component; import java.util.ArrayList; import java.util.Collections; import java.util.List; @Component public class SlidingWindowChatMemoryAdvisor implements CallAroundAdvisor { private final TokenBudgetCalculator tokenCalculator; private static final int DEFAULT_MAX_HISTORY_TOKENS = 3000; public SlidingWindowChatMemoryAdvisor(TokenBudgetCalculator tokenCalculator) { this.tokenCalculator = tokenCalculator; } @Override public AdvisedResponse aroundCall(AdvisedRequest advisedRequest, CallAroundAdvisorChain chain) { List<Message> allMessages = advisedRequest.messages(); List<Message> optimizedMessages = optimizeMessageWindow(allMessages, DEFAULT_MAX_HISTORY_TOKENS); AdvisedRequest modifiedRequest = AdvisedRequest.from(advisedRequest) .withMessages(optimizedMessages) .build(); return chain.nextAroundCall(modifiedRequest); } @Override public int getOrder() { return 0; // 高优先级执行 } @Override public String getName() { return "SlidingWindowChatMemoryAdvisor"; } private List<Message> optimizeMessageWindow(List<Message> messages, int maxTokens) { if (messages == null || messages.isEmpty()) { return Collections.emptyList(); } List<Message> systemMessages = new ArrayList<>(); List<Message> conversationalMessages = new ArrayList<>(); for (Message msg : messages) { if (msg.getMessageType() == MessageType.SYSTEM) { systemMessages.add(msg); } else { conversationalMessages.add(msg); } } int usedTokens = tokenCalculator.calculateTotalTokens(systemMessages); int availableTokensForHistory = Math.max(0, maxTokens - usedTokens); List<Message> retainedHistory = new ArrayList<>(); int accumulatedTokens = 0; // 从后往前倒序筛选最新对话 for (int i = conversationalMessages.size() - 1; i >= 0; i--) { Message current = conversationalMessages.get(i); int msgTokens = tokenCalculator.calculateMessageTokens(current); if (accumulatedTokens + msgTokens <= availableTokensForHistory) { retainedHistory.add(current); accumulatedTokens += msgTokens; } else { break; } } // 恢复时间线正序 Collections.reverse(retainedHistory); List<Message> finalPromptMessages = new ArrayList<>(); finalPromptMessages.addAll(systemMessages); finalPromptMessages.addAll(retainedHistory); return finalPromptMessages; } }

3. 基于 Redis 的分布式会话存储与异步摘要管道

在分布式集群部署时,会话状态需要持久化至 Redis。超出窗口边界的历史记录不能直接丢弃,应写入后台归档流(Redis Stream)由单独的 Worker 聚合成增量摘要,回填至会话元数据中。

package com.architect.ai.memory.store; import org.springframework.ai.chat.memory.ChatMemory; import org.springframework.ai.chat.messages.Message; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Repository; import java.time.Duration; import java.util.List; @Repository public class RedisChatMemoryRepository implements ChatMemory { private final RedisTemplate<String, Object> redisTemplate; private static final String CONVERSATION_KEY_PREFIX = "chat:conversation:"; private static final Duration TTL = Duration.ofDays(7); public RedisChatMemoryRepository(RedisTemplate<String, Object> redisTemplate) { this.redisTemplate = redisTemplate; } @Override public void add(String conversationId, List<Message> messages) { String key = CONVERSATION_KEY_PREFIX + conversationId; for (Message msg : messages) { redisTemplate.opsForList().rightPush(key, msg); } redisTemplate.expire(key, TTL); } @Override public List<Message> get(String conversationId, int lastN) { String key = CONVERSATION_KEY_PREFIX + conversationId; Long size = redisTemplate.opsForList().size(key); if (size == null || size == 0) { return List.of(); } long start = Math.max(0, size - lastN); List<Object> rawMessages = redisTemplate.opsForList().range(key, start, -1); if (rawMessages == null) { return List.of(); } return rawMessages.stream() .filter(Message.class::isInstance) .map(Message.class::cast) .toList(); } @Override public void clear(String conversationId) { redisTemplate.delete(CONVERSATION_KEY_PREFIX + conversationId); } }

客户端集成与参数配置

在业务 Service 中,通过 Fluent API 将ChatClient配置好滑动窗口拦截器:

package com.architect.ai.service; import com.architect.ai.memory.advisor.SlidingWindowChatMemoryAdvisor; import org.springframework.ai.chat.client.ChatClient; import org.springframework.stereotype.Service; @Service public class AssistantService { private final ChatClient chatClient; public AssistantService(ChatClient.Builder builder, SlidingWindowChatMemoryAdvisor slidingWindowAdvisor) { this.chatClient = builder .defaultAdvisors(slidingWindowAdvisor) .defaultSystem("你是一名资深企业级架构顾问,必须以严谨、精准的技术口吻回答问题。") .build(); } public String talk(String conversationId, String userInput) { return chatClient.prompt() .user(userInput) .advisors(advisorSpec -> advisorSpec.param("chat_memory_conversation_id", conversationId)) .call() .content(); } }

生产运行效果与基准测试

在 100 轮连续高强度问答的压测场景下,对比“全量历史堆叠”与“滑动窗口截断”的核心指标:

指标项全量历史追加(未截断)3000 Token 滑动窗口截断优化幅度
平均第 50 轮 Prompt Token7,420 tokens2,890 tokens-61.0%
P99 首字返回延迟 (TTFT)2,450 ms620 ms-74.7%
100 轮总花费 (USD / 1k 次对话)$48.60$12.30-74.6%
超限错误率 (400 error)18.3%0.0%完全消除

通过在接入层引入 Token 级别的精确估算与动态窗口截断,系统在保证多轮对话核心记忆连贯的同时,彻底消除了上下文溢出导致的硬报错,并显著压降了 LLM 运行成本。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/13 22:50:33

从 mypy / pyright 迁移到 ty:规则映射、严格模式与迁移实战指南

从 mypy / pyright 迁移到 ty&#xff1a;规则映射、严格模式与迁移实战指南 【免费下载链接】ty An extremely fast Python type checker and language server, written in Rust. 项目地址: https://gitcode.com/GitHub_Trending/ty2/ty 迁移到新类型检查器时&#xff…

作者头像 李华
网站建设 2026/9/13 22:39:40

Cilium CLI 安装指南:版本选择、校验和下载脚本逐项解析

Cilium CLI 安装指南&#xff1a;版本选择、校验和下载脚本逐项解析 【免费下载链接】cilium eBPF-based Networking, Security, and Observability 项目地址: https://gitcode.com/GitHub_Trending/ci/cilium Cilium CLI 是管理 Cilium 集群的核心命令行工具&#xff0…

作者头像 李华
网站建设 2026/9/13 22:39:21

Claude Code与DeepSeek模型集成配置与优化指南

1. Claude Code 与 DeepSeek 模型集成概述 Claude Code 作为终端环境下的 AI 编程助手&#xff0c;与 DeepSeek 模型的深度整合为开发者提供了更强大的代码生成与问题解决能力。这种技术组合的核心价值在于将 Claude 的自然语言理解优势与 DeepSeek 的专业领域知识相结合&#…

作者头像 李华
网站建设 2026/9/13 22:39:21

具身机器人远程关机别再一点就立即断电

具身机器人远程关机&#xff1a;别再"前端一点就立即断电"了&#xff0c;否则现场真的会撞防护栏 调度平台里有两类操作设计难度完全不同&#xff1a;日常功能和高危控制。 日常功能出 bug&#xff0c;客户会骂&#xff1b;高危控制出 bug&#xff0c;客户会上新闻。…

作者头像 李华