2024 年我做一个客服对话机器人。需求很常见:用户和机器人多轮对话,机器人要"记得"前面聊过的东西——用户上一句说的订单号,机器人下一句还得用得上。第一版我做得很直接:维护一个 messages 列表,用户每说一句就 append 进去,每次调用模型,把整个 messages 列表原封不动发过去,模型回的内容也 append 回列表。本地测试,聊个三五轮,完美——机器人清清楚楚记得前文,体验很顺。可一上线,问题就来了。有的用户和机器人聊得很深,一来一回几十轮、上百轮。某一轮请求,突然报错了:400,context_length_exceeded——这次请求的内容,超出了模型的上下文窗口上限。而且,就算是那些还没报错的长对话,我一看 API 账单也吓了一跳:一个聊了 50 轮的对话,它第 50 轮那一次请求,把前面 49 轮的内容全部又重发了一遍,这一次的 token 消耗,是第 1 轮的几十倍。我第一反应是:换一个上下文窗口更大的模型。从 8k 换到 32k,再换到 128k。确实有效——报错晚来了。可只要对话够长,它照样会来;而成本那个问题,窗口越大,我越敢往里塞,反而越糟。我盯着账单和那个报错看了很久才彻底想明白,第一版错在一个根本的认知上:我以为"把所有历史都拼进去,模型才记得住",我把上下文当成了一个可以无限往里堆东西的仓库。可它不是。上下文窗口是有限的,这是它的物理上限;而且大模型是无状态的——它不会自己记住上一轮,你想让它"记得",就只能每一轮都把历史重发一遍,于是 token 成本随轮数线性飙升。所以我真正要做的,从来不是"换个更大的窗口"——那只是把天花板抬高一点;而是主动管理上下文:该保留的保留,该压缩的压缩,该丢弃的丢弃。这,就是上下文窗口管理。我以为它不过是"把历史 append 进列表",结果真做下来坑一个接一个。那次之后我才认真把它从头搞明白。这篇文章就把它梳理一遍:为什么全量拼接历史迟早会爆、token 和上下文窗口到底是什么、滑动窗口怎么做、摘要压缩怎么做、混合策略怎么搭,以及 token 预算分配、消息边界、摘要丢信息这些把上下文管理真正做对要避开的坑。
问题背景
先把那次的现象和我的误判讲清楚,后面所有的设计都是冲着纠正这个误判去的。
现象:一个多轮客服对话机器人,把每一轮对话都追加进一个 messages 列表,每次调用模型时把整个列表全量发送。短对话一切正常;长对话(几十上百轮)某一轮突然报 context_length_exceeded,而且每一轮都重发全部历史,token 成本随轮数线性飙升。
我当时的错误认知:"把所有对话历史都拼进去,模型才记得住前文;上下文窗口换个更大的就行了。"
真相:上下文窗口是有限的物理上限,换更大的窗口只是抬高天花板,长对话照样会顶破。而且大模型是无状态的,想让它"记得"就必须每轮重发历史,token 成本随轮数线性增长。正确的做法不是堆历史,而是主动管理上下文:用滑动窗口只保留最近几轮、用摘要把更早的对话压缩、用混合策略把关键信息钉死,再配合 token 预算分配,让发给模型的内容永远控制在窗口之内。
要把上下文管理做好,需要几块认知:
- 为什么全量拼接历史会让 token 线性飙升、迟早顶破窗口;
- token 和上下文窗口到底是什么,窗口的上限是怎么算的;
- 滑动窗口——只保留最近 N 轮对话——怎么做;
- 摘要压缩——把滚出窗口的旧对话压成一段摘要——怎么做;
- token 预算分配、消息边界、摘要丢信息这些工程坑怎么处理。
一、为什么全量拼接历史迟早会爆
先把这件最根本的事钉死:大模型是无状态的,你每一轮都得把全部历史重发一遍,历史只增不减,token 就只涨不跌。
大模型处理一次请求,它的"记忆"只有这一次请求里你发给它的内容。它不会在两次请求之间替你保存任何东西。所以你想让它"记得"第一轮说过什么,唯一的办法,就是在第二轮、第三轮……每一轮,都把前面的对话重新发给它。下面这段代码,就是我那个会爆的第一版:
from openai import OpenAI
client = OpenAI()
history = [{"role": "system", "content": "你是客服助手。"}]
def chat_naive(user_input: str) -> str:
# 反面教材:每一轮都堆进 history,每次把整个 history 全发出去。
history.append({"role": "user", "content": user_input})
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=history, # 整个历史,一字不少地重发
)
answer = resp.choices[0].message.content
history.append({"role": "assistant", "content": answer})
return answer
# 问题:history 只增不减。聊到几十轮,messages 的 token
# 迟早顶破模型的上下文窗口 —— 请求直接报 context_length_exceeded。
# 而且每一轮都重发全部历史,token 成本随轮数线性飙升。
这段代码的错,不在某一行语法,在它的结构:history 这个列表,只 append,从不删。它就像一个只进不出的仓库,聊得越久,堆得越满。于是两件坏事同时发生:第一,迟早撞上窗口上限——模型的上下文窗口是有硬性 token 上限的,history 一旦涨过这条线,请求直接被拒,报 context_length_exceeded。第二,成本线性飙升——第 N 轮请求,要把前面 N−1 轮全部重发,你为这一轮付的 token 钱,是正比于 N 的。一个聊了 100 轮的对话,光是"重发历史"这一项,消耗的 token 就是个天文数字。
而"换个更大的窗口"治不了这个病。窗口从 8k 换到 128k,你只是把那条红线往后挪了——对话够长,照样撞上去;而成本那一头,窗口越大你越敢堆,只会烧得更快。真正的解法不是"堆得下",而是"别再无脑堆"。
二、token 和上下文窗口到底是什么
要管理上下文,先得能量化它。这一节把两个概念讲清楚:token 和上下文窗口。
token,是大模型处理文本的基本单位。模型不是按"字"或"词"来算的,它把文本切成一个个 token——一个英文单词可能是一个 token,一个汉字大致是一两个 token。你发给模型的所有内容、模型生成的所有内容,都是按 token 计数和计费的。上下文窗口,就是模型单次请求能容纳的 token 总量的上限。关键一点:这个上限,管的是"输入 token + 输出 token"的总和——你发进去的历史占掉的,和模型要生成的回复占掉的,合起来不能超过这个数。
既然一切都按 token 算,那你就必须能在发送前估出一组消息占多少 token。可以用 tiktoken 这个库:
import tiktoken
enc = tiktoken.encoding_for_model("gpt-4o-mini")
def count_tokens(messages: list) -> int:
"""估算一组对话消息总共占多少 token。"""
total = 0
for msg in messages:
# 每条消息除内容本身,还有角色等固定开销,这里粗略 +4
total += len(enc.encode(msg["content"])) + 4
return total
有了 count_tokens,上一节那个抽象的"迟早会爆",就变成了一个能算的数:每次请求前,把 history 丢进去算一下,你就清清楚楚知道它现在占了多少、离窗口上限还有多远。上下文管理的所有手段——丢、压、留——本质上都是围绕这个数做文章:让发给模型的 token,永远稳稳待在窗口上限之下。先看最简单的一种手段:滑动窗口。
三、滑动窗口:只保留最近 N 轮对话
最简单、最直接的上下文管理手段,叫滑动窗口。它的思路朴素得很:对话再长,我也只带最近的 N 轮给模型,更早的,直接丢掉。
这背后有一个朴素但成立的假设:在大多数对话里,离当前最近的几轮,和"用户现在想说什么"关系最紧密;而很久以前那几轮,相关性已经很弱了。所以,只保留最近 N 轮,丢掉更早的,通常不太影响模型接下来的回答。代码也很简单:
def keep_recent_turns(messages: list, max_keep: int = 10) -> list:
"""滑动窗口:system 消息钉住不动,其余只保留最近 max_keep 条。"""
system = [m for m in messages if m["role"] == "system"]
others = [m for m in messages if m["role"] != "system"]
# 只取末尾 max_keep 条,前面更早的对话被整条丢弃
recent = others[-max_keep:]
return system + recent
注意这段代码里一个不能省的细节:它把 system 消息单独拎出来,无论怎么裁,始终保留。因为 system 消息装的是"你是谁、你该怎么做事"这种全局设定——它一旦被当成"旧消息"丢掉,模型就立刻失忆,不知道自己是个客服了。所以滑动窗口的规矩是:system 钉死,只在普通对话消息里做滑动。
滑动窗口的好处是简单、可靠、token 占用恒定——无论聊到第几轮,发出去的永远是"system + 最近 N 轮",大小封顶,永远不会爆。但它的代价也很直白:被丢掉的那些早期对话,是真的丢了。如果用户在第 1 轮报了订单号,聊到第 30 轮,那个订单号早被滑出窗口了,模型再也想不起来。这个"丢了就真没了"的缺陷,要靠下一节的摘要来补。
四、摘要压缩:把旧对话压成一段摘要
滑动窗口的问题是"旧对话被硬丢"。摘要压缩要做的,是给这些旧对话一条体面的退路:不是粗暴删掉,而是在删掉之前,先把它们压缩成一段简短的摘要留下来。
思路是这样的:一段长对话,逐字逐句保留很占 token,但它真正有价值的信息——用户的诉求、报过的关键信息、已经达成的结论——其实没多少。那就调用模型,把这一大段旧对话读一遍,提炼成几句话的摘要。先写这个"压缩器":
def summarize_old_turns(old_messages: list) -> str:
"""把一批旧对话,调用模型压成一段简短摘要。"""
text = "\n".join(f'{m["role"]}: {m["content"]}' for m in old_messages)
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": "把下面的对话压缩成简短摘要,"
"保留关键事实、用户诉求和已确认的结论,丢弃寒暄。"},
{"role": "user", "content": text},
],
)
return resp.choices[0].message.content
有了压缩器,就能搭一个带摘要的对话管理器。它的核心机制是:最近的消息原样保留;一旦最近消息超过上限,就把溢出的那部分旧消息,压进摘要里:
class Conversation:
"""带摘要的对话:历史过长时,把更早的部分滚动压成摘要。"""
def __init__(self, keep_recent: int = 6):
self.keep_recent = keep_recent
self.summary = "" # 旧对话的累计摘要
self.recent = [] # 最近若干条原始消息
def add(self, role: str, content: str):
self.recent.append({"role": role, "content": content})
# 最近消息超过上限,把溢出的旧消息压进摘要
if len(self.recent) > self.keep_recent:
overflow = self.recent[:-self.keep_recent]
self.recent = self.recent[-self.keep_recent:]
# 把"已有摘要 + 这批溢出消息"一起,重新压成新摘要
self.summary = summarize_old_turns(
[{"role": "system", "content": "已有摘要:" + self.summary}]
+ overflow)
这里有个关键设计:压缩不是"一次性"的,而是"滚动"的——每次溢出,都把已有的摘要和新溢出的消息合在一起重新压一遍。这样,无论对话多长,summary 始终是"到目前为止所有旧对话"的一个固定长度的浓缩。这就比滑动窗口强了一截:聊到第 30 轮,第 1 轮报的订单号,虽然原文早被滚走,但它大概率被摘进了 summary 里——它没有彻底消失。
五、混合策略:摘要 + 最近对话 + 关键信息钉死
滑动窗口管"最近",摘要管"旧的",但还有一类信息,这两者都照顾不周:那些极其关键、绝不容许出错的事实——比如用户的身份、正在处理的订单号。把它们交给摘要?摘要是模型生成的,有可能把订单号摘错、摘漏。所以真正可靠的做法是混合策略:三种手段各管一段,再拼到一起。
def build_context(conv: "Conversation", system_prompt: str,
pinned: list) -> list:
"""混合策略:system + 钉死的关键事实 + 旧对话摘要 + 最近原始对话。"""
messages = [{"role": "system", "content": system_prompt}]
# 钉死的关键信息(用户身份、订单号等),原文照搬,永不摘要永不丢
for fact in pinned:
messages.append({"role": "system", "content": "关键信息:" + fact})
# 旧对话的摘要,作为一条 system 消息塞进去
if conv.summary:
messages.append({"role": "system",
"content": "早前对话摘要:" + conv.summary})
# 最近若干轮原始对话,原汁原味
messages.extend(conv.recent)
return messages
这个 build_context 拼出来的上下文,是分层的,每一层职责清楚:system prompt 定身份;pinned 关键信息是原文照搬、绝不经过摘要的硬事实,保证订单号这种东西一个字都不会错;summary 是旧对话的浓缩,管"很久以前聊过的大致内容";recent 是最近几轮的原始对话,管"刚刚在聊什么"。这样,既压住了总 token,又让近期细节、历史脉络、关键事实都各有着落。最后,把它接成一个完整的对话循环:
def chat_managed(conv: Conversation, user_input: str,
pinned: list) -> str:
"""改造后的对话:每轮都重新构建一个受控大小的上下文。"""
conv.add("user", user_input)
messages = build_context(conv, "你是客服助手。", pinned)
resp = client.chat.completions.create(
model="gpt-4o-mini", messages=messages)
answer = resp.choices[0].message.content
conv.add("assistant", answer)
return answer
# 对比第一节的 chat_naive:这里发出去的 messages,大小是【受控】的
# —— 不管聊到第几轮,它永远是 system + 关键信息 + 摘要 + 最近几轮。
对比第一节的 chat_naive:那个版本发出去的 messages 随轮数无限膨胀;这个 chat_managed,每一轮都重新构建一个大小受控的上下文。聊到第 1 轮和第 100 轮,它发出去的 token 量大致是一个常数——这就从根上掐死了"撞窗口"和"成本线性飙升"这两个问题。
六、工程坑:token 预算、消息边界与摘要丢信息
上下文管理的主干都有了,但要把它真正做对,还有几个绕不开的工程坑。
坑 1:必须给"输出"留预算。上下文窗口管的是"输入 + 输出"的总和。如果你把输入塞到几乎顶满窗口,那留给模型生成回复的空间就所剩无几——模型的回答会被硬生生截断,话说一半就没了。所以必须先从总窗口里,给输出划走一块,剩下的才是输入(含历史)的预算。
def allocate_budget(window: int, reserve_output: int = 1024) -> dict:
"""token 预算分配:从总窗口里,先给输出留足空间。"""
if reserve_output >= window:
raise ValueError("输出预留不能超过总窗口")
input_budget = window - reserve_output
return {
"window": window,
"reserve_output": reserve_output, # 留给模型生成回复的空间
"input_budget": input_budget, # 留给输入(含历史)的上限
}
坑 2:裁剪要按消息边界,绝不能切半条。当上下文还是超了预算,需要进一步裁。这里有个致命细节:裁剪的单位必须是"一整条消息",绝不能按 token 数硬切到某条消息中间。切出半条消息,模型读到的是一段没头没尾的残文,轻则答非所问,重则被那段残文带偏。正确做法是:超预算时,从最旧的非 system 消息开始,一整条一整条地丢。
def trim_to_budget(messages: list, input_budget: int) -> list:
"""按 token 预算裁剪:超了就从最旧的非 system 消息开始整条丢弃。"""
system = [m for m in messages if m["role"] == "system"]
others = [m for m in messages if m["role"] != "system"]
while others and count_tokens(system + others) > input_budget:
# 关键:整条丢弃最旧的一条,绝不切半条消息
others.pop(0)
return system + others
坑 3:摘要一定会丢信息,要承认它、而不是假装它无损。摘要是一种有损压缩——它必然丢掉细节。所以两条原则:第一,凡是"错了就出事"的硬信息(订单号、金额、用户身份),绝不交给摘要,走第五节的 pinned 原文钉死;第二,给摘要的 prompt 要明确指出该保留什么(关键事实、诉求、结论)、该丢什么(寒暄、客套),别让模型自由发挥地"瞎概括"。
坑 4:别忘了单条消息本身就可能超长。上面几节默认"一条消息不大"。但用户完全可能一次性粘进来一篇几千字的文档——这单独一条消息就可能撑爆窗口。这种长内容不能直接进对话历史,要单独处理:要么先摘要再入历史,要么走 RAG 那一套——切块、检索,只把相关片段取回来。下面这张图,把一轮对话的上下文管理流程串起来:
关键概念速查
| 概念 / 手段 | 说明 |
|---|---|
| 模型无状态 | 模型不跨请求记忆,想让它记得就必须每轮重发历史 |
| 全量拼接的坑 | 历史只增不减,token 随轮数线性飙升,迟早顶破上下文窗口 |
| token | 模型处理文本的基本单位,输入输出都按 token 计数和计费 |
| 上下文窗口 | 单次请求能容纳的 token 上限,管的是输入加输出的总和 |
| 滑动窗口 | 只保留最近 N 轮对话,token 占用恒定,但旧信息被硬丢 |
| 摘要压缩 | 把溢出的旧对话滚动压成简短摘要,旧信息不彻底消失 |
| 关键信息钉死 | 订单号身份等硬事实原文照搬,绝不经过摘要、绝不被丢弃 |
| 混合策略 | system 加钉死信息 加摘要 加最近对话,四层各管一段 |
| 给输出留预算 | 先从总窗口划走输出空间,否则回复会被硬生生截断 |
| 按消息边界裁剪 | 裁剪以整条消息为单位,绝不切半条留下没头没尾的残文 |
避坑清单
- 大模型是无状态的,想让它记得前文只能每轮重发历史,历史只增不减 token 就只涨不跌。
- 全量拼接历史会让 token 随轮数线性飙升,迟早顶破上下文窗口报 context_length_exceeded。
- 换更大的窗口只是抬高天花板,长对话照样撞,而且窗口越大成本烧得越快,治标不治本。
- 一切按 token 算,发送前先用 tiktoken 估出消息占多少 token,管理才有可量化的依据。
- 上下文窗口管的是输入加输出的总和,必须先给输出划走一块预算,否则回复被截断。
- 滑动窗口只留最近 N 轮 token 恒定,但 system 消息要钉死,只在普通对话里做滑动。
- 滑动窗口会硬丢旧信息,用滚动摘要把溢出的旧对话压成简短摘要,让旧信息不彻底消失。
- 摘要是有损压缩,订单号金额身份这类硬信息绝不交给摘要,走 pinned 原文钉死。
- 裁剪上下文要以整条消息为单位,绝不能按 token 硬切到消息中间留下没头没尾的残文。
- 单条消息本身也可能超长,粘进来的长文档要先摘要或走 RAG,不能直接塞进对话历史。
总结
回头看那次"对话聊久了突然报 token 超限、账单还高得吓人"的事故,以及我后来在上下文管理上接连踩的坑,最该记住的不是某一段裁剪代码,而是我动手前那个想当然的判断——"把所有历史都拼进去,模型才记得住"。这句话错在它把大模型的上下文,理解成了一个"可以无限堆东西的仓库"。可它不是仓库,它是一张大小固定的桌子——桌面就那么大,你能在上面摊开的东西是有上限的;你想往上放新东西,就得先把旧的收走一些。而且模型这张桌子,每次用完就擦干净了——它不替你保管任何东西,下次你想用,得自己重新摆一遍。上下文管理想清楚的,正是这件事:既然桌子有限、又每次清空,那"这一轮该往桌上摆什么",就不能再随手乱堆,而要精心安排。
所以做上下文管理,真正的工程量不在"把消息 append 进列表"那一下。那一下谁都会写,它在你聊三五轮的 Demo 里也确实够用。真正的工程量,在于你有没有为"对话变长之后"做好准备:历史涨到逼近窗口了,你是有预算意识地主动裁,还是等模型报 context_length_exceeded 才发现?旧对话装不下了,你是压成摘要留住要点,还是硬生生丢光?用户第 1 轮报的那个订单号,聊到第 50 轮你还拿得到吗?给模型生成回复的空间,你提前留了吗?这篇文章的几节,其实就是顺着这条思路展开的:先想清楚全量拼接为什么会爆,再看清 token 和窗口到底是什么,然后是滑动窗口、摘要压缩、混合策略这三段主干,最后是 token 预算、消息边界、摘要丢信息这几个把上下文管理真正做对的工程细节。
你会发现,上下文管理的思路,和我们处理一切"空间有限、必须取舍"的问题都是相通的。你出门旅行,行李箱的容积是固定的——你不会把家里所有东西都往里塞,你会分类:护照、钱包这种绝不能丢的,贴身放好(这是 pinned 钉死);最近几天要穿的衣服,原样带上(这是 recent);整个行程的安排,记在一张小纸条上而不是把所有攻略都打印出来(这是 summary);至于"万一用得上"的杂物,狠心不带(这是裁剪)。你做的不是"把箱子换大一号",而是想清楚每一样东西的去留。和大模型对话,正是这个道理:窗口就是那个行李箱,而你,得学会打包。
最后想说,上下文管理做没做扎实,差距永远不会在 Demo 里暴露——Demo 里你聊三五轮就重开,历史短得很,有没有滑动窗口、有没有摘要,跑起来一模一样。它只在真实的、被用户聊了几十上百轮的长对话面前才显形。那时候它会用最难堪的方式给你结账:一个用户和你的机器人深聊了一下午,在某一轮,他刚打出问题,界面却返回一个冰冷的报错——你的接口抛出了 context_length_exceeded,这段对话再也聊不下去了;月底你拉 API 账单,发现绝大部分钱,都烧在了"一遍又一遍重发同一段越来越长的历史"上;还有的用户会困惑地发现,机器人把他开头说过的订单号忘得一干二净,聊到后面像是在和一个失忆的人对话。所以别等长对话把这些问题摆到你面前,在你写下第一个多轮对话循环的时候就该想清楚:历史涨上去,我的请求扛得住吗?旧对话装不下了,我留得住要点吗?关键信息,我钉得牢吗?给模型回复的空间,我留够了吗?这几个问题都有了答案,你的对话机器人才不只是 Demo 里那个聊几轮就重开的样子,而是一个无论用户聊多久、聊多深,都能稳稳记住该记的、从容忘掉该忘的、把每一个 token 都花在刀刃上的可靠系统。
—— 别看了 · 2026