Token 计算与上下文窗口管理实战
Token 计算与上下文窗口管理实战
本文是【AI 专题精讲】系列第 10 篇。 上一篇:AI 缓存策略:精确缓存 + 语义缓存,省钱又提速 | 下一篇:大模型微调实战:从 LoRA 到部署上线的完整方案
这篇文章你会得到什么
你一定遇到过这些情况:
- API 返回
context_length_exceeded,对话直接断了 - 月底一看账单,Token 用量比预期多了 3 倍
- 用户聊了 20 轮,模型开始”忘记”之前说的话
- 不同模型的上下文窗口不一样,切换模型就爆了
Token 是 LLM 的”计量单位”——你花的每一分钱、模型能记住的每一句话,都以 Token 为单位计算。
今天给你:Token 精确计算方法、各模型窗口对比、动态上下文管理策略,以及一个即插即用的聊天历史自动裁剪中间件。
Token 到底是什么
BPE 编码
Token 不是字、不是词、不是字符。它是 BPE(Byte Pair Encoding) 算法切出来的”片段”:
英文:
"Hello world" → ["Hello", " world"] → 2 tokens
中文:
"你好世界" → ["你好", "世界"] → 2 tokens(GPT-4o)
"你好世界" → ["你", "好", "世", "界"] → 4 tokens(GPT-3.5)
代码:
"console.log('hello')" → ["console", ".", "log", "('", "hello", "')"] → 6 tokens
关键认知:中文每个字大约 0.51.5 个 Token(取决于模型),英文每个单词约 11.5 个 Token。
为什么 Token 数不好估
同一句话,不同模型的 Token 数可能不同——因为每个模型的 tokenizer(分词器)不同:
| 模型 | ”如何申请退货退款” 的 Token 数 |
|---|---|
| GPT-4o / GPT-4o-mini | 4 |
| GPT-3.5-turbo | 7 |
| Claude 3 | 5 |
所以不能用粗略估算,必须用对应模型的 tokenizer 精确计算。
精确计算:tiktoken
Python
pip install tiktoken
import tiktoken
def count_tokens(text: str, model: str = "gpt-4o") -> int:
encoding = tiktoken.encoding_for_model(model)
return len(encoding.encode(text))
# 单条文本
print(count_tokens("如何申请退货退款")) # 4
# 查看 Token 细节
encoding = tiktoken.encoding_for_model("gpt-4o")
tokens = encoding.encode("如何申请退货退款")
print(tokens) # [12345, 23456, ...]
print([encoding.decode([t]) for t in tokens]) # ['如何', '申请', '退货', '退款']
计算完整 messages 的 Token 数
ChatGPT API 的 messages 格式有额外的结构开销(role 标签、分隔符等),不能只算 content:
def count_messages_tokens(messages: list[dict], model: str = "gpt-4o") -> int:
encoding = tiktoken.encoding_for_model(model)
# 不同模型的结构开销不同
if model.startswith("gpt-4o") or model.startswith("gpt-4"):
tokens_per_message = 3 # <|im_start|>role\ncontent<|im_end|>
tokens_per_name = 1
else:
tokens_per_message = 4
tokens_per_name = -1
num_tokens = 0
for message in messages:
num_tokens += tokens_per_message
for key, value in message.items():
num_tokens += len(encoding.encode(str(value)))
if key == "name":
num_tokens += tokens_per_name
num_tokens += 3 # 每个回复的 priming
return num_tokens
JavaScript / TypeScript
npm install tiktoken
import { encoding_for_model } from 'tiktoken';
function countTokens(text: string, model: string = 'gpt-4o'): number {
const enc = encoding_for_model(model as any);
const tokens = enc.encode(text);
enc.free();
return tokens.length;
}
console.log(countTokens('如何申请退货退款')); // 4
快速估算(不用 tiktoken)
有些场景不需要精确计算(比如粗略估计成本),可以用经验公式:
def estimate_tokens(text: str) -> int:
"""
粗略估算,误差 ±20%
中文约 0.6 token/字,英文约 0.25 token/词
"""
chinese_chars = sum(1 for c in text if '\u4e00' <= c <= '\u9fff')
other_chars = len(text) - chinese_chars
return int(chinese_chars * 0.6 + other_chars * 0.25)
各模型上下文窗口对比
| 模型 | 上下文窗口 | 输入价格 | 输出价格 |
|---|---|---|---|
| GPT-4o-mini | 128K | $0.15/1M | $0.60/1M |
| GPT-4o | 128K | $2.50/1M | $10.00/1M |
| Claude 3.5 Sonnet | 200K | $3.00/1M | $15.00/1M |
| Claude 3 Haiku | 200K | $0.25/1M | $1.25/1M |
| DeepSeek V3 | 64K | ¥1.00/1M | ¥2.00/1M |
| Gemini 1.5 Pro | 1M | $1.25/1M | $5.00/1M |
| Qwen-Max | 32K | ¥20/1M | ¥60/1M |
窗口大不等于随便用
128K 窗口能装大约 10 万字中文——但这不代表你该把 10 万字都塞进去:
- 成本线性增长:塞 10 万 Token 比 1000 Token 贵 100 倍
- 注意力稀释:上下文太长,模型对中间内容的关注度下降(“Lost in the Middle” 问题)
- 延迟增加:Token 越多,首字响应时间越长
经验法则:实际使用的上下文控制在窗口的 50%~70%,留余量给输出。
成本计算
Input / Output 分别计价
大部分 API 的输入和输出 Token 分别计价,且输出通常更贵:
def estimate_cost(
input_tokens: int,
output_tokens: int,
model: str = "gpt-4o-mini",
) -> float:
pricing = {
"gpt-4o-mini": {"input": 0.15, "output": 0.60},
"gpt-4o": {"input": 2.50, "output": 10.00},
"claude-3-5-sonnet": {"input": 3.00, "output": 15.00},
"deepseek-v3": {"input": 0.14, "output": 0.28},
}
prices = pricing.get(model, {"input": 1.0, "output": 2.0})
cost = (input_tokens * prices["input"] + output_tokens * prices["output"]) / 1_000_000
return round(cost, 6)
# 一次 RAG 问答的典型成本
input_tokens = 2000 # system prompt + 检索结果 + 用户问题
output_tokens = 500 # AI 回答
print(f"GPT-4o-mini: ${estimate_cost(input_tokens, output_tokens, 'gpt-4o-mini')}")
# $0.0006
print(f"GPT-4o: ${estimate_cost(input_tokens, output_tokens, 'gpt-4o')}")
# $0.01
月成本预估
def monthly_cost(
daily_requests: int,
avg_input_tokens: int,
avg_output_tokens: int,
model: str,
) -> float:
per_request = estimate_cost(avg_input_tokens, avg_output_tokens, model)
return per_request * daily_requests * 30
# 日均 1000 次请求
print(f"GPT-4o-mini: ${monthly_cost(1000, 2000, 500, 'gpt-4o-mini'):.2f}/月")
# $18.00/月
print(f"GPT-4o: ${monthly_cost(1000, 2000, 500, 'gpt-4o'):.2f}/月")
# $300.00/月
动态上下文管理
问题:聊天历史无限增长
用户聊了 50 轮,对话历史可能有几万 Token,远超窗口限制。必须有策略裁剪。
策略 1:滑动窗口
最简单——只保留最近 N 轮对话:
def sliding_window(messages: list[dict], max_turns: int = 10) -> list[dict]:
system = [m for m in messages if m["role"] == "system"]
non_system = [m for m in messages if m["role"] != "system"]
recent = non_system[-(max_turns * 2):] # 每轮 = user + assistant
return system + recent
缺点:早期重要信息直接丢了。用户第 1 轮说的需求,到第 15 轮就没了。
策略 2:Token 预算裁剪
不按轮数,按 Token 总量裁剪:
def trim_by_token_budget(
messages: list[dict],
max_tokens: int,
model: str = "gpt-4o",
reserve_for_output: int = 1000,
) -> list[dict]:
budget = max_tokens - reserve_for_output
system = [m for m in messages if m["role"] == "system"]
non_system = [m for m in messages if m["role"] != "system"]
system_tokens = count_messages_tokens(system, model)
remaining = budget - system_tokens
kept = []
for msg in reversed(non_system):
msg_tokens = count_messages_tokens([msg], model)
if remaining >= msg_tokens:
kept.insert(0, msg)
remaining -= msg_tokens
else:
break
return system + kept
策略 3:摘要压缩
把旧的对话历史用 LLM 压缩成摘要,然后只保留摘要 + 最近几轮:
class ConversationSummarizer:
def __init__(self, client, model: str = "gpt-4o-mini"):
self.client = client
self.model = model
def summarize(self, messages: list[dict]) -> str:
conversation = "\n".join(
f"{'用户' if m['role'] == 'user' else '助手'}: {m['content']}"
for m in messages
if m["role"] != "system"
)
response = self.client.chat.completions.create(
model=self.model,
messages=[
{
"role": "system",
"content": "请将以下对话历史压缩为简洁的摘要,保留关键信息和决策。200字以内。",
},
{"role": "user", "content": conversation},
],
temperature=0,
max_tokens=300,
)
return response.choices[0].message.content
class SmartContextManager:
def __init__(
self,
client,
model: str = "gpt-4o",
max_context_tokens: int = 100000,
summary_threshold: int = 20,
keep_recent: int = 6,
):
self.client = client
self.model = model
self.max_tokens = max_context_tokens
self.summary_threshold = summary_threshold
self.keep_recent = keep_recent
self.summarizer = ConversationSummarizer(client)
self.summary = ""
def build_messages(
self,
system_prompt: str,
history: list[dict],
user_message: str,
) -> list[dict]:
messages = [{"role": "system", "content": system_prompt}]
# 如果历史超过阈值,压缩旧的部分
if len(history) > self.summary_threshold:
old = history[:-self.keep_recent]
recent = history[-self.keep_recent:]
self.summary = self.summarizer.summarize(old)
messages.append({
"role": "system",
"content": f"之前的对话摘要:{self.summary}",
})
messages.extend(recent)
else:
messages.extend(history)
messages.append({"role": "user", "content": user_message})
# 最终 Token 预算检查
total = count_messages_tokens(messages, self.model)
if total > self.max_tokens * 0.7:
messages = trim_by_token_budget(messages, self.max_tokens, self.model)
return messages
策略 4:重要性排序
不是所有消息都同等重要。给消息打分,优先保留重要的:
def importance_score(message: dict) -> float:
content = message.get("content", "")
score = 0.5
# 包含用户明确需求的更重要
if any(kw in content for kw in ["需求", "要求", "目标", "请", "帮我"]):
score += 0.3
# 包含决策结论的更重要
if any(kw in content for kw in ["决定", "方案", "总结", "结论"]):
score += 0.2
# 短消息(确认、闲聊)不太重要
if len(content) < 10:
score -= 0.3
return min(max(score, 0), 1)
def trim_by_importance(
messages: list[dict],
max_tokens: int,
model: str = "gpt-4o",
) -> list[dict]:
system = [m for m in messages if m["role"] == "system"]
non_system = [m for m in messages if m["role"] != "system"]
scored = [(m, importance_score(m)) for m in non_system]
scored.sort(key=lambda x: x[1], reverse=True)
budget = max_tokens - count_messages_tokens(system, model) - 1000
kept = []
used = 0
for msg, score in scored:
msg_tokens = count_messages_tokens([msg], model)
if used + msg_tokens <= budget:
kept.append(msg)
used += msg_tokens
# 恢复原始顺序
order = {id(m): i for i, m in enumerate(non_system)}
kept.sort(key=lambda m: order.get(id(m), 0))
return system + kept
即插即用中间件
把上面的逻辑封装成中间件,透明地在 AI 调用前自动处理上下文:
class TokenMiddleware:
def __init__(
self,
model: str = "gpt-4o",
max_context_ratio: float = 0.7,
strategy: str = "trim",
):
self.model = model
model_limits = {
"gpt-4o-mini": 128000,
"gpt-4o": 128000,
"claude-3-5-sonnet": 200000,
"deepseek-v3": 64000,
}
self.max_tokens = int(model_limits.get(model, 128000) * max_context_ratio)
self.strategy = strategy
def process(self, messages: list[dict]) -> dict:
input_tokens = count_messages_tokens(messages, self.model)
if input_tokens <= self.max_tokens:
return {
"messages": messages,
"input_tokens": input_tokens,
"trimmed": False,
}
if self.strategy == "trim":
trimmed = trim_by_token_budget(messages, self.max_tokens, self.model)
elif self.strategy == "importance":
trimmed = trim_by_importance(messages, self.max_tokens, self.model)
else:
trimmed = sliding_window(messages, max_turns=10)
return {
"messages": trimmed,
"input_tokens": count_messages_tokens(trimmed, self.model),
"trimmed": True,
"original_tokens": input_tokens,
}
# 使用
middleware = TokenMiddleware(model="gpt-4o-mini", strategy="trim")
result = middleware.process(messages)
if result["trimmed"]:
print(f"上下文已裁剪: {result['original_tokens']} → {result['input_tokens']} tokens")
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=result["messages"],
)
实用工具函数
Token 用量追踪器
class TokenTracker:
def __init__(self):
self.total_input = 0
self.total_output = 0
self.request_count = 0
self.history = []
def record(self, model: str, input_tokens: int, output_tokens: int):
cost = estimate_cost(input_tokens, output_tokens, model)
self.total_input += input_tokens
self.total_output += output_tokens
self.request_count += 1
self.history.append({
"model": model,
"input": input_tokens,
"output": output_tokens,
"cost": cost,
"timestamp": time.time(),
})
def report(self) -> dict:
total_cost = sum(h["cost"] for h in self.history)
return {
"requests": self.request_count,
"total_input_tokens": self.total_input,
"total_output_tokens": self.total_output,
"total_cost": f"${total_cost:.4f}",
"avg_input_per_request": self.total_input // max(self.request_count, 1),
"avg_output_per_request": self.total_output // max(self.request_count, 1),
}
max_tokens 该设多少
max_tokens 限制的是输出 Token 数量。设太小回答被截断,设太大浪费预算:
| 场景 | 推荐 max_tokens |
|---|---|
| 简短回答(是/否、分类) | 50~100 |
| 知识库问答 | 500~1000 |
| 长文生成 | 2000~4000 |
| 代码生成 | 1000~3000 |
| JSON 提取 | 200~500 |
def smart_max_tokens(intent: str) -> int:
defaults = {
"chitchat": 100,
"knowledge_query": 800,
"data_query": 300,
"code_generation": 2000,
"structured_output": 500,
}
return defaults.get(intent, 1000)
总结
- Token 是 LLM 的计量单位——中文约 0.6 token/字,必须用
tiktoken精确计算,不能估算。 - 上下文窗口大不等于随便用——成本线性增长,注意力稀释,控制在窗口的 50~70%。
- Input/Output 分别计价——输出通常更贵,用
max_tokens控制输出长度。 - 四种上下文管理策略:滑动窗口(最简单)→ Token 预算裁剪(推荐)→ 摘要压缩(最智能)→ 重要性排序(最精细)。
- 中间件模式——
TokenMiddleware透明处理,业务代码无需关心 Token 限制。 - 追踪成本——
TokenTracker记录每次调用的用量和花费,月底不再惊讶。
下一篇是本系列最后一篇:大模型微调实战。Prompt 调不动了、RAG 也不够时,怎么用 LoRA 低成本微调一个垂直领域模型?
讨论话题:你的 AI 应用一个月花多少 Token 钱?有做过成本优化吗?评论区聊聊。