☰
大模型多轮对话上下文:三种 context-mode 实现与 token 优化
2026/10/6 14:39:06 网站建设 项目流程

我去年做企业级 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_factory=list) summary_block: str = "" summary_model: str = "gpt-4o-mini" mode: str = "auto" max_context_tokens: int = 8000

is_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(key=lambda m: (m.priority, m.created_at), reverse=True) 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(key=lambda 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_mode=full绕过自动判断。

自动判断的规则我总结成了一张决策表:

条件模式选择
历史消息不足 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 末尾始终保留当前这轮的用户消息原文,不要做任何截断和改写。所有模式策略都只作用于历史消息,当前消息必须是完整的。这是我被线上事故教育出来的教训——有次摘要逻辑误把当前消息也当成历史做了截断,用户的问题少了一半,模型当然回答得莫名其妙。把"当前消息永远完整"写进你的代码规范里,能少踩很多坑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询