LLM 上下文管理完全指南:从一次"聊到一半 AI 突然失忆又报错"看懂 token 与对话历史

2024 年我做一个基于大模型的多轮客服机器人,第一版很简单:维护一个 messages 列表,用户和模型每说一句就 append 进去,整个列表发给模型。短对话表现完美,可一旦聊久,问题接连冒出:同一个问题对话开头两秒答完、几十轮后要等十几秒;单次请求 token 消耗一路飙升;聊到很长时 AI 开始失忆,前面交代过的信息又来问一遍;最后某轮直接抛出 context_length_exceeded,对话彻底没法继续。我以为是模型不稳定,盯着请求体看了很久才想明白:大模型是无状态的,它没有任何记忆,它能记得上文完全是因为我每一轮都把从头到尾的全部对话历史重新发了一遍。对话越长重发的历史越长,于是越慢越贵,一旦超过上下文窗口就直接报错。本文把上下文管理从头梳理。为什么必须管:模型无状态,记忆是每轮重发历史造就的假象,历史是有成本的。token:模型处理文本的最小单位,也是计费和长度单位,中文约一到两字一个,用 tiktoken 估算、用 response.usage 拿精确账单。上下文窗口:单次请求 token 总量上限,输入输出一起算,撞上就直接拒绝整个请求,发送前要自检并为回复预留空间。滑动窗口:只保留最近若干轮,简单可靠但会硬遗忘早期关键信息,按 token 预算裁比按轮数裁更准。摘要压缩:把旧对话让模型压成简短摘要替代原文,保留要点不硬遗忘,代价是一次额外调用和细节损耗。工程坑:system prompt 必须钉死永不裁剪,裁剪边界要落在完整一轮之间不能拆散 user/assistant 或 tool 消息,token 预算要在 system、历史、回复之间显式分账。核心一句:多轮对话真正的工程量不在调用模型那一下,而在那个 messages 列表的管理上。

2024 年我做一个客服对话机器人,基于大模型的多轮对话。第一版很简单:维护一个 messages 列表,用户每说一句就 append 进去,模型每答一句也 append 进去,然后把整个列表发给模型。短对话里它表现完美,记得住上文、接得住话。可一旦用户和它聊得久一点,问题就接连冒出来。先是变慢——同一个问题,对话刚开始时两秒就答,聊到几十轮之后要等十几秒。然后是变贵——我盯着账单,发现单次请求的 token 消耗一路飙升,明明用户最后那句话就几个字。再然后是更诡异的:聊到很长的时候,AI 开始"失忆",用户前面明明交代过的信息,它后面又来问一遍。而压垮它的最后一根稻草,是某天一个用户聊了很久之后,接口直接抛出一个错误:context_length_exceeded——上下文长度超限,这一轮对话彻底没法继续了。我一开始以为是模型不稳定,后来盯着请求体看了很久才想明白:问题全都出在我那个"无限 append"的 messages 列表上。我一直以为大模型像人一样,跟它聊过的话它"记得",我只要把新消息发过去就行。真相是——大模型是无状态的,它没有任何记忆。它之所以"记得"上文,完全是因为我每一轮都把从头到尾的全部对话历史重新发了一遍。对话越长,我重发的历史就越长,于是请求越慢、越贵;一旦这个历史的长度超过了模型的上下文窗口,请求就直接报错。我以为上下文管理就是"把历史发过去"这么简单,结果真做下来坑一个接一个:历史不能无限发,要裁剪;可一刀切裁掉旧消息,AI 就真的失忆了;想用摘要压缩,又得考虑 system prompt 怎么保住、token 预算怎么分……那次之后我才认真把 LLM 的上下文管理从头搞明白。这篇文章就把它梳理一遍:为什么必须管上下文、token 是什么、上下文窗口这堵墙、滑动窗口怎么裁、摘要压缩怎么做,以及把多轮对话真正做稳要避开的那些坑。

问题背景

先把那次的现象和我的误判讲清楚,后面所有的设计都是冲着纠正这个误判去的。

现象:基于大模型的多轮客服机器人,随着对话变长,出现一连串问题——响应越来越慢、单次请求 token 消耗越来越贵、AI 开始遗忘用户早先交代的信息,最终因对话历史超过模型上下文窗口,接口抛出 context_length_exceeded 错误,对话无法继续。

我当时的错误认知:"大模型像人一样记得跟它聊过的话,我只要把用户的新消息发给它就行;对话历史无非就是一直往一个列表里 append。"

真相:大模型是无状态的,它对之前的对话没有任何记忆。它每一轮回答,靠的全是你这一次请求里塞给它的全部内容。所谓"多轮对话",本质是你每一轮都把从头到尾的完整历史重新发一遍——是这个重发,而不是模型的记忆,造就了"它记得上文"的错觉。这意味着对话历史是有成本的:它直接决定每次请求的延迟、费用,而且它的总长度受模型上下文窗口的硬性限制,撞上就报错。上下文管理,就是在"让模型记得足够多"和"历史不能太长"之间做工程权衡——靠裁剪、摘要、token 预算分配来实现。

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

  • 为什么大模型是无状态的,"记忆"其实是每轮重发历史造就的;
  • token 是什么——模型的计价单位,也是长度单位;
  • 上下文窗口是一堵硬墙,超了就直接报错;
  • 滑动窗口裁剪怎么做,它会带来什么代价;
  • 摘要压缩、system prompt 保护、token 预算这些工程坑怎么处理。

一、为什么要管上下文:大模型是"无记忆"的

先纠正那个最根本的误解:大模型没有记忆

每一次调用 API,对模型来说都是一次全新的、孤立的请求。它不知道你五分钟前问过什么,也不知道这是第一轮还是第五十轮。它能"接住上文",唯一的原因是你在这次请求里,把上文也一起发给它了。下面这段代码,就是大多数人写的第一版多轮对话——它的"记忆"是个彻头彻尾的假象:

from openai import OpenAI

client = OpenAI()
# 反面教材:维护一个永远只增不减的对话历史。
messages = [{"role": "system", "content": "你是一个客服助手。"}]


def chat(user_input: str) -> str:
    messages.append({"role": "user", "content": user_input})
    # 每一轮都把【从头到尾】的全部 messages 发出去 ——
    # 模型没有记忆,它"记得"上文,纯粹是因为我们重发了历史。
    resp = client.chat.completions.create(
        model="gpt-4o-mini", messages=messages,
    )
    reply = resp.choices[0].message.content
    messages.append({"role": "assistant", "content": reply})
    return reply
    # 问题:messages 只增不减。聊得越久,每次请求带的历史越长 ——
    # 请求越来越慢、越来越贵,直到某轮历史撑爆上下文窗口,直接报错。

这段代码根本没有任何"记住"东西的机制,它做的只是把一个不断变长的 messages 列表重复发送。理解了这一点,开头那串现象就全部说通了:慢和贵,是因为每轮重发的历史在变长;失忆,是因为历史长到一定程度后,模型对中间部分的注意力会变弱(更何况后面我们还会主动裁掉一部分);报错,是因为这个列表的总长度,有一个不可逾越的上限。要管理它,第一步是得能量化"历史有多长"——而模型衡量长度的单位,不是字数,是 token。

二、token:模型的计价单位和长度单位

你也许以为模型是按"字数"算长度的,不是。模型处理文本的最小单位叫 token(词元)

一段文本送进模型前,会先被一个叫 tokenizer 的组件切成一个个 token。一个 token 可能是一个完整的英文单词、一个单词的一部分、一个标点;对中文来说,大致是一到两个字一个 token。最关键的是:模型的计费、模型的长度上限,算的都是 token,不是字符数。所以管理上下文,第一件事就是学会数 token:

import tiktoken


def count_tokens(messages: list[dict], model: str = "gpt-4o-mini") -> int:
    """估算一组对话消息总共占多少 token —— 这才是模型眼里的"长度"。"""
    enc = tiktoken.encoding_for_model(model)
    total = 0
    for msg in messages:
        # 每条消息除了内容本身,还有 role 等结构性开销,约 4 个 token
        total += 4
        total += len(enc.encode(msg["content"]))
    return total + 2   # 整个回复的引导也占几个 token

为什么必须会数 token?因为后面所有的裁剪、预算分配,衡量的都是 token 数,不是消息条数、也不是字数。一条消息可能很短也可能很长,按"条数"裁剪是不准的;真正的预算单位永远是 token。注意 count_tokens 是个估算——不同模型 tokenizer 不同,每条消息的结构性开销也略有差异,但用于"判断会不会超、该裁多少",这个精度完全够用。如果想要精确值,模型其实每次都会在响应里如实告诉你:

resp = client.chat.completions.create(
    model="gpt-4o-mini", messages=messages,
)
# 响应里的 usage 字段,是模型给的【精确】token 账单
usage = resp.usage
print(f"输入 {usage.prompt_tokens} + 输出 {usage.completion_tokens}"
      f" = 共 {usage.total_tokens} token")
# prompt_tokens 就是你这次发过去的历史有多长 —— 它会随对话增长一路涨。
# 把它打到日志里,你能清清楚楚看到"重发历史"那条成本曲线。

会数 token 之后,下一个问题就来了:这个 token 数,到底不能超过多少?这就是上下文窗口。

三、上下文窗口:一堵撞上就报错的硬墙

每个模型都有一个上下文窗口(context window),它是这个模型单次请求能处理的 token 总量上限。

关于这堵墙,有两个细节必须钉进脑子。第一,它是输入和输出一起算的。你发过去的对话历史(输入),和你期望模型生成的回答(输出),两者的 token 数加起来不能超过窗口。第二,它是一堵硬墙,不是软性建议。一旦你拼出来的请求超了,模型不会"尽力而为地截断一部分",而是直接拒绝整个请求,抛出 context_length_exceeded 这类错误——这一轮对话当场就断了。所以稳妥的做法,是在发送之前就自检一遍:

# 不同模型上下文窗口不同;这里用一个保守的窗口大小演示
CONTEXT_WINDOW = 8192
# 给模型的回复预留出空间 —— 窗口要把输入和输出【一起】装下
RESERVED_FOR_REPLY = 1024


def will_overflow(messages: list[dict]) -> bool:
    """发送前自检:历史 + 预留回复空间,会不会撑爆上下文窗口。"""
    history_tokens = count_tokens(messages)
    # 输入历史 + 预留给输出的空间,合起来不能超过窗口
    return history_tokens + RESERVED_FOR_REPLY > CONTEXT_WINDOW

这里有个极易被忽略的点:必须为回复预留空间。窗口是输入输出一起算的,如果你把历史塞到只差 10 个 token 就到窗口上限,模型连一句完整的话都生成不出来。所以真正能留给"对话历史"的预算,是窗口大小减去你给回复留的空间

那撞墙了怎么办?你不能让用户的对话就这么断掉。唯一的办法,是在发送之前就主动把历史裁短,让它落在预算之内。怎么裁,就是下面两节的核心——它们是两种思路,各有各的取舍。

四、滑动窗口:最朴素的裁剪和它的代价

最直观的裁剪方法,叫滑动窗口:只保留最近的若干轮对话,把更早的直接丢掉。就像一个固定大小的窗口,在对话历史上往前滑,只露出最近的一段。

def trim_by_rounds(messages: list[dict], keep_rounds: int = 10) -> list[dict]:
    """滑动窗口裁剪:只保留 system 消息 + 最近 keep_rounds 轮对话。"""
    system = [m for m in messages if m["role"] == "system"]
    chat = [m for m in messages if m["role"] != "system"]
    # 一轮 = 一条 user + 一条 assistant,保留最近 keep_rounds 轮
    kept = chat[-keep_rounds * 2:]
    return system + kept

但按"轮数"裁不够准——前面说过,每条消息长短不一,10 轮短对话和 10 轮长对话占的 token 可能差好几倍。更可靠的做法是按 token 预算裁:从最新的消息往回收,边收边累加 token,加到预算上限就停:

def trim_by_tokens(messages: list[dict], budget: int) -> list[dict]:
    """按 token 预算裁剪:从最新消息往回保留,直到逼近预算上限。"""
    system = [m for m in messages if m["role"] == "system"]
    chat = [m for m in messages if m["role"] != "system"]
    used = count_tokens(system)        # system 消息先占掉一部分预算
    kept = []
    # 从最近的消息倒着往前收,累加 token,逼近预算就停
    for msg in reversed(chat):
        cost = count_tokens([msg])
        if used + cost > budget:
            break
        kept.append(msg)
        used += cost
    kept.reverse()                     # 收的时候是倒序,用前要翻回正序
    return system + kept

滑动窗口的优点是简单、可靠、绝不会超窗口。但它的代价你必须看清——它是"硬遗忘"。被滑出窗口的那些早期对话,是被彻彻底底丢掉的。如果用户在第 2 轮交代了一个关键信息("我的订单号是 A2024"),聊到第 30 轮时这条消息早被滑出去了,模型就真的不知道订单号了——这正是开头那个"AI 失忆"现象的直接来源。滑动窗口在"保证不超窗口"和"记住早期信息"之间,选择了前者、彻底牺牲了后者。对很多业务来说,这个代价太大了。我们需要一种"既裁短、又不彻底遗忘"的办法。

五、摘要压缩:把旧对话折叠起来

摘要压缩的思路是:与其把旧对话直接丢掉,不如先让模型自己把它们浓缩成一段简短的摘要,用这段摘要去替代那一大段原文。

旧的十几轮对话原文可能占两千个 token,但它们的"要点"——用户是谁、问了什么、确认了什么——可能两百个 token 就能讲清。我们就用这两百个 token 的摘要,把那两千个 token 的原文换下来。早期信息没有被丢掉,只是被"折叠"了:

def summarize_old_messages(old_messages: list[dict]) -> str:
    """把一段旧对话压成摘要,用它替代原文,既省 token 又不丢要点。"""
    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": "把下面的对话压缩成要点摘要,务必保留用户"
                        "提到的关键信息:身份、订单号、诉求、已确认事项。"},
            {"role": "user", "content": transcript},
        ],
    )
    return resp.choices[0].message.content

把它接进上下文管理:当历史超出预算时,把较旧的那一半压成摘要,最近的一半原样保留(细节最重要的部分不能损耗):

def compress_history(messages: list[dict], budget: int) -> list[dict]:
    """历史超预算时:把较旧的一半压成摘要,最近的对话原样保留。"""
    if count_tokens(messages) <= budget:
        return messages                       # 没超预算,无需压缩
    system = [m for m in messages if m["role"] == "system"]
    chat = [m for m in messages if m["role"] != "system"]
    half = len(chat) // 2
    old, recent = chat[:half], chat[half:]
    # 旧的一半压成摘要,作为一条 system 消息接在原 system 之后
    digest = {"role": "system",
              "content": f"【早先对话摘要】{summarize_old_messages(old)}"}
    return system + [digest] + recent

摘要压缩用一次额外的模型调用(和一点信息损耗)换来了"记住早期要点"。它不是免费的:摘要本身要花钱、花时间,而且压缩必然有损,细节会丢。所以实践中通常是分层的——最近几轮对话保留原文(细节最重要),更早的对话压成摘要(只留要点),再早的、连摘要都太久远的,才真正丢弃。摘要解决了"硬遗忘",但它和滑动窗口都还面临同一个问题:到底该给历史留多少预算,这个预算又该怎么在 system、摘要、近期对话之间分。

六、工程坑:system prompt 钉死、裁剪边界、预算分账

机制都有了,但要把多轮对话真正做稳,还有几个绕不开的工程坑。

坑 1:system prompt 必须钉死,永远不能被裁掉。system 消息定义了模型的角色、规则、约束(比如"你是客服助手,只回答本公司业务")。它通常在对话最开头,如果你用"保留最近 N 条"的朴素裁剪,聊得久了 system 消息会第一个被滑出去——然后模型瞬间"人格分裂",忘了自己是谁、该守什么规矩。前面 trim_by_tokenscompress_history 里都先把 system 单独拎出来、最后再拼回去,就是为了这个:system 永远在,只裁普通对话

坑 2:裁剪不能从一轮对话的中间切开。一轮完整的对话是"一条 user 提问 + 一条对应的 assistant 回复"。如果你的裁剪正好砍在中间,留下一条没有提问的 assistant 回复,或者两条连着的 user 消息(因为中间的 assistant 回复被裁了),模型会困惑。更要命的是,如果历史里有 Function Calling 的 tool 消息,一条 tool 结果消息必须跟着它对应的那条带 tool_calls 的 assistant 消息——裁剪时把它们拆散,直接就是 API 报错。所以裁剪的边界,必须落在"完整的一轮"之间

坑 3:token 预算要明确地分账。上下文窗口是固定的,它要被几方瓜分:system prompt、对话历史(或其摘要)、还要给模型回复留出空间。这几块加起来不能超过窗口。把这个分账显式地写出来,而不是含糊地"尽量少发点":

CONTEXT_WINDOW = 8192


def build_context(system: list[dict], history: list[dict],
                  reserve_reply: int = 1024) -> list[dict]:
    """显式分配 token 预算:窗口 = system + 历史 + 预留回复。"""
    system_cost = count_tokens(system)
    # 留给"对话历史"的预算 = 窗口 - system 开销 - 回复预留
    history_budget = CONTEXT_WINDOW - system_cost - reserve_reply
    if history_budget < 500:
        raise ValueError("system prompt 过长,挤占了历史空间,需精简")
    # 历史超预算就先压缩,再拼成最终请求
    history = compress_history(history, history_budget)
    return system + history

坑 4:把每轮的 token 用量打进日志。response.usage 里的 prompt_tokenscompletion_tokens 是模型给的精确账单。把它打进日志,你才能看见成本曲线:哪些对话特别烧钱、裁剪策略有没有真的生效、预算分配是否合理。多轮对话的成本问题,十有八九是因为"看不见"——你不数 token,就永远不知道钱花在了哪。下面这张图,把一轮对话从用户发消息到请求发出的完整处理链路串起来:

关键概念速查

概念 / 手段 说明
无状态 大模型没有记忆,每次请求都是孤立的,"记得上文"靠每轮重发历史
token 模型处理文本的最小单位,也是计费和长度的单位,中文约 1-2 字一个
上下文窗口 单次请求 token 总量上限,输入 + 输出一起算,超了直接报错
context_length_exceeded 历史过长撑爆窗口时的报错,这一轮对话直接中断
回复预留 窗口要输入输出一起装,必须为模型回复预留出 token 空间
滑动窗口 只留最近 N 轮 / N token,简单可靠但会硬遗忘早期信息
按 token 裁剪 按 token 预算而非消息条数裁,因为每条消息长短差异大
摘要压缩 把旧对话压成简短摘要替代原文,保留要点、不硬遗忘
system 钉死 system prompt 定义角色规则,裁剪时必须永远保留
预算分账 窗口在 system、历史、回复预留之间显式分配,不能含糊

避坑清单

  1. 大模型是无状态的,没有任何记忆;"它记得上文"是错觉,真相是你每轮都把完整历史重新发了一遍。
  2. 对话历史只增不减地 append,会让每次请求越来越慢、越来越贵,最终撑爆上下文窗口直接报错。
  3. 模型的长度和计费单位是 token 不是字符;中文大致 1-2 字一个 token,裁剪和预算都按 token 算。
  4. 上下文窗口是输入 + 输出一起算的硬墙,超了模型不会截断而是直接拒绝整个请求。
  5. 必须为模型回复预留 token 空间,否则历史塞满窗口、模型一个字都生成不出来。
  6. 滑动窗口裁剪简单可靠但会硬遗忘——被滑出窗口的早期关键信息(如订单号)被彻底丢弃,这就是"AI 失忆"。
  7. 按消息条数裁不准,每条消息长短差异大;要按 token 预算从最新消息往回收。
  8. 摘要压缩把旧对话折叠成要点替代原文,保留早期信息,代价是一次额外模型调用和细节损耗。
  9. system prompt 必须钉死、永不裁剪,否则聊久了模型忘掉自己的角色和规则;裁剪要先拎出 system 再拼回。
  10. 裁剪边界要落在完整的一轮之间,不能把 user/assistant 或 tool_calls/tool 消息拆散,否则模型困惑甚至 API 报错。

总结

回头看那个聊到一半突然失忆又报错的客服机器人,以及我后来在上下文管理上接连踩的坑,最该记住的不是某一段裁剪代码,而是我动手前那个想当然的判断——"大模型像人一样记得跟它聊过的话"。这句话错得很彻底:模型没有记忆,它每一次回答你,看到的只有你这一次请求里塞给它的东西。所谓多轮对话的连贯,不是模型在"记",是你在每一轮不厌其烦地"重述"。把这件事看清,你就明白上下文管理为什么是多轮对话绕不开的核心——因为那个"被重述的历史",既是模型连贯的来源,也是延迟、费用、报错的来源。

所以做多轮对话,真正的工程量不在"调用一次模型"那一下。把 messages 列表发出去、拿回一句回答,Demo 里它也确实对答如流。真正的工程量在那个 messages 列表的管理上:它有多长?长到什么程度会超窗口?超之前你怎么把它裁短、又不丢掉重要的东西?裁下来的预算怎么在 system、历史、回复之间分?这篇文章的几节,其实就是顺着这条思路展开的:先想清楚模型为什么无记忆、token 是什么、窗口这堵墙在哪,再看滑动窗口和摘要压缩这两种裁剪思路各自的取舍,最后是 system 保护、裁剪边界、预算分账这几个把多轮对话真正做稳的工程细节。

你会发现,上下文管理的思路和我们处理任何"有限资源"的工程经验都是相通的。内存不够,我们有缓存淘汰(LRU);磁盘不够,我们有日志滚动和归档;带宽不够,我们有压缩。上下文窗口,就是大模型应用里那个"有限资源",而滑动窗口是它的 LRU、摘要压缩是它的归档。你不会指望往内存里无限塞东西不出事,你也就不该指望往 messages 列表里无限 append 不出事——一切有上限的资源,都需要一套主动的、有策略的管理,而不是放任它自己增长到崩溃。

最后想说,上下文管理做没做扎实,差距永远不会在 Demo 里暴露——Demo 里你和它就聊三五句,历史短得很,裁不裁剪、压不压缩都一样跑得欢。它只在真实用户那种动辄几十上百轮的长对话面前才显形。那时候它会用最难堪的方式给你结账:一个聊了很久的用户,眼看着 AI 突然忘了他十分钟前报过的订单号,或者干脆收到一个 context_length_exceeded 的报错、对话再也接不下去。所以别等用户抱怨"这个 AI 怎么越聊越笨"再来找你,在你写下第一个 messages.append 的时候就该想清楚:这个列表会涨到多长?涨到撑爆窗口之前我裁了吗?裁的时候我保住早期的关键信息了吗?我的 system prompt 会不会被裁掉?这几个问题都有了答案,你的对话机器人才不只是 Demo 里那个聊三句很惊艳的演示,而是一个能陪用户从头聊到尾、始终清醒不失忆的可靠助手。

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

接口幂等性完全指南:从一次"网络抖动让用户被扣了两次款"看懂幂等设计

2026-5-21 19:13:55

技术教程

分布式锁完全指南:从一次"扩容后对账任务跑了三遍、结算被重复打款"看懂分布式锁

2026-5-21 19:26:30

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