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 | 模型对窗口正中间内容注意力下降的现象,重要信息应放在两头 |
| 软阈值 | 低于真实上限的预警线,触到即主动裁剪,避免长期运行在边缘 |
避坑清单
- 不要把全部历史无脑拼进 prompt:对话会无限变长,上下文窗口是固定上限,迟早撞墙。
- 不要用字符数估算 token:在一条会导致请求失败的硬边界附近做模糊判断很危险,用 tiktoken 精确数。
- 不要忘记给输出预留额度:窗口是输入输出共享的,不预留会导致模型回复刚开头就被截断。
- 不要按"最近 N 条"粗暴截断:会把对话开头的订单号、用户身份等关键事实一起丢掉。
- 不要让滑动窗口冲走关键信息:对订单号、身份这类长期有用的事实打 pinned 标记,豁免裁剪。
- 不要每轮都做摘要:摘要要多调一次模型、有损耗,设触发阈值,平时走便宜的滑动窗口。
- 不要把大段长资料塞进 prompt:体量远大于窗口的知识应存进向量库,按需检索召回。
- 不要把重要信息埋在历史正中间:模型对窗口中段注意力弱,关键内容放 prompt 开头或结尾。
- 不要让 prompt 前缀频繁变动:稳定的系统提示、摘要靠前放,才能命中 prompt 缓存省成本。
- 不要等撞上限才裁剪:设一个软阈值提前触发裁剪,给真实窗口上限留出从容的缓冲。
总结
回头看第一版那个把全部历史拼进 prompt 的对话机器人,它错得其实很典型。它的错误不在某一行代码,而在一个对大模型的根本误解:以为模型有记忆,以为把历史给它就等于让它记住,以为历史越全效果越好。真相是,模型每次调用都是无状态的,它能依据的只有当次 prompt 里的 token,而这个 prompt 被上下文窗口死死卡住。一个无限增长的对话历史,撞上一个容量固定的窗口,崩溃是必然的。
而把这件事做对,工程量并不小。它不是加一个 if 判断,而是要建一整层位于你的代码和模型之间的上下文管理:精确地数 token,把窗口拆成预算,用滑动窗口裁剪近期对话,用钉住机制保护关键事实,用滚动摘要压缩旧历史,用检索召回处理大段知识,还要对抗 lost in the middle、留好输出余量、用好缓存。这一层不是模型送给你的,是要你自己一节一节搭出来的。
这件事其实很像一个人收拾自己有限的办公桌面。桌子就那么大,放不下你所有的文件。聪明的做法不是抱怨桌子小,而是经营它:最常用的几样东西摆在手边最顺眼的位置,正在处理的文件摊在正中间,处理完的旧文件归纳成一页便签塞进抽屉,真要用到某份资料时再去文件柜里取。上下文窗口管理,做的就是这件事——系统提示和关键事实摆在注意力最强的两头,近期对话留在中间,旧对话压成摘要,长资料放进"文件柜"按需去取。
这类问题有一个共同的麻烦:它在本地几乎暴露不出来。你自己测对话机器人,聊个五六轮就觉得"挺好,记性不错"——而五六轮的历史离上下文窗口的上限还差得远,所有的裂缝都还没张开。真正会聊上几十轮、几百轮的,是上线后那些真实的用户。所以如果你正在做一个多轮对话或带长文档的 AI 应用,别等线上报出 context length exceeded 才回头补这一层。在它还只聊五六轮、一切看起来都很好的时候,就把数 token、裁剪、摘要、检索这套预算经营的机制搭起来——这是这篇文章最想留给你的一句话。
—— 别看了 · 2026