ARTICLE DETAIL

资讯详情

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

context-mode:大模型对话上下文管理实战指南

context-mode:大模型对话上下文管理实战指南 如果你最近也在做大模型相关的应用开发大概率会遇到一个很磨人的问题对话轮数一多模型就开始答非所问速度肉眼可见变慢账单数字也跟着飞涨。我最早把锅甩给模型能力不行直到把每次请求的 payload 打出来看才发现问题出在我们自己身上——我们把所有东西不分主次地往上下文里塞硬生生把 Prompt 塞成了一个杂物间。那段时间我反复调 prompt、换模型、调温度效果都撑不过二十轮。后来我干脆停下来把问题拆开看模型本身没变变的只是“每次请求时我们往窗口里放什么”。顺着这个思路我整理出了一套可以落地实现的 context-mode上下文模式体系。这篇文章就聊聊我在这套体系上的完整设计、真实代码和踩坑过程适合正在做 AI 对话产品、智能客服、RAG 检索问答的开发者也适合那些想控制 token 成本、提升回答稳定性的团队参考。1. context-mode 到底是什么从一次翻车现场说起1.1 一个每天都在发生的翻车现场我有一个内部工具型对话系统初期实现简单粗暴每轮对话都把历史消息完整拼进 messages系统 prompt 里再贴一份长长的产品文档。刚上线时效果很好前二十轮几乎挑不出毛病。到了第四十轮左右问题开始集中爆发。用户问“我们刚才说的那个阈值是多少”模型回答一个完全对不上的数字我再往下翻日志发现单次请求 payload 已经超过 3 万 token接口延迟从 800ms 飙到 3 秒以上。我用一个很粗糙但够用的公式复盘了一下每轮用户消息约 120 字助手回复约 300 字加上 system prompt 里的 2000 字文档第 N 轮请求的 token 总量大约是 system(2000) 累计历史(420 × N)。到第 40 轮时光历史就是 16800 token再叠加 system 和当前问题早就把模型的注意力窗口塞满了。模型不是变笨了是它的“工作记忆”被垃圾信息淹没了。1.2 context-mode 的准确定义所谓 context-mode我给的正式定义是在调用大模型之前对进入模型上下文窗口的内容进行结构化组织、折叠、裁剪和注入的一套策略集合。它不改变模型本身的参数也不改变推理引擎只改变“组装请求时到底放哪些信息、以什么顺序放、以什么形态放”。听起来好像只是 prompt engineering 的另一种说法不太一样。Prompt engineering 更多在琢磨“同一段话怎么写模型更能理解”而 context-mode 解决的是“哪些信息值得进入上下文、哪些必须留下但可以压扁、哪些可以彻底丢弃”。前者是文案问题后者是架构问题。我建议把 context-mode 理解成传统后端里的“缓存策略”或者“内存管理”不决定业务逻辑对不对但决定系统在长生命周期下会不会把自己拖垮。凡是需要多轮交互、外部知识注入、长期记忆的 LLM 应用最后都会撞上这堵墙绕不开。1.3 上下文是 LLM 应用的第一杠杆我在项目里反复验证过一个规律模型输出质量极大程度取决于输入上下文的信息密度。同样一道数学题上下文里全是无关日志它就容易跑偏上下文里精准给出公式和已知条件它基本一次算对。这也解释了为什么很多团队拼命调 prompt 却效果平平——问题往往不在最后一公里的表述而在更早的“信息筛选”环节。把十几轮轮无关闲聊放进窗口再好的 prompt 也拉不回来。context-mode 要做的事情就是把这个筛选环节从“人工凭感觉”变成“规则化、可复现、可量化”的工程能力。2. 四种基础模式全量、滑动、摘要、检索增强2.1 全量模式Full Mode简单直接的暴力方案全量模式是我最先采用的方案也是最容易理解的一种不管多少轮历史全部原样放进 messagessystem 照常带上。它的优势是零信息丢失。模型能看到完整的对话轨迹所有上下文关系都在适合短对话、单文件代码重构、单个文档提问这类窗口压力小的场景。我试过用它做一次性长文本润色效果非常好因为全文都在窗口里指哪打哪。它的致命问题也摆在那token 成本线性增长而且模型对中段信息的注意力会衰减。我做过实验把一段 8000 token 的对话从第 5 轮截断和从第 30 轮拼接同样是回答当前问题前者明显更准确。窗口里塞满了旧信息新问题的注意力就被稀释了。所以我现在对全量模式只有一条使用原则预估 token 不超过模型窗口的 50% 时优先用它一旦超过立刻切到其他模式。2.2 滑动窗口模式Window Mode控制增量丢掉历史滑动窗口模式的思路很像操作系统里的 LRU 缓存只保留最近 N 轮消息更早的直接丢弃。我常用的配置是保留最近 10 轮。用户问题、最近几轮对话细节、当前上下文都在模型不会丢最近的线索同时请求体被严格限制在可控范围。这个模式对日志分析、客服会话、连续问答类场景特别友好。代价也很直接用户在第 3 轮说过的重要信息到第 20 轮时被窗口挤出去了。模型到了后面会一脸茫然地问“您刚才说的是哪次配置”。所以滑动窗口模式适合“单次任务型对话”不适合“需要跨轮引用早期信息”的复杂任务。2.3 摘要压缩模式Summary Mode用成本换记忆摘要压缩模式是在滑动窗口基础上加了一层“记忆压缩”把被移出窗口的早期消息用一次额外的模型调用生成结构化摘要再塞回 system prompt。我最初对摘要模式有顾虑因为它要多花一次模型调用的费用和时间。但实际跑下来这个成本往往很值得。一次摘要调用大约消耗 500~800 token换来的是把几千 token 的早期历史压成 200 token 的关键结论整体 token 开销反而大幅下降模型对长期任务的理解能力也明显提升。摘要不是简单复述而是有结构的。我要求在生成摘要时至少保留四类信息用户的核心目标、已经确认的事实、待办事项、关键参数。这样后面组装上下文时模型能快速识别“这个用户从头到尾都在解决某件事”而不是把摘要当成一段背景故事读。2.4 检索增强模式RAG Mode让相关内容自己浮上来检索增强模式不依赖历史对话而依赖外部知识库或文档库。用户提问进来先走向量检索把最相关的若干片段召回再和问题一起组装进上下文。我实践中常用的召回量是 4~6 段每段控制在 300~500 token。再加一层重排会把精度拉高不少但延迟代价也上来了。RAG 模式最适合知识库问答、大型代码仓库问答、政策文档咨询这类场景它不追求“记住一切”只追求“回答当前问题时把最需要的几块拼图放到位”。这个模式的坑也我在后面章节里慢慢说最典型的是召回的片段和当前问题表面相关、实际不相关模型就会被带偏。2.5 模式对比速查表模式核心策略优点缺点典型场景Full全量保留信息无丢失token 线性增长短对话、单文档提问Window只留最近 N 轮请求体可控早期信息丢失多轮短任务、日志分析Summary压缩历史为摘要折中记忆与成本需要额外摘要调用长会话、跨日跟踪RAG检索相关片段注入知识面广检索质量决定上限知识库、代码库问答3. 为什么不能只绑定一种模式切换思路才是核心3.1 单一模式的三个坑有些人看完上面四种模式会在项目里长期固定一种。我一开始也这样后来在同一个项目里连续撞了三回墙。固定 Full对话一长就爆炸每轮请求延迟不可控。固定 Window用户聊到第 12 轮时问“我最早报的那个故障编号是多少”模型一脸茫然。固定 RAG用户问的是上下文里刚刚聊过的历史细节检索器根本不知道这段对话发生过因为对话历史没进索引。单一模式只解决单一阶段的单类问题。真实产品里的用户行为是多变的有人只问两三句就离开有人连续聊一小时有人边聊边查资料库。如果上下文组装策略只有一套静态逻辑那系统注定只对一部分用户友好。3.2 动态切换的触发条件我最后实现的是一个按规则动态切换的选择器判断维度有四个对话轮数。这个最容易量化。轮数少用全量中间用滑动窗口很多了走摘要。我项目的阈值是 6 轮以内全量6~15 轮滑动窗口超过 15 轮优先摘要。Token 预算。每轮请求前先预估当前组装方案的 token 总量超过窗口 50% 就升级到压缩策略。这是最稳妥的兜底逻辑因为轮数不能完全代表 token 量有人一句话能顶别人十句话。意图类型。如果检测到用户要查知识库、问文档、查代码直接切 RAG。意图识别可以很轻一个基于关键词的规则或者一个小分类模型都行不需要上重模型。时延敏感度。对实时性要求高的入口少用摘要模式因为它要额外等一次摘要调用对不敏感的场景大胆用摘要。3.3 切换的连续性设计模式切换最担心的不是切不过来而是切完之后模型接不上。用户刚说完一件事你啪一下把前 20 轮压成摘要模型回复说“我不太清楚之前的细节”——这就不及格了。我在切换时做了一层“关键信息桥接”不管从哪个模式切到摘要模式系统都会额外保留三样东西用户最初的目标、最近一次确认的结论、所有未完成事项。这三样被固定放进 system 字段。这样切换后模型虽然看不到完整历史但它至少清楚三件事用户来干嘛、现在到哪了、下一步要做啥。对话的连续感主要靠这三个锚点撑起来而不是靠逐字记忆。我后来把这个桥接结构也复用到窗口模式的保留策略里让窗口丢掉的是“冗余过程”而不是“关键节点”。4. 实操一个可运行的 context-mode 调度器4.1 先定义统一的数据结构所有模式最后都要生成 messages 数组给模型 API所以我先定义一个统一的数据结构让四种模式都基于它工作。from enum import Enum from dataclasses import dataclass, field class ContextMode(Enum): FULL full WINDOW window SUMMARY summary RAG rag dataclass class Message: role: str # user / assistant / system content: str meta: dict field(default_factorydict) dataclass class Conversation: mode: ContextMode system_prompt: str messages: list budget_tokens: int 12000 summary: str user_goal: str confirmed_facts: list field(default_factorylist) pending_items: list field(default_factorylist)Conversation 这个类里除了 messages我特意放了 user_goal、confirmed_facts、pending_items 三个字段这就是前面说的“关键信息桥接”。它们是摘要模式的核心原料也是窗口模式裁剪时的保留清单建议所有模式都维护这三个字段切换时才不容易断片。4.2 实现 Token 预估与模式选择选择器是整个 context-mode 的大脑它决定走哪条组装路径。我在项目里用的是 tiktoken 做 token 预估比 len(text) // 4 靠谱得多。import tiktoken _enc tiktoken.get_encoding(cl100k_base) def count_tokens(text: str) - int: return len(_enc.encode(text)) def estimate_messages_tokens(messages) - int: return sum(count_tokens(m.content) for m in messages) def select_mode( conversation: Conversation, need_search: bool ) - ContextMode: total estimate_messages_tokens(conversation.messages) # 场景优先需要查文档就切 RAG if need_search: return ContextMode.RAG # 预算优先消息量太大直接压缩 if total conversation.budget_tokens * 0.5: return ContextMode.SUMMARY # 轮数兜底越多越倾向窗口和摘要 turns len(conversation.messages) // 2 if turns 6: return ContextMode.FULL if turns 15: return ContextMode.WINDOW return ContextMode.SUMMARY需要注意cl100k_base 对中文编码并不是一个字一个 token中文在几百字时偏差不大但长文本一定要用实际编码器。如果你的模型用的是其他 tokenizer务必换成对应的编码器否则预算算不准切换就是不稳定的。4.3 四种模式的组装逻辑接下来是四种模式各自的组装实现。我先把代码贴出来再逐段解释为什么这么写。def build_full(conversation: Conversation): messages [Message(system, conversation.system_prompt)] messages.extend(conversation.messages) return messages def build_window(conversation: Conversation, keep: int 10): tail conversation.messages[-keep:] bridge _build_bridge(conversation) return [Message(system, conversation.system_prompt bridge)] tail def _build_bridge(conversation: Conversation) - str: parts [] if conversation.user_goal: parts.append(f用户目标{conversation.user_goal}) if conversation.confirmed_facts: facts .join(conversation.confirmed_facts) parts.append(f已确认信息{facts}) if conversation.pending_items: todo .join(conversation.pending_items) parts.append(f待办事项{todo}) return \n \n.join(parts) if parts else def summarize_history(messages) - str: # 实际项目里这里调用一次模型把早期 messages 压缩成结构化摘要 prompt 请把以下对话压缩成结构化摘要包含用户目标、已确认事实、待办事项、关键参数。\n .join(m.content for m in messages) return call_llm(prompt) def build_summary(conversation: Conversation, keep: int 4): old conversation.messages[:-keep] recent conversation.messages[-keep:] # 增量更新避免每次全量压缩 if not conversation.summary or len(old) 20: conversation.summary summarize_history(old) else: conversation.summary increment_summary( conversation.summary, old[-8:] ) bridge _build_bridge(conversation) sys_content conversation.system_prompt \n早期对话摘要 conversation.summary bridge return [Message(system, sys_content)] recent def build_rag(conversation: Conversation, query: str, hits) - list: docs [] for i, hit in enumerate(hits, 1): docs.append(f[{i}] {hit.text}) context \n\n.join(docs) user_content f参考资料\n{context}\n\n问题{query} return [Message(system, conversation.system_prompt), Message(user, user_content)]build_full 没什么好解释的就是原样组装。build_window 里我把桥接信息塞进 system而不是塞进末尾目的是让模型在处理每一条历史时都知道“用户的核心目标是什么”这样即使窗口丢失部分细节模型也能围绕目标推断。另一个细节是 keep 参数我没有写死线上做 AB 测试时可以通过配置中心动态调整非常方便。build_summary 里我做了一个增量摘要的优化第一次切到摘要时把全部旧历史压缩一遍之后每次只要旧历史新增超过 8 条就基于上一次摘要做增量更新避免每轮都重新读一遍全量历史。这个优化直接把摘要调用的 token 成本降了大约一半。build_rag 的组装相对标准但有一个细节值得注意引用编号。我给每个命中段落编号在 system 里强调“优先引用 [编号] 对应的内容”回答时可溯源后续做答案校验也容易。4.4 与模型调用层对接调度器最终要变成一次真实请求。我的封装长这样def build_request( conversation: Conversation, query: str, need_search: bool False, search_hits: list None ) - list: conversation.messages.append(Message(user, query)) mode select_mode(conversation, need_search) conversation.mode mode if mode ContextMode.FULL: messages build_full(conversation) elif mode ContextMode.WINDOW: messages build_window(conversation) elif mode ContextMode.SUMMARY: messages build_summary(conversation) elif mode ContextMode.RAG: messages build_rag(conversation, query, search_hits or []) return messages这个封装看似平平无奇但它把一个很重要的原则落地了一切模式切换在请求侧完成不改动模型、不改动 prompt 措辞、不污染用户会话数据。任何时刻用户原始消息都保存在 conversation.messages 里窗口中看到的只是经过组装后的视图。这样即使模式逻辑出 bug原始数据还在回放和修复成本都很低。5. 踩坑实录我在 context-mode 项目中遇到的四个问题5.1 上下文污染折叠之后遗留了过期信息第一次上线摘要模式时我发现一个很诡异的现象用户已经明确说“那个方案我们不采用”但模型还是反复引用方案里的参数。查到最后发现问题出在摘要生成。摘要严格记录了早期讨论的细节但没有标记信息的时效状态。用户后来否定了方案可否定这个动作发生在最近的原始对话里没被及时更新进摘要。我的解决方法是每次维护桥接字段时如果检测到用户对某一事实提出否定或修改就把旧事实从未确认列表移除同时把否定结论写进待办或已确认信息。这套逻辑现在写成了一个简单的 change-log 结构摘要只从最新状态中取结论。5.2 模式切换抖动窗口尾巴把摘要带崩了另一个翻车场景是滑动窗口切摘要的瞬间。用户正在讨论一个操作细节窗口模式保留了最近 10 轮一切到摘要模式旧历史压成两行窗口尾巴只剩 4 轮。模型下一轮回答明显变敷衍因为它看到的“前因”只剩几句话。问题根源是我把 keep 参数从 10 改成 4 太激进导致最近讨论中的部分过程被挤掉了。我现在把滑动窗口到摘要的过渡阶段 keep 设成 8同时桥接字段补充一条“当前正在讨论的细节”模型就不会因为上下文突然缩水而断片。模式切换不是一刀切最好有渐变过程。5.3 Token 预估不准不同语言差距很大我早期用 len(content) // 4 估 token上线后经常出现实际调用被截断的情况。后来换成 tiktoken 才发现中文文本里一个汉字经常能到 0.6~1 个 token而英文单词平均 1.3 个 token我的粗暴公式在中文场景下严重低估。这个问题的排查方式很简单把线上请求的实际 usage 字段记录下来和预估数做对比。我在日志里加了 usage 埋点后发现平均偏差 20% 以上。换用对应模型的 tokenizer 之后误差控制在 5% 以内。建议所有做上下文预算的团队都把这一步做好否则后续所有模式切换阈值都是空中楼阁。5.4 小步灰度与离线回归改造后反而变差怎么办context-mode 不是一个“改了就好”的功能切换之后很有可能出现线上指标下降比如用户满意度、任务完成率下降。第一次灰度切换摘要模式时我就遇到这种情况。后来我总结出一套离线回归方法把线上真实会话日志完整保存下来按时间顺序重放每一步记录模式选择、token 消耗、模型回复。然后对比新旧方案在同一批会话上的表现。重点看两个指标关键信息是否在生成结果中成功保留、单次请求成本是否真的下降。有了这套回归链路模式切换就能变成数据驱动的工作而不是靠感觉调参数。我后面每次微调 keep、摘要触发阈值都先离线跑一遍历史数据再决定要不要上灰度。最后分享一个实践心得如果你现在正在做一个 LLM 应用我的建议是先别急着把四种模式都实现。先上全量模式 滑动窗口模式把请求 logs 和 usage 统计做好等你真的遇到长上下文问题再逐步加摘要和 RAG。context-mode 的价值不是“功能多炫”而是“在合适的时机只放最重要的东西进窗口”。另外接口设计时一定要把 mode 选择做成可插拔的。我最初把选择逻辑硬编码在业务代码里后面加 RAG 时被迫重构。现在整个 context-mode 调度器独立成模块新增模式只需实现一个 build 函数切换逻辑只改选择器模型层完全不受影响。这套结构后来在多个项目里复用每次都能省下大量联调时间。
返回列表