大模型多轮对话上下文:三种 context-mode 实现与 token 优化 我去年做企业级 AI 客服助手的时候第一版直接被客户吐槽像个失忆患者——用户前面刚说完订单号下一句问物流它就开始胡编。后来我们把context-mode这个功能彻底重做了一遍把上下文管理从能用做到了可控这里面的坑和方案今天一次性说清楚。如果你正在做聊天机器人、AI 知识库问答、或者任何依赖大模型多轮对话的产品这个项目里的决策过程、代码实现和排错思路应该能帮你少走两个月的弯路。我会把三种上下文模式的取舍逻辑、token 预算的计算方式、以及我实测踩过的五个典型问题全部摊开来讲。1. 需求拆解context-mode 到底要解决什么问题1.1 大模型的金鱼记忆困境所有做过 LLM 应用的人都会遇到同一个问题大模型本身是没有记忆的。每次调用 API你传进去的是一段独立的文本模型只根据这段文本生成回复之前的对话它一概不知。所谓的多轮对话能力其实完全靠应用层把历史消息重新塞进请求里。这就引出了 context-mode 这个功能存在的根本原因我们需要一套策略决定哪些历史该保留、保留多少、以什么形式保留。这不是一个技术选择题而是产品体验、成本、效果三者之间的平衡问题。我见过太多团队一上来就无脑把所有消息全量塞进 prompt结果客户反馈回复越来越慢、越来越贵或者模型开始重复用户说过的话。也有人矫枉过正只传最近一轮对话结果模型完全丢失了前文关键信息。这两个极端都是因为没想清楚 context-mode 的设计目标。1.2 三种典型使用场景决定了模式划分我梳理了自己项目里几十个真实会话记录发现用户对上下文的需求其实可以分成三类第一类纯查询型。用户问一句你们发货用哪家快递答完就结束不需要任何历史信息。这类会话如果把前面几百轮都塞进去纯粹是浪费 token。第二类任务延续型。用户分多轮提供信息比如帮我查订单订单号是 1688对就是这个查物流。每一轮都依赖前面轮次提供的关键实体漏掉任何一环模型就答非所问。第三类长程对话型。用户断断续续聊了半小时中间穿插了需求变更、补充说明、否决之前的结论。这类场景如果只保留最近几轮模型会把用户已经推翻的旧结论当真理。context-mode 的三种经典模式——短上下文、滑动窗口、摘要压缩——正好对应这三类场景。没有哪一种模式能通吃所有情况这也是为什么这个功能必须做成可切换的而不是写死一种策略。1.3 为什么不能只用一种模式你可能想省事问我就全程用滑动窗口不行吗答案是行但不是最优。全用滑动窗口意味着每轮对话都要把所有历史消息按 token 预算截断一遍高频 API 调用场景下延迟和成本都会上去。而且对纯查询型会话来说这种处理完全没有必要白白增加了一次额外的 token 统计计算。全用摘要压缩也有问题摘要本身是有损压缩模型在总结时可能丢掉细节比如具体的订单编号、价格数字、时间节点。一旦摘要丢错了关键实体后续所有回答都会建立在错误前提上而且这种错误很难追溯——你根本不知道是哪一轮摘要开始丢信息的。所以我在项目里把 context-mode 做成了三个明确档位auto自动判断、full全量保留、compact压缩保留。用户不感知这个细节但我们内部自动根据会话类型、历史长度、token 消耗三个维度做切换。后面我会详细讲每个模式的实现逻辑。2. 方案设计上下文模式的三种实现路线2.1 短上下文模式只保留当前会话的必要信息短上下文模式的核心思路是少即是多。它并不是完全丢弃历史而是只保留对当前回复有直接影响的那些信息。我这里的实现方式是维护一个会话关键信息槽位包含用户身份标识、最近一次提及的订单号/工单号、用户当前的诉求标签查物流、退款、改地址等。每次请求时把这些结构化字段拼接成一小段上下文跟在最新一条用户消息前面。比如[会话上下文] 当前用户:13800138000; 最近订单: SO20240516-888; 当前意图: 查询物流 [用户] 现在到哪了这样模型既知道用户在问什么又不会把前 20 轮的历史消息全部重读一遍。这个模式的 token 消耗大概只有全量模式的 5% 左右响应速度几乎和单轮对话一样快。不过它有个明显缺陷如果用户的诉求在对话中途发生转变比如从查物流变成申请退款槽位里的意图标签必须实时更新否则模型会一直按旧意图理解。我专门加了一个意图识别步骤每轮消息进来先用轻量分类器判断意图是否变更变更了才更新槽位。2.2 滑动窗口模式固定 token 预算内择优保留滑动窗口是绝大多数团队首先想到的方案设定一个最大 token 上限把最近的消息按时间倒序往窗口里塞塞不下就把最老的丢出去。听起来简单实操中有三个细节很容易做错。细节一不能只按条数截断必须按 token 数截断。用户一条消息可能几百字也可能只有两个字按条数截断会导致 token 预算忽高忽低。我统一用 tokenizer 算出每条消息的实际 token 数然后从最新消息开始往前累加直到达到预算的 80% 就停止。留 20% 的原因是给系统提示词和生成回复预留空间免得刚截断完就触发 max_tokens 报错。细节二连续对话中的用户消息-助手回复必须成对保留。如果你只保留用户消息、丢掉助手之前的回复模型会看到一堆没人回答过的问题语义连贯性会断掉。我处理的时候是按对话轮次为单位截断的一次截掉一整轮而不是单独丢某一条。细节三系统提示词要重新组织。滑动窗口截断之后窗口最开始那条消息可能是从对话中间开始的模型不知道前情。我在截断后的消息数组最前面插入一行简短说明以下是对话的中间部分之前的对话内容已被省略请根据当前提供的信息回答。这个小改动实测能明显减少模型困惑式反问。2.3 摘要压缩模式用便宜模型记住关键信息摘要模式解决的是对话太长窗口装不下但又不能丢的问题。核心思路是定期把已经超出窗口的历史段落交给一个小而快的模型总结成要点然后用这些要点替代原文。我在生产环境用的是两步式当历史消息总 token 超过窗口上限的 1.5 倍时触发一次摘要压缩。先把最早的一段对话比如前 10 轮发送给一个小模型提示词要求提取所有事实性信息订单号、地址、时间、金额、用户明确表达的需求和否定过的结论。然后把这 10 轮原文删除替换成一段 200 token 以内的摘要放在消息数组的最前面。这里有个我踩过的坑摘要提示词里不能只写总结这段对话必须明确要求区分已确认事实和用户已撤回的表述。否则小模型会把用户随口说的一句要不退货吧当成确定指令后续模型就会频繁建议退货哪怕用户下一句已经说算了不退了我再想想。摘要的更新策略我用的是增量追加而不是全量重写。每次新的摘要只基于上一次摘要加最新的一段对话生成这样可以避免反复压缩导致的信息二次丢失。实测下来5 轮以内的增量摘要基本无损超过 5 轮就需要基于原始消息做一次全量重摘要校准。2.4 方案对比与选型建议模式典型 token 消耗信息完整度响应延迟适合场景短上下文极低低只保关键实体最低简单问答、意图明确的查询滑动窗口中等中近期待完整远期丢失低中短长度的多轮任务摘要压缩中高高含远期要点中需额外模型调用长会话、需要跨轮次记忆选型建议很简单先用滑动窗口作为默认兜底因为它实现简单、行为可预期。然后在两类场景上做优化——意图非常明确的会话走短上下文模式省成本超过窗口长度 1.5 倍的会话触发摘要压缩。如果你的业务场景长会话占比超过 30%摘要压缩模式就不是可选项而是必选项。3. 核心实现把 context-mode 落地成代码3.1 数据结构设计messages 怎么组织context-mode 的基础是消息数据结构。我统一用 OpenAI 风格的 messages 数组但内部加了一层 metadata用来支撑模式切换。from dataclasses import dataclass, field from typing import List, Dict, Optional dataclass class ContextMessage: role: str # system | user | assistant content: str created_at: float token_count: Optional[int] None msg_id: str # 关键字段这条消息是否属于被摘要替代的段落 is_summarized: bool False # 关键字段这条消息的来源模式 source_mode: str full dataclass class ConversationState: session_id: str messages: List[ContextMessage] field(default_factorylist) summary_block: str summary_model: str gpt-4o-mini mode: str auto max_context_tokens: int 8000is_summarized这个字段是我后来补的。没有它之前摘要块和其他原始消息混在一起一旦需要恢复完整上下文比如用户询问之前的某个细节根本不知道该去哪个位置找原始记录。加上这个标记我就能在必要的时候从持久层拉取原始消息重新组装。3.2 token 计数别靠猜要能算所有上下文模式的核心都是 token 预算控制而预算控制的前提是精确计数。这个环节有个技术路线选择是用模型自带的 tokenizer还是用第三方库估算。我建议能用官方 tokenizer 就用官方的。OpenAI 的tiktoken、Anthropic 的claude-tokenizer、开源的transformers对应分词器精度都远高于字符数除以 4 的粗略估算。特别是中文内容同样的字符数在不同 tokenizer 下的差异能到 50%。import tiktoken encoding tiktoken.get_encoding(cl100k_base) def count_tokens(text: str) - int: return len(encoding.encode(text)) def count_message_tokens(msg: ContextMessage) - int: # 消息的实际 token 不只是 content还有 role 标记、换行等固定开销 return count_tokens(msg.content) 4 # 固定附加role 分隔符 后缀那个固定附加 4 个 token不是拍脑袋定的是 OpenAI 文档里写明的 message 序列化格式开销。每个消息除了正文之外还有|im_start|role\n、content后面的换行和|im_end|等固定 token。不加上这部分你的预算会比实际消耗少几十甚至上百 token量大的时候会频繁触发限额报错。3.3 滑动窗口与优先级策略滑动窗口的实现要解决窗口满了丢谁留谁的问题。最朴素的按时间丢弃其实效果一般因为有些历史消息虽然旧但包含关键实体信息。我给每条消息加了一个 priority 分计算逻辑是包含订单号、手机号、地址等实体信息的消息priority 加 5用户明确表达记住重点是我要的是这些指令的priority 加 3上一轮助手回复被用户明确确认过的对是的没错紧跟其后priority 加 2其余消息 priority 为 0截断时先按 token 预算从最新消息往前覆盖如果预算还有剩余再把最早的高 priority 消息捞回来。def build_sliding_window( messages: List[ContextMessage], max_tokens: int, reserve_ratio: float 0.2 ) - List[ContextMessage]: budget int(max_tokens * (1 - reserve_ratio)) selected: List[ContextMessage] [] used 0 # 优先从最新消息开始反向选取 for msg in reversed(messages): msg_tokens msg.token_count or count_message_tokens(msg) if used msg_tokens budget: continue selected.insert(0, msg) used msg_tokens if used budget: break # 如果预算还有剩余回头补捞高优先级的老消息 if used budget: older [m for m in messages if m not in selected] older.sort(keylambda m: (m.priority, m.created_at), reverseTrue) for msg in older: msg_tokens msg.token_count or count_message_tokens(msg) if used msg_tokens budget: continue # 插入到按时间排序的正确位置 selected.append(msg) selected.sort(keylambda m: m.created_at) used msg_tokens if used budget: break return selected注意一个边界情况如果用户最新一条消息本身就超过了预算比如粘贴了一整篇文章上面这个逻辑会把所有历史都丢掉只留这一条。这没问题但要在返回的消息列表最前面加一条 system 说明告诉模型用户消息超长历史上下文已省略避免模型以为自己在回答一个孤立问题。3.4 摘要模式的实现细节摘要压缩模式的完整链路是这样的def maybe_compress( state: ConversationState, trigger_ratio: float 1.5 ) - None: total_tokens sum( m.token_count or count_message_tokens(m) for m in state.messages ) if total_tokens state.max_context_tokens * trigger_ratio: return # 找出最老的一段可压缩消息跳过已经摘要过的 compressible [ m for m in state.messages if not m.is_summarized and m.role ! system ] if not compressible: return # 以 1500 token 为一段 chunk: List[ContextMessage] [] chunk_tokens 0 for m in compressible: mt m.token_count or count_message_tokens(m) if chunk_tokens mt 1500: break chunk.append(m) chunk_tokens mt if len(chunk) 2: return summary summarize_chunk(chunk, state.summary_model) # 替换从 messages 中移除 chunk插入摘要消息 state.summary_block merge_summary(state.summary_block, summary) state.messages [ m for m in state.messages if m not in chunk ]merge_summary这一步值得细说。我不会每次直接拿新摘要替换旧摘要而是判断旧摘要是否已经很长如果旧摘要超过 400 token就用旧摘要 新摘要再让模型合并一次把重复信息去掉。这样整个摘要块始终保持精炼。摘要块在最终请求里的位置也有讲究放在 system 提示之后、消息正文之前并且用明确的分隔标识。[历史会话摘要] 用户此前查询过订单 SO-1688 的物流状态被告知预计 3 月 2 日到达用户表示会等待。3 月 1 日用户再次询问退款政策客服已解释 7 天无理由规则。 [当前对话开始]这样设计是为了让模型把摘要当作背景信息而不是当前正在发生的对话。如果你直接把它混在 messages 里当普通消息模型有时会误以为摘要内容是刚刚发生的从而在回复里重复引用。4. 实操过程从 demo 到可用版本的调优记录4.1 初始化配置与参数选择我在生产环境的初始配置是这样的CONTEXT_MODE_CONFIG { max_context_tokens: 8000, # 整个上下文预算 reserve_ratio: 0.2, # 给回复预留的 token 比例 window_priority_topup: True, # 是否启用优先级补捞 summary_trigger_ratio: 1.5, # 超限多少倍触发摘要 summary_chunk_tokens: 1500, # 单次压缩块大小 summary_model: gpt-4o-mini, # 摘要用便宜模型 summary_max_tokens: 300, # 单条摘要长度限制 entity_keywords: [订单, 地址, 电话, 退款, 日期], }几个参数的选择逻辑说一下。max_context_tokens 8000是基于线上模型 128k 上下文窗口倒推的。我刻意不用满留出大量余量给系统提示词、工具调用结果和用户在单轮里可能输入的长文本。原因很实际如果上下文预算设置得接近模型上限任何一次超长用户输入都会导致整条链路崩溃。summary_chunk_tokens 1500是实验出来的。太大摘要质量虽然稳定但压缩粒度太粗可能一次吃掉 20 轮对话太小频繁触发摘要调用成本和延迟双升。1500 这个量级差不多是 8 轮中文对话的规模摘要一次 200-300 token压缩比在 5 倍左右效果和成本都比较均衡。reserve_ratio 0.2很多人不理解为什么要单独留。其实你算一笔账就知道了假设上下文预算是 8000 token如果滑动窗口真的把 8000 全部用满生成回复时模型能用的生成空间就只有 max_tokens 里剩下的额度这可能导致回复被截断。我在项目里遇到过 200 多次这种回复突然中断的线上告警后来统一加了这个预留比例才根治。4.2 模式切换的用户交互设计context-mode 虽然内部逻辑复杂但用户侧必须保持极简。我的方案是做了三级透明切换第一级对普通用户完全隐藏模式概念。系统在 auto 模式下自动判断当前会话应该用哪种模式用户无感知。第二级在管理后台暴露一个记忆强度滑块对应三档低短上下文、中滑动窗口、高摘要压缩。运营人员可以根据业务场景调整比如客服机器人调低知识库问答调高。第三级提供强制模式 API。某些特殊场景比如合规审计需要完整保留所有对话语境可以直接用force_modefull绕过自动判断。自动判断的规则我总结成了一张决策表条件模式选择历史消息不足 4 轮且无关键实体短上下文历史 4 轮以上总 token 未超限滑动窗口总 token 超过 1.5 倍上限摘要压缩历史消息少于 2 轮但包含敏感操作退款、删除滑动窗口保守这个决策表看着简单但每一条都是线上数据分析出来的。比如最后一条少于 2 轮但包含敏感操作走滑动窗口是因为我发现有些用户只聊了两句就直接要求退款如果走短上下文模式关键实体槽位还没有被更新模型可能找不到退款对象。4.3 实测数据与效果对比我在测试环境用 500 条真实脱敏会话跑了一轮对比核心指标有三个回答准确率按人工标注、单次请求 token 消耗、端到端延迟。结果非常有意思。短上下文模式在意图明确的会话上准确率能做到 88%和滑动窗口的 91% 没有显著差异但 token 消耗只有后者的 6%延迟也从平均 1.8 秒降到了 0.6 秒——对高频客服场景来说这就是每个月几万块的成本差。摘要压缩模式在 30 轮以上的长会话中表现最突出。不加摘要的情况下第 40 轮回答准确率掉到 62%加上摘要后准确率稳定在 84% 左右。虽然还是比不过短会话的 90%但已经从一个不可用的状态拉回到了可接受的区间。还有一个意外发现加了摘要块之后模型对用户早期提到、后来又再也没出现的信息召回能力大幅提升。比如用户在第 3 轮提过我们公司在杭州第 35 轮问运费怎么算没有摘要时模型完全不记得杭州这个信息回答的是全国统一运费有摘要后模型会说发往杭州按华东地区标准计费。这就是摘要模式的隐藏价值——它不仅仅是为了塞进更多历史更是为了让远期信息在经历大量噪声对话后仍然保持可用。5. 常见问题与排查技巧实录5.1 上下文污染历史消息里的噪声干扰我踩过最深的坑之一是上下文污染。现象是模型突然变得犹豫回答里频繁出现针对您刚才提到的…这种含糊表述甚至重复用户已经撤回的问题。排查后发现罪魁祸首是系统接入了一条第三方天气查询工具工具返回的结果被原样塞进了 messages而这些结果包含大量无关的预报数据。模型分不清这些数据是对话内容还是需要回应的问题于是开始画蛇添足。解决方案是给所有非用户产生的内容打上kind标记区分对话消息工具结果系统插话摘要块。滑动窗口截断时工具结果优先被丢弃摘要压缩时工具结果不参与摘要生成只保留最终的工具结论。这个修改上线后模型的犹豫率从 12% 降到了 3%。5.2 token 超限与成本失控线上环境最容易出事故的就是 token 超限。我第一次遇到是在用户连续发送超长文本的场景用户粘了一段 6000 token 的技术文档进来加上历史消息直接顶爆上下文窗口API 返回 400 错误用户端看到的却是服务器内部错误。这里要给两个建议。第一个建议是在入口处做单条消息长度限制。超过 2000 token 的用户消息先做分段处理截取前 2000 token 作为当前对话的输入其余部分转入一个待检索存储用户问及具体内容时再用相似度检索捞出相关段落。第二个建议是给 context-mode 全链路加 token 审计日志。每次请求都要记录输入消息总 token、窗口截断后 token、摘要块 token、生成回复 token。这样一旦成本异常飙升你能很快定位是哪个环节出了问题而不是对着账单瞎猜。5.3 模式切换后的语义断裂自动模式切换最常见的副作用是语义断裂。典型表现为用户在长对话里触发了摘要压缩然后突然问一句我刚才说的那个方案你还没回复。原因是模型只看到了摘要块里的信息但摘要里恰恰没有提到那个方案对应的具体内容。这个问题的根治方案是触发摘要压缩时不仅生成摘要还要生成一个待确认问题清单。清单里列出摘要中信息不完整的点比如用户提到的方案具体指什么地址是北京还是上海。下一次用户消息进来时如果模型检测到句子里的指代无法匹配摘要中的任何信息就触发一条澄清回复您指的是之前提到的 XX 方案吗——这比让模型硬猜要稳得多。5.4 避坑清单汇总最后把我的经验浓缩成一张速查表你直接照着检查问题症状解决方案上下文污染模型答非所问、犹豫反复给非用户消息打 kind 标记按类型决定去留token 超限API 报 400、回复截断设置 reserve_ratio入口做单条消息限长摘要信息丢失关键实体在长会话中被遗忘增量摘要 事实性提示词 待确认清单滑动窗口切断对话轮次模型看不到问题的对应答复按轮次成对截断插系统提示说明成本失控账单暴涨但说不清来源全链路 token 审计日志按模式分账统计我个人在实际操作中最深的体会是context-mode 从来不是一个写好就完事的功能它需要你持续用真实会话数据去调整触发条件和预算参数。我跑线上数据三个月之后才把自动模式判断的准确率从最初的 71% 提到 90%。所以别指望第一次实现就完美搭好数据反馈链路比把所有参数一次调对更重要。另外分享一个小技巧无论你用哪种模式在 messages 末尾始终保留当前这轮的用户消息原文不要做任何截断和改写。所有模式策略都只作用于历史消息当前消息必须是完整的。这是我被线上事故教育出来的教训——有次摘要逻辑误把当前消息也当成历史做了截断用户的问题少了一半模型当然回答得莫名其妙。把当前消息永远完整写进你的代码规范里能少踩很多坑。