LLM 多轮对话上下文管理完全指南:从一次"聊到十几轮突然崩"看懂为什么模型没有记忆

2024 年我做一个 AI 对话助手用户能和大模型一轮一轮地连续对话像聊天一样模型要记得前面聊过什么第一版我做得很顺手我维护一个 messages 列表用户每说一句就 append 进去模型每答一句也 append 进去每一轮调用模型时就把这个列表里的全部历史原样发过去本地我聊了五六轮测了测模型确实记得前面的内容答得很连贯我心里很笃定多轮对话嘛无非就是把之前所有的对话历史每轮都带上模型看到完整历史自然就记得可等它一上线用户开始真正长聊一串问题冒了出来第一种最先把我打懵有些对话进行到十几二十轮突然就报错了错误信息说上下文太长超出了模型的处理上限第二种最难缠对话越长每一轮的响应就越慢越贵第三种最头疼有些长对话模型开始忘事明明用户前面交代过的信息聊到后面它答得驴唇不对马嘴第四种最莫名其妙我做了个补救只保留最近 8 轮对话结果模型把对话最开头用户交代的关键背景整个忘了我盯着这一连串问题想了很久才彻底想明白第一版错在一个根本的认知上我以为多轮对话就是把之前的所有对话历史每一轮都原样拼进提示词发给模型模型看到了完整的历史它就记住了前面发生的一切可这个认知是错的本文从头梳理为什么每轮拼全部历史迟早会崩上下文窗口为什么要当预算来管历史太长怎么压缩关键信息怎么保住以及一些把多轮对话上下文管理做扎实要避开的工程坑

2024 年我做一个 AI 对话助手——用户能和大模型一轮一轮地连续对话,像聊天一样,模型要"记得"前面聊过什么。第一版我做得很顺手:我维护一个 messages 列表,用户每说一句就 append 进去,模型每答一句也 append 进去,每一轮调用模型时,就把这个列表里的全部历史原样发过去。本地我聊了五六轮测了测,模型确实记得前面的内容,答得很连贯,我心里很笃定:多轮对话嘛,无非就是把之前所有的对话历史每轮都带上,模型看到完整历史,自然就"记得"——这对话功能稳了。可等它一上线、用户开始真正长聊,一串问题冒了出来。第一种最先把我打懵:有些对话进行到十几二十轮,突然就报错了,模型直接拒绝响应,错误信息说上下文太长、超出了模型的处理上限。第二种最难缠:对话越长,每一轮的响应就越慢、越贵——同一个模型,聊到第二十轮时,单轮的耗时和费用是第一轮的好几倍。第三种最头疼:有些长对话,模型开始"忘事"——明明用户前面交代过的信息,聊到后面它答得驴唇不对马嘴,像是没看见。第四种最莫名其妙:我做了个补救,只保留最近 8 轮对话、更早的丢掉,结果模型把对话最开头用户交代的关键背景(他是什么身份、想解决什么问题)整个忘了,后面越答越偏。我盯着这一连串问题想了很久,才彻底想明白:第一版错在一个根本的认知上。我以为多轮对话就是把之前的所有对话历史,每一轮都原样拼进提示词发给模型——模型看到了完整的历史,它就"记住"了前面发生的一切,对话的连续性是自动成立的、不需要我操心的。可这个认知是错的。大模型本身是完全无状态的:它不会在两次调用之间"记住"任何东西。所谓多轮对话的"记忆",根本不是模型自己有记忆,而是你每一轮都把历史重新喂给它、人为制造出来的一种错觉。而模型的上下文窗口是有硬性上限的——它一次能"看"的 token 数量是固定的,历史不可能无限地拼下去。当对话变长,你那个"每轮拼全部历史"的做法,会一头撞上这个窗口上限(于是报错),会让每轮要处理的 token 越堆越多(于是越来越慢、越来越贵),会因为历史里塞了太多东西而稀释掉关键信息(于是模型"忘事")。所以多轮对话的上下文管理,根上不是"把历史拼进去"这一个动作,而是一整套工程:要把上下文窗口当成一份有限的预算来精打细算、要在历史太长时用滑动窗口和摘要去压缩它、要把关键信息单独"钉住"不让它被截断丢掉、要想清楚系统提示和检索内容和历史之间该怎么分配那份预算。本文从头梳理:为什么"每轮拼全部历史"迟早会崩,上下文窗口为什么要当预算来管,历史太长怎么压缩,关键信息怎么保住,以及一些把多轮对话上下文管理做扎实要避开的工程坑。

问题背景

先把"多轮对话"这件事说清楚。大模型的一次调用是无状态的:你发一段文本(提示词),它返回一段文本,这次调用结束后,模型不保留任何关于这次对话的东西。所谓多轮对话,是应用层造出来的:你把"用户说的、模型答的"按顺序记下来,下一轮调用时,把这些历史连同新的用户输入一起,重新发给模型。模型"看起来记得",是因为你每轮都把历史完整地告诉了它。

错误认知是:把历史每轮都拼上,模型就记住了,对话连续性自动成立。真相是:模型无状态、上下文窗口有硬上限,"记忆"是你用 token 预算换来的,而 token 预算必须被精打细算地管理。把这一点摊开,第一版的几类问题就都能解释了:

  • 聊到十几轮就报错:历史无限累积,迟早超出模型上下文窗口的硬上限。
  • 越聊越慢越贵:每轮要处理的 token 随历史线性增长,延迟和费用跟着涨。
  • 长对话模型忘事:历史里塞了太多低价值内容,关键信息被稀释、被淹没。
  • 截断丢了关键背景:简单地"只留最近 N 轮",会把对话开头交代的关键信息一起丢掉。

所以让多轮对话稳,核心不是"拼上全部历史",而是一整套上下文工程:管好 token 预算、压缩长历史、钉住关键信息、安排好上下文的优先级。下面六节,就从第一版"每轮拼全部历史"的想当然讲起。

一、为什么"每轮拼全部历史"迟早会崩

第一版我管理对话的方式,朴素到极致:一个列表,所有历史往里塞,每轮全发。

# 反面教材:一个 messages 列表,所有历史无限累积,每轮全发

messages = [
    {"role": "system", "content": "你是一个专业的客服助手。"},
]

def chat_v1(user_input):
    # 用户每说一句,就 append 进历史
    messages.append({"role": "user", "content": user_input})

    # 每一轮,把至今为止的【全部】历史发给模型
    reply = call_model(messages)

    # 模型的回答也 append 进去,留给下一轮
    messages.append({"role": "assistant", "content": reply})
    return reply

# 我以为:历史全在 messages 里,模型每轮都看到完整历史,
# 它自然就"记住"了一切。
# 可 messages 只会涨、不会消 —— 聊得越久,它越大,
# 终有一轮,它大到模型一次装不下。

要理解这个列表为什么"装不下",得先认清一个事实:模型不是无限地"读"你的输入,它有一个固定大小的上下文窗口。你每轮发的全部内容,折算成 token,必须塞进这个窗口;塞不下,模型直接拒绝。

# 上下文窗口是一个硬上限:历史 + 新输入 + 留给回答的空间,
# 三者加起来,必须塞进这个固定大小的窗口

MODEL_CONTEXT_LIMIT = 8192      # 假设模型窗口是 8192 token

def estimate_tokens(messages):
    # 粗略估算:把所有消息的内容长度折算成 token
    total = 0
    for m in messages:
        total += len(m["content"]) // 2   # 中文粗估每 2 字符约 1 token
    return total

def will_overflow(messages, reserve_for_reply=1000):
    used = estimate_tokens(messages)
    # 别忘了:还要给模型的【回答】留出空间
    return used + reserve_for_reply > MODEL_CONTEXT_LIMIT

# 第一版的 messages 每轮都在涨,estimate_tokens 的结果一路爬升。
# 当它 + 回答预留 越过 8192,这一轮调用就会直接报错 ——
# 这就是"聊到十几轮突然崩"的全部真相。

这一节要建立的认知是:大模型没有记忆,它每一次调用都是一次"失忆的初次见面"——多轮对话的连续感,完全是你这个应用层,靠每轮重新递交历史,人为伪造出来的;而这种伪造,受限于一个你绕不开的物理边界:上下文窗口。第一版最深的想当然,是把"模型记得前面的对话"当成了模型自己的能力。它不是。模型处理完一轮就彻底忘光,下一轮它面对的 messages,在它眼里就是一份全新的、从没见过的文本。是你——应用层——每一轮都把历史重新誊写一遍交上去,才让模型"显得"有记忆。一旦你意识到"记忆是我每轮花 token 买来的",两件事就立刻清楚了。第一,记忆不是免费的:历史越长,你每轮要递交的 token 越多,花的钱、等的时间就越多——记忆是有成本的。第二,记忆不是无限的:你能递交的历史,被上下文窗口这个硬边界卡死,超过就崩——记忆是有容量上限的。第一版"无限累积、每轮全发"的写法,正是同时无视了成本和容量。所以多轮对话的第一课,是把"模型会记住"这个幻觉彻底丢掉,换成一个清醒的认知:我有一个固定大小的窗口,我得自己决定,每一轮往这个窗口里放什么。怎么放,就是下一节"token 预算"要解决的。

二、token 预算:把上下文窗口当成一份要精打细算的预算

既然窗口是固定大小、且什么都得从里面挤,那管理它最有用的思维模型,就是"预算"——把这固定的 token 数,当成一笔总预算,明确地划分给几个不同的用途。

# 把上下文窗口当成一份预算,明确划分给几个用途

MODEL_CONTEXT_LIMIT = 8192

# 一份清晰的预算分配(数字按业务调)
BUDGET = {
    "system":   500,    # 系统提示:角色设定、规则,固定不变
    "pinned":   800,    # 钉住的关键信息:用户身份、核心需求等
    "history":  4500,   # 对话历史:能放多少放多少,放不下就压缩
    "reply":    2000,   # 必须给模型的回答预留的空间
}
# 500 + 800 + 4500 + 2000 = 7800,留一点余量,不顶满 8192

def history_budget():
    # 历史能用的预算 = 总窗口 - 其他几项固定占用
    return MODEL_CONTEXT_LIMIT - BUDGET["system"] \
           - BUDGET["pinned"] - BUDGET["reply"]

# 关键转变:历史不再是"有多少塞多少",
# 而是"只有 history 这一格预算,塞不下就必须想办法压"。

有了预算的概念,每一轮组装上下文时,就要先做一次"预算检查":这一轮要发的东西,加起来有没有超预算;超了,就必须触发压缩。

# 每一轮组装前,先做预算检查:超了就必须压缩,不能硬发

def fit_to_budget(system, pinned, history, new_input):
    # 固定占用:系统提示 + 钉住的信息 + 本轮新输入 + 回答预留
    fixed = (estimate(system) + estimate(pinned)
             + estimate(new_input) + BUDGET["reply"])

    # 留给历史的预算 = 总窗口 - 固定占用
    history_allowance = MODEL_CONTEXT_LIMIT - fixed

    if estimate(history) <= history_allowance:
        return history                       # 没超,历史原样用
    # 超了:必须把历史压缩到 history_allowance 以内
    return compress_history(history, history_allowance)

# 第一版没有这一步检查,它默认历史想多长就多长 ——
# 而真相是:历史能占多大,是被其他几项"挤剩下"的,
# 它是一个动态的、必须主动算出来的余量。

这一节的认知是:上下文窗口不是一个"装不下才需要操心"的容器,而是一份"从第一轮起就该精打细算"的预算——预算思维和容器思维的根本区别在于,容器思维是被动的(撞满了才反应),预算思维是主动的(每一轮都先规划再使用)。第一版对窗口的态度是容器式的:把东西往里扔,扔到装不下为止——它对窗口的唯一互动,就是"溢出"。预算思维彻底反过来:你在动手组装上下文之前,就已经为每一类内容划好了额度——系统提示多少、关键信息多少、历史多少、给回答留多少。这个划分会强迫你回答一个第一版从来没问过的问题:当总额度不够时,该牺牲谁?答案不该是"随便砍掉点历史",而该是一个有优先级的决策——系统提示和钉住的关键信息是刚性的、不能动,该被压缩的永远是大段的、低密度的历史对话。预算思维最大的价值,就是把"砍什么、留什么"从一个崩溃时的慌乱应急,变成一个事先想清楚的、有章法的分配。而一旦你承认历史这一格是有限的、放不下就得压,那"怎么压"就成了下一个必须解决的核心问题。

三、滑动窗口与摘要:历史太长了怎么压

历史超出预算,要压缩。最直接的压法是"滑动窗口"——只保留最近的 N 轮对话,更早的直接丢。

# 压缩法一:滑动窗口 —— 只保留最近 N 轮,更早的丢掉

def sliding_window(history, keep_recent_turns=8):
    # 一轮 = 一条 user + 一条 assistant,所以乘 2
    return history[-keep_recent_turns * 2:]

# 滑动窗口的优点:实现极简单,且最近的对话通常最相关。
# 但它有一个致命缺陷 —— 它是"硬丢":
# 被丢掉的那些早期对话里,如果有重要信息(用户开头说的需求),
# 就跟着永远消失了。这正是第一版第四种问题的来源。

滑动窗口"硬丢"会丢信息。更聪明的压法是"摘要":把早期那段要被丢弃的历史,先让模型总结成一小段摘要,用这段短摘要代替那一大段原文。

# 压缩法二:摘要 —— 把要丢弃的早期历史,先压成一段短摘要

def summarize_old_history(old_messages):
    # 把早期对话拼起来,让模型提炼成一段简短摘要
    transcript = "\n".join(
        f'{m["role"]}: {m["content"]}' for m in old_messages)
    prompt = (f"把下面这段对话压缩成一段简短摘要,"
              f"务必保留:用户的身份、需求、已确认的关键事实。\n\n{transcript}")
    return call_model([{"role": "user", "content": prompt}])

def compress_with_summary(history, keep_recent_turns=6):
    recent = history[-keep_recent_turns * 2:]      # 最近几轮保留原文
    old = history[:-keep_recent_turns * 2]         # 更早的拿去摘要
    if not old:
        return history

    summary = summarize_old_history(old)
    # 用一条"摘要消息"代替那一大段早期原文
    return [{"role": "system",
             "content": f"【早期对话摘要】{summary}"}] + recent

# 摘要把"几十轮原文"压成"一段话",token 大幅下降,
# 而早期对话的【要点】被保留了下来 —— 比滑动窗口的硬丢聪明得多。

这一节的认知是:压缩历史的本质,是在"省 token"和"保信息"之间做权衡——滑动窗口把这个权衡做到了极端的一头(省得最干脆,也丢得最彻底),而摘要,是在这条权衡线上找了一个聪明得多的点。很多人一上来就用滑动窗口,因为它实现起来只有一行代码。但要看清它的代价:它压缩历史的方式,是"整段整段地物理删除"。这意味着它对信息的价值是完全无知的——它不管被删的那段里有没有用户开头交代的核心需求,时间一到、轮数一过,一律砍掉。这就是第一版补救之后反而"忘了开头"的原因:它砍历史的依据是"早不早",而不是"重不重要"。摘要的进步,在于它换了一种压缩的"颗粒度":它不是删除整段,而是提炼整段——把一大段对话原文里的关键信息抽出来,用一小段话承载。同样是把 token 降下来,滑动窗口降的同时丢光了信息,摘要降的同时尽量留住了信息。当然摘要也不是免费的:它要额外调一次模型(有成本)、且摘要这个动作本身可能丢细节(后面工程坑里会讲)。但方向是对的——压缩历史,不该是无脑地"丢旧的",而该是有判断地"留要紧的"。而说到"要紧的",有一类信息要紧到根本不该交给压缩去碰——这是下一节的事。

四、关键信息要"钉住":别让截断丢掉用户身份和需求

不管是滑动窗口还是摘要,都有"丢信息"的风险。可对话里有那么一小撮信息,是绝对不能丢的——用户的身份、他的核心诉求、已经确认过的关键事实。这些信息的正确待遇,不是"参与压缩、希望它被保留",而是把它从对话流里单独抽出来、"钉住",让它永远不进入压缩和截断的逻辑。

# 关键信息"钉住":从对话流里抽出来,单独存,永不参与截断

class PinnedFacts:
    def __init__(self):
        self.facts = {}        # 键值对形式存关键事实

    def pin(self, key, value):
        # 比如 pin("用户身份", "企业版付费客户")
        #      pin("核心需求", "迁移数据时不能停服")
        self.facts[key] = value

    def render(self):
        # 渲染成一段固定文本,每一轮都【原样】放进上下文
        if not self.facts:
            return ""
        lines = [f"- {k}:{v}" for k, v in self.facts.items()]
        return "【关于本次对话,必须始终记住】\n" + "\n".join(lines)

# 钉住的信息走的是 BUDGET["pinned"] 那一格固定预算,
# 它不在 history 那一格里,所以压缩历史时根本碰不到它。

关键信息从哪来?可以让模型在对话过程中,顺便把出现的关键事实抽取出来,存进 PinnedFacts。

# 让模型在对话中顺手抽取关键事实,存进"钉住区"

def extract_and_pin(user_input, pinned):
    prompt = (f"从这句用户输入里,抽取需要长期记住的关键事实"
              f"(身份、需求、约束等),没有就回空。\n用户输入:{user_input}")
    facts = call_model([{"role": "user", "content": prompt}])

    # 把抽取到的事实钉住(实际中会解析成结构化的键值)
    for key, value in parse_facts(facts):
        pinned.pin(key, value)

# 这样,哪怕对话进行到第一百轮、开头的原文早被压缩掉了,
# "用户是企业版客户、迁移不能停服"这些事实,
# 依然稳稳地待在钉住区里,每一轮都被原样送进模型。

这一节的认知是:对话历史里的信息,价值是分层的——绝大多数是"会过期的过程性内容"(寒暄、中间的来回澄清),而极少数是"全程有效的事实性内容";第一版的错,是把这两类信息混在同一个列表里,用同一套规则(留最近的)去处理。"留最近 N 轮"这个规则,对"过程性内容"是合理的——三十轮以前的一句寒暄,丢了毫不可惜。但同一个规则套到"事实性内容"上就是灾难:用户在第一轮说的"我是企业版客户、迁移不能停服",这是一个三十轮后依然 100% 有效的事实,可"留最近 N 轮"会因为它"旧"而把它丢掉。问题的根源,是第一版只有一个扁平的 messages 列表,所有信息不分贵贱地躺在里面,共享同一个生命周期。"钉住"做的事,是在数据结构上把这两类信息分开:过程性的历史,放在那个会被压缩、被截断的 history 区;事实性的关键信息,抽取出来放进一个独立的、不受压缩规则管辖的 pinned 区。一旦分开,它们就能各自被正确地对待——历史可以放心地压,因为该长期记住的东西已经不在里面了;关键事实可以永久地留,因为它走的是另一条不会被截断的通道。识别出"哪些信息值得钉住",并给它一个独立于历史的存放位置,多轮对话才不会"聊着聊着忘了自己在跟谁聊"。

把一轮新对话进来后,该怎么一步步组装出要发给模型的上下文,这个流程画出来就是下面这张图:

[mermaid]
flowchart TD
A[用户发来新一轮输入] --> B[抽取关键事实 更新钉住区]
B --> C[算出留给历史的预算余量]
C --> D{历史超出预算了吗}
D -->|没超| E[历史原文直接用]
D -->|超了| F[早期历史压成摘要 近期保留原文]
E --> G[拼装 系统提示 钉住区 历史 新输入]
F --> G
G --> H[发给模型 得到本轮回答]

五、上下文该放什么:系统提示、检索、历史的优先级

到这里,历史的压缩和钉住都解决了。但真实的对话应用,上下文窗口里要塞的往往不止"系统提示 + 历史"——很多时候还有检索内容(比如从知识库里查到的、和用户问题相关的文档)。这几样东西一起争抢那份有限的预算,就需要一个明确的优先级。

# 多种内容争抢上下文预算时,要有明确的优先级

def assemble_context(system, pinned, retrieved, history, new_input):
    # 优先级从高到低,依次是:
    parts = []
    parts.append(system)          # 1. 系统提示:角色和规则,最高优先级
    parts.append(pinned.render()) # 2. 钉住的关键事实:不可丢

    # 3. 检索内容:和【本轮问题】相关的知识,优先级高于旧历史
    budget_left = MODEL_CONTEXT_LIMIT - estimate(system) \
                  - estimate(pinned.render()) - estimate(new_input) \
                  - BUDGET["reply"]
    retrieved = trim_to(retrieved, budget_left // 2)   # 检索分一半余量
    parts.append(retrieved)

    # 4. 对话历史:用剩下的预算,放得下多少放多少
    budget_left -= estimate(retrieved)
    history = compress_history(history, budget_left)
    parts.append(render_history(history))

    parts.append(new_input)       # 5. 本轮用户输入
    return parts

# 核心:窗口不够时,先保住 system 和 pinned,
# 再让"和本轮强相关的检索内容"挤掉"边缘的旧历史"。

这一节的认知是:上下文窗口里的内容,不存在"都重要、都要留"这种好事——你必须给它们排一个明确的优先级,而排序的依据,是"这条内容对回答好【当前这一轮】问题的贡献有多大"。第一版的上下文里只有"系统提示 + 全部历史",它从没面对过"多种内容抢空间"的局面,所以也从没建立过优先级的概念。可真实应用里,争抢是常态:系统提示、钉住的事实、检索来的知识、对话历史,全都想进那个窗口。这时候必须有一把尺子来排序,而这把尺子就是"对当前这一轮的贡献度"。用这把尺子量一量:系统提示定义了模型的角色,不给它模型就跑偏,贡献度最高;钉住的事实是这场对话的根基,贡献度同样是顶格的;检索内容是专门为了回答"用户这一轮的问题"才查出来的,它和当前问题的相关性,通常高于那些聊了很久的、边缘的旧历史;而旧历史,恰恰是这一摞东西里贡献度最不稳定的——有的旧历史很关键,但那部分关键的,你在上一节已经"钉住"了,剩下没被钉住的旧历史,多半就是可以让位的。把"上下文里放什么"想成一道带优先级的取舍题,而不是"能塞就都塞",你才能在窗口不够时,做出对的牺牲。

六、把多轮对话上下文管理做扎实,要避开的工程坑

前面五节讲清了上下文管理的核心:预算、压缩、钉住、优先级。但要在生产里真正用稳,还有几个工程坑得专门讲。第一个,也是最容易被忽略的:摘要本身也要花 token、也会丢信息,它不是一个免费又无损的操作。

# 坑一:摘要不是免费午餐 —— 它要花钱、会丢信息、还可能滚雪球

def compress_with_summary_safe(history, prev_summary=""):
    # 坑 A:别每轮都重新摘要全部历史 —— 那是巨大的浪费。
    #       应该是"增量摘要":在上一次的摘要基础上,只追加新内容。
    new_part = history_since_last_summary(history)
    if estimate(new_part) < 500:
        return prev_summary        # 新增内容还不多,先不摘要

    # 坑 B:摘要会丢信息,所以摘要【之前】,
    #       关键事实必须已经被"钉住"(走第四节的 extract_and_pin),
    #       绝不能指望摘要去保住它们。
    summary = call_model([{"role": "user", "content":
        f"在已有摘要基础上,补充新对话的要点:\n"
        f"已有摘要:{prev_summary}\n新对话:{render(new_part)}"}])
    return summary

# 坑 C:摘要的摘要的摘要…… 多次摘要会层层失真。
#       关键信息靠"钉住"来兜底,摘要只负责过程性内容,就不怕失真。

第二个坑,是多轮对话里那些"特别占地方"的内容——上一轮贴进来的长文档、一次工具调用返回的大段结果——别把它们原样留在历史里。

# 坑二:工具调用结果、长文档,别原样长留在历史里

def trim_bulky_content(messages):
    trimmed = []
    for m in messages:
        # 工具返回的大段结果:只在【它所在的那一轮】有用,
        # 后续轮次没必要带着它,用一个占位摘要替代
        if m.get("role") == "tool" and estimate(m["content"]) > 1000:
            trimmed.append({
                "role": "tool",
                "content": f"[此处曾有一次工具调用,返回结果已省略]",
            })
        else:
            trimmed.append(m)
    return trimmed

# 一次查询返回 3000 token 的结果,如果原样留在历史里,
# 之后【每一轮】都要为这 3000 token 付费、占预算 ——
# 而它往往只对当时那一轮有意义。用完就该把它从历史里瘦身掉。

还有几个坑值得点一下。其一,token 估算不能靠"字符数除以二"这种土办法,生产里要用模型官方的 tokenizer 精确计算,否则你以为没超、实际超了,照样报错。其二,不同模型的上下文窗口大小差别极大,预算分配的那些数字不能写死在代码里,要随模型可配置。其三,要把每轮实际用掉的 token 记录下来、监控起来,这样你能及时发现某些对话的上下文异常膨胀,而不是等用户报错。下面把上下文管理的几条主轴集中对照一下:

多轮对话上下文管理的几条主轴对照

  手段         解决什么问题              核心要点
  --------------------------------------------------------------
  token 预算   窗口有限 内容要抢空间      把窗口划成 system pinned history reply
  滑动窗口     历史无限增长              只留最近 N 轮 实现简单但硬丢信息
  摘要压缩     历史长又想保住要点        早期历史压成短摘要 增量地做
  关键信息钉住 截断会丢掉用户身份和需求   抽取成独立区 永不参与压缩
  上下文优先级 多种内容争抢窗口          按对当前轮的贡献度排序取舍

  原则:模型没有记忆,记忆是你每轮花 token 买来的;
        既然要花钱买,就得精打细算地买。

这一节这几个坑,串起来是同一个意思:多轮对话的上下文,不是一个"把历史堆进去"的被动列表,而是一个需要你像管理预算一样,每一轮都主动经营的资源。第一版把 messages 当成一个只进不出的垃圾桶——什么都往里扔,从不清理。但上下文窗口是一种稀缺资源:它有固定的容量上限,你往里放的每一个 token 都在花钱。对一种稀缺资源,正确的态度是"经营":每一轮你都要主动地决定,什么该进来(本轮相关的检索、新输入)、什么该被压缩(变长的历史)、什么该被瘦身(用过的工具结果、长文档)、什么必须雷打不动地留着(系统提示、钉住的事实)。这些工程坑——增量摘要而非重复摘要、给臃肿内容瘦身、精确算 token、监控用量——没有一个是花哨的技巧,它们全都是"经营一种稀缺资源"该有的基本动作。把上下文从"一个会自己变大的列表"重新理解成"一份需要你逐轮规划收支的预算",你才会去做这些事,多轮对话才能在长对话、高并发的真实场景里稳得住。

关键概念速查

概念 说明
无状态 模型两次调用之间不保留任何信息,"记忆"全靠应用层每轮重新递交
上下文窗口 模型一次调用能处理的 token 总量,有固定硬上限
token 预算 把窗口当预算,划分给系统提示、钉住信息、历史、回答预留
滑动窗口 只保留最近 N 轮历史,实现简单但会硬丢早期信息
摘要压缩 把早期历史提炼成短摘要,省 token 同时尽量保住要点
增量摘要 在已有摘要上只追加新内容,避免每轮重复摘要全部历史
关键信息钉住 把用户身份、核心需求抽成独立区,永不参与压缩截断
上下文优先级 多种内容争抢窗口时,按对当前轮的贡献度排序取舍
内容瘦身 工具结果、长文档用完即从历史替换为占位摘要,不长留
tokenizer 模型官方的分词器,用于精确计算 token 数,别用字符估算

避坑清单

  1. 不要以为模型有记忆:它无状态,记忆是你每轮重新喂出来的。
  2. 不要让历史无限累积:迟早撞上上下文窗口的硬上限报错。
  3. 不要不做预算划分:窗口要明确分给系统提示、钉住、历史、回答。
  4. 不要只会滑动窗口:它硬丢信息,会丢掉对话开头的关键背景。
  5. 不要指望摘要保住关键事实:关键信息要单独钉住,不靠摘要。
  6. 不要每轮重新摘要全部历史:用增量摘要,只追加新内容。
  7. 不要把关键信息和过程性历史混在一起:它们生命周期不同。
  8. 不要把工具结果、长文档原样长留历史:用完就替换成占位摘要。
  9. 不要用字符数估算 token:要用模型官方 tokenizer 精确算。
  10. 不要把预算数字写死:不同模型窗口差别极大,要可配置。

总结

回头看第一版那个"messages 列表无限累积、每轮全发"的对话功能,它的崩溃很典型。它不在某一行代码,而在一个对多轮对话的根本误解:以为模型自己会记住历史,把历史每轮拼上就万事大吉。真相是,模型完全无状态、没有任何记忆,多轮对话的连续感是应用层每轮重新递交历史伪造出来的;而递交多少历史,被上下文窗口这个硬上限死死卡住。无限累积的历史,迟早撞窗口、迟早把每轮拖慢拖贵、迟早把关键信息淹没。

而把多轮对话的上下文管理做对,工程量并不小。它不是"append 进列表"那么简单,而是要把窗口当预算来精打细算地划分、要在历史变长时用滑动窗口和增量摘要去压缩、要把用户身份和核心需求这类关键信息单独钉住、要在系统提示和检索和历史争抢空间时排好优先级,还要给臃肿内容瘦身、精确计算 token、监控用量。一套真正经得起长对话的上下文方案,是这些环节一个不少地拼起来的。

这件事其实很像一个人用一块容量固定的白板,跟人做一场很长的讨论。第一版的想法是"把每一句话都写到白板上,这样就都记着了"。可白板就那么大,写满了,新的话没地方落笔——这就是撞上下文窗口。聪明的做法是怎么用这块白板?第一,把白板划分成几块区域:角落里一小块写死讨论的目标和规则(系统提示),旁边一小块写对方的身份和核心诉求(钉住的关键信息),中间一大块留给正在进行的讨论(历史),还要留一块空白等着写结论(回答预留)——这就是 token 预算。第二,中间那块写满了,不是停下,而是把前面那些已经聊过的、不那么重要的内容,擦掉、概括成一两句话写在边上(摘要压缩)。第三,有一类信息——对方的身份、最核心的需求——是写在那个专门的角落里的,无论中间怎么擦写,那个角落永远不动(钉住)。一块白板能支撑一场很长的讨论,靠的从来不是"白板无限大",而是"你始终清楚白板上什么该擦、什么该留、什么该概括"。

这类问题还有一个共同的麻烦:它在开发和测试时几乎暴露不出来。你自己测,跟模型聊个五六轮就觉得功能没问题了,这点历史离上下文窗口的上限还差得远,你完全感觉不到"窗口"这堵墙的存在,会觉得"多轮对话嘛,把历史拼上就行了"。真正会把问题撑爆的,是上线后真实用户的长对话:有人会一口气聊上几十上百轮,把你那个"无限累积"的 messages 列表撑到撞穿窗口;有人的对话里贴了长文档、触发了一堆工具调用,让单轮的上下文异常膨胀。这些场景,你自己那几轮测试一个都覆盖不到。所以如果你正在做一个多轮对话的 AI 功能,别等用户的长对话在线上崩了、或者越聊越贵了,才回头怀疑你管理历史的方式。在写下第一个 messages.append 的时候就想清楚:模型的窗口有多大、我这一轮的预算怎么分、历史长了我怎么压、关键信息我怎么保住——把"让对话能聊起来"和"让对话能在几十轮、上百轮之后依然又稳又省地聊下去"当成两件必须分别去做的事,这是这篇文章最想留给你的一句话。

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

数据库分区表完全指南:从一次"分了区查询却没变快"看懂为什么分区键才是根本

2026-5-22 22:11:37

技术教程

数据库读写分离完全指南:从一次"改完昵称还显示旧的"看懂为什么主从延迟绕不开

2026-5-22 22:28:23

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