一、问题引入:对话越长,AI 越"忘事"?
你和一个 AI 助手连续聊了 50 轮。此时用户问"根据我们刚才讨论的方案,最终结论是什么?"——但 AI 已经不记得前面聊了什么。
原因:GPT-4 的上下文窗口是 128K Token,GPT-4o-mini 是 16K Token。超过这个限制的消息会被静默丢弃。你以为是 AI 笨,其实是它根本没见过前 30 条消息。
| 消息数 | 平均 Token | 总 Token | 是否超过 16K |
|---|---|---|---|
| 20 轮 | 200/轮 | 4,000 | 否 |
| 50 轮 | 200/轮 | 10,000 | 接近 |
| 80 轮 | 200/轮 | 16,000 | 是(截断) |
| 100 轮 | 200/轮 | 20,000 | 大幅截断 |
二、核心概念:Token vs 字符
Token 不是字符。一个中文字可能是 1-3 个 Token,一个英文单词也是 1-3 个 Token。
import { encode } from 'gpt-tokenizer'
encode('hello').length // → 1 Token
encode('你好').length // → 2 Token(中文每个字 1-2 Token)
encode('AI').length // → 1 Token
encode('人工智能').length // → 3 Token
不能用 .length 估算 Token 数——必须用官方的 tokenizer 计算。
三、完整可运行代码
3.1 上下文截断器
import { encode } from 'gpt-tokenizer'
/**
* 精确 Token 截断对话历史
* @param {Array<{role: string, content: string}>} messages 对话历史
* @param {number} maxTokens 最大允许的 Token 数
* @param {Object} options.systemPrompt 系统提示词(始终保留)
* @returns {Array} 截断后的消息数组
*/
export function truncateChatContext(messages, maxTokens = 3800, options = {}) {
// 1. 分离系统提示词(始终保留)
const systemMsgs = messages.filter(m => m.role === 'system')
const chatMsgs = messages.filter(m => m.role !== 'system')
// 2. 计算系统提示词占用的 Token
let usedTokens = 0
for (const msg of systemMsgs) {
usedTokens += encode(msg.content).length + 4 // +4 是 role 标记的开销
}
// 剩余可用 Token
const availableTokens = maxTokens - usedTokens
// 3. 倒序遍历(保留最新消息,丢弃最旧消息)
const kept = []
let currentTokens = 0
for (const msg of [...chatMsgs].reverse()) {
const msgTokens = encode(msg.content).length + 4
// Token 超限则停止(不再添加更旧的消息)
if (currentTokens + msgTokens > availableTokens) {
break
}
currentTokens += msgTokens
kept.push(msg)
}
// 4. 反转回正常顺序
const result = [...systemMsgs, ...kept.reverse()]
console.log({
原始消息数: messages.length,
保留消息数: result.length,
原始Token: usedTokens + chatMsgs.reduce((s, m) => s + encode(m.content).length + 4, 0),
保留Token: usedTokens + currentTokens,
截断率: `${((1 - result.length / messages.length) * 100).toFixed(1)}%`,
})
return result
}
3.2 在 AI 请求中使用
import { truncateChatContext } from './utils/token-truncate'
async function sendMessage(userInput, history) {
// 构建完整消息列表
const systemPrompt = {
role: 'system',
content: '你是一个专业的前端技术助手,答案要包含可运行代码。',
}
const allMessages = [
systemPrompt,
...history, // 从 IndexedDB/localStorage 加载的历史
{ role: 'user', content: userInput },
]
// 截断(保留最多 3800 Token = 约 10000 中文字)
const truncated = truncateChatContext(allMessages, 3800)
const response = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: truncated }),
})
return response.json()
}
3.3 可视化 Token 使用情况
/**
* 计算对话历史的 Token 分布
*/
function analyzeTokenUsage(messages) {
const stats = {
total: 0,
byRole: { system: 0, user: 0, assistant: 0 },
perMessage: [],
}
for (const msg of messages) {
const tokens = encode(msg.content).length + 4
stats.total += tokens
stats.byRole[msg.role] = (stats.byRole[msg.role] || 0) + tokens
stats.perMessage.push({ role: msg.role, tokens, preview: msg.content.substring(0, 30) })
}
// 绘制简单的 ASCII 柱状图
const maxBarLen = 40
for (const item of stats.perMessage) {
const barLen = Math.round((item.tokens / stats.total) * maxBarLen)
const bar = '█'.repeat(barLen) + '░'.repeat(maxBarLen - barLen)
console.log(`${item.role.padEnd(10)} |${bar}| ${item.tokens} tokens ${item.preview}`)
}
console.log(`
总计: ${stats.total} tokens`)
return stats
}
// 使用
// analyzeTokenUsage(chatHistory)
// 输出:
// system |████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░| 156 tokens 你是一个专业的前端...
// user |███░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░| 98 tokens React useEf...
// assistant |████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░| 312 tokens useEffect 是...
// user |██░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░| 72 tokens 那 useLayou...
// assistant |█████████████████░░░░░░░░░░░░░░░░░░░░░░░| 445 tokens useLayoutEf...
// ...
// 总计: 3840 tokens
四、逐行精讲
4.1 为什么倒序遍历?
for (const msg of [...chatMsgs].reverse()) {
保留最新,丢弃最旧——这是 ChatGPT、豆包等所有 AI 产品的标准策略。
假设你聊了 100 轮:
- 正序截断:保留前 20 轮 → AI 记住"你好",忘了"最终方案是什么"
- 倒序截断:保留最后 20 轮 → AI 知道刚才聊的上下文,能正确回答
4.2 +4 是什么?
const msgTokens = encode(msg.content).length + 4
OpenAI 的 Token 计算规则中,每条消息还有 role 字段的开销。每条消息额外占用约 4 个 Token(role 标记 + 分隔符)。虽然不多,但 50 轮对话就差了 200 Token,对精度要求高的场景不可忽略。
4.3 系统提示词必须保留
const systemMsgs = messages.filter(m => m.role === 'system')
系统提示词定义了 AI 的"人设"和行为规则(如"只回答技术问题""用中文回复")。如果截断时把它也丢了,AI 的行为会崩坏。
五、常见问题
Q1:gpt-tokenizer vs tiktoken 怎么选?
A:gpt-tokenizer 是纯 JavaScript 实现,浏览器/Node.js 都能用。tiktoken 是 OpenAI 官方 Python 包,需要 Node 原生模块。前端直接用 gpt-tokenizer 足够了。
Q2:截断后用户丢失了上下文怎么办?
A:在前端 UI 上提示"对话过长,早期内容已自动截断"。更好的方案:自动摘要——对丢弃的消息生成摘要并合并到 system prompt 中。
Q3:不同模型的 maxTokens 怎么确认?
A:
| 模型 | 上下文窗口 | 建议 maxTokens |
|---|---|---|
| GPT-4o-mini | 16K | 4000(留 buffer) |
| GPT-4o | 128K | 12000(留 buffer) |
| DeepSeek-V3 | 64K | 8000(留 buffer) |
六、决策框架
你的对话管理策略?
├── 对话 < 10 轮 → 不截断,全量送
│
├── 对话 10-50 轮 → Token 截断(本文方案)
│ │
│ ├── 需要保留核心事实 → 摘要模式
│ │ └── 对被丢弃的消息生成摘要,塞入 system prompt
│ │
│ └── 只需要最新上下文 → 倒序截断模式
│ └── 直接丢旧消息,保留最新 N 条
│
├── 对话 > 50 轮 → 混合策略
│ ├── 最近 10 轮保留完整
│ ├── 10-30 轮保留摘要
│ └── 30+ 轮丢弃
│
└── 需要长期记忆 → 向量检索(RAG 方案)
└── 每次对话都存向量,检索时召回历史相关片段
七、面试速记
Q:如何计算一条消息的 Token 数?
A:用 gpt-tokenizer 的 encode() 方法。不能直接数 .length,因为 Token 和字符不是 1:1 的关系(中文每字 1-2 Token,英文每词 1-3 Token)。
Q:Token 超限时怎么截断对话历史?
A:1. 分离系统提示词(始终保留);2. 倒序遍历消息数组;3. 实时计算 Token 累加值;4. 超限则停止添加;5. 反转恢复正常顺序。核心:保留最新、丢弃最旧。
八、总结
- Token ≠ 字符,必须用官方 tokenizer 计算。
- 截断策略:倒序保留最新消息。倒序 = 不丢关键上下文。
- 系统提示词永远不截断(它定义了 AI 的人设)。
+4是每条消息的 role 开销,高精度场景不可忽略。- 进阶方案:截断 + 自动摘要 + 向量检索召回。
Comments 留言讨论
还没有评论,来抢个沙发,聊聊你的看法~