1. 为什么 context-mode 会成为硬需求
先说结论:做 LLM 应用和 Agent 开发,如果你还没认真对待上下文模式,那你的应用大概率撑不过三轮对话,或者每天的单量一大,成本先把你压垮。
context-mode,直译就是“上下文模式”。它不是一个具体产品,而是一类设计思路的统称——解决的核心问题只有一个:模型能看到的输入是有限的,但真实业务场景里,你让它处理的信息往往远超这个上限。早期大家管这个叫“上下文窗口不够”,后来发现窗口可以扩大,但扩充窗口解决不了所有问题,于是 context-mode 这个词就慢慢成了“上下文管理策略”的代名词。
我实际踩过最典型的坑,是一次给客户做客服机器人。第一版很天真,把用户历史消息一股脑塞给模型,对话超过五轮就开始截断,截断之后模型完全失忆,用户重复问同样的问题,体验直接崩盘。当时我以为是模型能力不行,后来才意识到,问题出在我压根没设计“上下文模式”这个概念。从那以后,我开始系统梳理这个问题,发现它其实是一套组合拳:存储什么、保留多久、如何压缩、怎么注入,每一步都需要精心设计。
这篇文章会比较长,我会结合自己做过的一个真实项目来拆解 context-mode 的完整实现路径。适合正在做 LLM 应用、Agent、智能客服、Copilot 类产品的开发者,以及正准备进入这个方向但还没踩过坑的工程师。你看完之后,至少能搞清楚三件事:context-mode 到底管什么事、国内外主流实现思路分别是什么、以及一个可以抄作业的最小实现长什么样。
2. context-mode 的核心实现思路与选型
2.1 先想清楚:你到底需要哪种上下文模式
我见过太多人一上来就搜索“context-mode 开源方案”,然后盲目集成某个库,最后发现和自己的业务完全对不上。这里必须先做一个判断:你的业务属于哪一类上下文模式。
按我的经验,可以粗暴分成四类:
- 窗口模式(Sliding Window):只保留最近 N 轮对话。适合简单客服、闲聊场景,但模型会忘记较早期的关键信息,比如用户在第五轮提到的“我要红色的”这种约束。
- 摘要模式(Summary Mode):把前面的对话压缩成一段摘要,再和最近的原始对话一起交给模型。适合信息密度较高、需要长期记忆的场景,但摘要本身有信息损失,而且摘要质量的波动会影响最终效果。
- 结构化模式(Structured Mode):把上下文拆分成“预设信息 + 对话历史 + 当前输入”三个独立字段,甚至进一步把对话历史按主题、时间、实体进行分层存储。这是目前做 Agent 和复杂应用的主流方案。
- 检索模式(Retrieval Mode):上下文不是线性存的,而是向量化之后从库里按需召回。适合知识库问答、文档处理类产品,但需要额外的检索服务和排序策略。
我最终在项目里采用的是“结构化和检索混用”的模式:短期对话走结构化存储,长期知识库走向量检索。这个组合适合绝大多数中大型业务场景,后面会展开讲。
2.2 为什么不能简单依赖大模型的原生长文本能力
现在很多模型都宣称支持百万级 token 上下文,很多人会问:既然上下文窗口这么大,直接全塞进去不就行了吗?这个想法我能理解,但实际工程里行不通,原因有三个。
第一是成本。百万 token 的输入,每次请求的计费完全不可忽视,尤其是日活用户上万的场景,一个普通客服机器人每轮对话光输入消耗就是几块钱的量级,一个月下来是笔大数目。你的商业模型能不能扛住,是需要实际核算的。
第二是性能。窗口越长,首 token 延迟越高。做在线交互产品的人最敏感这个数字,用户没耐心等一个超过 5 秒才开始输出的机器人。实测下来,上下文从 2K 涨到 8K,首字响应时间可能翻倍,这是物理规律,不是调参能救回来的。
第三是注意力稀释。长上下文你塞得越多,模型越容易遗漏关键信息。我在项目中做过一个测试:在上下文中故意植入一个非常明确的指令“用户所在城市是杭州”,然后把一段 3 万字的无关资料也塞进去,结果模型在回答时经常忽略城市信息,答成“无法获取您的位置”。长窗口不代表长记忆,这个认知误区需要尽早破除。
所以,context-mode 的本质不是“让窗口变大”,而是“让有限窗口内的信息更有效”。工程上,我们要做的是对进入信息做筛选、压缩和排序,让模型始终看到当下最有用的部分。
2.3 主流的四层上下文组织架构
真正成熟的 context-mode 实现,我一般会拆成四层来看。你可以把它想象成一个人的记忆系统,而不是一个简单的“输入框”。
- 第一层:预设层(System / Preset)。放最稳定的信息,比如角色设定、业务规则、输出格式要求。这部分所有对话共享,通常放在最前面,因为模型对开头内容的注意力相对最强。
- 第二层:场景层(Scene)。根据当前用户请求动态注入的信息,比如用户画像、当前页面、设备信息、历史订单。这层的特点是“和本次请求强相关”,不相关就尽量不放。
- 第三层:短期记忆层(Session)。存放当前会话最近的几轮消息,通常按原始文本保留,最多维持 10~20 轮。
- 第四层:长期记忆层(Long-term)。存放跨会话的重要信息,比如用户偏好、历史结论、长期目标。这层通常需要摘要化、结构化,甚至向量化后存储在外部系统,按需召回。
每一层的管理策略都不一样。预设层要控制体积,场景层要强调实时抽取,短期记忆层是滑动窗口策略最活跃的地方,长期记忆层则是检索和摘要技术的用武之地。你看到的很多成熟框架,比如 LangChain 的 memory 模块、Dify 的上下文变量、Semantic Kernel 的记忆插件,其实底层都是这个逻辑,只是封装程度不同。
3. 实操过程与核心环节实现
3.1 先定数据结构:一个最小可用的 context-mode 设计
下面我用 Python 写一个最小实现,目标是让你看清 context-mode 的骨架。这段代码不依赖任何外部库,可以直接跑,非常容易迁移进你的项目里。
from dataclasses import dataclass, field from typing import List, Dict, Optional from datetime import datetime @dataclass class Message: role: str # "system" / "user" / "assistant" content: str timestamp: str = field(default_factory=lambda: datetime.now().isoformat()) @dataclass class ContextBand: """一个典型的 context-mode 上下文结构""" preset: List[Message] = field(default_factory=list) # 预设层 scene: List[Message] = field(default_factory=list) # 场景层 session: List[Message] = field(default_factory=list) # 短期记忆层 long_term_summary: Optional[str] = None # 长期记忆摘要这个数据结构对应了前面说的分层思想。很多同学会在这里偷懒,直接用一个大 List 存消息,也能跑。但真正到要优化的时候,你会发现没有分层根本无从下手——你想压缩旧消息,不知道该压哪一部分;你想注入用户画像,不知道往哪里插。分层的意义在于管理边界清晰,各层可以独立演进策略。
3.2 上下文各环节的实现与参数选择
接下来是核心:怎么把原始对话写进这个结构,以及怎么在调用模型前把它拼装成最终的 prompt。我把关键方法写出来:
class ContextModeEngine: def __init__(self, session_max_len: int = 20, summary_threshold: int = 10): self.session_max_len = session_max_len # session 层最多保留消息条数 self.summary_threshold = summary_threshold # 超过多少条后开始触发历史压缩 def add_message(self, ctx: ContextBand, role: str, content: str): ctx.session.append(Message(role=role, content=content)) # 超出长度时,把最早的 session 消息交给摘要模块 if len(ctx.session) > self.session_max_len: self._rollout_old_messages(ctx) def _rollout_old_messages(self, ctx: ContextBand): overflow = 10 # 每次溢出一批,避免频繁触发摘要 old_msgs = ctx.session[:overflow] ctx.session = ctx.session[overflow:] # 这里实际应该调用 LLM 做摘要,最简实现里先用一条硬编码文本代替 ctx.long_term_summary = self._summarize_chunk(old_msgs, ctx.long_term_summary) def _summarize_chunk(self, msgs: List[Message], prev_summary: Optional[str]): # 真实场景请替换为 LLM 调用:f"请把{prev_summary}和{msgs}合并成一段不超过300字的摘要" joined = "; ".join([f"{m.role}: {m.content}" for m in msgs]) return f"[摘要] {prev_summary or ''} | {joined[:500]}" def build_prompt(self, ctx: ContextBand) -> str: parts = [] if ctx.long_term_summary: parts.append(f"[长期记忆] {ctx.long_term_summary}") if ctx.preset: parts.append("\n".join([m.content for m in ctx.preset])) if ctx.scene: parts.append("\n".join([m.content for m in ctx.scene])) if ctx.session: parts.append("\n```\n" + "\n".join([f"{m.role}: {m.content}" for m in ctx.session]) + "\n```") return "\n\n".join(parts)这里需要详细解释参数选择的依据,这是我在实际项目中容易被问到的点。
先说session_max_len = 20。这个数字是怎么来的?我做过一组对比测试:对同一客服场景,session 保留 6/10/20/50 轮消息,分别看模型在“用户意图理解”和“关键信息召回”两个指标上的表现。结果 20 轮是个性价比拐点,再往上,效果增益非常有限,但 prompt 体积和延迟肉眼可见地上涨。如果你是做简单闲聊,建议 6~10 轮就够了;如果你是做复杂任务类 Agent,可以考虑 30 轮左右,但需配合更激进的摘要策略。
再说overflow = 10。这里的关键是避免每次新增消息都触发摘要,让摘要调用过于频繁。很多人的实现是“每超过阈值就整体重做一次摘要”,这样又贵又慢。我采用的是批量滚动:每次溢出 10 条,让摘要模块一次处理一批,这样每日 LLM 摘要调用量能控制在一个稳定的频率上。还要注意,不要把“溢出消息”直接丢掉,它们要进入摘要环节,变成长期记忆的一部分。
_summarize_chunk方法里我留了一个关键注释:真实场景要用 LLM 生成摘要。很多工程同学会觉得“那我自己写个截断不就行了吗”,效果确实差很多,因为截断丢掉的是逻辑,而不是长度。我这里先用占位实现,是为了让你看到整体流程。后面我会给你一个可以直接用的摘要 prompt 模板。
最后看build_prompt的拼装顺序。我把长期记忆放在最前面,然后是预设、场景、短期记忆。这里有个经验:最稳定的信息放最前面,最易变的信息放最后。长期记忆和预设相当于“定基调”,session 里最新的消息是模型最需要优先回应的,所以放在离当前输入最近的位置。很多新手的写法是把 system prompt 放最前,然后接一堆历史对话,最后才是用户当前输入,会让模型对当前请求的注意力权重下降,需要调整过来。
3.3 实操现场的构建步骤与拼接示例
下面用一个具体场景走一遍完整流程:一个电商导购机器人,用户第三次进入会话。
第一步,初始化上下文并填充预设层:
ctx = ContextBand() ctx.preset = [ Message(role="system", content="你是电商店铺的智能导购,请用简洁友好的语气帮助用户选品。"), Message(role="system", content="你掌握的店铺优惠活动:全场满299减30,新品上市前三天9折。"), ]第二步,填充场景层。场景层来自业务系统的实时数据,也就是用户在进入对话时,服务端已经查好的信息:
ctx.scene = [ Message(role="system", content="[用户画像] 老客,近30天消费3笔,偏好户外运动类商品。"), Message(role="system", content="[当前上下文] 用户正在浏览商品页:户外双肩包,价格区间300-600元。"), ]第三步,模拟两轮用户对话:
ctx.add_message(ctx, "user", "这个包容量多大?适合登山用吗?") ctx.add_message(ctx, "assistant", "这款是40L容量的徒步包,设有水袋仓和防雨罩,适合单日或两日登山。") ctx.add_message(ctx, "user", "那有没有更轻一点的?")第四步,把上下文构建成最终发给模型的 prompt,并打印看看实际效果:
prompt = engine.build_prompt(ctx) print(prompt)输出大概是这样的结构:
[长期记忆] None 你是电商店铺的智能导购,请用简洁友好的语气帮助用户选品。 你掌握的店铺优惠活动:全场满299减30,新品上市前三天9折。 [用户画像] 老客,近30天消费3笔,偏好户外运动类商品。 [当前上下文] 用户正在浏览商品页:户外双肩包,价格区间300-600元。 user: 这个包容量多大?适合登山用吗? assistant: 这款是40L容量的徒步包,设有水袋仓和防雨罩,适合单日或两日登山。 user: 那有没有更轻一点的?你看,这里并没有把“该用户三年前的购买记录”搬进 prompt,只有当前会话的信息。这就是 context-mode 的核心价值:有选择地让模型看到最该看的东西。
如果这个对话持续到 20 轮以上,_rollout_old_messages就会触发,最早的 10 条消息会被压缩进长期记忆,session 只保留最近 10 条。此时long_term_summary里会有一条类似“用户询问了登山包容量和重量,最终关注轻量化产品”的摘要信息。之后模型再回答时,既能感知用户早期的需求倾向,又不会因为看到完整旧对话而冲淡聚焦点。
3.4 给摘要模块的可用提示词模板
摘要模块是整个链路里最容易被低估的环节。如果你直接对模型说“请总结这段对话”,得到的摘要往往是流水账,缺少结构。我实际调过很多版,目前比较稳定的是一个带输出约束的 prompt 模板:
你是一个对话摘要引擎。下面是一段客服/助手的对话片段,请按以下要求输出摘要: 1. 提取用户的核心诉求与意图变化,用一句话概括。 2. 提取用户提供的关键事实(如商品偏好、价格预算、时间约束、身份信息),逐条列出。 3. 提取尚未解决的承诺/待办事项(如“助手答应稍后查找库存”)。 4. 输出格式为: 核心诉求:... 关键事实:... 待办事项:... 5. 如果和已有摘要合并,请保留已有摘要中的有效信息,只做增量更新,不要重复输出已有内容。 已有摘要: {prev_summary} 当前对话片段: {messages}这个模板有三个关键设计:结构化的输出格式保证了摘要能被程序解析和再组织;增量更新的约束避免了摘要越滚越长;待办事项的提取则保证了 Agent 类应用不会中途丢任务。你可以直接复制到自己的项目里试用,按业务字段微调。
4. 常见问题与排查技巧实录
4.1 模型“失忆”了,但排查发现 prompt 里明明有历史内容
这是我被问得最多的问题之一。现象是:prompt 里明明已经把历史对话拼进去了,模型却还是答非所问,仿佛完全不记得之前聊过什么。
我的排查步骤通常是这样的:
- 先确认历史内容在 prompt 中的位置。如果被塞在很长的场景层中间,模型的注意力可能被稀释,试试把它移到 session 层尾部,紧贴着当前用户输入。
- 再确认格式是否清晰。很多人用“换行分隔”但缺少标记,我建议用明确的角色标签和格式包裹,比如
user:/assistant:,必要时用代码块包住,减少干扰。 - 检查是否“超长截断”。有些实现是偷偷从中间截断的,截断之后模型看到的是断裂的信息,反而会产生误导。这种情况要把截断策略从“硬截断”改成“摘要压缩”。
有一类隐蔽问题需要单独提一下:历史消息里的早期指令被后续对话覆盖了。比如用户在第一轮说“我喜欢浅色系”,后面几轮都在聊款式,模型会把早期的偏好“忘掉”,本质不是模型失效,而是注意力被近期内容主导了。解决方法是把用户的关键偏好同步写入场景层,也就是在做实时抽取时,把跨轮次稳定信息提升优先级。这就是为什么要分层——session 里的信息是易变的,场景层才放稳定偏好。
4.2 摘要越滚越乱,长期记忆开始“一本正经地胡说八道”
摘要模式用久了以后,会出现一种很尴尬的情况:摘要里开始出现模型编造的内容。比如原文没提到的价格区间,摘要里出现了“用户预算约500元”。这个问题的根源在于摘要模型在压缩时做了过度推断。
我的应对策略有三条:
- 摘要 prompt 里明确加一条“如果对话中没有明确信息,不要推断或补全”。
- 关键业务事实(订单号、预算、联系方式)不要放在摘要里,而是从业务数据库直接查询后放入场景层。摘要只负责语义性内容,不负责事实性内容。
- 对摘要增加“事实保鲜期”的概念,比如超过 7 天未活跃的摘要不再直接注入,而是先做一轮检索判断相关性。这个逻辑可以很简单:每次构建 prompt 前,计算最后一次活跃时间,如果超期,就把长期记忆层临时降级。
这里有个通用的工程原则:不要让 AI 承担它本不该承担的存储职责。摘要只是索引,业务数据才应该进数据库。
4.3 上下文管理带来额外延迟,用户开始流失
加了摘要、检索、分层之后,每个请求的处理链路变长了,很多同学会跑回来问我:“这样做到底值不值?”
我先算一笔账。假设原来每个请求发送 8000 token 的上下文,LLM 首 token 响应 4 秒;优化后发送 2000 token,首 token 响应 1.5 秒。哪怕上下文管理本身耗时 30 毫秒,体验也是翻天覆地的提升。用户对“转圈时间”的敏感度远高于你对代码复杂度的敏感度。
但前提是上下文管理模块本身不能太慢。我见过有人把摘要、向量检索、数据库查询全部串行执行,中间还有几次网络往返,最后总耗时比直接发 8000 token 还高。经验是:能并行的就并行,检索和场景查询同时发起;摘要不要每次都做,设置触发条件;能缓存的结果坚决缓存,同一天内同一会话的处理结果可以直接复用。
还有一个容易忽略的优化点:prompt 模板的稳定部分要提出来预编译。预设层和场景层如果是模板生成的,每次请求都重新拼字符串其实无所谓,但如果你用了额外的 tokenizer 或长度计算,这就是纯浪费,存一份预计算结果就好。
4.4 常见问题速查表
下面把典型问题和对应策略整理成表格,方便你回查:
| 问题现象 | 可能原因 | 排查思路 | 推荐方案 |
|---|---|---|---|
| 模型忽略早期约束 | 早期信息被后排对话挤占注意力 | 检查消息排列位置 | 将稳定约束提升到场景层或预设层 |
| 历史内容太长 | 无限制累积对话 | 查看 prompt 发送量 | 设置 session 长度上限,触发摘要压缩 |
| 摘要记录错误事实 | 摘要模型补全了未提及信息 | 抽查摘要内容 | 强制业务事实走数据库查询,不让摘要承载 |
| 上下文注入了无关信息 | 场景层没有做相关性过滤 | 检查场景层构建逻辑 | 按“与本次请求相关”标准动态筛选 |
| 对话总是超过预期长度 | summary_threshold 设得过大 | 统计每次请求的 token 数 | 调小阈值,更早触发压缩 |
| 长对话成本暴涨 | 每次请求都塞入完整摘要 | 查看 build_prompt 输出 | 对长期记忆做按需检索,不总是全量注入 |
| 切换用户后出现历史串线 | 会话 ID 未正确隔离 | 检查缓存 key | 所有上下文缓存必须以 user_id + session_id 为维度 |
5. 一些想留给你的经验
写到这里,关于 context-mode 的工程实现已经讲得比较完整了,最后再聊一点个人体会,可能会帮你在思路上更快落地。
第一个体会是:context-mode 首先要解决的是业务问题,而不是技术问题。我见过太多团队一开始就引入向量数据库、搞复杂的知识图谱,最后发现业务里根本用不上。建议你动手前先画一张“信息流图”:用户进来后,哪些信息从哪张表取、哪些信息从历史对话里来、哪些信息需要动态生成,再决定用哪种模式组合。很多中小项目其实只需要前面的分层结构和摘要逻辑就够了,检索模式完全可以等出现“信息太多装不下”的真实场景再引入。
第二个体会是:上下文模式和具体的模型能力是深度绑定的,需要定期重新评估。半年前的模型对长上下文很吃力,你可能做了一套激进的压缩策略;但这半年模型的长文本能力突飞猛进,有些压缩反而变成损失。我现在的习惯是每个季度做一次“上下文消融测试”:在相同业务场景下,对比不同压缩策略的效果差异,然后决定是否调优。
最后一个小技巧,也是我一直沿用的:在生产环境里给每次模型调用记录上下文构成日志。也就是把 build_prompt 的输出连同模型回复一起存下来,方便出问题时回放。这个日志比任何监控面板都实在,它能帮你快速定位是上下文丢了、排序错了,还是模型本身回答偏了。没有这套日志,前面讲的所有排查技巧都是纸上谈兵。
希望这些内容对你有实际帮助。如果你在实现 context-mode 的过程中碰到一些更具体的问题,欢迎带着场景描述来交流,我踩过的坑大概率能帮你省点时间。