做 AI 应用开发这几年,我越来越觉得“上下文”这个词被低估了。很多人把 context-mode 当成一个简单的开关——开着就是有记忆,关着就是没记忆,实际上完全不是这么回事。它是一套关于“系统该记住什么、忘记什么、以什么顺序组织记忆”的策略体系。我最初是在一个会话类产品里第一次认真做 context-mode,对接大模型接口、处理用户多轮对话,结果发现上下文管理的好坏,直接决定了产品是“能用”还是“好用”。这篇文章就把我在这类项目里总结的上下文模式设计思路、核心实现以及踩过的坑完整整理出来,适合正在做聊天机器人、智能助手、Agent 编排,或者任何需要长对话记忆的开发者参考。
1. 项目背景与整体设计思路
1.1 context-mode 到底在解决什么问题
先说结论:大模型本身是无状态的。同一个模型接口,你给它什么它回什么,上一轮说了什么都没留下。所谓 context-mode,本质上是我们在应用层自己造出一套“记忆系统”,把多轮对话历史、系统指令、知识检索结果、工具调用信息,按一定策略组织成每次请求要发送的上下文。
这个问题的复杂度,往往要等对话超过十轮、二十轮才会暴露出来。对话越短,上下文管理越简单;一旦历史消息堆积,Token 开销成倍上涨,响应延迟跟着变高,更麻烦的是模型会“迷失”在长上下文里——早先的约束记不清了,最近一句用户指令反而不突出。context-mode 要解决的问题就是三个:记忆组织、成本控制、行为切换。记忆组织解决“哪些内容该进上下文”,成本控制解决“怎么让每次请求不过量”,行为切换解决“不同场景下用不同策略”。
我做这个项目时,第一反应是"直接把最近 N 轮消息拼进去不就行了",结果很快被打脸。因为真实对话里有大量噪声:用户反复追问、临时打断、复制粘贴的长文本、工具返回的 JSON 片段。这些东西全部堆进去,模型不是变聪明,而是变糊涂。后面我才慢慢意识到,上下文管理不是简单的消息拼接,而是一套有优先级、有预算、有淘汰机制的资源调度系统。
1.2 三条设计原则:可观测、可裁剪、可回滚
做 context-mode 之前,我先给自己定了三条设计原则,后来整个开发过程都靠这三条兜底。
第一条是可观测。上下文是生产出来给模型“吃”的,但你必须在发送前能完整看到最终拼接结果。我在项目里给每次请求都加了一条结构化的日志,记录最终进入上下文的每条消息的 role、长度、来源,以及这一次消耗的 Token 数。没有这些数据,后面所有优化都是瞎子摸象。
第二条是可裁剪。任何一条消息都不应该被无条件保留。我设计的 context-mode 支持对单条消息设置最大长度、对同一来源的消息设置数量上限、对整个上下文设置总预算。裁剪不是简单截断字符串,而是按优先级保留核心信息,淘汰冗余内容。
第三条是可回滚。上下文一旦被污染,很难靠人肉排查。我在生成上下文之前,会把清理前的状态做一个快照缓存在本地。如果线上发现模型回答异常,我可以直接把某一轮请求的上下文快照导出,逐条对比哪些信息被裁掉了、哪些摘要被替换了。这个能力在排查“模型为什么忘了用户说过的话”时,几乎是唯一高效的手段。
2. context-mode 的核心机制拆解
2.1 两种基础模式:长对话模式与单轮精确模式
context-mode 第一步要做的,是先把模式拆开。一个系统不可能靠一套上下文策略通吃所有场景,最少要分两种基础模式。
长对话模式适合闲聊、客服、陪伴类产品,核心目标是“记得住”。它需要保留足够多的历史消息,同时通过滑动窗口丢弃过期内容,必要时用摘要压缩早期信息。单轮精确模式适合问答、知识库检索、工具调用类场景,核心目标是“不干扰”。它只保留当前用户输入、系统指令和必要的检索片段,历史消息基本不留,避免旧信息干扰模型的判断。
| 模式 | 适用场景 | 上下文组成 | Token 开销 | 记忆强度 |
|---|---|---|---|---|
| 长对话模式 | 闲聊、陪伴、多轮任务 | 系统指令 + 近期历史 + 摘要 + 当前输入 | 高,需持续维护 | 强,有连续记忆 |
| 单轮精确模式 | 问答、检索、工具调用 | 系统指令 + 检索片段 + 当前输入 | 低,相对稳定 | 弱,无长期记忆 |
| 混合模式 | 客服、Agent、复杂任务 | 系统指令 + 关键历史摘要 + 检索片段 + 当前输入 | 中,按需动态调整 | 中,关键信息保留 |
我实际项目里用的是第三种:混合模式。因为纯粹的闲聊场景不多,绝大多数产品是“一边回答问题一边记住用户偏好”。所以我设计 context-mode 时没有机械地二选一,而是让模式支持组合:以单轮精确为基础,叠加一层“关键历史记忆”和“摘要记忆”,形成可动态调整的上下文。
2.2 上下文来源归一化:先解决“输入怎么进来”
上下文不是只有“对话历史”一种来源。我梳理了一下,实际产品里进入上下文的输入至少有五类:系统指令、用户消息、助手历史回复、知识库检索片段、工具调用结果。难点在于,这些来源格式完全不同,有的是一段纯文本,有的是一大段 JSON,有的是字段很多的表单数据。
如果直接把它们拼成一个字符串,后面做裁剪、做 Token 统计、做消息过滤都会非常痛苦。我的做法是先做一次归一化:不管你来自哪里,最终都变成一个统一的“消息对象”,包含 role、content、来源标签和元数据。来源标签特别关键,它决定了这条消息在 context-mode 里的优先级。比如系统指令永远不裁剪,工具返回的 JSON 只保留前 500 Token,知识库片段按相关度排序后取前三条。
归一化之后,上下文构建器就不用关心内容到底是从哪来的了,它只需要根据每条消息的优先级和预算,决定“要不要放进去、放进去后留多长”。这个抽象层非常值得做,哪怕前期会多写一些适配代码,后面每次新增一个输入来源,都只需要写一个转换器,不用再动核心逻辑。
2.3 Token 预算与动态窗口:算好每一笔账
Token 是上下文管理的货币。不同模型,Token 计算方式不同,但原则一致:你必须在有限的预算内塞下最有效的信息。
我在项目里给 context-mode 定了一个总预算,比如模型上下文窗口是 8000 Token,我会把实际的上下文预算设成 6000,留 2000 给模型输出。在这个总预算里再按比例切分:系统指令 500,关键历史摘要 500,近期对话 1500,检索片段 1000,当前用户输入 1500,剩余约 1000 作为弹性空间。
这个切分不是拍脑袋。我观察到的规律是:模型对最新用户输入的注意力最强,所以这块预算必须给足;系统指令决定了整个回答的行为边界,属于全局影响,不能省;历史对话离当前越远,影响力衰减越厉害,所以越老的内容越适合用摘要代替原文。一开始我不舍得裁历史,生怕丢掉关键信息,后来做了 A/B 对比才发现,保留 10 条旧消息对回答质量的提升,远不如把当前用户意图写清楚。
实际计算时,可以用模型的 tokenizer 精确计数,也可以用经验值估算:英文大约 4 个字符一个 Token,中文大约 1 到 1.5 个字一个 Token。我在构建上下文前会先做一次快速估算,超过预算才触发精确计数和裁剪,这样能省掉大量不必要的 tokenize 调用。
2.4 分级记忆策略:不是所有历史都值得留
长对话最大的敌人是“平均用力”。如果把 30 轮对话每一轮都平等对待,上下文很快就满了,而且最有价值的用户偏好会被淹没在流水账里。我的做法是把记忆分成三级。
第一级是近期窗口,保留最近 5 到 8 轮完整消息,这是模型回答当前问题最依赖的部分。第二级是摘要记忆,把更早的消息分批压缩成一小段摘要,每次对话超过一定长度就触发重写。第三级是关键事实,单独抽取用户明确说过的重要信息,比如“我喜欢简洁回答”“我住在杭州”“我上周提过预算上限是三千元”,这些信息独立存储,每次构建上下文时优先注入。
三级记忆的淘汰策略也不同。近期窗口按轮数淘汰,摘要按时间衰减重写,关键事实除非用户明确更改,否则一直保留。这套策略跑下来,我最直观的感受是:模型偶尔还是会忘东西,但忘的内容已经从“用户刚说的诉求”退化成“三个月前随口提起的细节”,这个差距就是分级记忆的价值。
3. 从零实现一个可用的 context-mode 组件
3.1 数据模型:用 MessageList 而不是字符串拼接
确定策略之后,下一步就是落地成代码。我强烈建议用结构化的消息列表来承载上下文,而不是拼一个长字符串。字符串拼接只能做整体截断,做不了细粒度的裁剪和过滤。
下面是我在项目里用的最小数据结构。这个设计参考了常见大模型 SDK 的消息格式,但额外加了 timestamp、source、meta 这三个字段,用于支持上下文管理逻辑。
from dataclasses import dataclass, field from typing import Literal, Optional, Any import time MessageRole = Literal["system", "user", "assistant", "tool"] @dataclass class Message: role: MessageRole content: str source: str = "chat" # chat / retrieval / tool / summary / instruction timestamp: float = field(default_factory=time.time) token_count: Optional[int] = None meta: dict[str, Any] = field(default_factory=dict)source 字段是上下文管理的抓手。我在构建上下文时会对不同 source 采用不同策略:source 为 instruction 的消息永远排在列表最前面,且禁止裁剪;source 为 retrieval 的消息会根据相关度分数做排序和截断;source 为 summary 的消息通常会作为一段独立文本放在历史消息之前。timestamp 字段用于滑动窗口判断哪些消息该被淘汰。
token_count 字段一开始容易被忽略,后来我发现它非常重要。每次构建完上下文,我会把每条消息的 Token 数缓存下来,下次构建时直接累加,而不是重新 tokenize 一遍。对话场景高频调用,这个缓存能省掉大量无谓计算。
3.2 上下文构建器:动态拼接与截断
上下文构建器是 context-mode 的核心执行者。它的输入是当前会话的全部消息和本次用户输入,输出是最终发给模型的上下文列表。我实现了一个简化但可用的版本,核心逻辑按优先级排序、按预算动态裁剪。
def build_context( session, user_input: Message, mode: str = "mixed", budget: int = 6000, ) -> list[Message]: # 系统指令永远排在第一位 system_messages = [m for m in session.messages if m.source == "instruction"] # 关键事实摘要优先于普通历史 summary_messages = [m for m in session.messages if m.source == "summary"] # 近期历史按时间排序,最新的放最后 history_messages = [m for m in session.messages if m.source == "chat"] history_messages.sort(key=lambda m: m.timestamp) # 检索片段按重要程度保留 retrieval_messages = [m for m in session.messages if m.source == "retrieval"] ctx = [] used = 0 # 1. 系统指令:完整保留,不参与裁剪 for msg in system_messages: ctx.append(msg) used += count_tokens(msg.content) # 2. 关键事实:即使超预算也要尽量保留 for msg in summary_messages: if used + count_tokens(msg.content) > budget * 0.3: continue ctx.append(msg) used += count_tokens(msg.content) # 3. 检索片段:只保留前 1000 Token retrieval_budget = 1000 for msg in retrieval_messages: if retrieval_budget <= 0: break trimmed = truncate_to_tokens(msg.content, retrieval_budget) msg.content = trimmed msg.token_count = count_tokens(trimmed) ctx.append(msg) used += msg.token_count retrieval_budget -= msg.token_count # 4. 近期历史:从最新的消息往前放,直到剩余预算被用完 remaining = budget - used for msg in reversed(history_messages[-30:]): if remaining <= 0: break cost = count_tokens(msg.content) if cost > remaining and len(ctx) > 0: break ctx.append(msg) used += cost remaining -= cost # 5. 当前用户输入:保留全部,但同时控制整体不超预算 ctx.append(user_input) return ctx这段代码的核心逻辑是“系统指令优先、关键摘要次之、检索片段截断、近期历史从后往前补位”。注意,我在处理近期历史时用的是倒序插入,最后再整体反转,或者在这里直接 append,配合后续格式转换时保持顺序。总之核心原则是一样的:最新的消息永远优先保留。
有一点必须提醒:不要用“从中间随机截断”的方式处理历史。我把所有历史消息按时间排好后,从最新的一条开始向前保留。原因很简单,模型对紧挨着用户输入的那几条消息上下文依赖最强,丢掉它们最伤回答质量。更早的消息最多也就是让它忘掉一些细节,最近的几条若被截掉,它直接连用户在问什么都没法理解。
3.3 模式切换与持久化
context-mode 不能是一个只存在于请求周期的临时逻辑,它需要有状态。我在项目里给每个会话维护了一个 SessionContext 对象,专门存档模式、摘要、关键事实和消息列表。
class SessionContext: def __init__(self, session_id: str, mode: str = "mixed"): self.session_id = session_id self.mode = mode self.messages: list[Message] = [] self.summary: str = "" self.key_facts: list[str] = [] self.token_cache: dict[str, int] = {} def switch_mode(self, new_mode: str): self.mode = new_mode # 模式切换时重新计算摘要和关键事实的优先级 if new_mode == "single": self.key_facts = self.key_facts[-1:] elif new_mode == "long": self.key_facts = self.key_facts模式切换时会触发一个副作用:重新评估摘要和关键事实的保留策略。比如从 mixed 切换到 single 时,摘要保留数量会下调,因为单轮精确模式不希望过多历史内容干扰模型。这个设计让同一个会话可以在不同场景之间灵活切换,而不是一套策略用到底。
持久化方面,我用本地 SQLite 存储每个 SessionContext 的消息、摘要和关键事实。为什么不用纯内存?因为线上服务经常重启,一旦内存里的会话丢了,用户会发现机器人“失忆”。哪怕是单机部署,我也建议把会话持久化到本地或 Redis,序列化格式用 JSON 就够了。
3.4 与 Agent/工具调用场景的结合
context-mode 真正复杂的地方在和工具调用结合的时候。大模型在 Agent 场景中会返回 function_call,这时需要先执行工具,再把工具结果作为一条 tool 消息放进上下文,模型才能基于结果生成最终回答。
工具返回结果有两个典型问题:一是又长又杂,二是格式不稳定。我处理的原则是:工具调用指令和结果都会进入上下文,但结果只保留经过裁剪后的内容。比如一个查询用户订单的接口返回了 2000 Token 的 JSON,我会先抽取其中最关键的订单状态、金额、时间和关联 ID,压缩到 300 Token 以内再放进去。
def format_tool_result(raw_result: dict, max_tokens: int = 300) -> str: simplified = { "status": raw_result.get("status"), "amount": raw_result.get("amount"), "order_id": raw_result.get("order_id"), "created_at": raw_result.get("created_at"), } text = json.dumps(simplified, ensure_ascii=False) return truncate_to_tokens(text, max_tokens)工具调用结果的优先级低于系统指令,但高于一般的近期历史,因为它是模型完成当前任务的关键依据。我在构建上下文时会把 tool 消息紧跟在其对应的 user 消息之后,这样模型能清楚看到“用户问了什么 -> 系统调了什么工具 -> 工具返回了什么”,因果链路完整,模型不容易把工具结果错用在其他问题上。
4. 上线后最常见的四个坑与排查方法
4.1 上下文污染:检索片段盖过了用户意图
第一个坑,也是最隐蔽的坑,是上下文污染。常见症状是:用户明明问了一个新问题,模型却按照旧问题或检索片段来回答。我排查过一次典型案例:用户先问“杭州有哪些适合周末去的地方”,助手给了一堆景点,后来用户追问“那下雨天去合适吗”,结果模型开始推荐西湖和千岛湖,完全没有意识到用户在问天气适配性,而不是要更多地点。
原因出在检索片段太多。我在构建上下文时,把知识库检索的前 1000 Token 都塞了进去,里面包含大量“杭州景点推荐”的详细描述,盖过了用户最新的追问。用户的最新意图虽然也在上下文里,但特征信号被大片无关文本稀释了。
解药是调整优先级和长度限制。我把检索片段的总预算从 1000 降到 400,同时要求检索结果按相关度排序,只保留最相关的一条;另外在拼接时,把用户当前输入放在检索片段之后、紧跟其后的位置,让模型在读完检索内容后立刻看到“用户真正问的是什么”。这个改动之后,同类污染问题下降了七成。
4.2 截断导致语义断裂:中间截断是新手最容易犯的错
第二个坑是截断方式不对。最初我的实现很简单:上下文超过预算,就从第 10 条消息开始删。结果模型回答变得非常诡异,经常答非所问。后来我把出问题的上下文导出来逐条看,发现被删掉的中间几条消息正是用户陈述条件的关键部分,比如“预算三千以内”“只要江浙沪”“当天能往返”。这些条件一旦丢失,模型自然只能瞎猜。
正确的滑动窗口姿势是“保头保尾,中间摘要”。保头,是保护系统指令和关键事实;保尾,是保护离当前用户输入最近的几轮消息。中间那些过早的对话,不应该直接全部删掉,而应该先触发摘要压缩。我在 SessionContext 里增加了一个触发逻辑:历史消息超过 15 条时,把最旧的 5 条合并成一段摘要;超过 25 条时,再把摘要扩大到覆盖前 10 条。这样即使早期信息偶尔丢失,也是在摘要层面的丢失,而不是“突然消失”。
4.3 Token 重复计算带来的性能问题
第三个坑不太影响回答质量,但直接影响服务性能。早期我每次构建上下文,都会把列表里所有消息重新 tokenize 一遍来计算 Token 数。对话一长,tokenize 耗时呈线性上涨,请求延迟从 200ms 涨到 800ms,用户体感非常明显。
优化方案是增量缓存。每条消息在首次写入时计算 token_count 并缓存,之后一直复用。只有消息内容被裁剪或摘要重写时,才重新计算。SessionContext 里的 token_cache 就是干这个用的。而且上下文的预算判断可以先用估算值快速过滤,超预算再精确计算,大多数场景根本不需要完整的 tokenize。
4.4 长会话下的摘要失真
最后一个坑来自摘要本身。会话超过 50 轮后,摘要机制开始频繁运行,但摘要越长,失真越严重。我开始是把 30 轮历史压成一句话,结果模型经常忘掉用户一些隐含要求。后来改成多级摘要,细化摘要粒度,同时把“用户明确说过的硬性要求”单独抽成 key_facts,不随摘要压缩。
我现在用的规则是:摘要只覆盖对话中的“一般闲聊”部分,而所有带“不要”“必须”“我希望”等强约束的内容,一律进关键事实列表。这样即使摘要再怎么压缩,硬性约束也不会丢。这个调整对比如“用户要求回答不要超过 50 字”这类指令尤其有效。
4.5 问题排查速查表
| 问题 | 典型现象 | 可能原因 | 解决方向 |
|---|---|---|---|
| 上下文污染 | 新问题被旧信息带偏 | 检索片段太长/优先级过高 | 压缩检索预算,调整拼接顺序 |
| 语义断裂 | 模型漏掉关键条件 | 中间截断把条件消息删了 | 改为保头保尾+摘要压缩 |
| 重复计算 | 接口延迟升高 | 每轮 tokenize 全量历史 | 增量缓存 token_count |
| 摘要失真 | 早期硬性要求被忽略 | 摘要过度压缩 | 关键约束独立存储,不走摘要 |
| 记忆错位 | 模型张冠李戴 | 历史消息无来源标记 | 统一 source 字段,按来源管理优先级 |
排查的顺序我建议是:先看上下文快照,确认“模型到底看到了什么”;再看 Token 预算分配,确认“是不是某类信息占了大头”;最后才去调模型参数。因为大部分上下文问题,根源都在输入侧,不在模型侧。
5. 几条值得长期坚持的优化原则
5.1 先埋点再优化,否则所有调整都是拍脑袋
我在这个项目里做了很多调整,大部分都依赖日志和埋点。每一次上下文构建,我都会记录最终消息数、Token 总数、各来源占比和触发裁剪的次数。这些数据不用多精细,但一定要连续。否则你看不到某次调整前后的差异,优化就只能靠猜。上线之后你会发现,“感觉回复变好了”这件事是靠不住的,你要的是那个数字。
5.2 没有万能模式,按场景动态切换才是正解
有些人会把 context-mode 做成一个固定策略,然后所有请求都走同一个逻辑。我试过,体验很一般。同一个产品里,用户有时候闲聊,有时候查资料,有时候要求执行任务,一个固定模式必然在某些场景下表现不佳。最好的状态是让模式动态切换,或者干脆采用混合模式,让系统指令、近期历史、检索片段、关键事实按各自的优先级动态组合。成本比固定模式高一些,但换来的是“所有场景下都不太差”。
5.3 把上下文当一等公民,而不是附加功能
最后想说的是开发心态。很多项目把上下文管理当成一个工具函数,写个函数往里塞消息就行。但真实项目里,上下文管理会逐渐膨胀成独立模块,影响对话质量、成本、延迟,甚至影响产品设计。我现在的习惯是:任何对话类项目开工第一件事,先把上下文构建、Token 统计、快照导出三件套做好,再开始接模型。这个前置投入可能在项目头两天看不出价值,但一旦进入联调和优化阶段,你会发现省下来的时间远远超过最初那两天的成本。
做 context-mode 到这个程度,其实已经没有太多惊险的坎了。剩下的功夫都在数据细节里:哪条消息该留,哪条该裁,摘要什么时候触发,预算怎么切分。这些磨出来的经验,比任何一个模型参数调整都管用。如果你正打算做自己的对话产品,建议先别急着接模型,先把上下文构建的这个模块写扎实,后面你会回来感谢这个决定的。