我做的聊天机器人聊久了就开始报错、还越聊越贵,我把整段对话历史每轮都塞进 prompt,对着上下文窗口超限和 token 爆炸排查了大半天的复盘
那是我接手的第一个正经的 LLM 应用——一个带多轮对话的客服机器人。功能很快做完了,demo 时聊几句也好好的。可上线后,运营反馈了两个诡异的问题:一是用户聊得久了,机器人会突然开始报错、回不出话;二是账单上的 token 费用,高得离谱,远超预估。我一开始百思不得其解:同样一句"你好",为什么有的用户问就没事、有的就报错?直到我打印出每次实际发给模型的 prompt,看到那个长到滚屏都滚不到底的请求体时,才恍然大悟:原来我为了"让机器人记住上下文",每一轮都把从头到尾的完整对话历史,一股脑全塞进了 prompt。这篇就把这场"上下文窗口超限 + token 爆炸"的事故,从头复盘一遍。
故障现场:越聊越长的 prompt,和越来越贵的账单
先看现场。报错信息和 token 增长曲线,共同指向了同一个根源:
# 1. 聊久了之后, 接口开始报这个错:
openai.BadRequestError: This model's maximum context length is 8192 tokens.
However, your messages resulted in 9847 tokens.
Please reduce the length of the messages.
# → 请求的 token 数(9847)超过了模型上下文窗口上限(8192)。
# 2. 打印每次实际发送的 messages, 发现它越来越长:
第1轮: messages 共 2 条 (system + 用户问题1) ≈ 150 tokens
第2轮: messages 共 4 条 (加上 回答1 + 问题2) ≈ 400 tokens
第3轮: messages 共 6 条 (又加 回答2 + 问题3) ≈ 750 tokens
...
第20轮: messages 共 40 条 (前面所有对话全在里面!) ≈ 9847 tokens → 爆!
# 3. 我那段"维护对话历史"的代码(问题根源):
history = [] # 全局/会话级, 存整个对话
def chat(user_input):
history.append({"role": "user", "content": user_input})
resp = client.chat.completions.create(
model="gpt-4",
messages=[{"role":"system","content": SYSTEM_PROMPT}] + history,
# ↑ 每次都把【从第一句开始的全部 history】塞进去!
)
answer = resp.choices[0].message.content
history.append({"role": "assistant", "content": answer})
return answer
# 现象拼图:
# - LLM 是无状态的: 它不记得上一轮, 要"记忆"必须每次把历史一起发过去。
# - 我的做法: 每轮都把【完整历史】拼进 messages → 历史线性增长。
# - 后果1: 聊到一定轮数, 累计 token 超过上下文窗口上限 → 报错。
# - 后果2: token 按量计费, 每轮都重复发送全部历史 →
# 第N轮要为前面 N-1 轮的内容再付一次钱 → 成本呈"平方级"增长!
# (总成本 ≈ 1+2+3+...+N ≈ N²/2, 而不是线性的 N)
看到那个第 20 轮、塞了 40 条消息、9847 个 token 的请求体时,两个问题瞬间都有了答案。报错,是因为累计 token 超过了模型的上下文窗口上限(8192);而账单贵,是因为我每一轮都把前面所有轮的对话重新发送了一遍。更要命的是成本的增长方式:token 按量计费,第 N 轮要为前面 N-1 轮的全部内容"再付一次钱",于是总成本不是随轮数线性增长,而是接近"平方级"(1+2+3+…+N ≈ N²/2)增长。这就解释了为什么"聊得越久越贵、且贵得不成比例"。根源,是我对 LLM 一个最基础特性的误解。
第一件事:搞懂 LLM 的"无状态"与上下文窗口
要解决它,得先搞懂两个 LLM 的根本特性:它是"无状态"的,以及它有一个有限的"上下文窗口"。
LLM 的两个根本特性
# 特性一: LLM 是"无状态"(stateless)的
# - 模型本身不记得你上一轮说了什么。每次 API 调用都是独立的。
# - 所谓"多轮对话记忆", 是靠"客户端每次把历史对话一起发过去"模拟的。
# - 你不发历史, 它就是个"失忆"的; 你发多少历史, 它就"记得"多少。
# 特性二: 上下文窗口(context window)是有限的
# - 每个模型有最大 token 数上限(输入+输出 都算在内):
# gpt-3.5: 4K/16K, gpt-4: 8K/32K/128K, claude: 200K, ...
# - 输入(prompt, 含历史) + 输出(回答) 的 token 总和, 不能超过这个上限。
# - 超了 → 直接报错(BadRequest), 不是截断, 是拒绝。
# token 是什么?
# - 模型处理文本的基本单位, 不等于字符也不等于单词。
# - 粗略: 英文 1 token ≈ 4 字符 ≈ 0.75 词; 中文 1 字 ≈ 1~2 token。
# - 计费按 token 算(输入和输出通常不同价)。
# 这两个特性放一起的矛盾:
# - 要"记忆" → 必须把历史发过去(因为无状态)。
# - 但历史会越来越长 → 迟早撑爆有限的上下文窗口, 且重复计费。
# - ★ 所以: "对话记忆"不能是"无脑拼接全部历史", 必须做"取舍管理"。
# 核心: LLM 无状态(记忆靠每次发历史模拟)+ 上下文窗口有限(超了报错);
# 二者矛盾决定了 —— 对话历史必须被"管理"(裁剪/压缩), 不能无限拼接。
原来我犯的错,源于对 LLM 两个最根本特性的理解缺失。特性一:LLM 是"无状态"的——模型本身不记得你上一轮说了什么,每次 API 调用都是独立的;所谓"多轮对话记忆",纯粹是靠客户端每次把历史对话一起发过去模拟出来的。特性二:上下文窗口是有限的——每个模型有最大 token 上限(输入 + 输出都算在内),超了直接报错(是拒绝,不是截断)。这两个特性放在一起,就构成了一个内在矛盾:要"记忆"就必须发历史(因为无状态),但历史会越来越长、迟早撑爆有限的窗口、还重复计费。我天真地用"无脑拼接全部历史"去实现记忆,正好一头撞进了这个矛盾里。这让我明白了那个关键结论:"对话记忆"绝不能是"把所有历史都塞进去",而必须是一种有取舍的"管理"(裁剪、压缩、筛选)。搞懂了这层,正解的方向就清晰了:在"让模型记得足够多"和"不超窗口、不浪费钱"之间,找到平衡。
第二件事:正解——管理对话历史,而不是无脑拼接
搞懂了原理,正解就清晰了:给对话历史做"内存管理"——滑动窗口保留最近 N 轮、超长时摘要压缩、始终留足输出空间、用 token 预算兜底。
import tiktoken
enc = tiktoken.encoding_for_model("gpt-4")
def count_tokens(messages):
return sum(len(enc.encode(m["content"])) for m in messages)
# ====== 正解一: 滑动窗口 —— 只保留最近 N 轮对话 ======
MAX_TURNS = 10 # 只记最近 10 轮(20 条消息)
def build_messages_sliding(system, history, user_input):
recent = history[-(MAX_TURNS * 2):] # 截取最近 N 轮
return [{"role": "system", "content": system}] + recent + \
[{"role": "user", "content": user_input}]
# → 历史不再无限增长, 稳定在一个上限内。简单有效, 最常用。
# ====== 正解二: token 预算 —— 按 token 数裁剪(比按轮数更精准)======
def build_messages_budget(system, history, user_input, max_input=6000):
msgs = [{"role": "system", "content": system}]
tail = [{"role": "user", "content": user_input}]
budget = max_input - count_tokens(msgs) - count_tokens(tail)
kept = []
for m in reversed(history): # 从最近往前加, 加到预算用完
t = len(enc.encode(m["content"]))
if budget - t < 0:
break
kept.insert(0, m); budget -= t
return msgs + kept + tail
# → 不管每条多长, 总能控制在 token 预算内, 给输出留足空间。
# ====== 正解三: 摘要压缩 —— 历史太长时, 把旧对话总结成一段摘要 ======
def summarize_old(old_history):
text = "\n".join(f"{m['role']}: {m['content']}" for m in old_history)
resp = client.chat.completions.create(model="gpt-3.5-turbo",
messages=[{"role":"user","content": f"用三句话总结以下对话要点:\n{text}"}])
return resp.choices[0].message.content
# 用法: 旧的 N 轮压成一段 summary 放进 system, 只保留最近几轮原文。
# → 既保留长期上下文的"要点", 又不占大量 token。适合长对话。
# ====== 正解四: 永远给"输出"留足 token 空间 ======
# 上下文窗口 = 输入 + 输出。若输入占满, 模型没空间生成回答(或被截断)。
# 留: max_tokens(输出) + 安全余量。如 8K 窗口, 输入最多用 ~6K, 留 2K 给输出。
# ====== 正解五: 关键信息别依赖"对话记忆", 放进 system 或外部存储 ======
# 用户名、订单号等关键事实, 别指望它"记在对话里"(可能被裁掉),
# 显式放进 system prompt, 或存数据库/RAG, 每次按需取。
# 核心: 滑动窗口/token预算 控制历史长度; 长对话用摘要压缩保留要点;
# 永远给输出留空间; 关键信息显式存储别依赖对话记忆。
修复的核心,是把"对话历史"当成一种需要精心管理的有限资源来对待。正解一:滑动窗口——只保留最近 N 轮对话,历史不再无限增长,简单有效、最常用。正解二:token 预算——按 token 数(而非轮数)裁剪,从最近往前加直到预算用完,不管每条多长都能精准控制在窗口内。正解三:摘要压缩——历史太长时,把旧对话用一个便宜的模型总结成一段摘要放进 system,只保留最近几轮原文,既保住长期上下文的要点、又省 token,适合长对话。正解四:永远给输出留足空间——上下文窗口 = 输入 + 输出,输入占满了模型就没空间生成回答(8K 窗口,输入最多用 ~6K)。正解五:关键信息别依赖对话记忆——用户名、订单号这类关键事实,显式放进 system prompt 或存外部存储,别指望它"记在对话里"(可能被裁掉)。归根结底:滑动窗口/token 预算控制长度;长对话用摘要压缩;永远给输出留空间;关键信息显式存储。
第三件事:几种对话记忆策略的取舍
修完后我把业界常见的几种"对话记忆"管理策略系统梳理了一遍,它们各有适用场景。
对话记忆管理策略对比
# 1. 全量拼接(我踩的坑)
# 把所有历史都发过去。
# ✓ 实现最简单, 记忆最完整。
# ✗ 必爆窗口、成本平方级增长。只适合"明确很短"的对话。
# 2. 滑动窗口(Sliding Window)
# 只保留最近 N 轮。
# ✓ 简单、token 稳定可控。
# ✗ 会"忘记"很久以前说的(N 轮之前的丢失)。最常用的基线方案。
# 3. token 预算裁剪
# 按 token 数从最近往前保留, 加到预算上限为止。
# ✓ 比按轮数更精准(每条长短不一时), 严格控制在窗口内。
# ✗ 同样会丢失较早的内容。
# 4. 摘要压缩(Summarization)
# 旧对话总结成摘要, 摘要 + 最近几轮原文一起发。
# ✓ 长对话也能保留"要点", token 可控。
# ✗ 摘要有信息损失; 额外的总结调用有成本和延迟。
# 5. 向量检索记忆(RAG-based memory)
# 把历史存向量库, 每轮按"与当前问题的相关性"检索最相关的几条。
# ✓ 能从超长历史里精准捞回相关内容, 不受轮数限制。
# ✗ 实现复杂, 依赖检索质量(召回不准就丢上下文)。
# 实战常组合: 滑动窗口(近期原文) + 摘要(远期要点) + 关键信息放system。
# 核心: 短对话用滑动窗口/token预算即可; 长对话加摘要压缩;
# 超长/需精准长期记忆用向量检索; 常组合使用, 按场景权衡。
原来"对话记忆"远不是一道单选题,而是一系列各有取舍的策略。全量拼接(我踩的坑)最简单但必爆窗口、成本平方级,只适合明确很短的对话;滑动窗口简单、token 稳定,但会忘记很久以前的内容,是最常用的基线;token 预算裁剪比按轮数更精准;摘要压缩能在长对话里保留要点(代价是信息损失和额外调用);向量检索记忆能从超长历史精准捞回相关内容(代价是实现复杂、依赖检索质量)。它们的共同启示是:没有"完美的记忆方案",只有"适合你对话特征的权衡"——对话有多长?对早期内容的依赖有多强?能接受多少成本和延迟?答案不同,选择就不同。实战中常常是组合使用:滑动窗口保近期原文、摘要保远期要点、关键信息放 system。下面这张图,是这次上下文超限的成因与解法:
第四件事:常见模型的上下文窗口与计费速查
这次踩坑后,我把常用模型的上下文窗口大小和计费特点整理成一张表,做容量和成本预估时对照着看。
| 模型(示例) | 上下文窗口 | 特点 | 适用 |
|---|---|---|---|
| gpt-3.5-turbo | 4K / 16K | 便宜快,窗口小 | 短对话、摘要、分类 |
| gpt-4 | 8K / 32K | 能力强,贵,窗口中等 | 复杂推理 |
| gpt-4-turbo / 4o | 128K | 大窗口,性价比高 | 长文档、长对话 |
| claude 系列 | 200K | 超大窗口 | 超长文档/代码库 |
| 开源(如 Llama) | 视版本 4K~128K | 可私有部署 | 数据敏感场景 |
这张表,让我对"窗口和成本"有了量化的概念。核心认知是:窗口越大不代表越该"无脑塞满"——即便用 128K/200K 的大窗口模型,把历史塞满依然意味着高昂的 token 成本和更慢的响应(输入越长,处理越慢、越贵)。它给我的启发是:大窗口是"能力上限",不是"使用建议";窗口大,缓解的是"会不会报错",但缓解不了"贵不贵、快不快"。所以即便换了大窗口模型,对话历史管理(裁剪/压缩)依然是必须做的——它省的是实打实的钱和延迟。更深一层,这让我意识到:用云服务/按量计费的资源时,"能用多少"和"该用多少"是两个问题;额度(窗口)给得大,不等于你就该用满;成本意识,应该贯穿每一次调用的设计。
第五件事:token 估算与成本控制要点
这次事故也逼我建立了"token 成本"的意识。我把 token 估算和成本控制的要点梳理了一下。
| 要点 | 说明 | 做法 |
|---|---|---|
| token≠字符 | 中文1字≈1~2token,英文1token≈4字符 | 用 tiktoken 精确算 |
| 输入输出分别计费 | 通常输出比输入贵 | 控制 max_tokens 输出长度 |
| 历史重复计费 | 每轮重发历史=反复为旧内容付费 | 裁剪/压缩历史 |
| system prompt 也算钱 | 每轮都发,长system很费 | 精简 system,别堆废话 |
| 选对模型 | 简单任务用便宜模型 | 摘要/分类用 3.5,推理用 4 |
| 上线前预估 | 按平均轮数×每轮token估月成本 | 压测算账,别上线才发现 |
这张表,是我用一张超预期的账单换来的"省钱清单"。它把 LLM 应用最容易"悄悄烧钱"的几个点都列了出来:token 不等于字符(要用 tiktoken 精确算)、输出通常比输入贵、历史重复计费、连 system prompt 每轮都在花钱、简单任务该用便宜模型、上线前要压测预估成本。它给我的最大启发,超出了 token 本身:用任何"按量计费"的服务(LLM、云存储、云函数……),都必须建立"每一次调用都在花钱"的成本意识。传统编程里,我们习惯了"计算资源近乎免费"(多跑个循环、多存点数据,几乎不心疼);但在按量计费的世界里,每一个 token、每一次请求、每一字节,都直接对应着账单上的数字。这要求工程师的脑子里,除了"正确性"和"性能",还要时刻装着一根"成本"的弦:同样能跑通的两种实现,在按量计费下,成本可能差几倍甚至几十倍;而"成本最优"的设计,往往就藏在"别浪费、按需取、用对档位"这些朴素的原则里。
第六件事:做对话类 LLM 应用,我现在的设计决策
现在再做带对话的 LLM 应用,我不再一上来就 history 无脑拼接,而是按这张图先想清楚记忆策略:
这张图的精髓,是"先根据对话长度预期,选定记忆策略,再动手"。第一步永远是问 "这个对话预期有多长":很短就滑动窗口、中等就 token 预算裁剪、很长就滑动窗口(近期原文)+ 摘要(远期要点)、需要精准回忆很早的细节再加向量检索。无论哪种,都要做两件事:关键信息放 system 或外部存储(别赌它在对话里活着)、用 tiktoken 监控每轮 token。最后上线前一定压测估成本 + 设 token 预算上限兜底(哪怕逻辑有漏洞,也有个硬上限防止爆窗口和爆账单)。这套决策,让我做对话应用时,从"先写出来再说"变成了"先想清楚记忆和成本"——核心始终是:对话记忆是要主动设计和管理的资源,不是 history.append 就完事的。
我立下的几条规矩
这场"上下文超限 + token 爆炸"的事故,换来了我做 LLM 应用时,刻进骨子里的几条铁律:
- 对话历史必须管理,不能无脑拼接。LLM 无状态 + 窗口有限,全量拼接必爆窗口、成本平方级。
- 用滑动窗口或 token 预算控制历史长度。按 token 裁剪比按轮数更精准。
- 长对话用摘要压缩。旧对话总结成摘要保要点,只留最近几轮原文。
- 永远给输出留足 token 空间。窗口=输入+输出,别让输入占满导致回答被截。
- 关键信息显式存储。用户名/订单号放 system 或外部库,别赌它在对话历史里。
- 建立 token 成本意识。每次调用都在花钱,用 tiktoken 监控、选对模型、精简 system。
- 上线前压测估成本、设 token 上限兜底。别等账单爆了才知道贵。
附:一个带滑动窗口+摘要+token兜底的对话管理器
口说无凭。下面把前面的几种策略合到一个可直接用的对话管理器里:滑动窗口保近期、超长自动摘要、token 预算硬兜底:
import tiktoken
class ConversationManager:
def __init__(self, system, model="gpt-4",
max_input_tokens=6000, keep_recent=6):
self.system = system
self.enc = tiktoken.encoding_for_model(model)
self.max_input = max_input_tokens # 输入token硬上限(给输出留空间)
self.keep_recent = keep_recent # 至少保留最近几条原文
self.history = [] # [{role, content}, ...]
self.summary = "" # 远期对话的摘要
def _ntok(self, text):
return len(self.enc.encode(text))
def add(self, role, content):
self.history.append({"role": role, "content": content})
def build_messages(self, user_input):
# 1. system(含远期摘要)
sys_content = self.system
if self.summary:
sys_content += f"\n\n[早前对话要点]: {self.summary}"
msgs = [{"role": "system", "content": sys_content}]
tail = [{"role": "user", "content": user_input}]
# 2. 从最近往前, 按token预算保留历史(滑动窗口+token预算)
budget = self.max_input - self._ntok(sys_content) - self._ntok(user_input)
kept = []
for m in reversed(self.history):
t = self._ntok(m["content"])
if budget - t < 0 and len(kept) >= self.keep_recent:
break # 预算用完且已保住最近几条 → 停
kept.insert(0, m); budget -= t
return msgs + kept + tail
def maybe_summarize(self, client):
# 3. 历史太长时, 把"较老的部分"压成摘要, 从history里移除
if len(self.history) <= self.keep_recent * 3:
return
old = self.history[:-self.keep_recent] # 旧的部分
text = "\n".join(f"{m['role']}: {m['content']}" for m in old)
resp = client.chat.completions.create(
model="gpt-3.5-turbo", # 用便宜模型做摘要
messages=[{"role":"user",
"content": f"把以下对话压缩成要点(保留关键事实):\n{text}"}])
new_sum = resp.choices[0].message.content
self.summary = (self.summary + " " + new_sum).strip()
self.history = self.history[-self.keep_recent:] # 只留最近几条原文
# 用法:
# cm = ConversationManager(system="你是客服助手", max_input_tokens=6000)
# def chat(client, user_input):
# messages = cm.build_messages(user_input) # 永不超token预算
# resp = client.chat.completions.create(
# model="gpt-4", messages=messages, max_tokens=1500) # 给输出留空间
# answer = resp.choices[0].message.content
# cm.add("user", user_input); cm.add("assistant", answer)
# cm.maybe_summarize(client) # 必要时压缩远期历史
# return answer
# 核心: build_messages 用"滑动窗口+token预算"保证输入永不超限、且留输出空间;
# maybe_summarize 把远期对话压成摘要保要点; 三招合一, 既不爆窗口也不烧钱。
这个 ConversationManager,把这篇文章所有的策略,落成了一个可以直接用的类。它的三个核心方法,正对应三道防线:build_messages 用"滑动窗口 + token 预算"保证每次输入永不超 token 预算、且始终给输出留足空间(这道线堵死了"爆窗口报错");maybe_summarize 在历史太长时,把较老的部分用便宜模型压成摘要、只留最近几条原文(这道线既保住了远期上下文的要点、又控制了 token 成本)。三招合一,既不会爆窗口、也不会烧钱,还尽量保住了记忆。这,正是我想用这个类,留给每个做对话 LLM 应用的人的最后一课:"对话记忆",是一个需要被认真设计的子系统,而不是一行 history.append。它内部要同时权衡记忆的完整性、token 的成本、窗口的限制、响应的延迟这四件事——而把这些权衡显式地、有结构地封装起来(就像这个管理器做的),正是从"能跑的 demo"走向"扛得住生产的应用"之间,那段最关键的距离。LLM 应用的工程化,很大程度上,就是把这些"看不见的资源管理",一件一件认真做好。
写在最后
回头看,这场由"无脑拼接对话历史"引发的、又报错又烧钱的事故,真正教给我的,远不止"怎么管理对话历史"。它让我作为一个从传统软件开发转过来的工程师,完成了一次重要的思维切换。过去写代码,我脑子里的核心是"逻辑对不对";计算和存储资源,几乎是"免费且无限"的背景板——多跑个循环、多存点数据,从不心疼。但 LLM 应用,把我拽进了一个全新的范式:这里,"上下文窗口"是有限的(撑爆就报错)、"token"是要花钱的(每一个都计入账单)、"记忆"是要自己实现和管理的(模型本身无状态)。我那段 history.append 的代码,放在传统思维里天经地义("不就是把数据存起来嘛");可放在 LLM 的世界里,它同时踩中了"有限窗口"和"按量计费"两个全新的约束,于是酿成了事故。这让我深刻领悟到:每当我们进入一个新的技术范式,最危险的,往往不是那些"我们不会的新东西",而是那些"我们以为还成立、实则已经改变了的旧假设"。"资源免费无限""程序自带状态/记忆"——这些在传统编程里根深蒂固的假设,在 LLM 应用里统统不再成立;而能不能及时识别并放下这些"过时的旧假设",决定了你能不能真正驾驭一个新范式。这,是我用一次"又报错又烧钱"的事故,换来的、关于 LLM 应用、也关于"范式切换"的、最朴素也最深刻的领悟。如果这篇复盘,能让你在写下一行 messages 拼接代码时,顺手想一想"它会涨到多大、要花多少钱",那我对着那个滚不到底的请求体和那张超预期的账单熬的这大半天,就值了。
—— 别看了 · 2026