ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

LLM上下文管理实战:从滑动窗口到滚动摘要的context-mode设计

LLM上下文管理实战:从滑动窗口到滚动摘要的context-mode设计 context-mode 这词做 AI 应用的同学应该都不陌生。不管是写聊天机器人、知识库问答还是做编程助手凡是需要跑多轮对话的场景最后一定会撞上同一个问题模型不记得刚才说过什么你要手动维护上下文而且上下文越长代价越高、越容易乱。说白了“上下文模式”就是把“历史”变成一种可以在请求之间反复使用、随时裁剪、按需组装的数据形态而不是每次调用都从零开始。这篇文章就从我自己做的一个 context-mode 小项目说起把我踩过的坑、试过有效的方案、还有最终的代码结构全部拆开讲。适合刚接触 LLM 应用开发、对上下文管理一头雾水的同学也适合已经在用 LangChain 这类框架、但总觉得黑盒不透彻的人。读完之后你至少能自己写出一套够用的上下文管理模块不用再遇事就翻官方文档。1. context-mode 到底在解决什么问题——设计思路拆解1.1 上下文不是“对话记录”而是模型的临时工作台先明确一个底层事实大模型本身没有记忆。所谓上下文在工程实现上就是拼接到输入里的那一段文本序列。模型每次推理都是独立进行的它“看到什么就基于什么回答”不记得上一个请求里你说过什么。所以 context-mode 的核心任务不是“帮模型记住”而是“在每次请求前帮模型把该看的信息拼齐”。我习惯用一个类比来理解这件事假设你每天换一个新实习生来对接你不能指望他记得上周你交代过什么只能每次把项目背景、当前进度、客户偏好、禁忌事项重新讲一遍。context-mode 做的事情就是保证这堆“交接材料”不会越堆越厚、不会漏掉关键项、也不会把过时的信息混进去。这个理解非常重要。很多人一开始会把 context 等同于“消息历史”这没错但不完整。真正生产环境里上下文的结构应该比你想象中更复杂它包含系统设定、用户画像、历史对话、工具返回结果、临时检索片段。如果这些都混在一个 messages 数组里那每一次请求实际上都在赌赌模型能自己分辨哪些信息重要、哪些可以忽略。这个赌注早期 demo 能赢一旦对话变长、业务变复杂就会不断翻车。所以我在设计 context-mode 时先给自己定了一条原则上下文必须“分层”每一层都有自己的生命周期和组装规则。系统层永远在场对话层按窗口滚动工具层按需临时插入。这样任何时候我都能回答一个问题“模型现在到底看到了什么”如果一个上下文结构不能让工程师在五分钟内说清楚这个问题那它迟早会变成一个黑洞。1.2 三个典型的上下文失控现场我在实际项目中见过太多上下文失控的案例总结起来基本逃不出下面三类。第一类token 膨胀。对话每轮增长几十到几百 token感觉不多但跑到第五十轮的时候一次请求的输入量可能就超过三千 token。如果还开了工具调用、检索增强输入量轻松上万。后果很明显请求变慢、账单变厚、部分 API 直接报错。更隐蔽的是有些模型的“聪明程度”会随着输入变长而下降它会更容易被中段信息干扰。这个问题在长对话里几乎一定会遇到只是早晚和严重程度的区别。第二类截断后的“失忆”。最粗暴的方案是只保留最近 N 轮N 取 10 或 20。实现简单但代价是早期用户说过的关键信息会被挤出去。我之前有个客服场景用户在前面反复强调“不要用 Markdown 格式回复”结果到了第十一轮模型又开始满屏输出标题和列表。用户当然觉得你很蠢但问题本质不在模型而是你根本没把那个约束放进上下文里。第三类上下文污染。系统提示、参考文档、历史问答全塞在一起模型分不清哪些是任务指令、哪些是历史记录、哪些是“举例”。有时候它看了旧回答的措辞就把旧回答当成新规则来执行更严重的是多用户共用进程时上下文串了A 用户的问题里混进了 B 用户的数据。这类问题最难查因为它不报错只是行为怪往往要排查很久才发现是数据结构的问题。1.3 我的 context-mode 设计原则三层分离第一版 context-mode 我做得特别简单就是把 messages 数组从左到右拼起来发给模型就完事。结果跑到二十轮以后效果和成本双双失控。后来我彻底重构把上下文拆成了三层系统层放的是角色设定、固定规则、业务约束。比如“你是客服助手回复简洁不要用 Markdown 格式”“价格一律按美元计算”“遇到违规内容必须拒绝回答”。这一层几乎不变只在业务规则更新时修改。对话层放的是真正的多轮聊天记录。这一层不是全量保留而是拆成两个部分最新几轮完整保留更早的内容压缩成摘要。这样无论对话多长这一层的体积基本稳定。工具层是可选的临时上下文。需要查数据库时把查询结果塞进去需要做 RAG 时把检索到的文档片段塞进去不需要的时候这一层完全不出现。拆完之后事务性的规则非常清楚增删系统层规则只影响系统消息对话层的摘要策略调整只影响 messages 组装逻辑临时检索内容不会污染长期历史。后面别人问我这个工具到底做了什么我的回答是两层缓存加一条管道。摘要缓存和最近对话缓存不动管道负责把工具数据按需接进来。就这样。2. 核心细节与方案选型——四种上下文处理方式对比2.1 全量拼接最简单但贵且慢全量拼接是最天然的做法把 user 和 assistant 的历史消息全部保留每次请求都放进 messages 数组。我早期做 demo 时就是这么干的代码量最小逻辑最直白适合快速验证想法。但它的缺点在长对话场景下非常致命。我实测过一个简单测试模型一次回复大约 200 token用户一轮输入大约 50 token跑到第三十轮时单次请求的输入 token 已经超过七千首字延迟明显变慢账单也跟着肉眼可见地增长。而且很多模型有上下文窗口上限全量拼接超过上限就直接报错你不得不回头处理截断问题。所以全量拼接只适合短对话、少量轮次的场景不适合生产环境。2.2 固定轮次滑动窗口稳定但会“丢记忆”滑动窗口是大多数人第二个想到的方案只保留最近 N 轮完整消息超出部分直接丢弃。N 取 10、12、20 的都有。它的好处是请求大小基本稳定token 消耗可控实现也简单。但问题是模型“失忆”的速度比你想的快。如果用户在第 3 轮说过“我叫张三”窗口只保留最近 10 轮的话从第 11 轮开始模型就不知道用户叫张三了。更麻烦的是这种失忆不报错、不明显你只会发现模型越来越“健忘”但很难定位到底丢了哪条关键信息。我之前那个 Markdown 场景就是典型例子——约束被挤出窗口模型重新开始用 Markdown用户开始投诉。滑动窗口适合临时会话、客服这类“不太依赖早期信息”的场景但在需要持续记忆用户偏好、历史决策的对话里单靠滑动窗口是不够的。2.3 滚动摘要 滑动窗口早期记忆用摘要兜底真正进入可生产状态的方案是“滚动摘要 滑动窗口”混合模式。核心思路最近几轮完整保留保证模型对当前话题有精细理解更早的轮次压缩成摘要保证关键历史信息不丢失。摘要可以由模型生成也可以由规则提取关键字段。这个方案的优点很明显输入不会随对话轮数无限增长同时早期信息还能以“摘要”的形式参与推理。代价是要多维护一个 summary 字段并且摘要本身也会引入新的风险——如果摘要生成质量不稳错误信息会被长期保留在上下文里反而误导模型。想降低风险可以在每次生成摘要时把旧摘要和新消息一起交给模型让它合并成新版摘要而不是只基于新消息重新生成。2.4 检索式上下文不常用但要心里有数还有一种相对少见的做法把历史消息切块、向量化存储等用户提问时用相似度检索召回相关片段再拼进当前请求。这种方法适合超长历史比如几十万字聊天记录或知识库问答能覆盖全历史而不会让输入无限膨胀。但它的成本和复杂度很高你需要处理切块策略、向量库、召回阈值、相关性排序等一系列问题。对于多数 AI 应用来说这不是默认选项。我自己的 context-mode 里预留了检索接口但默认关闭。一句话总结如果对话历史超过一万 token且用户会反复提起早期细节检索式上下文才值得考虑否则滚动摘要方案更划算。四种方案各有适用场景我做了一个对比表方便你直接对照选择方案实现成本输入大小信息保持适合场景全量拼接低线性增长完整demo、短对话、调试滑动窗口低基本稳定近期完整、早期丢失客服、临时会话摘要窗口中稳定重点保留、早期可查多数生产环境检索式高可控全历史可覆盖长历史知识库、专业问答2.5 token 预算到底怎么算项目里踩过几次 token 超限的坑之后我总结了一个经验公式可用上下文 模型窗口上限 × 0.8 − 系统提示 token − 当前问题 token − 函数定义 token − 工具返回 token。为什么要留这 20% 的余量而不是把窗口用满原因有三个第一不同 API 的 token 统计存在偏差你本地算的和服务端算的可能不完全一致第二模型输出也要占上下文窗口空间如果你把输入塞到 99%模型几乎没法生成内容第三部分模型还会把一些内部指令、格式控制信息偷偷算进 token你根本看不见。我习惯把输入控制在窗口上限的 80% 以内剩下 20% 作为安全冗余。还有一个容易踩的坑很多人只统计 messages 数组里的 token却忘了 system prompt 和 function calling 的参数定义也要占空间。尤其是工具定义写详细了可能上百甚至几百 token比一条普通对话消息还贵。所以一定要用 tiktoken 或对应 SDK 自带的 count_tokens 方法把整个 payload 完整统计一遍再做截断判断。3. 从零实现一个轻量 context-mode——代码与实操3.1 核心数据结构我用的消息格式和主流 API 保持一致每条消息是 {“role”: “system” | “user” | “assistant”, “content”: str}。context-mode 的关键就是维护好这个序列并且给它加上裁剪和摘要能力。下面是核心类的骨架我会逐个字段解释它是干什么的from dataclasses import dataclass, field from typing import Dict, List, Optional dataclass class ConversationContext: system_prompt: str summary: str max_tokens: int 8192 recent_rounds: int 10 messages: List[Dict[str, str]] field(default_factorylist) def add_user(self, content: str) - None: self.messages.append({role: user, content: content}) def add_assistant(self, content: str) - None: self.messages.append({role: assistant, content: content}) def set_summary(self, summary: str) - None: self.summary summary def should_trim(self) - bool: # 消息条数超过最近轮数的两倍就触发摘要压缩 return len(self.messages) self.recent_rounds * 2 def to_api_messages(self) - List[Dict[str, str]]: # 把摘要、系统提示、最近消息按顺序组装成 API 可用的 messages msgs [] if self.system_prompt: msgs.append({role: system, content: self.system_prompt}) if self.summary: msgs.append({role: system, content: f历史摘要{self.summary}}) msgs.extend(self.messages) return msgs这里几个字段的作用system_prompt是永远在场的系统设定不会因为对话变长被挤掉。summary是早期对话的压缩结果作为一条 system 消息放在消息序列最前面。recent_rounds控制保留多少轮完整消息。太少了容易失忆太多了 token 成本高。我自己实测下来8 到 12 轮是比较平衡的区间核心业务场景可以适当放大。messages只存“需要完整保留”的最近对话更早的内容会在should_trim()触发时被压缩进 summary。to_api_messages()是整个模块最关键的方法无论内部怎么裁剪最终交给模型的永远是一个干净、有序的消息数组。你不再需要在上层业务代码里手动拼字符串这样逻辑统一、排查也容易。3.2 上下文模式切换如何把历史压缩成摘要现在说压缩逻辑。我的触发条件很简单消息条数超过recent_rounds * 2就触发。为什么是两倍因为每个“轮次”包含用户消息和助手消息各一条N 轮就是 2N 条消息。超过这个阈值说明完整保留的负担变大了该做一次摘要更新了。核心逻辑大概是这样def trim_and_summarize(self, llm_call) - None: if not self.should_trim(): return # 把最早的一部分消息取出来准备压缩 overflow_count len(self.messages) - self.recent_rounds old_messages self.messages[:overflow_count] self.messages self.messages[overflow_count:] # 生成摘要合并旧摘要 旧消息 new_summary self._generate_summary(llm_call, old_messages) self.set_summary(new_summary)有一个细节非常关键生成新摘要时一定要把旧摘要和新拿出来的消息一起作为输入而不是单独用新消息重新生成。如果不带上旧摘要那前几次压缩进去的信息比如“用户是 VIP”“偏好用表格回复”这些关键字段就会在这一次压缩里彻底丢光。我见过好几个项目犯这个错现象就是对话越聊越“失忆”但每次看单次压缩都好像合理。3.3 让模型自己生成摘要摘要可以靠规则比如“提取所有包含‘我叫/我是/我喜欢/不要’的句子”但效果很硬。更好用的是让模型自己生成格式固定、字段可控。我用的摘要提示词大概是这样的你是对话摘要引擎。请阅读用户与助手的对话历史提取并保留以下信息 1. 用户身份相关姓名、称呼、VIP 等级、所在地区 2. 用户明确表达的偏好和禁忌 3. 当前未完成的事务和待办事项 4. 重要业务决策。 输出格式 用户信息... 偏好/禁忌... 待办事项... 业务决策... 如果某项没有写“无”。不要输出其他内容。注意生成摘要的调用最好单独走一个小的 model 调用不要和主对话用同一个 system prompt。如果你把摘要提示词塞进主对话的 messages 里主模型可能会把它当成对话内容来回复而不是执行摘要任务。这个坑我踩过一次后来把所有摘要逻辑收敛到_generate_summary方法里用独立的请求完成。3.4 持久化重启后还能继续聊上下文管理如果没有持久化就像聊天软件没有云端消息记录App 一重启就全部失忆。我早期做工具时忽略了这一点每次服务重启后用户都要重新交代一遍背景体验非常差。持久化方案很简单用 JSON 就能满足小项目需求。把system_prompt、summary、messages、recent_rounds全部存下来以session_id命名文件即可import json def save_context(ctx: ConversationContext, path: str) - None: with open(path, w, encodingutf-8) as f: json.dump({ system_prompt: ctx.system_prompt, summary: ctx.summary, recent_rounds: ctx.recent_rounds, messages: ctx.messages, }, f, ensure_asciiFalse, indent2) def load_context(path: str) - ConversationContext: with open(path, r, encodingutf-8) as f: data json.load(f) ctx ConversationContext( system_promptdata[system_prompt], summarydata[summary], recent_roundsdata[recent_rounds], ) ctx.messages data[messages] return ctx生产环境可以考虑换成 Redis 或数据库但逻辑是一样的以会话 ID 为维度保存上下文快照。这里有一个很容易忽略的点保存时要把摘要和消息分开存不要混在一条消息里。否则读取之后你无法区分哪些是历史摘要、哪些是真实对话后续压缩逻辑会乱套。3.5 完整调用示例把上面的模块串起来一个使用 context-mode 的请求流程大概是这样的ctx ConversationContext( system_prompt你是客服助手回复简洁不要用 Markdown 格式, max_tokens8192, recent_rounds10, ) load_context(ctx, session_123.json) ctx.add_user(你好我要退订单 12345) api_messages ctx.to_api_messages() # 组装好交给模型 reply call_llm(api_messages) ctx.add_assistant(reply) if ctx.should_trim(): ctx.trim_and_summarize(llm_call) save_context(ctx, session_123.json)这个流程里有几个顺序不能乱先组装再调用、先调用再保存、保存前判断要不要压缩。因为压缩需要在拿到最新模型回复之后再做否则刚说出口的话可能被直接压进摘要里丢失细节。运行一段时间之后你会形成肌肉记忆新增功能的时候只要问自己一个问题模型这次调用会看到哪些上下文如果答案含糊那就是组装逻辑有问题。4. 常见问题与排查技巧实录4.1 现象模型答非所问且和之前说的自相矛盾遇到这种情况我第一反应不是怀疑模型而是打印to_api_messages()的结果看模型到底“看到”了什么。很多时候问题是摘要不存在、摘要过期、或者系统提示被窗口挤出。排查步骤很简单打印完整 messages 数组确认摘要是否在第一位核对摘要内容是否包含关键背景身份信息、偏好、未完成事项检查是不是有代码在某个环节直接 append 了一条 user 消息破坏了原有的序检查摘要是否在多次压缩后累积了错误信息。我之前有过一次很奇怪的“失忆”现象用户从头到尾没换过但模型在二十轮之后突然忘了业务规则。排查半天发现是某次代码升级把system_prompt的赋值写到了空字符串结果系统层消息就没了。这类“静默丢失”最坑人因为代码不报错只能靠打印上下文的习惯才能发现。4.2 现象请求直接报 token 超限大多数 token 超限不是因为你消息真的超过窗口上限而是因为你只统计了消息内容忘了还有函数定义、工具返回结果和系统提示。特别是如果你用了 function calling工具 schema 的描述经常写得特别详细一个工具定义几十行描述加起来可能几百 token很容易被忽略。我的排查思路是把发给模型前整个 payload 一次性 dump 出来用官方计数工具逐字段统计system_prompt、summary、messages、functions、tools看哪一块占比最高优先压缩那部分超限时先裁剪messages的早期部分再重试。记住一个原则凡是准备发给模型的东西都要纳入 token 预算没有例外。4.3 现象长对话响应变慢大部分人以为响应慢是模型推理变慢了其实在长对话场景里输入 token 太大是首字延迟升高的主因。模型需要先处理完前面那一大堆上下文才开始生成第一个 token。这种问题要怎么排查给每个 API 调用加日志记录三个指标输入 token 数、输出 token 数、首字延迟。跑上一天你就能画出趋势图看到“当输入 token 超过多少时首字延迟开始明显恶化”。然后对应调整recent_rounds和摘要触发阈值。我实测下来把输入控制在两千 token 以内时首字延迟基本平稳超过五千 token 后会逐步恶化。不同模型表现不同但方向一致输入越小首字越快。所以如果业务对响应速度敏感不要过度追求“完美记忆”给摘要和窗口都留一点压缩空间。4.4 现象并发下上下文串了这是最隐蔽的问题之一。如果你用进程内共享变量存储对话上下文那多个用户同时访问时A 用户的消息可能被 B 用户的请求带到模型里。现象像“撞鬼”一样今天好端端排查明天又复现。根本原因是把全局唯一的对象当会话级状态用。解决办法是让每个ConversationContext实例只属于一个 session用session_id作为 key 存到一个字典或者 Redis 里绝不能跨会话共享。代码上可以加一层很薄的 SessionManager统一做 acquire 和 release避免业务代码里随手 new 一个实例导致上下文丢失。我做了一个速查表方便你以后遇到问题直接对号入座现象常见原因优先检查项模型失忆/前后矛盾窗口裁剪丢早期信息summary 策略、系统提示是否完整请求报 token 超限token 预算只算了消息整个 payload 的 token 统计响应变慢输入 token 过大日志里的输入 token 数和首字延迟上下文串话ctx 实例被共享session_id 是否隔离、存储是否加锁摘要里出现错误信息摘要生成时没合并旧摘要压缩逻辑的输入是否包含旧摘要重启后忘记一切没有持久化上下文save/load 是否在正确时机调用4.5 我自己的踩坑清单最后整理几条真金白银换来的经验摘要别用对话模型的主 prompt 来生成。单独写一个摘要专用提示词否则模型会把摘要任务当成聊天内容回复返回一长串废话摘要字段越存越乱。新摘要必须基于旧摘要加新消息生成。这是最容易犯的错误不带上旧摘要就等于每次都把所有历史推进一个碎纸机。系统提示词如果很长建议拆成多条 rolesystem 消息而不是塞成一条长文本。有些模型对单条消息长度比较敏感拆开后更稳定。我会把“用户身份、明确偏好、未完成事项”这三类信息作为摘要的强制保留字段。哪怕其他细节全丢这三类不丢对话质量基本不会崩。每次 API 调用前后打点记录三项指标消息轮数、输入 token 数、摘要长度。这个日志在排查和调优时价值非常大远胜过任何花哨的可视化面板。跑几天你就能知道自己项目的临界点在哪什么时候要压缩、什么时候要扩充完全不用靠猜。我自己的体会是context-mode 做得好不好关键不在某个算法有多高级而在于你对“哪些信息值得保留”这件事有没有清晰判断。有一次线上对话用户在第 20 轮提到“我是 VIP”第 35 轮问“我有没有折扣”如果摘要没有在这个中途把这个信息捞进来模型必答错。后来我坚持在摘要里强制保留“用户身份”这个字段类似的问题再也没出现过。这个设计很小但对所有业务的上下文管理都通用上下文的价值是把关键信息在以正确的顺序送到模型面前而不是把一切都堆给它。
返回列表