上一篇文章我们实现了多轮对话,让程序自动记录历史消息。但随着对话越来越长,迟早会遇到一个错误——请求被拒绝,提示“token 超限”。这不是 bug,而是模型的能力边界:每一次请求的消息总量不能超过模型的上下文窗口。本篇文章将讲清楚 Token 和上下文窗口的关系,并写出可运行的估算与裁剪代码,让对话程序在长聊中也能稳定运行。

Token 是什么

大模型不直接处理自然语言文本,而是把文本切分成一个个更小的单元——Token。一个 Token 可以是一个完整的英文单词、一个单词片段、一个汉字或一个标点符号。

以 OpenAI 的 GPT-4 分词器为例:

1
2
3
4
原文:我喜欢用 Python 写代码。

分词结果(示意):
我 | 喜欢 | 用 | Python | 写 | 代码 | 。

英文单词通常 1 个 token 左右,中文一个汉字占 1—2 个 token。一条看起来不长的中文句子,token 数可能远超直觉。可以用 OpenAI 官方的 Tokenizer 工具 直观体验,也可以用 Python 库精确计算。

上下文窗口是什么

每个模型都有一个硬性的上下文窗口(Context Window),即单次请求中 messages 数组能包含的最大 token 数。常见的几个窗口值:

模型 上下文窗口
GPT-4o-mini 128,000 tokens
GPT-4o 128,000 tokens
GPT-3.5-turbo 16,385 tokens
DeepSeek-V3 128,000 tokens

注意:上下文窗口包含输入和输出的总和。如果你发送 10,000 token 的消息,同时要求模型最多生成 4,096 token 的回复,实际消耗不会超过 14,096 token——远低于 128K,不会超限。

为什么多轮对话会超限

回顾上一篇文章:每轮对话都把全部历史消息和最新一条 user 消息一起发送。这意味着:

  • 第 1 轮:system + 第 1 个问答
  • 第 5 轮:system + 前 4 轮问答 + 当前问题
  • 第 50 轮:system + 前 49 轮问答 + 当前问题

消息越来越长,token 数持续增长。即便每次问答只有 200 token,50 轮也约 10,000 token——对 128K 窗口不算什么,但如果每轮返回的是几千字的分析报告,或者用的是 4K/8K/16K 窗口的老模型,超限就会发生。

用 tiktoken 估算 token 数

OpenAI 开源了 tiktoken 库,可以精确计算文本在不同模型下的 token 数。安装:

1
2
source .venv/bin/activate
python -m pip install tiktoken

计算单条文本:

1
2
3
4
5
6
7
8
9
10
import tiktoken

# "gpt-4o" 编码覆盖 gpt-4o、gpt-4o-mini 等模型
enc = tiktoken.encoding_for_model("gpt-4o")

text = "Token 是模型处理文本的最小单元。"

token_count = len(enc.encode(text))
print(f"文本长度:{len(text)} 字符,对应 {token_count} 个 token")
# 输出示例:文本长度:20 字符,对应 21 个 token

在实际项目中,更常用的是根据消息角色估算,因为不同角色的消息计入 token 的方式略有不同(每条消息都包含元信息开销)。tiktoken 提供了一个便捷方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import tiktoken

enc = tiktoken.encoding_for_model("gpt-4o")

messages = [
{"role": "system", "content": "你是一个 Python 助手。"},
{"role": "user", "content": "解释什么是 token。"},
{"role": "assistant", "content": "Token 是模型处理文本的最小单元。"},
]

# 每条消息有 4 个 token 的固定开销(针对 gpt-4o 系列)
tokens_per_message = 3
token_count = 0
for msg in messages:
token_count += tokens_per_message
token_count += len(enc.encode(msg["content"]))
token_count += 3 # 每次请求额外的固定开销

print(f"消息总 token 数:{token_count}")

更简单的方式是直接使用 API 返回的 usage 字段——它才是最终计费的依据,也最能反映实际 token 消耗。

裁剪历史:保持对话不超限

当估算的 token 数接近上下文窗口时,必须裁剪历史消息。核心原则:system 消息始终保留,旧对话从最前面开始丢弃

裁剪策略如下:设定一个安全上限(比如模型窗口的 80%),超过上限时从最旧的 user/assistant 对开始删除,直到 token 数回到安全线以下。

最小可运行示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
import tiktoken
import sys


def count_tokens(messages: list[dict], model: str = "gpt-4o") -> int:
"""估算消息列表的总 token 数(含固定开销)。"""
enc = tiktoken.encoding_for_model(model)
tokens_per_msg = 3 # gpt-4o 系列开销
total = 3 # 请求级开销
for msg in messages:
total += tokens_per_msg + len(enc.encode(msg["content"]))
return total


def trim_messages(
messages: list[dict],
max_tokens: int = 100000,
model: str = "gpt-4o",
) -> list[dict]:
"""裁剪历史消息,保留 system 消息,从最旧的 user/assistant 对开始移除。"""
if count_tokens(messages, model) <= max_tokens:
return messages

# 保护 system 消息:只处理 roles 为非 system 的消息
system_msgs = [m for m in messages if m["role"] == "system"]
body_msgs = [m for m in messages if m["role"] != "system"]

# 从最前面开始成对裁剪(保留 user/assistant 的配对关系)
trimmed = system_msgs + body_msgs
while count_tokens(trimmed, model) > max_tokens and len(body_msgs) >= 2:
# 移除最旧的一对 user + assistant(跳过 system 后的前两条非 system 消息)
body_msgs = body_msgs[2:]
trimmed = system_msgs + body_msgs

return trimmed


# ---- 模拟一个长对话的裁剪 ----
messages = [
{"role": "system", "content": "你是简洁的助手。"},
# 模拟大量历史消息(实际应用中来自真实对话)
*[
{"role": "user", "content": f"这是第 {i} 轮问题的正文,包含一些文字用于占位。"}
for i in range(1, 51)
],
*[
{"role": "assistant", "content": f"这是第 {i} 轮回答的正文,同样包含一些文字。"}
for i in range(1, 51)
],
{"role": "user", "content": "总结一下我们聊过的所有内容。"},
]

print(f"裁剪前消息数:{len(messages)}")
print(f"裁剪前 token 数:{count_tokens(messages)}")

trimmed = trim_messages(messages, max_tokens=8000)
print(f"裁剪后消息数:{len(trimmed)}")
print(f"裁剪后 token 数:{count_tokens(trimmed)}")

运行这段代码,你会看到 50 轮历史被裁剪到安全范围内。关键逻辑只有两点:保留 system 消息,从最旧的 user/assistant 对开始丢弃。

裁剪的注意事项

  • 不要只删 user 不删 assistant。删除不配对的消息会破坏对话结构,可能导致模型行为异常。
  • 裁剪是对信息的丢弃,模型会”忘记”被删掉的内容。如果某些旧信息仍然重要(如用户偏好、关键结论),应当在裁剪前做摘要压缩,把核心信息写回 system 消息。
  • 不同模型的固定开销不同。上面的 tokens_per_msg = 3 适用于 gpt-4o 系列。其他模型需查阅官方文档确认,OpenAI 对 gpt-3.5-turbo 系列是 4。

如果不用 tiktoken

并非所有模型都有官方 Token 计数库。对于其他模型,以下方法可以兜底:

  • 使用 API 返回的 usage 字段。每次请求后,response.usage.total_tokens 给出实际消耗。在裁剪逻辑中,可以把最近 N 条消息的累计 token 数作为估算依据。
  • 按中文字符粗略估算。一条中文消息的 token 数大致在字符数的 1.2—2.5 倍之间。这个比例不稳定,只能用于非严格场景。
  • 留足安全余量。如果把安全阈值设为模型窗口的 60% 而不是 80%,即使估算有误差也不容易超限。

常见问题

tiktoken 报错 Unknown encoding 可能是模型名写错了,或者用的是不兼容的非 OpenAI 模型。对非 OpenAI 模型直接使用 API 返回的 usage 字段来跟踪 token 消耗,不要依赖 tiktoken。

裁剪后模型回答质量下降。 裁剪导致模型丢失了重要的前置信息。可以考虑在裁剪前让另一个模型调用做一次摘要,把摘要作为 system 消息的补充。

count_tokens 的结果和 API 返回的不一致。 这是正常的。tiktoken 的本地估算和 API 服务器的实际计数可能有微小差异(通常 < 1%)。生产环境中以 API 返回值为准。

明明没超过窗口,API 还是报错。 检查 max_tokens 参数——它指的是模型最多生成多少 token。如果你同时设了很大的 max_tokens,可能会导致输入 + 输出超出窗口。调整 max_tokens 或减少输入长度。

小结

  • Token 是模型处理文本的最小单元,中英文的 token 密度不同。
  • 上下文窗口是模型单次请求能处理的 token 上限,包含输入和输出。
  • 多轮对话随时间推移必然增长,需要主动估算和裁剪。
  • 裁剪时务必保留 system 消息,从最旧的 user/assistant 对开始删除。
  • 以 API 返回的 usage 为准,tiktoken 作辅助估算。

掌握了 token 估算与裁剪,你的多轮对话程序就不会再因为“聊太久”而崩溃。下一篇文章我们将讨论常用的生成参数——temperature、top_p 和 max_tokens——以及它们如何影响输出质量。