LLM 上下文窗口管理完全指南:从一次"对话变长后机器人开始胡说八道"看懂 token 预算与多轮记忆

2024 年我做一个客服对话机器人用户和机器人多轮对话怎么让模型记得前面聊过的事这件事我没多想就有了方案把历史对话也一起发过去第一版我做得很顺手每来一条新消息就把之前的所有对话历史连同新消息一起拼成一个长长的 prompt 发给模型本地测试聊上五六轮前言后语接得严丝合缝我心里很笃定我把全部历史都给它了它当然什么都记得可等这个机器人真正上线面对会聊得很长的真实用户一串问题冒了出来第一种最先把我打懵对话变长之后机器人开始胡说八道甚至忘记几轮之前刚确认过的事第二种最难缠对话再长一点接口直接报错 context length exceeded 我的 prompt 根本发不出去第三种最头疼每一轮对话的成本都比上一轮高账单像滚雪球一样涨第四种最莫名其妙我把历史粗暴地截断只留最近几条结果机器人忘了对话最开头用户报的订单号和姓名我盯着这一连串问题想了很久才彻底想明白第一版错在一个根本的认知上我以为把全部历史都拼进 prompt 模型就拥有了完整的无限的记忆可大模型并没有记忆它每一次调用都是从零开始的它能记得的只有这一次你塞进 prompt 里的那些 token 而这个 prompt 能有多长被一个叫上下文窗口的东西死死卡住它是模型一次能看到的 token 总量的硬上限是一张固定大小的工作台不是一个无限的抽屉把所有历史无脑往上堆迟早撑爆就算没撑爆模型对窗口正中间那段内容的注意力也会明显下降要把多轮对话做扎实核心不是把历史全给它而是认清上下文窗口是一笔有限的要精打细算去经营的预算真正把上下文窗口管理做扎实核心是学会精确地数 token 学会用滑动窗口裁剪历史又不丢关键信息学会用摘要把旧对话压缩成记忆学会用检索做到按需召回本文从头梳理为什么把全部历史拼进 prompt 迟早会崩怎么精确地数 token 怎么用滑动窗口裁剪怎么用摘要压缩历史怎么用检索按需召回以及一些把它做扎实要避开的工程坑

2024 年我做一个客服对话机器人,用户和机器人多轮对话,我要在每一轮把用户说的话发给大模型,再把模型的回复返回给用户。怎么让模型记得前面聊过的事?这件事我没多想,就有了方案:把历史对话也一起发过去。第一版我做得很顺手——每来一条新消息,我就把这一轮之前的所有对话历史,连同新消息,一起拼成一个长长的 prompt,发给模型。本地测试,我和机器人聊上五六轮,它前言后语接得严丝合缝,记性好得很。我心里很笃定:我把全部历史都给它了,它当然什么都记得,这套对话机器人稳了。可等这个机器人真正上线,面对会聊得很长很长的真实用户,一串问题冒了出来。第一种最先把我打懵:对话变长之后,机器人开始胡说八道,甚至忘记几轮之前刚确认过的事。第二种最难缠:对话再长一点,接口直接报错,说什么 context length exceeded,我的 prompt 根本发不出去。第三种最头疼:每一轮对话的成本都比上一轮高,账单像滚雪球一样涨,因为 prompt 一轮比一轮长。第四种最莫名其妙:我把历史粗暴地截断,只留最近几条,结果机器人忘了对话最开头用户报的订单号和姓名,张口就问"请问您贵姓"。我盯着这一连串问题想了很久,才彻底想明白:第一版错在一个根本的认知上。我以为把全部历史都拼进 prompt,模型就拥有了完整的、无限的记忆。可大模型并没有记忆,它每一次调用都是从零开始的——它能"记得"的,只有这一次你塞进 prompt 里的那些 token。而这个 prompt 能有多长,被一个叫上下文窗口的东西死死卡住:它是模型一次能看到的 token 总量的硬上限,是一张固定大小的工作台,不是一个无限的抽屉。把所有历史无脑往上堆,迟早撑爆;就算没撑爆,模型对窗口正中间那段内容的注意力也会明显下降。要把多轮对话做扎实,核心不是"把历史全给它",而是认清上下文窗口是一笔有限的、要精打细算去经营的预算。本文从头梳理:为什么把全部历史拼进 prompt 迟早会崩,怎么精确地数 token 而不是估字符,怎么用滑动窗口裁剪历史又不丢关键信息,怎么用摘要把旧对话压缩成记忆,怎么用检索做到按需召回,以及一些把它做扎实要避开的工程坑。

问题背景

先把现象说清楚。一个多轮对话应用,本质上是在每一轮请求里,把"模型该知道的一切"组装成一段文本发出去。这段文本包括系统提示、历史对话、当前用户输入,有时还有检索来的资料。新手最自然的做法,就是把历史对话原封不动地全部累加进去——这一版在对话短的时候完全看不出问题,所以很容易让人以为它是对的。

问题出在"长"上。错误认知是:模型有记忆,我把历史给它,它就记住了,历史越全越好。真相是:模型本身无状态,它对话的全部依据,就是当次 prompt 里那些 token;而 prompt 的总长度,被上下文窗口这个硬上限卡住。一旦累加的历史触到这个上限,就不是"效果变差"那么温柔了,而是请求直接失败。具体来说,无脑累加全部历史会同时引发这样几类问题:

  • 请求直接报错:历史累加到超过上下文窗口,接口返回 context length exceeded,这一轮对话根本发不出去。
  • 成本线性膨胀:token 是按量计费的,prompt 每轮变长,每一轮的输入成本都比上一轮高,长对话的账单会失控。
  • 注意力被稀释:就算没超限,窗口里塞得越满,模型对正中间那段内容越容易"看漏",这就是有名的 lost in the middle。
  • 粗暴截断丢关键信息:为了不超限只留最近几条,会把对话开头的订单号、用户身份这些关键事实一起丢掉,模型于是开始"失忆"。

所以上下文窗口管理这件事,要解决的不是"怎么把历史塞得更多",而是"在一个固定大小的预算里,怎么让最该被模型看到的信息留下来"。下面六节,从数 token 开始,一步步把这套预算经营起来。

一、为什么"把全部历史拼进 prompt"迟早会崩

要理解这一版为什么必崩,得先建立一个概念:上下文窗口。大模型处理文本不是按字、按词,而是按 token——一段文本会被切成一串 token 再喂给模型。上下文窗口,就是模型单次调用里输入 token 加输出 token 的总数上限。这个数字是固定的、写死在模型规格里的,比如某个模型是 8192,另一个是 128000。它就是一张工作台的面积:你的系统提示、全部历史、用户输入、还有留给模型回复的空间,统统得摆在这张台子上。

无脑累加全部历史的代码,长这样——它没有任何地方关心过这张工作台还剩多少面积:

# 反面教材:每轮把全部历史无脑拼进 prompt,从不关心长度

class NaiveChat:
    def __init__(self, system_prompt):
        # history 会随对话无限增长,没有任何上限
        self.history = [{"role": "system", "content": system_prompt}]

    def ask(self, user_input, client):
        # 直接把新消息追加到历史尾部
        self.history.append({"role": "user", "content": user_input})

        # 把"目前为止的全部历史"整个发出去
        resp = client.chat(messages=self.history)
        answer = resp["content"]

        # 模型的回复也追加进历史,下一轮会一起再发一遍
        self.history.append({"role": "assistant", "content": answer})
        return answer

# 聊得越久,self.history 越长,每轮发出去的 prompt 越大
# 第 1 轮也许 200 token,第 50 轮可能已经 9000 token
# 一旦越过模型的上下文窗口,client.chat 直接抛错

盯着这段代码,崩溃的必然性就很清楚了。这一版的根本错误,是把"对话历史"当成了一个可以无限增长的东西,却把它发往一个容量固定的接口。对话是会无限变长的,而上下文窗口是写死的,一个无限增长的量去撞一个固定的墙,撞上只是时间问题。更关键的是,模型不像人——人聊久了会自然地忘掉无关细节、记住要点;模型不会,它要么把你给的全部 token 一字不漏地看一遍,要么因为超限而拒绝服务。它没有"自己挑重点"这个能力,挑重点这件事,必须由你的代码来做。所以这一版真正缺的,不是一个更大的模型,而是一层位于你的代码和模型之间的"上下文管理"——它负责在每次调用前,把要发的内容裁剪、压缩、组织到预算之内。这一层,就是后面五节要建的东西。

二、先学会精确地数 token,而不是估算字符数

既然上下文窗口是以 token 计量的硬上限,那么管理它的第一步,必然是能精确地数出"我这段文本到底是多少 token"。很多人这里会偷懒,用"一个汉字约等于多少 token""字符数除以某个系数"去估。估算在中英文混排、有代码、有标点的真实文本里误差很大,而你是在卡一条硬上限——估少了会超限报错,估多了会浪费预算。正确做法是用模型对应的分词器真实地数。

# 用 tiktoken 精确数 token,而不是凭字符数估算
import tiktoken

# 不同模型用不同的编码,必须按模型取对应的 encoding
_ENCODINGS = {}

def get_encoding(model: str):
    if model not in _ENCODINGS:
        try:
            _ENCODINGS[model] = tiktoken.encoding_for_model(model)
        except KeyError:
            # 模型未知时退回到一个通用编码
            _ENCODINGS[model] = tiktoken.get_encoding("cl100k_base")
    return _ENCODINGS[model]

def count_tokens(text: str, model: str = "gpt-4o-mini") -> int:
    enc = get_encoding(model)
    return len(enc.encode(text))

def count_messages_tokens(messages: list, model: str = "gpt-4o-mini") -> int:
    # 一条消息除了 content,role 和分隔符也占 token
    # 这里用每条 +4 token 作为结构开销的近似
    total = 0
    for m in messages:
        total += count_tokens(m["content"], model) + 4
    total += 2  # 整个 prompt 收尾的固定开销
    return total

sample = "把上下文窗口当成预算来经营"
print(count_tokens(sample))            # 真实 token 数,而非 13 个字符

有了数 token 的能力,就能把"预算"这个抽象的词,变成代码里一个能比较的数字。一次调用的 token 预算,要这样拆开看:窗口总量,减去你想留给模型输出的额度,减去系统提示固定占用的额度,剩下的才是真正能分给"历史对话"的预算。

# 把上下文窗口拆成几块预算,算出"留给历史"的额度

class TokenBudget:
    def __init__(self, model: str, window: int, reserve_output: int):
        self.model = model
        self.window = window               # 模型上下文窗口总量
        self.reserve_output = reserve_output  # 必须为模型回复预留

    def history_budget(self, system_prompt: str, user_input: str) -> int:
        fixed = count_tokens(system_prompt, self.model) \
              + count_tokens(user_input, self.model)
        # 窗口 - 输出预留 - 系统提示 - 当前输入 = 能给历史的额度
        budget = self.window - self.reserve_output - fixed
        return max(budget, 0)

budget = TokenBudget(model="gpt-4o-mini", window=128000, reserve_output=1024)
room = budget.history_budget("你是一个客服助手", "我的订单还没发货")
print(f"这一轮能分给历史对话的 token 预算:{room}")

这一节真正要建立的认知是:token 是这套系统里唯一精确的计量单位,一切裁剪、压缩的决策,都必须建立在"数得准"之上。用字符数估算之所以危险,是因为它让你在一条会直接导致请求失败的硬边界附近做模糊判断。而且要特别记住一件事:窗口是输入和输出共享的——你必须主动从预算里划走一块留给模型回复,否则会出现一种很隐蔽的故障:输入没超限,但模型刚说几个字就因为没有输出空间而被截断。把预算显式地拆成"输出预留 + 系统提示 + 历史 + 当前输入"这几块,后面每一种裁剪策略,本质上都是在"历史"这一块的额度内做文章。

三、滑动窗口裁剪:保留最近 N 轮,但要钉住关键信息

有了预算数字,最直接的裁剪策略是滑动窗口:对话历史只保留最近的若干轮,更早的丢掉。这背后的假设是"越近的对话越相关",这个假设在闲聊类场景里大致成立。但直接按"最近 N 条"砍会砍出问题——系统提示在历史最前面,会被一起砍掉;对话开头用户报的订单号、姓名这类关键事实,也会被砍掉。所以正确的滑动窗口,要分三种消息区别对待:系统提示永远保留,被标记为关键的信息永远保留,剩下的普通对话才参与"按预算从新到旧保留"。

# 滑动窗口裁剪:钉住 system 与 pinned,普通对话按预算从新到旧保留

def trim_history(messages: list, model: str, budget_tokens: int) -> list:
    system_msgs = [m for m in messages if m["role"] == "system"]
    pinned_msgs = [m for m in messages if m.get("pinned")]
    # 普通对话:既不是 system 也没被 pinned
    normal_msgs = [m for m in messages
                   if m["role"] != "system" and not m.get("pinned")]

    # 钉住的内容先占掉预算
    used = count_messages_tokens(system_msgs + pinned_msgs, model)
    kept_normal = []

    # 从最新一条往回遍历,能放下就放,放不下就停
    for m in reversed(normal_msgs):
        cost = count_tokens(m["content"], model) + 4
        if used + cost > budget_tokens:
            break
        kept_normal.append(m)
        used += cost

    kept_normal.reverse()  # 恢复成时间正序
    # 最终顺序:系统提示 -> 钉住的关键信息 -> 保留下来的最近对话
    return system_msgs + pinned_msgs + kept_normal

关键在于那个 pinned 标记。它不是凭空来的——当对话里出现订单号、用户身份、明确的需求约束这类"后面每一轮都还用得上"的事实,你的应用层应该在那条消息上打 pinned 标记,让它脱离"会被滑动窗口冲走"的普通对话队列。

# 在消息进入历史时,识别并钉住关键事实
import re

# 命中这些模式的用户消息,视为含关键信息,需要长期保留
_KEY_PATTERNS = [
    re.compile(r"订单号[::]?\s*([A-Za-z0-9]{6,})"),
    re.compile(r"我(叫|是)([一-鿿]{2,4})"),
]

def make_user_message(text: str) -> dict:
    msg = {"role": "user", "content": text}
    for pat in _KEY_PATTERNS:
        if pat.search(text):
            msg["pinned"] = True   # 钉住:滑动窗口不会冲走它
            break
    return msg

m1 = make_user_message("你好,我的订单号 A1B2C3D4 一直没发货")
m2 = make_user_message("那大概什么时候能到呢")
print(m1.get("pinned"), m2.get("pinned"))   # True None

这一节的认知是:滑动窗口不是"砍掉旧的"这么简单,而是要区分"旧但无关"和"旧但关键"。粗暴的按条截断之所以让机器人失忆,是因为它把这两者混为一谈,一刀切掉。真实对话里,信息的重要性和它的新旧并不挂钩——用户在第一句话里报的订单号,可能是整场对话里最重要的一条信息,它恰恰最旧,也最容易被滑动窗口冲走。所以滑动窗口必须配一个"钉住"机制:让你的代码有能力把某些信息从"按时间淘汰"的规则里豁免出去。这个豁免清单怎么维护,是体现一个对话应用做得用不用心的地方——可以靠正则匹配,可以靠模型抽取,也可以靠业务系统直接注入(比如登录用户的身份信息),但绝不能没有。

四、用摘要压缩历史:把旧对话滚动总结成一段记忆

滑动窗口有个硬伤:被它丢掉的旧对话,信息就彻底没了。如果一段旧对话里既没有能被正则识别的关键字段,又确实包含了对后续有用的背景(比如用户描述过的一个复杂故障现象),滑动窗口会把它整段丢弃。更好的办法是:不直接丢,而是把旧对话交给模型,压缩成一段简短的摘要,用摘要顶替掉原始的长对话。这样信息密度高了,token 少了,背景还在。

# 把一段旧对话丢给模型,压缩成一段简短的摘要记忆

SUMMARY_PROMPT = (
    "下面是一段客服对话历史。请用第三人称,简洁地总结其中"
    "对后续对话仍然有用的事实:用户是谁、遇到了什么问题、"
    "已经确认或承诺过什么。只输出总结,不要寒暄。"
)

def summarize_old_messages(old_messages: list, client, model: str) -> dict:
    # 把要压缩的旧对话拼成纯文本
    transcript = "\n".join(
        f'{m["role"]}: {m["content"]}' for m in old_messages
    )
    resp = client.chat(messages=[
        {"role": "system", "content": SUMMARY_PROMPT},
        {"role": "user", "content": transcript},
    ])
    # 摘要作为一条 system 消息回填,并钉住,后续不再被裁剪
    return {
        "role": "system",
        "content": "【历史对话摘要】" + resp["content"],
        "pinned": True,
    }

把摘要和滑动窗口组合起来,就得到一个完整的对话记忆管理器:它持有全部消息,但每次要发送时,先判断历史是否超预算;超了,就把较旧的一批对话压缩成摘要,再对剩下的做滑动窗口裁剪。这就是所谓的"滚动摘要"——对话越长,越早的内容被压缩得越狠,但从不彻底消失。

# 整合裁剪与摘要的对话记忆管理器

class ConversationMemory:
    def __init__(self, client, model, window, reserve_output,
                 summary_trigger_ratio=0.6):
        self.client = client
        self.model = model
        self.budget = TokenBudget(model, window, reserve_output)
        # 历史占用超过该比例的预算时,触发一次摘要压缩
        self.summary_trigger_ratio = summary_trigger_ratio
        self.messages = []

    def add(self, msg: dict):
        self.messages.append(msg)

    def build_prompt(self, system_prompt: str, user_input: str) -> list:
        room = self.budget.history_budget(system_prompt, user_input)
        history = list(self.messages)
        used = count_messages_tokens(history, self.model)

        # 历史过长:把最旧的一半普通对话压缩成摘要
        if used > room * self.summary_trigger_ratio:
            normal = [m for m in history if not m.get("pinned")]
            half = len(normal) // 2
            if half >= 2:
                summary = summarize_old_messages(
                    normal[:half], self.client, self.model)
                # 用摘要替换掉被压缩的那批旧消息
                survivors = normal[half:] + \
                    [m for m in history if m.get("pinned")]
                self.messages = [summary] + survivors
                history = self.messages

        trimmed = trim_history(history, self.model, room)
        return ([{"role": "system", "content": system_prompt}]
                + trimmed
                + [{"role": "user", "content": user_input}])

这一节的认知是:摘要的本质,是用一次额外的模型调用,换取上下文窗口里的空间。这是一笔交易,要算清楚它的成本和收益。成本是:每次压缩都要多调一次模型,有延迟、有费用;而且摘要是有损的,模型可能在压缩时丢掉某个后来才发现重要的细节。收益是:长对话不再撞窗口上限,长程的背景信息以高密度的形式保留了下来。所以摘要不该每轮都做,而要设一个触发阈值——平时走便宜的滑动窗口,只有当历史确实涨到威胁预算时,才掏钱做一次压缩。还要注意,摘要可以滚动叠加:新的摘要可以把"上一版摘要 + 这一批新的旧对话"一起再压一遍。这样无论对话多长,那段总结性的记忆始终被控制在一个小尺寸内。

把数 token、滑动窗口、摘要压缩这三步串起来,每一轮对话组装 prompt 的决策流程,就是下面这张图:

[mermaid]
flowchart TD
A[新用户消息进来] --> B[把消息加入完整历史]
B --> C[计算这一轮留给历史的 token 预算]
C --> D[数一遍当前历史占用的 token]
D --> E{历史是否超过预算阈值}
E -->|未超过| F[只做滑动窗口裁剪]
E -->|已超过| G[把较旧的对话压缩成摘要]
G --> F
F --> H[组装 system 加历史加当前输入]
H --> I[校验总量在窗口内 发送给模型]

五、不是所有历史都要进 prompt:按需检索召回

滑动窗口和摘要,处理的都是"连续的对话流"。但有一类场景它们都不够用:对话里需要参考的,是一份很长的外部资料——产品手册、政策文档、用户的历史工单。这些东西又长又大,全塞进 prompt 一定超窗口;做成摘要又会丢掉细节,而用户的问题可能恰恰需要某个具体细节。这时正确的思路是反过来的:不是"默认全带上,放不下再砍",而是"默认都不带,用到哪段才召回哪段"。这就是检索增强:把长资料切成小块、存进向量库,每一轮根据用户当前的问题,只召回最相关的几块拼进 prompt。

# 按需检索:每轮只把与当前问题相关的资料片段拼进 prompt

class RetrievalContext:
    def __init__(self, vector_store, model, max_chunks=3,
                 max_tokens=1200):
        self.store = vector_store      # 已建好索引的向量库
        self.model = model
        self.max_chunks = max_chunks    # 单轮最多召回几块
        self.max_tokens = max_tokens    # 召回内容的 token 上限

    def retrieve(self, query: str) -> str:
        # 按相似度取回候选片段
        chunks = self.store.search(query, top_k=self.max_chunks * 2)
        picked, used = [], 0
        for ch in chunks:
            cost = count_tokens(ch.text, self.model)
            if used + cost > self.max_tokens:
                break
            picked.append(ch.text)
            used += cost
            if len(picked) >= self.max_chunks:
                break
        if not picked:
            return ""
        return "【相关资料】\n" + "\n---\n".join(picked)

def build_with_retrieval(system_prompt, user_input, memory,
                         retriever) -> list:
    # 只召回与当前问题相关的资料,而不是把整份文档塞进去
    evidence = retriever.retrieve(user_input)
    sys_full = system_prompt
    if evidence:
        sys_full = system_prompt + "\n\n" + evidence
    return memory.build_prompt(sys_full, user_input)

这一节的认知是:上下文窗口管理的终极思路,是从"携带"转向"召回"。滑动窗口和摘要,骨子里还是"我先把东西都拿着,装不下再想办法",这在对话历史这种规模可控的数据上行得通。可一旦要参考的知识体量远大于窗口,"携带"这条路就彻底走不通了——再怎么压缩,也压不下一本手册。检索的思路是:知识不放在 prompt 里,放在 prompt 外的存储里;prompt 里只放一个"当前这一刻用得上"的最小子集。这样窗口的占用,不再随知识总量增长,只随"单轮问题的复杂度"波动。一个成熟的对话应用,往往是这两种思路的混合:近期对话用滑动窗口和摘要原样携带,长期知识和大段资料用检索按需召回。判断一段信息该走哪条路,标准很简单——它是否每一轮都可能用到。每轮都用的,携带;偶尔才用的,召回。

六、把上下文窗口管理做扎实,要避开的工程坑

前面五节搭出了一套能用的上下文管理。但要真正在生产里稳住,还有几个坑得专门讲。第一个是 lost in the middle:模型对上下文窗口里的信息,注意力并不均匀,开头和结尾记得牢,正中间那段最容易被忽略。所以重要的信息——系统指令、关键事实、用户当前的真实问题——不要埋在一长串历史的正中间,要放在 prompt 的开头或结尾。

# 对抗 lost in the middle:把关键信息放到 prompt 的两头

def reorder_for_attention(system_prompt, history, key_facts,
                          user_input) -> list:
    # 开头:系统提示 + 关键事实(模型注意力最强的位置之一)
    head = [{"role": "system",
             "content": system_prompt + "\n【务必记住】" + key_facts}]
    # 中间:大段的普通历史对话(允许注意力相对弱)
    middle = history
    # 结尾:用户当前问题 + 一句简短的关键事实复述(另一个强注意力位置)
    tail = [{"role": "user",
             "content": user_input + f"\n(背景提醒:{key_facts})"}]
    return head + middle + tail

第二个坑是输出预算被忘掉。前面强调过窗口是输入输出共享的,这里再给一个必须有的最终保险:在真正发送前,做一次硬校验,确认"输入 token + 计划留给输出的 token"没有越过窗口。这道校验是最后一道闸,它之前的裁剪和摘要都可能有近似误差,这里必须用真实的数字兜底。

# 发送前的最后一道硬校验:确保输入与输出预留都在窗口内

def assert_within_window(messages, model, window, reserve_output):
    input_tokens = count_messages_tokens(messages, model)
    if input_tokens + reserve_output > window:
        overflow = input_tokens + reserve_output - window
        raise ValueError(
            f"prompt 超出窗口 {overflow} token,"
            f"输入 {input_tokens},输出预留 {reserve_output},"
            f"窗口 {window};请检查裁剪逻辑"
        )
    return input_tokens

# 这道校验放在 client.chat 调用之前,任何时候都不应被跳过

第三个坑是关于成本和缓存。很多模型 API 支持 prompt 缓存:prompt 里不变的前缀(比如固定的系统提示、稳定的摘要),命中缓存后会便宜很多。这意味着你裁剪历史时,要尽量让 prompt 的前缀保持稳定——如果每轮都把摘要重写、把系统提示变来变去,缓存就一直命不中。把这几类信息的预算分配理清楚,是把成本压下来的关键,可以用一张表来固化这个分配:

预算分配示意(以一个 128000 窗口的模型为例)

  区块            额度        是否稳定    走缓存
  系统提示        ~500        稳定        是
  滚动摘要        ~1500       较稳定      是
  钉住的关键信息  ~500        增量变化    部分
  滑动窗口对话    ~8000       每轮变化    否
  检索召回资料    ~1200       每轮变化    否
  当前用户输入    ~500        每轮变化    否
  ----------------------------------------------
  输出预留        ~1024       —           —
  剩余安全余量    其余        —           —

  原则:稳定的前缀尽量靠前且不改动,以命中缓存;
        每轮变化的内容集中放在尾部。

第四个坑,也是最容易被忽略的:不要等到撞上限才处理。很多人把裁剪逻辑写成"超了就砍",结果系统长期运行在窗口的边缘,稍有波动就报错。正确的做法是设一个软阈值(比如窗口的 70%),一旦历史触到软阈值就主动开始摘要、裁剪,给真实上限留出一段从容的缓冲。这一节这几个坑,串起来是同一个道理:上下文窗口管理不是一段"出错时才跑"的补救代码,而是一套每一轮都在运行的常规调度。它要持续地数 token、持续地决定什么留什么走、持续地把重要信息摆到注意力强的位置。把它当成异常处理来写,系统就总在崩溃边缘;把它当成常规调度来写,系统才稳。

关键概念速查

概念 说明
上下文窗口 模型单次调用能处理的 token 总量硬上限,输入与输出共享这个额度
token 模型处理文本的基本计量单位,文本会被分词器切成 token 序列
tiktoken 按模型对应编码精确数 token 的工具,用来替代不可靠的字符数估算
token 预算 窗口减去输出预留、系统提示后,真正能分给历史与资料的额度
滑动窗口 只保留最近若干轮对话的裁剪策略,需配合钉住机制保护关键信息
pinned 钉住 把订单号、身份等关键事实标记为不可裁剪,豁免于按时间淘汰
滚动摘要 把较旧的对话交给模型压缩成简短摘要,用摘要顶替原始长对话
检索召回 知识存在 prompt 外,每轮按当前问题只召回最相关的片段拼入
lost in the middle 模型对窗口正中间内容注意力下降的现象,重要信息应放在两头
软阈值 低于真实上限的预警线,触到即主动裁剪,避免长期运行在边缘

避坑清单

  1. 不要把全部历史无脑拼进 prompt:对话会无限变长,上下文窗口是固定上限,迟早撞墙。
  2. 不要用字符数估算 token:在一条会导致请求失败的硬边界附近做模糊判断很危险,用 tiktoken 精确数。
  3. 不要忘记给输出预留额度:窗口是输入输出共享的,不预留会导致模型回复刚开头就被截断。
  4. 不要按"最近 N 条"粗暴截断:会把对话开头的订单号、用户身份等关键事实一起丢掉。
  5. 不要让滑动窗口冲走关键信息:对订单号、身份这类长期有用的事实打 pinned 标记,豁免裁剪。
  6. 不要每轮都做摘要:摘要要多调一次模型、有损耗,设触发阈值,平时走便宜的滑动窗口。
  7. 不要把大段长资料塞进 prompt:体量远大于窗口的知识应存进向量库,按需检索召回。
  8. 不要把重要信息埋在历史正中间:模型对窗口中段注意力弱,关键内容放 prompt 开头或结尾。
  9. 不要让 prompt 前缀频繁变动:稳定的系统提示、摘要靠前放,才能命中 prompt 缓存省成本。
  10. 不要等撞上限才裁剪:设一个软阈值提前触发裁剪,给真实窗口上限留出从容的缓冲。

总结

回头看第一版那个把全部历史拼进 prompt 的对话机器人,它错得其实很典型。它的错误不在某一行代码,而在一个对大模型的根本误解:以为模型有记忆,以为把历史给它就等于让它记住,以为历史越全效果越好。真相是,模型每次调用都是无状态的,它能依据的只有当次 prompt 里的 token,而这个 prompt 被上下文窗口死死卡住。一个无限增长的对话历史,撞上一个容量固定的窗口,崩溃是必然的。

而把这件事做对,工程量并不小。它不是加一个 if 判断,而是要建一整层位于你的代码和模型之间的上下文管理:精确地数 token,把窗口拆成预算,用滑动窗口裁剪近期对话,用钉住机制保护关键事实,用滚动摘要压缩旧历史,用检索召回处理大段知识,还要对抗 lost in the middle、留好输出余量、用好缓存。这一层不是模型送给你的,是要你自己一节一节搭出来的。

这件事其实很像一个人收拾自己有限的办公桌面。桌子就那么大,放不下你所有的文件。聪明的做法不是抱怨桌子小,而是经营它:最常用的几样东西摆在手边最顺眼的位置,正在处理的文件摊在正中间,处理完的旧文件归纳成一页便签塞进抽屉,真要用到某份资料时再去文件柜里取。上下文窗口管理,做的就是这件事——系统提示和关键事实摆在注意力最强的两头,近期对话留在中间,旧对话压成摘要,长资料放进"文件柜"按需去取。

这类问题有一个共同的麻烦:它在本地几乎暴露不出来。你自己测对话机器人,聊个五六轮就觉得"挺好,记性不错"——而五六轮的历史离上下文窗口的上限还差得远,所有的裂缝都还没张开。真正会聊上几十轮、几百轮的,是上线后那些真实的用户。所以如果你正在做一个多轮对话或带长文档的 AI 应用,别等线上报出 context length exceeded 才回头补这一层。在它还只聊五六轮、一切看起来都很好的时候,就把数 token、裁剪、摘要、检索这套预算经营的机制搭起来——这是这篇文章最想留给你的一句话。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

分布式锁完全指南:从一次"多实例部署后库存被超卖"看懂为什么单机锁挡不住分布式并发

2026-5-22 19:54:56

技术教程

数据库连接池完全指南:从一次"上线后数据库报 too many connections"看懂连接为什么不能即取即用

2026-5-22 20:05:31

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索