大模型多轮对话状态管理Spring Boot中的上下文工程实践一、对话爆炸与上下文失控大模型服务集成的核心痛点在大模型后端服务集成中多轮对话的状态管理是一个被严重低估的工程难题。当用户与 LLM 进行多轮交互时每一轮的输入输出都需要被合理地组织、裁剪和传递否则会面临两个极端上下文窗口溢出导致 API 调用失败或者上下文信息丢失导致模型失忆。生产环境中一个典型的客服对话场景可能在 20 轮交互后累积超过 8000 Token 的上下文。如果直接将全部历史拼接到 Prompt 中不仅 Token 成本线性增长模型的注意力也会被稀释导致回复质量显著下降。更棘手的是不同业务场景对上下文的保留策略截然不同——法律咨询需要精确保留每一条事实陈述而闲聊场景则可以激进地压缩历史。这个问题的本质是如何在有限的 Token 预算内最大化上下文的信息密度。它不是简单的截断或滑动窗口而是一个涉及语义压缩、优先级排序和动态预算分配的系统工程问题。二、上下文工程的底层机制与架构剖析多轮对话状态管理的核心在于上下文窗口的工程化利用。LLM 的上下文窗口是一个固定容量的工作记忆每轮对话都在争夺这个有限空间。flowchart TB subgraph 上下文窗口[上下文窗口 (Context Window)] direction TB SP[系统提示词br/System Prompt] T1[模板与指令br/Template Instructions] H[对话历史br/Conversation History] CI[当前输入br/Current Input] end subgraph 历史管理策略[历史管理策略] direction LR S1[全量保留br/Full Retention] S2[滑动窗口br/Sliding Window] S3[语义摘要br/Semantic Summary] S4[混合策略br/Hybrid Strategy] end H -- S1 H -- S2 H -- S3 H -- S4 S1 --|Token爆炸| R1[成本失控] S2 --|信息丢失| R2[上下文断裂] S3 --|摘要失真| R3[语义漂移] S4 --|动态平衡| R4[最优信息密度] subgraph 预算分配[Token 预算分配] direction LR B1[系统提示: 15%] B2[模板指令: 10%] B3[对话历史: 55%] B4[当前输入: 20%] end R4 -- B3关键机制解析Token 预算分配上下文窗口的总容量需要按比例分配给系统提示、模板指令、对话历史和当前输入。生产实践中对话历史通常占 50%-60% 的预算剩余空间留给系统指令和当前输入。滑动窗口的局限最简单的策略是保留最近 N 轮对话但这种方式忽略了语义重要性——早期对话中的关键决策可能比近期的寒暄更重要。语义摘要的引入将早期对话压缩为摘要保留关键实体和决策节点同时释放 Token 空间。摘要本身需要通过 LLM 生成这引入了额外的推理成本和潜在的语义损失。混合策略结合滑动窗口和语义摘要对最近 K 轮保留原文对更早的历史生成摘要。这是目前生产环境中最为实用的方案。三、Spring Boot 中的生产级实现3.1 对话状态模型与存储设计/** * 对话上下文状态模型 * 采用分层结构系统层、摘要层、原文层、当前输入层 */ Entity Table(name conversation_context) public class ConversationContext { Id private String conversationId; /** 系统提示词不可压缩 */ Column(columnDefinition TEXT) private String systemPrompt; /** 历史摘要由 LLM 定期生成 */ Column(columnDefinition TEXT) private String historySummary; /** 摘要覆盖的对话轮次范围 */ private int summaryCoverFrom; private int summaryCoverTo; /** 最近保留的原文轮次JSON数组 */ Column(columnDefinition TEXT) private String recentMessages; /** 当前 Token 预算 */ private int tokenBudget; /** 已使用的 Token 数 */ private int tokenUsed; /** 上下文压缩策略 */ Enumerated(EnumType.STRING) private CompressionStrategy strategy; private LocalDateTime lastCompressedAt; private LocalDateTime createdAt; private LocalDateTime updatedAt; } public enum CompressionStrategy { SLIDING_WINDOW, // 滑动窗口 SEMANTIC_SUMMARY, // 语义摘要 HYBRID, // 混合策略 PRIORITIZED // 优先级排序 }3.2 Token 预算管理器/** * Token 预算管理器 * 负责上下文窗口的预算分配与溢出检测 */ Component public class TokenBudgetManager { private final TokenCounter tokenCounter; /** 各部分的预算比例配置 */ Value(${llm.context.budget.system-ratio:0.15}) private double systemRatio; Value(${llm.context.budget.template-ratio:0.10}) private double templateRatio; Value(${llm.context.budget.history-ratio:0.55}) private double historyRatio; Value(${llm.context.budget.input-ratio:0.20}) private double inputRatio; /** * 检查并执行上下文压缩 * 当历史 Token 超出预算时触发压缩策略 */ public CompressionResult checkAndCompress(ConversationContext context, ListMessage recentMessages) { int totalBudget context.getTokenBudget(); int historyBudget (int) (totalBudget * historyRatio); // 计算当前历史的 Token 占用 int currentTokens recentMessages.stream() .mapToInt(msg - tokenCounter.count(msg.getContent())) .sum(); if (currentTokens historyBudget) { return CompressionResult.noCompressionNeeded(); } // 超出预算执行混合压缩策略 return executeHybridCompression(context, recentMessages, historyBudget); } /** * 混合压缩保留最近K轮原文更早的历史生成摘要 */ private CompressionResult executeHybridCompression( ConversationContext context, ListMessage messages, int targetBudget) { // 保留最近5轮原文 int retainCount 5; ListMessage retained messages.subList( Math.max(0, messages.size() - retainCount), messages.size()); // 需要压缩的历史 ListMessage toCompress messages.subList( 0, Math.max(0, messages.size() - retainCount)); if (toCompress.isEmpty()) { return CompressionResult.noCompressionNeeded(); } // 计算释放的 Token 空间 int compressedTokens toCompress.stream() .mapToInt(msg - tokenCounter.count(msg.getContent())) .sum(); return CompressionResult.builder() .compressed(true) .messagesToCompress(toCompress) .retainedMessages(retained) .freedTokens(compressedTokens) .build(); } }3.3 语义摘要生成服务/** * 语义摘要服务 * 使用 LLM 对早期对话历史进行压缩摘要 */ Service public class SemanticSummaryService { private final LlmClient llmClient; private final TokenCounter tokenCounter; private static final String SUMMARY_PROMPT 请将以下多轮对话历史压缩为一段简洁的摘要。 要求 1. 保留所有关键决策、事实陈述和用户偏好 2. 保留重要实体名称和数值信息 3. 丢弃寒暄、重复确认等低信息量内容 4. 摘要长度不超过原始长度的30%% 5. 使用客观陈述不添加推断 对话历史 %s ; /** * 对话历史摘要生成带重试机制 * 摘要失败时降级为滑动窗口策略 */ Retryable(value {LlmCallException.class}, maxAttempts 3, backoff Backoff(delay 1000, multiplier 2)) Fallback(fallbackMethod fallbackToSlidingWindow) public String generateSummary(ListMessage messages) { String historyText messages.stream() .map(msg - String.format([%s]: %s, msg.getRole(), msg.getContent())) .collect(Collectors.joining(\n)); // 摘要请求的 Token 预算不能太大 int inputTokens tokenCounter.count(historyText); if (inputTokens 6000) { // 分段摘要后合并 return chunkedSummary(messages); } String prompt String.format(SUMMARY_PROMPT, historyText); LlmResponse response llmClient.chat(prompt); // 校验摘要质量关键实体不应丢失 validateSummary(messages, response.getContent()); return response.getContent(); } /** * 降级策略摘要失败时使用滑动窗口 */ private String fallbackToSlidingWindow(ListMessage messages) { // 保留最后3轮对话作为摘要 int keepLast Math.min(3, messages.size()); return messages.subList(messages.size() - keepLast, messages.size()) .stream() .map(msg - String.format([%s]: %s, msg.getRole(), msg.getContent())) .collect(Collectors.joining(\n)); } /** * 分段摘要对超长历史进行分块摘要后合并 */ private String chunkedSummary(ListMessage messages) { int chunkSize 20; // 每20轮为一块 ListListMessage chunks IntStream.range(0, (messages.size() chunkSize - 1) / chunkSize) .mapToObj(i - messages.subList( i * chunkSize, Math.min((i 1) * chunkSize, messages.size()))) .toList(); ListString chunkSummaries chunks.stream() .map(chunk - generateSummary(chunk)) .toList(); // 合并各段摘要 return String.join(\n, chunkSummaries); } }3.4 上下文组装与 API 调用/** * 上下文组装器 * 将系统提示、摘要、原文历史和当前输入组装为完整的 API 请求 */ Service public class ContextAssembler { private final TokenBudgetManager budgetManager; private final SemanticSummaryService summaryService; private final ConversationContextRepository contextRepo; /** * 组装完整的 LLM 请求上下文 * 严格遵循 Token 预算约束 */ public AssembledContext assemble(String conversationId, String currentInput) { ConversationContext ctx contextRepo.findById(conversationId) .orElseThrow(() - new ConversationNotFoundException(conversationId)); ListMessage recentMessages parseRecentMessages(ctx.getRecentMessages()); // 检查是否需要压缩 CompressionResult result budgetManager.checkAndCompress(ctx, recentMessages); if (result.isCompressed()) { // 生成摘要并更新上下文 String newSummary summaryService.generateSummary( result.getMessagesToCompress()); // 合并新旧摘要 String mergedSummary mergeSummaries( ctx.getHistorySummary(), newSummary); // 更新保留的原文 ctx.setHistorySummary(mergedSummary); ctx.setRecentMessages(serializeMessages(result.getRetainedMessages())); ctx.setLastCompressedAt(LocalDateTime.now()); contextRepo.save(ctx); recentMessages result.getRetainedMessages(); } // 按顺序组装系统提示 → 摘要 → 原文历史 → 当前输入 ListChatMessage apiMessages new ArrayList(); apiMessages.add(new SystemMessage(ctx.getSystemPrompt())); if (ctx.getHistorySummary() ! null) { apiMessages.add(new SystemMessage( 以下是之前对话的摘要\n ctx.getHistorySummary())); } recentMessages.forEach(msg - apiMessages.add(convertToApiMessage(msg))); apiMessages.add(new UserMessage(currentInput)); return new AssembledContext(apiMessages, ctx.getTokenBudget()); } }四、上下文压缩的架构权衡与边界分析Token 成本与摘要质量的矛盾语义摘要需要额外的 LLM 调用每次压缩大约消耗原始历史 30% 的 Token。在对话频繁压缩的场景下摘要调用本身的成本可能占到总推理成本的 15%-20%。如果业务对成本极度敏感滑动窗口是更经济的选择但代价是信息丢失。摘要的语义漂移风险多轮压缩会导致电话游戏效应——每次摘要都可能丢失细节多次压缩后关键信息可能被稀释。生产环境中建议设置摘要轮次上限超过后直接丢弃而非继续压缩。并发安全与一致性多个请求同时触发压缩时可能出现摘要覆盖冲突。解决方案是对conversationId加分布式锁确保同一对话的压缩操作串行执行。但锁等待会增加延迟需要设置合理的超时时间。适用边界混合压缩策略适合对话轮次 10、上下文窗口 16K Token 的场景。对于短对话或超大窗口128K简单的滑动窗口即可满足需求。五、总结多轮对话状态管理的核心是在有限 Token 预算内最大化信息密度。落地路线建议起步阶段实现滑动窗口策略配置合理的保留轮次建议 5-8 轮快速上线验证业务效果。优化阶段引入语义摘要服务对超出窗口的历史进行压缩关注摘要质量和关键实体保留率。精细化阶段实现混合策略根据对话类型动态调整压缩参数建立 Token 使用量的监控告警。进阶阶段探索优先级排序策略基于对话内容的语义重要性而非时间顺序决定保留策略。
大模型多轮对话状态管理:Spring Boot中的上下文工程实践
大模型多轮对话状态管理Spring Boot中的上下文工程实践一、对话爆炸与上下文失控大模型服务集成的核心痛点在大模型后端服务集成中多轮对话的状态管理是一个被严重低估的工程难题。当用户与 LLM 进行多轮交互时每一轮的输入输出都需要被合理地组织、裁剪和传递否则会面临两个极端上下文窗口溢出导致 API 调用失败或者上下文信息丢失导致模型失忆。生产环境中一个典型的客服对话场景可能在 20 轮交互后累积超过 8000 Token 的上下文。如果直接将全部历史拼接到 Prompt 中不仅 Token 成本线性增长模型的注意力也会被稀释导致回复质量显著下降。更棘手的是不同业务场景对上下文的保留策略截然不同——法律咨询需要精确保留每一条事实陈述而闲聊场景则可以激进地压缩历史。这个问题的本质是如何在有限的 Token 预算内最大化上下文的信息密度。它不是简单的截断或滑动窗口而是一个涉及语义压缩、优先级排序和动态预算分配的系统工程问题。二、上下文工程的底层机制与架构剖析多轮对话状态管理的核心在于上下文窗口的工程化利用。LLM 的上下文窗口是一个固定容量的工作记忆每轮对话都在争夺这个有限空间。flowchart TB subgraph 上下文窗口[上下文窗口 (Context Window)] direction TB SP[系统提示词br/System Prompt] T1[模板与指令br/Template Instructions] H[对话历史br/Conversation History] CI[当前输入br/Current Input] end subgraph 历史管理策略[历史管理策略] direction LR S1[全量保留br/Full Retention] S2[滑动窗口br/Sliding Window] S3[语义摘要br/Semantic Summary] S4[混合策略br/Hybrid Strategy] end H -- S1 H -- S2 H -- S3 H -- S4 S1 --|Token爆炸| R1[成本失控] S2 --|信息丢失| R2[上下文断裂] S3 --|摘要失真| R3[语义漂移] S4 --|动态平衡| R4[最优信息密度] subgraph 预算分配[Token 预算分配] direction LR B1[系统提示: 15%] B2[模板指令: 10%] B3[对话历史: 55%] B4[当前输入: 20%] end R4 -- B3关键机制解析Token 预算分配上下文窗口的总容量需要按比例分配给系统提示、模板指令、对话历史和当前输入。生产实践中对话历史通常占 50%-60% 的预算剩余空间留给系统指令和当前输入。滑动窗口的局限最简单的策略是保留最近 N 轮对话但这种方式忽略了语义重要性——早期对话中的关键决策可能比近期的寒暄更重要。语义摘要的引入将早期对话压缩为摘要保留关键实体和决策节点同时释放 Token 空间。摘要本身需要通过 LLM 生成这引入了额外的推理成本和潜在的语义损失。混合策略结合滑动窗口和语义摘要对最近 K 轮保留原文对更早的历史生成摘要。这是目前生产环境中最为实用的方案。三、Spring Boot 中的生产级实现3.1 对话状态模型与存储设计/** * 对话上下文状态模型 * 采用分层结构系统层、摘要层、原文层、当前输入层 */ Entity Table(name conversation_context) public class ConversationContext { Id private String conversationId; /** 系统提示词不可压缩 */ Column(columnDefinition TEXT) private String systemPrompt; /** 历史摘要由 LLM 定期生成 */ Column(columnDefinition TEXT) private String historySummary; /** 摘要覆盖的对话轮次范围 */ private int summaryCoverFrom; private int summaryCoverTo; /** 最近保留的原文轮次JSON数组 */ Column(columnDefinition TEXT) private String recentMessages; /** 当前 Token 预算 */ private int tokenBudget; /** 已使用的 Token 数 */ private int tokenUsed; /** 上下文压缩策略 */ Enumerated(EnumType.STRING) private CompressionStrategy strategy; private LocalDateTime lastCompressedAt; private LocalDateTime createdAt; private LocalDateTime updatedAt; } public enum CompressionStrategy { SLIDING_WINDOW, // 滑动窗口 SEMANTIC_SUMMARY, // 语义摘要 HYBRID, // 混合策略 PRIORITIZED // 优先级排序 }3.2 Token 预算管理器/** * Token 预算管理器 * 负责上下文窗口的预算分配与溢出检测 */ Component public class TokenBudgetManager { private final TokenCounter tokenCounter; /** 各部分的预算比例配置 */ Value(${llm.context.budget.system-ratio:0.15}) private double systemRatio; Value(${llm.context.budget.template-ratio:0.10}) private double templateRatio; Value(${llm.context.budget.history-ratio:0.55}) private double historyRatio; Value(${llm.context.budget.input-ratio:0.20}) private double inputRatio; /** * 检查并执行上下文压缩 * 当历史 Token 超出预算时触发压缩策略 */ public CompressionResult checkAndCompress(ConversationContext context, ListMessage recentMessages) { int totalBudget context.getTokenBudget(); int historyBudget (int) (totalBudget * historyRatio); // 计算当前历史的 Token 占用 int currentTokens recentMessages.stream() .mapToInt(msg - tokenCounter.count(msg.getContent())) .sum(); if (currentTokens historyBudget) { return CompressionResult.noCompressionNeeded(); } // 超出预算执行混合压缩策略 return executeHybridCompression(context, recentMessages, historyBudget); } /** * 混合压缩保留最近K轮原文更早的历史生成摘要 */ private CompressionResult executeHybridCompression( ConversationContext context, ListMessage messages, int targetBudget) { // 保留最近5轮原文 int retainCount 5; ListMessage retained messages.subList( Math.max(0, messages.size() - retainCount), messages.size()); // 需要压缩的历史 ListMessage toCompress messages.subList( 0, Math.max(0, messages.size() - retainCount)); if (toCompress.isEmpty()) { return CompressionResult.noCompressionNeeded(); } // 计算释放的 Token 空间 int compressedTokens toCompress.stream() .mapToInt(msg - tokenCounter.count(msg.getContent())) .sum(); return CompressionResult.builder() .compressed(true) .messagesToCompress(toCompress) .retainedMessages(retained) .freedTokens(compressedTokens) .build(); } }3.3 语义摘要生成服务/** * 语义摘要服务 * 使用 LLM 对早期对话历史进行压缩摘要 */ Service public class SemanticSummaryService { private final LlmClient llmClient; private final TokenCounter tokenCounter; private static final String SUMMARY_PROMPT 请将以下多轮对话历史压缩为一段简洁的摘要。 要求 1. 保留所有关键决策、事实陈述和用户偏好 2. 保留重要实体名称和数值信息 3. 丢弃寒暄、重复确认等低信息量内容 4. 摘要长度不超过原始长度的30%% 5. 使用客观陈述不添加推断 对话历史 %s ; /** * 对话历史摘要生成带重试机制 * 摘要失败时降级为滑动窗口策略 */ Retryable(value {LlmCallException.class}, maxAttempts 3, backoff Backoff(delay 1000, multiplier 2)) Fallback(fallbackMethod fallbackToSlidingWindow) public String generateSummary(ListMessage messages) { String historyText messages.stream() .map(msg - String.format([%s]: %s, msg.getRole(), msg.getContent())) .collect(Collectors.joining(\n)); // 摘要请求的 Token 预算不能太大 int inputTokens tokenCounter.count(historyText); if (inputTokens 6000) { // 分段摘要后合并 return chunkedSummary(messages); } String prompt String.format(SUMMARY_PROMPT, historyText); LlmResponse response llmClient.chat(prompt); // 校验摘要质量关键实体不应丢失 validateSummary(messages, response.getContent()); return response.getContent(); } /** * 降级策略摘要失败时使用滑动窗口 */ private String fallbackToSlidingWindow(ListMessage messages) { // 保留最后3轮对话作为摘要 int keepLast Math.min(3, messages.size()); return messages.subList(messages.size() - keepLast, messages.size()) .stream() .map(msg - String.format([%s]: %s, msg.getRole(), msg.getContent())) .collect(Collectors.joining(\n)); } /** * 分段摘要对超长历史进行分块摘要后合并 */ private String chunkedSummary(ListMessage messages) { int chunkSize 20; // 每20轮为一块 ListListMessage chunks IntStream.range(0, (messages.size() chunkSize - 1) / chunkSize) .mapToObj(i - messages.subList( i * chunkSize, Math.min((i 1) * chunkSize, messages.size()))) .toList(); ListString chunkSummaries chunks.stream() .map(chunk - generateSummary(chunk)) .toList(); // 合并各段摘要 return String.join(\n, chunkSummaries); } }3.4 上下文组装与 API 调用/** * 上下文组装器 * 将系统提示、摘要、原文历史和当前输入组装为完整的 API 请求 */ Service public class ContextAssembler { private final TokenBudgetManager budgetManager; private final SemanticSummaryService summaryService; private final ConversationContextRepository contextRepo; /** * 组装完整的 LLM 请求上下文 * 严格遵循 Token 预算约束 */ public AssembledContext assemble(String conversationId, String currentInput) { ConversationContext ctx contextRepo.findById(conversationId) .orElseThrow(() - new ConversationNotFoundException(conversationId)); ListMessage recentMessages parseRecentMessages(ctx.getRecentMessages()); // 检查是否需要压缩 CompressionResult result budgetManager.checkAndCompress(ctx, recentMessages); if (result.isCompressed()) { // 生成摘要并更新上下文 String newSummary summaryService.generateSummary( result.getMessagesToCompress()); // 合并新旧摘要 String mergedSummary mergeSummaries( ctx.getHistorySummary(), newSummary); // 更新保留的原文 ctx.setHistorySummary(mergedSummary); ctx.setRecentMessages(serializeMessages(result.getRetainedMessages())); ctx.setLastCompressedAt(LocalDateTime.now()); contextRepo.save(ctx); recentMessages result.getRetainedMessages(); } // 按顺序组装系统提示 → 摘要 → 原文历史 → 当前输入 ListChatMessage apiMessages new ArrayList(); apiMessages.add(new SystemMessage(ctx.getSystemPrompt())); if (ctx.getHistorySummary() ! null) { apiMessages.add(new SystemMessage( 以下是之前对话的摘要\n ctx.getHistorySummary())); } recentMessages.forEach(msg - apiMessages.add(convertToApiMessage(msg))); apiMessages.add(new UserMessage(currentInput)); return new AssembledContext(apiMessages, ctx.getTokenBudget()); } }四、上下文压缩的架构权衡与边界分析Token 成本与摘要质量的矛盾语义摘要需要额外的 LLM 调用每次压缩大约消耗原始历史 30% 的 Token。在对话频繁压缩的场景下摘要调用本身的成本可能占到总推理成本的 15%-20%。如果业务对成本极度敏感滑动窗口是更经济的选择但代价是信息丢失。摘要的语义漂移风险多轮压缩会导致电话游戏效应——每次摘要都可能丢失细节多次压缩后关键信息可能被稀释。生产环境中建议设置摘要轮次上限超过后直接丢弃而非继续压缩。并发安全与一致性多个请求同时触发压缩时可能出现摘要覆盖冲突。解决方案是对conversationId加分布式锁确保同一对话的压缩操作串行执行。但锁等待会增加延迟需要设置合理的超时时间。适用边界混合压缩策略适合对话轮次 10、上下文窗口 16K Token 的场景。对于短对话或超大窗口128K简单的滑动窗口即可满足需求。五、总结多轮对话状态管理的核心是在有限 Token 预算内最大化信息密度。落地路线建议起步阶段实现滑动窗口策略配置合理的保留轮次建议 5-8 轮快速上线验证业务效果。优化阶段引入语义摘要服务对超出窗口的历史进行压缩关注摘要质量和关键实体保留率。精细化阶段实现混合策略根据对话类型动态调整压缩参数建立 Token 使用量的监控告警。进阶阶段探索优先级排序策略基于对话内容的语义重要性而非时间顺序决定保留策略。