AI 探索技术手记

《Token 上下文截断:用 GPT-Tokenizer 精确控制 AI 对话长度》

2026年7月23日◷ 5 分钟阅读
《Token 上下文截断:用 GPT-Tokenizer 精确控制 AI 对话长度》

一、问题引入:对话越长,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-tokenizerencode() 方法。不能直接数 .length,因为 Token 和字符不是 1:1 的关系(中文每字 1-2 Token,英文每词 1-3 Token)。

Q:Token 超限时怎么截断对话历史?
A:1. 分离系统提示词(始终保留);2. 倒序遍历消息数组;3. 实时计算 Token 累加值;4. 超限则停止添加;5. 反转恢复正常顺序。核心:保留最新、丢弃最旧。


八、总结

  1. Token ≠ 字符,必须用官方 tokenizer 计算。
  2. 截断策略:倒序保留最新消息。倒序 = 不丢关键上下文。
  3. 系统提示词永远不截断(它定义了 AI 的人设)。
  4. +4 是每条消息的 role 开销,高精度场景不可忽略。
  5. 进阶方案:截断 + 自动摘要 + 向量检索召回。

Comments 留言讨论

还没有评论,来抢个沙发,聊聊你的看法~

Michael.Meng

michaelnews@126.com
用 AI 记录,用文字沉淀

© 2026 Michael Meng · 保留所有权利 · Powered by FastAPI + Nuxt