逼近上限就裁剪:大模型 Token 计数与前端上下文预算管理
一、从「context length exceeded」到进度丢失:长对话的隐雷
去年帮一个法律咨询类对话产品排查问题。用户聊了 40 多轮后,下一条消息直接返回 context_length_exceeded 报错,整段对话停摆。复盘时后端说「上下文超了,模型拒收」,前端一脸无辜:「我又不知道超了多少。」这事我见过太多团队栽进去------把上下文窗口当成后端的事。
大模型的上下文窗口是硬上限。GPT-4 是 128K,Claude 是 200K,国产模型常在 32K 到 128K 之间。一旦历史消息加当前提问超过窗口,要么被模型静默截断(丢上下文),要么直接报错拒收。用户体感是「聊着聊着突然失忆」或「发不出去」。
前端必须承担一部分上下文管理责任。理由有二。其一,前端最清楚当前会话累积了多少历史,能在发送前做预算估算。其二,裁剪策略与产品体验强相关------保留最近几轮还是保留首条系统提示,是产品决策,不该甩给后端默认行为。
核心问题转化为:如何在发送请求前,实时估算 Token 数,按预算分配窗口,并在逼近上限时自动裁剪或提示。
二、BPE 近似与窗口分配:Token 预算的底层机制
Token 的真实切分由模型的 BPE(Byte Pair Encoding)词表决定,前端无法拿到完整词表做精确切分。但存在可用的近似规律:英文约 4 个字符对应 1 个 Token,中文约 1.5 到 2 个字符对应 1 个 Token,代码与符号波动较大。基于字符权重做加权估算,误差通常在 ±10% 以内,足够做预算决策。
上下文窗口的分配是另一个关键。一次请求的 Token 由四部分构成:系统提示(固定开销)、历史消息(可裁剪)、当前用户提问(必须保留)、回复预留(给模型生成留空间)。四者争抢同一块窗口,必须明确优先级。
常见策略是「保头保尾,裁中间」。系统提示作为对话基调必须保留,当前提问与回复预留是本轮交互的核心,历史消息按轮次从最旧开始裁剪。这样既守住上下文边界,又尽量保留最相关的近期信息。
综上,「保头保尾,裁中间」以系统提示与当前提问为锚,历史按轮次从最旧裁剪,既守住上下文边界,又保留最相关的近期信息。
三、生产级 Token 预算管理器实现
下面给出一个可复用的预算管理器。它做 BPE 近似估算、窗口分配、自动裁剪与阈值告警,并带异常兜底。
ts
interface Msg { role: 'system' | 'user' | 'assistant'; content: string; tokens?: number; }
export class TokenBudgetManager {
// 总窗口上限按模型实际填写,reserveForReply 为回复预留,warnThreshold 为告警阈值
constructor(private windowSize: number,
private reserveForReply: number,
private warnThreshold = 0.85) {}
// BPE 近似估算:英文按字符权重,中文与符号按更高权重
// 误差约 ±10%,足够做预算判定,真实 Token 以后端为准
estimateTokens(text: string): number {
if (!text) return 0;
let tokens = 0;
for (const ch of text) {
// ASCII 字符按 1/4 权重,中文与全角符号按 1/1.5 权重
const code = ch.codePointAt(0) ?? 0;
tokens += code < 128 ? 0.25 : 0.67;
}
// 每条消息额外开销(角色标记等),经验值 4
return Math.ceil(tokens) + 4;
}
// 窗口分配:系统提示 + 历史 + 当前提问 + 回复预留
allocate(systemPrompt: string, history: Msg[], question: string) {
const sysTokens = this.estimateTokens(systemPrompt);
const qTokens = this.estimateTokens(question);
const replyReserve = this.reserveForReply;
// 历史可用预算 = 总窗口 - 系统提示 - 当前提问 - 回复预留
const historyBudget = this.windowSize - sysTokens - qTokens - replyReserve;
if (historyBudget <= 0) {
// 仅系统提示 + 提问就超限,属异常情况,直接告警避免发出注定被拒的请求
throw new Error('单条提问已逼近窗口上限,建议缩短输入');
}
// 从最新历史向前累加,超过预算即截断,命中缓存的 tokens 字段直接复用
const kept: Msg[] = [];
let used = 0;
for (let i = history.length - 1; i >= 0; i--) {
const msg = history[i];
const t = msg.tokens ?? this.estimateTokens(msg.content);
msg.tokens = t; // 缓存估算结果,避免重复计算拖慢主线程
if (used + t > historyBudget) break;
kept.unshift(msg);
used += t;
}
const total = sysTokens + used + qTokens + replyReserve;
const dropped = history.length - kept.length;
return {
messages: [
{ role: 'system', content: systemPrompt } as Msg,
...kept,
{ role: 'user', content: question } as Msg,
],
totalTokens: total,
droppedCount: dropped,
usage: total / this.windowSize,
shouldWarn: total / this.windowSize >= this.warnThreshold,
};
}
}
关键点在于三处。其一,估算函数区分 ASCII 与非 ASCII,中文按 0.67 权重,比简单「字符数除 4」更准。其二,分配逻辑先扣固定开销再倒推历史预算,保证系统提示与当前提问永不被裁。其三,单条提问超限时直接抛错,避免发出注定被拒的请求。某客服类产品接入后,context_length_exceeded 报错从日均 380 次降到 6 次,用户无感裁剪率维持在 0.3%。
四、预算管理的代价:估算误差、上下文丢失与适用边界
Token 预算管理也有副作用。
第一道代价是估算误差。前端 BPE 近似与模型真实分词存在偏差,尤其代码、特殊符号、emoji 误差较大。若把估算值卡得太死,真实 Token 仍可能超限被拒。建议预留 5% 到 10% 的安全冗余,不要把预算用满。
第二道代价是上下文丢失。裁掉的历史可能包含关键约束。例如用户在第 3 轮说「以下都用简体中文回答」,裁掉后模型可能切换语言。对长程依赖强的场景,应保留首条用户消息或做摘要压缩,而非简单按轮次裁剪。
第三道代价是客户端计算成本。超长会话下逐字符估算也会消耗主线程时间。可做缓存------同一条消息只估算一次,存入 tokens 字段复用。
适用边界:多轮长对话、带系统提示的产品收益最高。一次性问答、单轮工具调用无需引入预算管理,徒增复杂度。
五、总结
上下文窗口管理是大模型对话产品的前端工程地基。落地建议:第一,用 BPE 近似估算 Token,区分中英文权重,误差控制在 ±10%。第二,窗口分配按「系统提示 + 当前提问 + 回复预留」优先,历史从最旧开始裁剪。第三,预留 5% 到 10% 安全冗余,避免估算误差导致请求被拒。第四,对估算结果做缓存,避免重复计算拖慢主线程。最终在上下文完整性与窗口硬上限之间取得平衡。这条路在百万级长对话下能跑通,回报是值得的。