接手过不少对话式 AI 项目之后,你会发现一个很现实的问题:模型能力本身进步很快,但真正让应用“好用”的,往往不是模型,而是你怎样管理它面前的那一摞“历史记录”。这个“历史记录”就是上下文。所谓 context-mode,就是我近几个项目里一直在打磨的一套上下文管理模式——简单说,是把“上下文怎么组织、用多长、怎么压缩、什么时候保留、什么时候丢掉”这件事,从拍脑袋变成一套可配置、可测试、可观测的机制。
这套东西适合谁?如果你正在做聊天机器人、AI Agent、RAG 问答系统,或者哪怕只是写一个带连续对话能力的工具脚本,只要你需要跟大模型反复交互,上下文的管理就是你绕不开的坎。我希望这篇分享能帮你搞清楚一个核心问题:面对千变万化的对话场景,为什么不能只用“全部历史都塞进去”这一种策略,以及怎样用一套清晰的模式来应对不同场景。
我不打算讲太虚的架构理念,尽量从实际需求出发,把设计思路、关键实现、参数选择和踩坑记录都过一遍。文章里的代码是基于 Python 的伪代码,但思路完全可以移植到任何语言和框架里。读完你至少能照着自己搭一个最小可用的 context-mode 模块,并且知道什么时候该用哪种模式。
1. 项目概述与核心思路
1.1 上下文为什么需要“模式”
先看一个具体场景。假设你在做一个客服助手,用户可能一次连续问十几个问题,也可能隔了半小时又回来继续之前的话题。如果你无脑把整个对话历史全部塞给模型,会遇到三件事:
第一,Token 成本直线上升。按目前主流模型的价格,几千字的上下文每一次请求都是真金白银,用户多聊几轮,成本就可能比开发成本还高。第二,模型注意力会稀释。上下文越长,模型对开头和最近内容的关注度波动越大,你喂了三万字历史,它反而可能把用户最新的那句话忘掉。第三,行为不可控。有的用户希望每次都得到独立回答,有的用户希望模型记住很多细节,一套策略永远顾此失彼。
于是,“模式”这个概念就变得非常自然:就像编辑器里的 Vim 模式和普通模式一样,你根据当前的操作意图切换输入处理策略。在对话系统里,模式决定了上下文对象的结构、长度、内容来源和压缩策略。显式定义这些模式,本质上就是在给模型的使用“规定上下文范式”。
1.2 三种基础模式的定义
我常把 context-mode 拆成三种基础类型,分别应对最常见的对话形态:
- 单轮模式(single-turn):每次请求自包含,不携带任何历史。适合一次性问答、参数校验、命令调用等场景。
- 滑窗模式(sliding-window):只保留最近 N 轮对话。适合大多数闲聊型助手、客服机器人,既保留连贯性,又控制成本。
- 摘要模式(summarized):保留一份随对话更新的摘要,再加最近少量轮次原始内容。适合长期任务型对话、AI Agent 长时间运行、记忆要求高的场景。
除了这三种,还有一种我后面会提的“检索增强模式”,它不依赖连续对话历史,而是从外部知识库拉相关片段作为上下文,跟上面三种可以组合使用。模式的设计核心不是越复杂越好,而是让你能明确回答一个问题:此刻我到底希望模型看到什么。
1.3 为什么用显式的模式切换,而不是自动判断
很多朋友第一反应是:能不能做成“自动判断”该用哪种模式?我的经验是:可以做,但不要把它做成黑盒。自动判断的触发条件越多,解释成本越高,出问题越难排查。你很难向一个使用你 API 的开发者解释“为什么这次请求没带历史记录”。
所以我坚持的做法是:在应用层做显式路由,在框架层提供自动建议的候选值。比如你可以在配置里写context_mode: "auto",但是最终落到请求上的,仍然是规则引擎选出来的一个明确模式值。用户和日志都能看到、能改、能复现。
2. 细节设计与关键技术选型
2.1 模式切换的核心算子设计
把模式切换变成一个普通函数调用,是我建议的第一步。不要把这套逻辑分散在业务代码的各个角落,否则项目后期你会很难维护。最小接口我设计成:
def resolve_context_mode( conversation_state: ConversationState, policy: ContextPolicy ) -> ContextMode: ...ConversationState记录当前对话的轮数、最近消息时间戳、配置的历史长度等,ContextPolicy是当前系统设定的策略(例如“客服机器人默认滑窗 20 轮,但用户输入了特定指令则切换为单轮”)。这样,模式解析就成了一个纯函数,输入是状态和策略,输出是明确的模式。好处是你可以给这个函数写单测,覆盖各种状态组合,比在业务代码里手动判断可靠得多。
2.2 上下文窗口模板
模式确定之后,真正决定模型输入的,是上下文窗口的组装模板。我的经验是把它当作一个“可插拔模板”来做,而不是直接写死拼接字符串。以滑窗模式为例,模板大致是:
def format_sliding_window_context(history: list[Message], head: str, tail: str) -> str: # head 一般是系统提示词或场景指令 # tail 是当前时刻用户的最新提问 selected = history[-K:] blocks = [head] for msg in selected: blocks.append(f"{msg.role}: {msg.content}") blocks.append(f"user: {tail}") return "\n\n".join(blocks)这样做的好处是:你可以针对不同场景替换 head 和 tail,而滑动窗口的“选中逻辑”完全一致,可复用性极好。另外,head 通常还承担了“场景锚定”的作用——比如“你现在是客服助手,回答要简洁”,它在模式切换时往往会一并重置。
2.3 Token 估算与长度控制
对上下文做长度控制,如果不先做 Token 估算,很容易出现“我以为没超长,结果被模型拒绝”的悲剧。Token 估算有两种层次:简单的按字符数比例估算,和精确的按模型分词器估算。我见过不少项目依赖前者,结果在中文场景下误差很大。
更靠谱的做法是利用模型自带的分词器做离线估算。如果用的是 OpenAI 系模型,可以用tiktoken做离线计算;如果用开源模型,一般也有对应的 tokenizer 实现。哪怕是离线估算版本,也务必留出安全边际。我在项目中通常的做法是:设定 90% 的软上限,超过就触发截断或压缩,硬顶是模型的真正上限,绝不让请求硬闯。这样做能大幅减少 400 错误和成本浪费。
3. 实操实现与核心代码
3.1 定义上下文模式的数据结构
第一步,先把模式枚举和状态数据定义清楚。
from enum import Enum from dataclasses import dataclass, field from typing import List, Literal, Optional class ContextMode(str, Enum): SINGLE_TURN = "single_turn" SLIDING_WINDOW = "sliding_window" SUMMARIZED = "summarized" RETRIEVAL_AUGMENTED = "retrieval_augmented" @dataclass class ConversationState: history: List[dict] = field(default_factory=list) summary: str = "" updated_at: float = 0.0 round_count: int = 0 last_turn_time: float = 0.0这里的summary字段专门为摘要模式预留。关键点是用 dataclass 而不是普通字典,因为字段约束和默认值都更清晰,后续写测试也方便。
3.2 实现模式路由逻辑
接下来是路由逻辑。我建议把模式解析与组装分成两个函数,一个回答“现在该用哪种模式”,一个回答“拿到这种模式后,上下文具体长什么样”。
def resolve_mode(state: ConversationState, policy: ContextPolicy) -> ContextMode: # 优先处理显式指令 if state.round_count == 0: return ContextMode.SINGLE_TURN # 用户或系统显式指定了单轮 if policy.force_single_turn: return ContextMode.SINGLE_TURN # 开始检索增强 if policy.use_retrieval and state.round_count >= 2: return ContextMode.RETRIEVAL_AUGMENTED # 默认长对话由摘要接管 if state.round_count >= policy.summarize_after: return ContextMode.SUMMARIZED return ContextMode.SLIDING_WINDOW这个逻辑看着很简单,但它至少解决了几个关键问题:第一,第一轮永远只走单轮模式,避免把空历史组装进上下文;第二,summarize_after这个阈值提供了长期对话的转折点,到了第 N 轮就自动切换为摘要模式;第三,所有分支都有明确返回,不会出现“模式未知”的分支。如果你有更复杂的业务规则,可以在这里加策略模式或规则引擎,但不要忘记保留一个默认分支。
3.3 组装上下文与执行路径
模式路由确定后,对应的上下文组装策略如下:
def build_context(mode: ContextMode, state: ConversationState, tail: str) -> List[dict]: if mode == ContextMode.SINGLE_TURN: return [{"role": "user", "content": tail}] if mode == ContextMode.SLIDING_WINDOW: window = state.history[-policy.window_size:] return window + [{"role": "user", "content": tail}] if mode == ContextMode.SUMMARIZED: # 摘要 + 最近两轮原始消息 recent = state.history[-2:] summary_block = {"role": "system", "content": f"[对话摘要] {state.summary}"} return [summary_block] + recent + [{"role": "user", "content": tail}] if mode == ContextMode.RETRIEVAL_AUGMENTED: docs = retrieve_docs(tail, top_k=policy.retrieval_k) rag_block = {"role": "system", "content": f"[参考资料]\n" + "\n".join(docs)} recent = state.history[-4:] return [rag_block] + recent + [{"role": "user", "content": tail}]这里有一个关键设计:摘要模式和检索增强模式都把额外的“系统级提示块”放在最前面,然后再拼接多轮历史,最后才是当前用户输入。这样模型会在进入具体对话前先读到“背景知识”或“摘要”,对稳定回答方向有很大帮助。关于为什么不用普通用户角色来注入这些内容,单纯就是把“历史对话”和“上下文背景”两种信息区分开,模型对 system 优先级的处理通常更稳定。
3.4 各类模式的开销对比
我个人很看重“运行成本可视化”,所以项目里做了一个简单表格,对每种模式预估 Token 消耗差异。这里我列一个典型的低成本例子供参考:
| 模式 | 输入 Token 估算(10 轮会话) | 是否可扩展至百轮 | 风险点 |
|---|---|---|---|
| single_turn | 约 30~60 | 否 | 无法处理指代、多轮追问 |
| sliding_window | 约 300~600 | 否 | 早期信息完全丢失 |
| summarized | 摘要 100 + 近期 200 = 约 300 | 是 | 摘要可能失真 |
| retrieval_augmented | 参考 200 + 近期 200 = 约 400 | 是(依赖检索质量) | 检索结果不相关时反而干扰 |
对照这个表格,你可以根据业务形态快速选择默认模式。如果是短期工具类调用,单轮足够;如果是客服对话,滑窗 + 适当窗口大小就好;如果是需要长期记忆的虚拟角色或 Agent,摘要模式或 RAG 模式才扛得住。
4. 常见问题与排查技巧
4.1 为什么对话一多就跑题
跑题是上下文问题里最常见的。我排查时第一件事不是调 prompt,而是看上下文里到底有什么。很多跑题的根因是滑窗窗口太大或太小:窗口太大,模型被大量无关历史干扰;窗口太小,连用户刚才说的重要信息都忘了。
我最常用的排查方法是“上下文快照日志”。在每次请求发出前,把组装好的上下文结构(不要含敏感内容)打印或写入日志。具体就是输出mode,window_size,summary,retrieval_docs几个字段,以及上下文的总 Token 数。一旦用户反馈跑题,你翻日志对比就能定位问题是出在窗口截断了关键信息,还是摘要丢失了重要实体。
4.2 摘要模式越滚越失真
摘要模式的通病是“摘要像滚雪球一样越滚越笼统”。如果你让模型每次把整个对话重新总结一遍,早期细节会快速消失。我的做法是“增量摘要 + 定期全量重建”。例如每 5 轮做一次增量摘要,把新产生的关键信息并进旧摘要;每 30 轮做一次全量摘要,用全部原始对话重新生成一份干净的摘要,防止增量累计带来的漂移。
这里有一个细节:增量摘要时,你需要给模型一个明确的“摘要更新指令”,而不是简单说“请总结对话”。更好的写法是:“以下是对之前对话的摘要,请结合最近几轮内容更新它,保留所有重要的人名、偏好和决定。”
4.3 模式切换失误导致行为突变
有时候系统今天表现正常,明天突然变得“拖沓”,往往不是模型的问题,而是模式切换的触发条件变了。尤其是“用户主动指定要长对话”的场合,如果有人手动把模式改成 summarized,但摘要模块没有正确初始化,请求就会发送一个空的摘要块,模型当然就“失去记忆”了。
排查这类问题,要养成在路由入口记录前一个模式和当前模式的习惯。每次模式切换都打印一行结构化日志,比如ctx_mode_switch: sliding_window -> summarized, reason: round_threshold。长期积累后,你就能找到规律,知道什么场景下不该触发切换。
4.4 推荐排查速查表
我把以前遇到过的问题整理成表,给团队新人也当作速查手册:
| 异常表现 | 优先排查方向 | 临时缓解 |
|---|---|---|
| 回答与最新问题无关 | 滑窗是否截掉了最近两轮 | 增大窗口到 10,并核对快照 |
| 用户说“你忘了”频繁出现 | 摘要模式未生效或摘要为空 | 强制重建摘要 |
| 上下文长度错误率上升 | 估算器与实际 tokenizer 不一致 | 引入离线精确估算 |
| 每次回答风格跳跃 | 模式频繁切换 | 给切换设置冷却轮数 |
| 检索内容完全无关 | RAG 相关度阈值太低 | 提高相似度阈值,加结果筛选 |
4.5 统一排查的一般思路
如果以上表里没有命中你的问题,我建议按“上下文快照 → 模式日志 → 输入输出 Token 对比”三步走。先确认模型实际看到了什么,再看路由是否按预期工作,最后确认成本是否异常增长。大多数上下文问题都能在这三步里暴露出来。
5. 我自己的实操习惯与收尾建议
做了几个项目的 context-mode 之后,我私下养成的习惯是:任何跟大模型交互的工具,第一天上线就必须带context_mode参数和结构化日志,否则后面一定后悔。因为上下文问题不像语法错误那样直接报错,它往往是“能运行、但效果越来越差”,最容易拖到后期才暴露。
另外一个很实用的小技巧:把模式解析做成一个独立的 CLI 小工具。平时写代码时,我直接在终端里跑ctx resolve --rounds 12 --history-len 3200之类命令,就能看到它推荐什么模式、预计多少 Token。这样在没启动完整服务的情况下也能快速验证策略是否正确,调试效率提升非常明显。
最后分享一个小细节给实际动手做的人:不要只盯着窗口轮数这个参数,更要关注“单轮长度”。20 轮对话如果每轮上千字,那滑窗模式的成本也会爆炸。更合理的模式是把窗口轮数控制在小值,同时为超长单轮提供额外摘要或截断策略。组合使用,而不是追求一个魔法数字。
context-mode 不是一个需要多高深技术才能掌握的东西,它真正考验的是你对业务场景的理解,以及能不能把这种理解沉淀成可配置、可观测的机制。希望这篇分享能给你一些可以直接拿去用的思路,也欢迎在实践中不断调整出最适合自己场景的那套模式。