☰
Context-Mode实战:大模型上下文管理的三种模式与工程实践
2026/10/7 14:02:37 网站建设 项目流程

1. context-mode 到底是个什么东西

如果你最近在写大模型相关的应用,不管是聊天机器人、Agent、还是 RAG 问答系统,大概率绕不开一个词:context-mode。

我最早看到这个说法是在一个开源项目的配置项里,当时没太当回事,以为是“上下文模式”的普通开关。但真正自己上手做多轮对话功能、给企业搭知识库问答的时候才发现,context-mode 不是一行配置那么简单,它直接决定了你的应用是“好用”还是“能跑”,决定了你的账单是“可控”还是“失控”。

简单说,context-mode 指的是:你的应用在跟大模型交互时,用什么样的策略来组织和管理历史对话上下文。它回答的是几个非常实际的问题——把所有历史都发给模型吗?还是只保留最近几轮?聊了很久之后,前面聊过的重点怎么留住?多人同时在用,每个人的上下文怎么隔离?

这篇文章是我自己在几个真实项目中反复调 context-mode 的经验总结,里面没有太多教科书理论,全是踩坑、对比参数、看 token 账单之后留下的实操笔记。适合正在做 LLM 应用、对话机器人、Agent 编排的后端开发者参考,也适合想了解上下文管理逻辑的产品和技术负责人当个速查手册。

2. 三种主流 context 模式的底层逻辑

2.1 full-context 模式:直观但昂贵

full-context,全量上下文模式,逻辑最简单:把用户从第一句话到当前这轮的所有消息,全部塞进 prompt 里发给模型。

我第一次做聊天机器人就用的这个模式,因为实现起来最不费脑子。一个数组,往里面 push 消息,满了就清空或者继续存,每次请求把整个数组传过去。实测效果确实好,模型记得所有细节,用户上一周说过什么它都还记得,体验很“聪明”。

但问题出在钱和速度上。

我们算一笔账。假设用户平均一轮对话输入 100 个 token,输出 150 个 token,聊了 30 轮之后,第 31 轮你发给模型的内容就有将近 7500 个 token。如果这是调用 GPT-4 级别的模型,按输入价格算,每一轮成本在持续上升,聊到 50 轮的时候,光上下文就有 1 万多 token,一次请求的成本已经是最开始的十几倍。

更糟的是延迟。模型处理时间跟输入长度正相关,上下文越长,首字返回越慢。用户聊到后面会明显感觉“变笨了”,不是模型退化,是它要读的东西太多了。

所以 full-context 适合的场景很窄:测试环境、内部工具、短会话场景,或者你的模型上下文窗口足够大——比如 128k 甚至 200k——且用户对话轮次本来就不多。

2.2 sliding-window 模式:取舍的艺术

sliding-window,滑动窗口模式,是我现在最常用的方案,也是多数成熟产品默认的选择。

核心思路很简单:只保留最近 N 轮对话。比如窗口大小设为 10 轮,那么第 11 轮请求发出时,只带第 2 轮到第 11 轮的内容,第 1 轮已经被滑出窗外。

用生活类比解释一下:这就像你在微信聊天的“最近联系人”列表,只显示最近聊过的人,早前的要被顶掉。

实现上比 full-context 稍微麻烦一点,但也不复杂:维护一个固定长度的队列,超过长度就 pop 最早的元素。关键参数就是窗口大小 N——它决定了“记忆长度”和“成本/延迟”之间的平衡点。

N 设置多少合适?我的经验是分场景:

  • 客服机器人:8~12 轮足够,用户描述问题一般不会拖太长
  • 编程助手类 Agent:15~20 轮,代码上下文往往需要跨多轮引用
  • 闲聊陪伴类:可以到 20 轮以上,但配合摘要模式效果更佳

滑动窗口的问题也显而易见:被滑出去的内容永久丢失。如果用户在第 2 轮说过“我住在深圳,预算 5000 以内”,到第 12 轮推荐产品时,模型已经忘了这个约束,结果就是频频给出不合适的推荐。

2.3 summary 模式:用压缩换记忆

summary 模式,也叫摘要模式或压缩模式,是对滑动窗口丢失记忆的一种补偿。

它的做法是:当对话快要超出窗口上限时,把这之前的对话交给模型做一轮总结,压缩成一段摘要文本,然后作为“历史记忆”前置到新的上下文中。

举个例子,用户聊了 20 轮,你设置窗口为 10 轮。当第 21 轮开始时,系统会先调用一次模型,把第 1~10 轮的内容总结成“用户是深圳用户,预算 5000,偏好日系品牌,已排除 A 和 B 两款产品”,然后新的上下文中就带着这段摘要 + 第 11~20 轮的原始消息。

这样既控制住了 token 总量,又保留了关键信息。代价是多了一次额外的摘要调用,会多花一点钱和时间,但如果配置合理,整体成本仍然远低于 full-context。

我这里直接给出一个常用的混合方案:滑动窗口 + 周期摘要。当对话轮数超过窗口大小的一半时,触发对过期轮次的摘要压缩,而不是等到全部溢出才处理。这样摘要生成的时机更均匀,上下文里的“记忆”始终保持一份最新摘要 + 最近 N 轮原始消息的结构。

2.4 三种模式的横向对比

模式记忆能力Token 成本延迟实现复杂度首选场景
full-context最强,全记忆最高,随轮数线性增长最高最低短会话、内部工具
sliding-window中,仅最近 N 轮可控,稳定在峰值中低客服、常见问答
summary较强,关键信息经压缩保留中,摘要调用增加少量中高中长会话、Agent 记忆管理

3. 实操:从零实现一个 context-mode 管理器

3.1 设计思路与目录结构

动手之前先想清楚一件事:context-mode 不是一个什么魔法库,你完全可以用几十行代码自己实现,而且自己实现的好处是可控性极高——日志清楚、行为可预期、想加什么逻辑都方便。

我建议的目录结构是这样的:

app/ ├── context/ │ ├── manager.py # 上下文管理器,核心逻辑 │ ├── modes.py # 各模式的策略实现 │ ├── summarizer.py # 摘要生成封装 │ └── storage.py # 上下文持久化(Redis/内存) └── main.py # 入口与 API 路由

这里把模式策略独立成模块而不是塞进 manager,是为了后续每加一种新模式不需要动主逻辑。

3.2 核心数据结构:消息队列与模式状态

先说数据结构。不管哪种模式,底层都需要一个有序的消息列表。我推荐用 Python 的deque来承载,它有线程安全的append和popleft,天然适合滑动窗口的淘汰逻辑。

from collections import deque import time import uuid class Message: def __init__(self, role: str, content: str): self.id = str(uuid.uuid4()) self.role = role # "user" / "assistant" self.content = content self.timestamp = time.time() class ContextWindow: def __init__(self, max_rounds: int = 10): self.messages = deque(maxlen=max_rounds * 2) # 每轮含 user+assistant 两条 self.summary = "" # 摘要缓存 self.mode = "sliding" # 当前模式

注意deque的maxlen参数非常好用,当队列超过上限时,左侧元素自动被弹出,省去了手写淘汰逻辑的麻烦。我第一版没用maxlen,自己写判断,结果多出不少边界 bug。

3.3 三种模式统一入口:build_context

无论哪种模式,对外暴露的核心方法只有一个:build_context(),它把当前的所有状态组装成一份发给模型的 prompt 消息列表。

class ContextManager: def __init__(self, mode: str = "sliding", max_rounds: int = 10): self.window = ContextWindow(max_rounds) self.mode = mode def add_message(self, role: str, content: str): self.window.messages.append(Message(role, content)) self._maybe_trigger_summary() def build_context(self) -> list[dict]: if self.mode == "full": return self._build_full() elif self.mode == "sliding": return self._build_sliding() elif self.mode == "summary": return self._build_summary() else: raise ValueError(f"未知模式: {self.mode}") def _build_full(self): return [ {"role": m.role, "content": m.content} for m in self.window.messages ] def _build_sliding(self): # deque 已经通过 maxlen 完成了滑动,这里只需按格式组装 return [ {"role": m.role, "content": m.content} for m in self.window.messages ] def _build_summary(self): messages = [ {"role": m.role, "content": m.content} for m in self.window.messages ] if self.window.summary: messages.insert(0, {"role": "system", "content": f"历史对话摘要:{self.window.summary}"}) return messages

这里有个容易忽略的细节:_build_sliding和_build_full看起来一样,但行为完全不同。因为deque(maxlen=N)在add_message阶段就已经执行淘汰了,所以 sliding 模式下数据天然只保留最近 N 轮;而 full 模式下我没设置 maxlen,messages 会无限增长。所有模式差异都提前到写入阶段处理,build 阶段只做格式化,逻辑清晰很多。

3.4 摘要触发的时机与实现

摘要模式的灵魂在_maybe_trigger_summary这个方法。我的触发策略是这样的:当当前消息超过max_rounds的一半时,每次新增消息前先清理一次“过期的老消息”,并把它们一并送入摘要。

import openai class Summarizer: def __init__(self, model: str = "gpt-4o-mini"): self.model = model def summarize(self, messages: list[dict]) -> str: prompt_messages = [ {"role": "system", "content": "你负责压缩对话历史。请保留所有关键信息:用户偏好、约束条件、已确定的事实、未解决的问题。用简洁的中文输出,不超过300字。"}, {"role": "user", "content": "请总结以下对话:\n" + "\n".join(f"{m['role']}: {m['content']}" for m in messages)} ] resp = openai.ChatCompletion.create( model=self.model, messages=prompt_messages, temperature=0 ) return resp.choices[0].message.content

触发逻辑我放在 ContextManager 里:

def _maybe_trigger_summary(self): if self.mode != "summary": return threshold = self.window.maxlen // 2 if len(self.window.messages) > threshold: # 把最早的一半送入摘要,并移除 to_summarize = list(self.window.messages)[:threshold] for m in to_summarize: self.window.messages.remove(m) if self.window.summary: to_summarize.insert(0, {"role": "system", "content": f"之前摘要:{self.window.summary}"}) self.window.summary = self.summarizer.summarize(to_summarize)

温度设成 0,这一步很重要。摘要任务属于信息提取而非创作,温度高会导致每次摘要措辞不稳,甚至丢失细节。踩过一次坑:温度默认 0.7 跑出来的摘要,把“预算5000”总结成了“预算适中”,差点导致推荐系统出错。

3.5 多会话隔离:context-mode 落地的隐藏关卡

单独跑一个 ContextManager 很容易,但生产环境里,你的服务同时面向几百上千个用户,每个人都有自己的上下文。这就引出一个关键设计:每个用户/每个会话一个独立的 ContextManager 实例。

我推荐用一个简单的注册表来管理:

class SessionRegistry: def __init__(self): self._sessions: dict[str, ContextManager] = {} def get_or_create(self, session_id: str, mode: str = "sliding") -> ContextManager: if session_id not in self._sessions: self._sessions[session_id] = ContextManager(mode=mode) return self._sessions[session_id]

前期我用全局单个 ContextManager 测试,功能一切正常,一上并发就出大问题:用户 A 问的问题被用户 B 看到了。这不是模型幻觉,是典型的上下文串台事故。所以从第一天起就把 session_id 作为上下文的第一层级隔离键。

真要上多实例部署,这个 SessionRegistry 需要换成 Redis 之类的分布式存储,序列化方式可以选用 JSON,把 Message 队列和摘要字段都存进去:

import redis, json class RedisRegistry: def __init__(self, redis_client: redis.Redis): self.redis = redis_client self.ttl = 60 * 60 * 24 # 会话保留24小时 def save(self, session_id: str, cm: ContextManager): data = { "mode": cm.mode, "summary": cm.window.summary, "messages": [ {"role": m.role, "content": m.content, "ts": m.timestamp} for m in cm.window.messages ], } self.redis.setex(f"ctx:{session_id}", self.ttl, json.dumps(data))

序列化时记得要带上消息时间戳。我丢了两次时间戳,导致排查“为什么某个用户的历史莫名丢失”时完全无从下手,恢复数据也没法按时间排序。

4. 工具选型:什么时候该自己写,什么时候该用框架

4.1 自研 vs LangChain 的取舍

前几年刚出现 LangChain 这类框架时,很多人直接用它自带的 memory 组件,我当时也试过。LangChain 提供了多种 memory 类,比如ConversationBufferMemory(类似 full-context)、ConversationBufferWindowMemory(类似 sliding-window)、ConversationSummaryMemory(类似 summary 模式)。

听起来很省事,但实际用下来发现两个问题:

一是抽象层级太厚。框架把消息处理逻辑黑盒化,出了问题你想看中间态很难,得扒源码。有一次摘要触发时机跟预期不符,我翻了半天文档才知道它有自己的一套触发规则。

二是升级不兼容。不同版本之间 API 变化频繁,2023 年到 2024 年就经历了好几次 breaking change,改造成本比自研还高。

我的建议是:如果你只是做原型验证或 demo,用框架没问题;但如果是正式产品,尤其是需要精细控制 token 成本和记忆行为的场景,强烈推荐自研这个 context 管理器。核心逻辑不过一百多行代码,维护成本远比追随框架迭代低得多。

4.2 存储层选型建议

context 管理除了内存操作,还涉及持久化。三种常见选型:

存储优点缺点适合场景
内存 dict零依赖、速度快重启丢失、多实例不共享单机开发/测试
Redis高性能、支持 TTL、天然分布式需要额外部署生产环境,多实例
SQLite/Postgres可查询、可分析读写速度不如 Redis需要审计/离线分析

生产环境我首选 Redis。理由很简单:TTL 自动过期功能太适合会话管理了,能自动清理僵尸会话,不用自己写定时任务。我的 TTL 设置是 24 小时,超过一天没活跃的会话直接删除——大多数用户也不会隔一天继续上次的聊天。

5. 常见问题与排查技巧实录

5.1 上下文串台:多用户共用 ContextManager

现象:用户 B 在对话中提到了用户 A 的信息,往往很隐蔽,比如用户 B 问“刚才那个方案多少钱”,模型回答的是用户 A 对话里的方案价格。

排查思路:第一件事看日志,确认每次请求带的 session_id 是否正确,再确认后端创建的 ContextManager 是否是按照 session_id 隔离的。常见原因有两个:SessionRegistry 用了全局单例;或者前端没有正确传递 session_id。

解决方案:在每次请求进来时强制校验:

session_id = request.headers.get("X-Session-Id") if not session_id: raise HTTPException(400, "缺少 X-Session-Id 请求头") cm = registry.get_or_create(session_id, mode=cfg.mode)

这个坑我踩得最疼,第一次上线时由于这个 bug 被测试部门一口气报了好几个“隐私泄露”级别的缺陷,直接被拉去复盘。

5.2 摘要丢失关键数字信息

现象:使用 summary 模式后,用户明确说过的数量、价格、日期经常在摘要后丢失或变形。比如“需要 500 件”被缩成“需要一批”。

根因:摘要模型在压缩时自动做了“泛化”,它认为“一批”比“500件”更概括,但对业务来说数字恰恰是最关键的约束条件。

解法:在摘要的 system prompt 里强约束“必须保留所有数字、专有名词、否定信息”。还可以在总结后加一道校验:

def _verify_summary(self, original: str, summary: str) -> bool: # 简单做法: 提取原文中的所有数字,检查摘要是否都包含 import re numbers = re.findall(r"\d+", original) missing = [n for n in numbers if n not in summary] if missing: # 需要修正或重新摘要 return False return True

如果校验失败,就原样保留那部分历史消息而不做摘要,宁可多花几个 token 也不能丢信息。条件允许的话,让摘要模型直接参照这些数字重新生成。

5.3 成本失控:token 消耗暴涨

现象:部署一段时间后,账单涨幅远超预期。

排查步骤:

  1. 先加 token 日志,每次请求记录prompt_tokens、completion_tokens、total_tokens
  2. 观察是否单次 prompt 太长——说明上下文模式可能是 full-context 且对话轮数很多
  3. 观察是否摘要调用过于频繁——配置了 summary 模式且阈值太小,导致每次对话都触发摘要

我遇到过最极端的情况:摘要阈值设置不合理,每新增一条消息就触发一次摘要,而摘要本身消耗的 token 比原始对话还多,成本直接翻倍。

经验参数:摘要触发阈值不要小于 10 轮。摘要调用频率应该控制在“每 5~10 轮一次”,而不是每轮一次。同时摘要模型用一个便宜的型号(比如 gpt-4o-mini),把成本压到最低。

5.4 上下文顺序错乱导致模型理解偏差

现象:模型偶尔“忘了”最近的用户要求,答非所问。

根因:有时候不是模型忘了,而是 prompt 组装时消息顺序不对。模型对 prompt 末尾的内容注意力最强,如果历史消息被错误地追加到了末尾,最新的用户问题反而被淹没在中间。

解法:在 build_context 里强制确保最后一条消息一定是当前的 user 消息:

def build_context(self, current_user_message: str) -> list[dict]: ctx = self._build_sliding() # 移除可能的同轮旧消息 if ctx and ctx[-1]["role"] == "user": ctx = ctx[:-1] ctx.append({"role": "user", "content": current_user_message}) return ctx

这个细节极其容易被忽略,但影响巨大。很多“模型变笨了”的反馈,追根究底就是因为消息排列顺序出了问题。

6. 一些我认为特别值得记住的实操心得

最后聊几句我自己的体会,不展开讲,每一条都是从项目里趟出来的。

第一,context-mode 一定要做成可配置项,而不是硬编码。同一个产品,不同业务线可能对记忆的需求完全不同。客服机器人要短记忆,导购机器人要长记忆,内部知识库问答可能要全量。上线之后通过配置中心动态调整模式,省去改代码重发布的折腾。

第二,监控 token 是 context-mode 的基本盘。我在生产环境给每次请求都打了日志,按 session 维度聚合 token 消耗。这样哪条会话开销异常,一眼就能发现,也能为优化模式参数提供数据支撑。

第三,不要迷信单一模式。我最终上线的方案其实是组合式的:默认 sliding-window(窗口 10 轮),当会话超过 15 轮时自动切换成 summary 模式,摘要集中在过期消息上,同时保留最近 5 轮原文。这个组合在记忆、成本、延迟三者之间取得了不错的平衡,实测平均单次请求 token 消耗比 full-context 下降约 60%,用户侧“记得前面聊过什么”的满意度反而更高。

第四,也是我认为最重要的:上下文管理不是一个技术问题,而是一个产品问题。你需要想清楚你的用户到底需要多长的记忆。这个答案要来自数据分析,不是来自开发者的直觉。花一天时间把用户对话轮次的分布统计出来,比任何技术选型都更帮助决策。

context-mode 这个组件,说复杂也不复杂,说简单也有很多暗坑。希望这篇梳理能帮你少走一些弯路。如果你正在做对话类产品,建议从自己的场景出发,先用最简单的 sliding-window 跑起来,再逐步引入摘要和动态切换,每一步都加上日志和监控,你会对“上下文”这三个字有完全不同的理解。

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

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

立即咨询