大模型上下文窗口完全指南:从一次"AI 聊着聊着就失忆"看懂 token 与上下文管理

2024 年我做一个多轮对话客服 AI,短对话一切正常,可只要用户聊久一点——来回二三十轮——AI 就开始"失忆":用户第一句报过订单号,聊到后面又来问"请问您的订单号是多少";更糟时直接 API 报错 context_length_exceeded 整个对话崩掉。我以为是模型记性差,换了上下文窗口更大的模型,清净几天又犯。盯日志才反应过来:我每轮请求都是把"从第一句到现在的全部对话历史"原封不动拼成 prompt 发过去,对话越长 prompt 越长,token 一路单调累积,迟早撑爆模型一次能"看见"的上限。换大窗口只是抬高上限、推迟发作,累积趋势没变。本文把 token 和上下文窗口从头梳理:token 是模型处理文本的最小单位、中文一字常占 1-2 个 token;上下文窗口是输入输出共享的总额度;大模型 API 是无状态的,多轮对话靠你每轮重发完整历史,token 因此结构性累积;怎么用 tiktoken 精确数 token、别漏每条消息的固定格式开销;放不下时两种解法——截断(保 system+最近几轮、从最老成对丢,但会连信息一起丢)和摘要压缩(老历史用一次 LLM 调用压成只含事实的要点,保住信息又省一两个数量级 token);最后覆盖 lost in the middle(关键信息别埋中间)、必须给输出留 token 预算、流式不改变 token 账、token 直接等于钱和延迟四个工程坑。核心一句:多轮对话的"记忆"不是模型的能力,而是你的代码每轮维护出来的假象,真正要管的是 token 而非模型记性。

2024 年我做一个多轮对话的客服 AI。短对话一切正常,可只要用户聊得久一点——来回问了二三十轮——AI 就开始"失忆":用户明明在第一句就报过订单号,聊到后面 AI 又来一句"请问您的订单号是多少";更糟的时候直接 API 报错 context_length_exceeded,整个对话当场崩掉。我一开始以为是模型"记性差",换了个上下文窗口更大的模型,清净了几天,对话再长一点又犯。我盯着请求日志看了很久才反应过来:我每一轮请求,都是把"从第一句到现在的全部对话历史"原封不动拼成 prompt 发给模型。对话越长,这个 prompt 越长,token 数一路单调累积,迟早撑爆模型一次能"看见"的那个上限——上下文窗口。换大窗口的模型,只是把这个上限抬高了,token 累积的趋势一点没变,所以不过是晚几天再犯病。那次之后我才认真搞懂一件事:做多轮对话,真正要管的根本不是"模型记性好不好",而是"每一轮我到底往上下文里塞了多少 token、还能塞多少、塞不下的怎么办"。这篇文章就把 token 和上下文窗口从头梳理一遍:token 到底是什么、上下文窗口为什么会被撑爆、怎么精确地数 token、放不下了怎么截断和压缩,以及那些不注意就会踩的工程坑。

问题背景

先把那次"失忆"事故的现象和我的误判讲清楚,后面所有的方案都是冲着纠正这个误判去的。

现象:多轮对话客服 AI,短对话正常;对话轮次一多,AI 会忘掉开头用户提供的关键信息,或直接 API 报错 context_length_exceeded。换上下文窗口更大的模型能缓解几天,对话再长又复发。

我当时的错误认知:"多轮对话嘛,把历史都发给模型,模型自己会记住、会从里面找需要的信息;它忘事就是模型不够强,换个更强的就好。"

真相:大模型的 API 是无状态的——它根本不"记得"上一轮,所谓多轮对话,是你每一轮都把完整历史重新发了一遍。模型一次能处理的 token 是有硬上限的(上下文窗口),而多轮对话的历史 token 是单调累积的。所谓"失忆",要么是历史被你或被 API 截断了,要么是直接超限报错。这是一个结构性的容量问题,不是模型的"记性"问题,换大模型只是推迟它发作。

要把多轮对话做稳,需要几块认知:

  • token 和上下文窗口到底是什么,窗口为什么同时约束输入和输出;
  • 多轮对话的 token 为什么会一路累积,这是结构性的还是 bug;
  • 怎么精确地数 token,为什么不能用字数估算;
  • 历史放不下时,怎么截断、怎么用摘要压缩;
  • lost in the middle、输出预算这些工程坑怎么绕。

一、token 与上下文窗口:模型不按字数算账

要理解窗口为什么会爆,先得理解模型处理文本的两个基本单位:token 和上下文窗口。

token 是模型处理文本的最小单位。模型不是按"字"或"词"来处理文本的,而是按 token。一段文本会先被分词器(tokenizer)切成一串 token,模型再处理这串 token。粗略地说,英文里大约 4 个字符、或 0.75 个单词是 1 个 token;中文则更"贵"——一个汉字常常就要占 1 到 2 个 token。所以"这篇文章有多少字"和"这篇文章有多少 token"是两码事,而模型只认后者。

上下文窗口是模型一次能处理的 token 总数上限。每个模型都有一个固定的上下文窗口大小(比如 8k、128k、200k token)。最关键、也最容易被忽略的一点是:这个窗口是输入和输出共享的。也就是说,你发过去的 prompt(所有历史消息)的 token 数,加上你希望模型生成的回答的 token 数,两者之和必须小于等于上下文窗口。窗口不是"输入额度",是"输入 + 输出"的总额度。很多人算容量时只盯着输入,结果历史刚好塞满窗口,模型连一个字的回答都吐不出来。

记住这个不等式,后面所有的预算计算都基于它:

输入 token(system + 全部历史消息) + 输出 token(模型的回答) ≤ 上下文窗口

举例:一个 128k 窗口的模型
  - 如果你的历史已经累积到 127k token
  - 那留给模型回答的空间只剩 1k token —— 回答会被严重截断
  - 如果历史到了 128k —— 直接 context_length_exceeded 报错

二、token 为什么会累积:多轮对话是每轮重发全部历史

知道了窗口有上限,下一个问题是:为什么对话越长越容易爆?根源在于大模型 API 的一个本质特性——它是无状态的

很多人(包括当年的我)默认模型像人一样,聊到第十轮时还"记得"第一轮说过什么。其实不是。每一次 API 调用都是完全独立的,模型这次调用根本不知道上次调用发生过什么。那"多轮对话"是怎么实现的?答案朴素得有点意外:是你的代码,每一轮都把从头到现在的完整对话历史,重新拼进 prompt 发过去。模型的"记忆",其实是你每轮亲手喂给它的。

这就解释了 token 为什么会累积。第 1 轮,你发的是 system + user1;第 2 轮,你发的是 system + user1 + assistant1 + user2;第 N 轮,你发的是 system 加上前面所有轮次的全部消息,再加上这一轮的 user。历史只增不减,token 数自然单调上涨。再叠加一个细节:每条消息除了内容本身,还有固定的格式开销 token(role 标记、消息分隔符等),消息条数越多,这部分开销也越大。所以 token 撑爆窗口不是某个 bug,而是"每轮重发全量历史"这个机制必然导致的结构性结果——你不主动管理它,它就一定会爆。

下面用代码把"无状态"和"累积"这两件事直观地呈现出来——注意每轮的 messages 是怎么越拼越长的:

# 大模型 API 是无状态的:每轮都要把完整历史重新发过去。
# history 这个列表,就是你为模型"代为保管"的全部记忆。

history = [{"role": "system", "content": "你是客服助手。"}]


def chat_turn(client, user_input: str) -> str:
    """一轮对话:把完整历史 + 本轮输入一起发出去。"""
    history.append({"role": "user", "content": user_input})

    # 关键:发出去的是整个 history,不是单条消息 —— 它每轮都更长
    resp = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=history,          # 第 30 轮时,这里可能有 60+ 条消息
    )
    answer = resp.choices[0].message.content

    # 模型的回答也要存回历史,下一轮还得带上它
    history.append({"role": "assistant", "content": answer})
    return answer

# 对话进行得越久,history 越长,每轮发出去的 token 就越多 —— 单调累积

三、精确数 token:用分词器,别用字数估算

既然 token 是要管理的核心资源,第一步就得能准确地"数"出来。一个常见的错误做法是用"字数 × 某个系数"来估算——这在中英文混排、含代码、含特殊符号的真实对话里误差极大,而你要做的是卡一个硬上限,估算不准就会要么白白浪费窗口、要么意外超限。

正确做法是用模型对应的分词器精确计算。OpenAI 系列模型用官方的 tiktoken 库,它能给出和模型实际看到的完全一致的 token 数。注意:不同模型家族的分词方式不同,要加载和你目标模型匹配的编码器。下面是数单段文本的 token:

import tiktoken

# 加载与目标模型匹配的编码器(不同模型分词规则不同)
enc = tiktoken.encoding_for_model("gpt-4o-mini")


def count_tokens(text: str) -> int:
    """精确计算一段文本的 token 数。"""
    return len(enc.encode(text))


print(count_tokens("Hello, world!"))   # 英文:4 个字符约 1 token
print(count_tokens("你好,世界,今天天气不错"))  # 中文:每字往往 1-2 token
# 同样的"字数",中文文本的 token 数通常明显高于英文

但真正要数的不是单段文本,而是一个完整的 messages 列表的总 token。这里有个细节绝不能漏:每条消息除了 content,还有固定的格式开销——role 字段、消息之间的分隔标记等。只把每条消息的 content 加起来,会系统性地低估几个到十几个百分点。下面这个函数把这部分开销也算进去:

def count_message_tokens(messages: list[dict]) -> int:
    """计算一个 messages 列表的总 token 数。
    每条消息除内容外,还有固定的格式开销,必须算进去。"""
    total = 0
    for msg in messages:
        total += 4                              # 每条消息固定开销(role/分隔符)
        total += count_tokens(msg["role"])
        total += count_tokens(msg["content"])
    total += 2                                  # 整个对话的收尾开销
    return total


# 用它就能在每轮发请求前,先知道这次会消耗多少输入 token
used = count_message_tokens(history)
print(f"当前历史已占用 {used} 个 token")

有了精确的 token 计数,你才能在每轮发请求前就知道这次会消耗多少输入 token——这是后面所有截断、压缩决策的基础。

四、放不下了怎么办之一:截断历史

能数 token 了,接下来就是真正的问题:当历史 token 逼近上限,该怎么办?最直接的办法是截断——主动丢掉一部分老历史,把 token 压回预算之内。

截断不能瞎丢,有两条铁律。第一,system 消息永远保留。它定义了 AI 的角色、规则、语气,丢了它整个对话的"人设"就崩了。第二,最近的几轮永远保留。用户当前正在聊的话题、刚说的上下文,全在最近这几轮里,这是对话能接得上的命脉。所以截断真正要丢的,是中间偏老的那部分——既不是 system,也不是最近几轮。还有个容易忽略的细节:对话是一问一答成对的,丢的时候要成对地丢(一个 user 配一个 assistant),只丢半轮会让历史变得不连贯,模型容易被带歪。

下面这个函数实现"保 system + 保最近若干轮、从最老开始成对丢弃"的截断逻辑:

def truncate_history(messages: list[dict], max_tokens: int) -> list[dict]:
    """token 超预算时:保留 system + 尽量多的最近对话,从最老的开始丢。"""
    system = [m for m in messages if m["role"] == "system"]
    convo = [m for m in messages if m["role"] != "system"]

    # 预算先扣掉 system 的固定占用,剩下的留给对话历史
    budget = max_tokens - count_message_tokens(system)

    # 从最新的消息往最老的方向保留,累计不超过预算
    kept: list[dict] = []
    for msg in reversed(convo):
        cost = count_message_tokens([msg])
        if budget - cost < 0:
            break               # 再加就超了,停止保留
        kept.append(msg)
        budget -= cost

    kept.reverse()              # 恢复成时间正序
    return system + kept

截断简单可靠,但它有一个致命的副作用:被丢掉的老对话里,如果藏着关键事实,那就真的丢了。用户在第二轮报的订单号、在第五轮确认过的诉求,一旦那几轮被截断,模型就再也看不到——这正是开头"AI 失忆"的另一种成因。所以截断适合做兜底,但不能是唯一手段。

五、放不下了怎么办之二:用摘要压缩历史

截断的硬伤是"信息随轮次一起被丢"。更聪明的办法是压缩:被丢弃的老历史,不直接扔掉,而是先用一次额外的 LLM 调用,把它压成一段简短的摘要,作为一条消息保留在上下文里。

这个思路的精髓在于:对话里真正重要的是事实(用户是谁、什么订单、确认了什么诉求),而不是原话。一段几千 token 的寒暄式对话,提炼出的关键事实可能只有几十 token。摘要保住了事实,却把 token 占用压下去了一两个数量级。实践中常用的策略是分层:最近 N 轮保留原文(细节完整,接得上当前话题),更早的历史压成摘要(只留事实,极省 token)。代价是每次压缩多一次 LLM 调用,而且摘要本身可能丢失一些细节——所以"最近 N 轮留原文"这个保底很重要。

先看怎么取"最近 N 轮",注意要保持问答成对:

def recent_turns(messages: list[dict], n_turns: int) -> list[dict]:
    """取最近 n 轮对话(1 轮 = user + assistant 各一条),成对返回。"""
    convo = [m for m in messages if m["role"] != "system"]
    # 每轮 2 条消息,最近 n 轮就是末尾的 2*n 条
    return convo[-2 * n_turns:] if n_turns > 0 else []

再看核心的压缩函数——把一批老对话喂给模型,让它提炼成只含事实的摘要:

def summarize_old_turns(client, old_messages: list[dict]) -> dict:
    """把一批老对话压成一条摘要消息:保留关键事实,丢弃寒暄。"""
    # 把老对话拼成纯文本,交给模型去提炼
    transcript = "\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": "把下面的对话压缩成要点,只保留事实性信息"
                        "(用户身份、订单号、已确认的诉求和结论),"
                        "省略所有寒暄和重复内容,控制在 100 字以内。"},
            {"role": "user", "content": transcript},
        ],
    )
    summary = resp.choices[0].message.content
    # 摘要作为一条 system 角色的"背景"消息,塞回上下文最前面
    return {"role": "system", "content": f"【早前对话摘要】{summary}"}

把截断和压缩组合起来,就是一个完整的上下文管理器。它每轮构建上下文时:最近 N 轮留原文,更老的压成摘要,组合后若仍超预算,再用截断兜底:

class ContextManager:
    """多轮对话上下文管理:最近 N 轮留原文,更早的压成摘要,超限再截断。"""

    def __init__(self, client, max_tokens: int = 6000, keep_turns: int = 6):
        self.client = client
        self.max_tokens = max_tokens   # 留给"历史"的 token 预算
        self.keep_turns = keep_turns   # 最近多少轮保留原文

    def build(self, system_msg: dict, history: list[dict]) -> list[dict]:
        recent = recent_turns(history, self.keep_turns)
        # 不在"最近 N 轮"里的,就是要被压缩的老历史
        old = history[: len(history) - len(recent)]

        context = [system_msg]
        if old:
            # 老历史压成一条摘要,而不是直接丢弃
            context.append(summarize_old_turns(self.client, old))
        context += recent

        # 压缩之后若仍然超预算,用截断做最后兜底
        if count_message_tokens(context) > self.max_tokens:
            context = truncate_history(context, self.max_tokens)
        return context

六、工程坑:lost in the middle、输出预算、流式、成本

把上下文管理真正放进生产,还有几个绕不开的工程坑。它们不在"算 token"这件事本身,却实实在在地影响效果和成本。

坑 1:lost in the middle——关键信息别埋在中间。多项研究发现,模型对放在 prompt 开头和结尾的信息记得最牢,而对夹在长长一段中间的信息最容易忽略。所以即便某个关键事实在窗口里"放得下",如果它被埋在第 15 轮对话的中段,模型也很可能"看不见"。对策是:把关键事实(用户身份、核心约束、订单号)主动钉在 system 里(开头)或贴近末尾再强调一次,别指望模型从中间长文里自己捞出来。

坑 2:一定要给输出留 token 预算。第一节强调过,窗口是输入输出共享的。算"历史预算"时,必须从上下文窗口里扣掉你打算让模型生成的 max_tokens,再留一点安全余量,剩下的才是历史能用的。漏了这步,历史会把窗口占满,模型的回答要么被硬生生截断,要么直接报错。

坑 3:别用字数估 token,流式输出也不改变 token 账。很多人以为开了流式(stream)输出,token 计算会不一样——不会。流式只是把同一个回答分块逐步返回给你,它消耗的 token 和计费,跟非流式完全一致。流式优化的是"用户多快看到第一个字",不是"花多少 token"。

坑 4:token 直接等于钱和延迟。每轮都发全量历史,后果不只是会撑爆窗口——它还在持续烧钱、拉高延迟。第 30 轮的输入 token 可能是第 1 轮的几十倍,而 API 是按 token 计费、token 越多响应越慢的。所以上下文管理不只是"防报错",它同时是实打实的省钱和提速手段。

先把"坑 2"落地——一个计算历史预算的函数,它替你把输出空间先扣掉:

def history_budget(context_window: int, reserve_for_output: int,
                    safety_margin: int = 256) -> int:
    """算出真正能留给'对话历史'的 token 预算。
    上下文窗口是输入+输出共享的,必须先扣掉给输出留的空间。"""
    budget = context_window - reserve_for_output - safety_margin
    if budget <= 0:
        raise ValueError("窗口太小:扣掉输出预算后已放不下任何历史")
    return budget


# 例:128k 窗口的模型,打算让它最多生成 2000 token 的回答
print(history_budget(128_000, reserve_for_output=2000))   # 历史可用预算
# 这个返回值,才是该传给 ContextManager / truncate_history 的 max_tokens

再把"坑 1"落地——把关键事实钉在上下文首尾,避开 lost-in-the-middle:

def pin_key_facts(system_msg: dict, key_facts: dict,
                  history: list[dict]) -> list[dict]:
    """把关键事实'钉'在上下文的开头和结尾。
    模型对 prompt 首尾的信息最敏感,对中间的最容易忽略。"""
    facts = ";".join(f"{k}={v}" for k, v in key_facts.items())

    # 开头:并入 system,模型最优先、最稳定地看到
    head = {"role": "system",
            "content": f'{system_msg["content"]}\n【已知关键信息】{facts}'}
    # 结尾:在最末尾再提醒一次,紧贴模型即将生成回答的位置
    tail = {"role": "system",
            "content": f"【回答前请核对】务必基于以下事实作答:{facts}"}

    return [head] + history + [tail]

最后是"坑 4"——把 token 换算成钱,让你对"不做管理"的代价有个量化的体感:

# 不同模型价格不同,这里以"每百万 token 美元"为例(示意值)
PRICE = {"gpt-4o-mini": {"input": 0.15, "output": 0.60}}


def estimate_turn_cost(model: str, input_tokens: int,
                       output_tokens: int) -> float:
    """估算单轮对话的成本(美元)。"""
    p = PRICE[model]
    return (input_tokens / 1_000_000 * p["input"]
            + output_tokens / 1_000_000 * p["output"])


# 多轮对话里 input_tokens 每轮都在涨:不做上下文管理,
# 第 1 轮输入也许 500 token,第 30 轮可能涨到 15000 token
turn_1 = estimate_turn_cost("gpt-4o-mini", 500, 300)
turn_30 = estimate_turn_cost("gpt-4o-mini", 15000, 300)
print(f"第 1 轮 ${turn_1:.6f}  vs  第 30 轮 ${turn_30:.6f}")

关键概念速查

概念 / 手段 说明
token 模型处理文本的最小单位,中文一字常占 1-2 token,非按字数
上下文窗口 模型一次能处理的 token 上限,输入与输出共享这个总额度
API 无状态 模型不记得上一轮,多轮对话靠你每轮重发完整历史
token 累积 多轮历史 token 单调增长,是结构性结果,不管理迟早爆窗口
tiktoken OpenAI 官方分词器,精确数 token,不能用字数估算
消息开销 每条消息除 content 外还有固定格式 token,漏算会低估
截断 超预算时保 system + 最近几轮,从最老对话成对丢弃
摘要压缩 老历史用一次 LLM 调用压成摘要,保住事实又大幅省 token
输出预算 算历史预算前必须先扣掉留给模型回答的 token
lost in the middle 模型对首尾信息最敏感,关键信息别埋在 prompt 中间

避坑清单

  1. 大模型按 token 处理文本,不是按字数;中文一个汉字常占 1-2 个 token,估容量务必用分词器。
  2. 上下文窗口是输入和输出共享的总额度,算预算必须把要生成的回答也算进去。
  3. 大模型 API 是无状态的,多轮对话靠你每轮重发完整历史,token 因此单调累积。
  4. "AI 聊久了失忆或报错"基本不是模型记性问题,是历史 token 撑爆了上下文窗口。
  5. 换更大窗口的模型只是抬高上限、推迟发作,token 累积趋势不变,治标不治本。
  6. 用 tiktoken 等官方分词器精确数 token,别用"字数 × 系数",中英文混排误差很大。
  7. 数 messages 的 token 时别漏掉每条消息的固定格式开销,只算 content 会系统性低估。
  8. 截断历史时 system 永远保留、最近几轮永远保留,丢中间偏老的部分,且要成对丢。
  9. 截断会连同信息一起丢;关键事实多的场景应改用摘要压缩,把老历史压成要点而非扔掉。
  10. 关键信息别埋在 prompt 中间(lost in the middle),钉在 system 或末尾;每轮发全量历史还在持续烧钱提延迟。

总结

回头看那次"AI 聊着聊着就失忆"的事故,最该记住的不是某个截断函数,而是我上线前那个想当然的假设——"把历史发给模型,模型自己会记住"。这句话里藏着两个错:第一,模型不会"记住",是我每轮亲手把历史喂回去的;第二,我能喂的量有硬上限。多轮对话给人的体验是连续的、有记忆的,但这份"记忆"不是模型的能力,而是你的代码在每一轮精心维护出来的一个假象。一旦你接受"记忆是我在管,不是模型在管"这个事实,问题就从"模型怎么这么健忘"变成了"我这套记忆管理该怎么设计"——而后者,是个可以扎实解决的工程问题。

所以做多轮对话,真正的工程重心在于回答三个问题:这一轮我往上下文里塞了多少 token?上限还剩多少?塞不下的时候丢什么、留什么、怎么留?这篇文章其实就是顺着这三个问题展开的:先认清 token 和窗口是什么、窗口被输入输出共享;再理解 token 为什么会累积——因为 API 无状态、历史每轮重发;然后学会用分词器精确地数它;接着是两种应对——截断简单但会丢信息,摘要压缩更聪明、用一次额外调用把老历史的事实留住;最后是 lost in the middle、输出预算这些坑。

你会发现,这套思路和传统工程里管理任何一种"有限资源"几乎一模一样。上下文窗口就是内存,token 就是字节,你要做的是监控用量、设预算、在快满时按优先级淘汰——system 和最近几轮是"热数据"常驻,老对话是"冷数据"该换出或压缩。我们对内存、对缓存、对连接池,做的都是同一件事。LLM 应用没有发明新的工程纪律,它只是给你一种新的有限资源,而你那套"管理有限资源"的老经验,一条都用得上。

最后想说,上下文管理这件事,做与不做的差距不会在 Demo 阶段暴露——Demo 里的对话都很短,怎么写都不会爆。它只在真实用户、长对话、规模化之后才显形,而那时候它一次性给你三张账单:失忆导致的体验崩坏、超限导致的报错、以及每轮全量历史悄悄烧掉的真金白银。所以别等上线后被告警叫醒,在你写下第一个 messages.append 的时候,就该想清楚:这个列表会一直长下去,我打算在它长到多大的时候、用什么策略把它管起来。想清楚了,你的多轮对话才不只是 Demo 里那几句漂亮的来回,而是一个能扛住真实用户聊上一整天的系统。

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

Kubernetes 探针完全指南:从一次"健康的 Pod 被反复重启"看懂 liveness 与 readiness

2026-5-21 18:08:09

技术教程

WebSocket 完全指南:从一次"消息莫名丢失、连接悄悄断开"看懂实时通信

2026-5-21 18:23:09

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