Skip to content
LLM 应用中的 Token 管理机制:上下文窗口、截断与成本优化
LLM 应用的每次请求都在“上下文窗口”这一有限空间内完成。输入历史、系统提示、工具定义和输出内容共享同一份 token 预算。理解 token 如何产生、窗口如何分配,以及在超出预算时如何压缩或截断上下文,是控制请求质量与成本的基础。
1 Token 的计量方式
语言模型处理的文本单位不是字符,也不是单词,而是 token。token 是分词器(tokenizer)对文本切分后得到的最小单元。OpenAI 的 tiktoken 使用 BPE(Byte Pair Encoding)算法将文本转换为 token id 序列 [3]。
在 Node.js 项目中安装 js-tiktoken:
bash
npm install js-tiktoken在 Node.js 中使用 js-tiktoken 可以复用 tiktoken 的编码规则:
js
import { encoding_for_model } from 'js-tiktoken';
const enc = encoding_for_model('gpt-4');
const ids = enc.encode('Hello, world!');
console.log(ids);
// 输出 token id 数组
console.log(ids.length);
// 输出 token 数量
enc.free(); // 释放编码资源encode 接受一个字符串,返回该文本对应的 token id 数组,数组长度即 token 数。decode 是反向操作,把 token id 数组还原为字符串。encoding_for_model 会根据模型名自动选择对应的编码表;如果只关心某个编码表,也可以用 get_encoding('cl100k_base') 直接获取。
注意 encoding_for_model 每次调用都会加载编码数据,在服务端应复用同一个实例,不要在每个请求里重复创建。用完 free() 可以释放底层资源,但释放后该实例不能再使用。
1.1 中英文与代码的 Token 差异
token 的切分规则由编码算法决定。不同内容的 token 密度差异很大,以下是实际使用中常见的规律:
- 中文:同样的内容,中文文本通常比英文文本消耗更多 token。
- 英文:常见单词往往整体映射为 1 个 token,如
hello、world。 - 代码:空格、缩进、运算符和括号会分别产生 token,因此同样字符数的代码通常比自然语言消耗更多 token。
用上文创建的 enc 对不同文本计数:
js
console.log(enc.encode('你好,世界').length);
console.log(enc.encode('const a = () => { return 1; };').length);第一行对中文短句计数,第二行对一段 JavaScript 代码计数。代码行包含空格、括号、箭头函数等多个 token,token 数通常高于第一行,具体数值取决于编码表。
因此做 token 预算时不能按字数估算,而应直接调用编码器计数。
2 上下文窗口:输入与输出共享 Token 预算
上下文窗口(context window)是模型单次请求能够处理的全部 token 上限。它包含三部分:输入、输出、控制 token [2]。
input_tokens + output_tokens + control_tokens ≤ context_windowmax_tokens 参数控制输出上限。以 gpt-4-1106-preview 为例,上下文窗口为 128,000,若 max_tokens 设为 4,096,那么输入可用空间约为 123,904;实际还会被系统提示、对话格式等控制 token 占据,每条消息约 4 token [2]。
这意味着 max_tokens 不只是“回复多长”的问题,它还直接挤压输入空间。在固定上下文窗口下,调大输出上限,历史消息能容纳的条数就变少。
官方文档建议不要试图把上下文用到极限,因为接近上限时更容易出错 [2]。设计预算时应留出余量,经验上通常只使用窗口的 70%~90%。具体比例取决于任务类型和模型,并无统一标准。
3 输入超限的处理策略
对话历史不断增长,不可能永远塞进上下文窗口。常见策略有四种:直接截断、滑动窗口、摘要压缩、检索式记忆。前两种简单但会丢失信息;后两种用额外机制保留信息,代价是复杂度更高。
3.1 直接截断与滑动窗口
直接截断的思路是丢弃最旧的消息,保留最新消息,直到总 token 数低于预算。
js
import { encoding_for_model } from 'js-tiktoken';
// 模块级单例,避免重复加载编码表
const enc = encoding_for_model('gpt-4');
const MESSAGE_OVERHEAD = 4; // 每条消息的控制 token 开销
function truncateOldest(messages, maxTokens) {
let total = 0;
const kept = [];
// 从最新消息开始向前累加,直到预算耗尽
for (let i = messages.length - 1; i >= 0; i--) {
const cost = enc.encode(messages[i].content).length + MESSAGE_OVERHEAD;
if (total + cost > maxTokens) break;
kept.unshift(messages[i]);
total += cost;
}
return kept;
}MESSAGE_OVERHEAD 表示每条消息在 ChatML 格式下的固定 token 开销,约 4 token [2]。这个值不是精确的,不同消息格式可能略有差异,但用来做预算估计足够。
滑动窗口与直接截断的行为类似,差异在于:直接截断是“从旧到新累加到预算”,滑动窗口是“只保留最近 N 条消息或最近 M 个 token”。可以组合使用:先按条数截断,再按 token 数精确修剪。
这两种策略的主要问题是:早期对话中的任务目标、用户偏好、关键决策会被直接丢弃,后续回复可能偏离用户意图。
3.2 摘要压缩
摘要压缩的思路是:超出预算前,用一次额外的 LLM 调用把较早的对话压缩成摘要,然后以“摘要 + 最近完整消息”的结构继续对话。一种常见的实现是:
系统:将下面的对话压缩为要点。
要求:保留人名、产品名、决策、待办事项。
用户:<早期对话文本>压缩后的消息列表结构:
js
const messages = [
{ role: 'system', content: `${systemPrompt}\n\n历史摘要:${summary}` },
...recentMessages
];摘要压缩的信息密度远高于原始对话,同样预算下能覆盖更长的时间范围。代价是压缩本身需要一次额外的 LLM 调用,增加延迟和费用;摘要也可能遗漏细节。
需要留意的是:简单摘要很容易丢失多轮对话中的实体和待办事项。比如“用户要求下周三前交付订单 #1024”如果被概括成“用户有一个订单”,那后续回复就无法产生正确行为。缓解方法是将压缩内容分离为两类:
- 语义摘要:自然语言概述对话过程。
- 结构化槽位:用固定字段维护当前待办、已确认决策、关键实体。
js
// 压缩结果除了摘要文本,还保留结构化状态
const compressed = {
summary: '用户正在处理订单 #1024,要求修改收货地址。',
todos: ['修改订单 #1024 的收货地址'],
entities: ['订单 #1024'],
decisions: ['发货方式改为顺丰']
};结构化槽位与摘要文本一起放入系统提示,能有效减少压缩带来的信息丢失。上述压缩指令和槽位字段仅为一种可行的组织方式,实际需要根据业务场景调整。
3.3 检索式记忆与外部知识注入
对于跨会话、跨天的长期对话,把所有历史都装进窗口既不经济,也超出模型的有效利用范围。更常见的做法是:历史消息存入外部存储,需要时检索相关内容注入上下文 [4]。
流程通常包含三个部分:
- 存储:对话记录按消息或固定长度切片存储;近期上下文放 Redis 等快速存储,长期记忆放 NoSQL 或向量数据库。
- 索引:对历史片段计算 embedding 并写入向量索引。
- 检索:收到新请求时,用当前用户问题作为查询向量,检索 top-k 相关片段,拼入上下文。
js
// 示意:从向量库检索历史上下文并注入
async function buildContext(query, recentMessages) {
const related = await searchMemory(query, 5);
const systemContent =
`与当前问题相关的历史记录:\n${related.join('\n')}`;
return [
{ role: 'system', content: systemContent },
...recentMessages
];
}searchMemory 是业务层的检索函数,实现因存储选型而异。这种“检索式记忆”的本质是:不是所有历史都重要,只有与当前请求相关的片段才值得占用上下文预算 [4]。
4 在 Node.js 中实现 Token 管理
在 Node.js 中实现 token 管理,主要涉及计数、预算分配和历史处理三个部分。本节展示一种可组合的实现方式。
4.1 使用 js-tiktoken 计数与预算分配
js-tiktoken 是 tiktoken 的 Node.js 移植版,实现了与 Python 版 tiktoken 相同的主要 API(encoding_for_model、get_encoding、encode、decode)[3]。
js
import { get_encoding, encoding_for_model } from 'js-tiktoken';
const enc = encoding_for_model('gpt-4');
// 文本 → token id 数组
const ids = enc.encode('副作用:token 数取决于编码表');
// 计算 token 数
const tokenCount = ids.length;
// 用固定编码表创建
const enc2 = get_encoding('cl100k_base');
enc.free();
enc2.free();在请求进入模型之前,先完成 token 计数,然后根据预算分配决定如何处理历史消息。预算计算可以封装为一个模块:
js
// budget.js
const CONTEXT_WINDOW = 128_000;
const MAX_OUTPUT = 4_096;
const INPUT_BUDGET = CONTEXT_WINDOW - MAX_OUTPUT;
export function allocateBudget(historyTokens) {
const safeLimit = Math.floor(INPUT_BUDGET * 0.9); // 保留 10% 余量
return Math.min(historyTokens, safeLimit);
}0.9 是示例中的安全系数,表示允许使用输入预算的 90%,余量留给可能的控制 token 或格式变化。具体比例可根据模型和任务调整。
预算结果是一个数字,表示本次请求允许占用的最大输入 token 数。截断和压缩逻辑都基于这个数字做决策。
4.2 实现截断、压缩与记忆管理流水线
截断、压缩、记忆检索是三个相对独立的环节。将它们封装为独立函数后,可以用函数组合的方式串成一条流水线。
js
import { encoding_for_model } from 'js-tiktoken';
const enc = encoding_for_model('gpt-4');
const MESSAGE_OVERHEAD = 4;
export function createTruncation({ maxInputTokens }) {
return function truncate(messages) {
let total = 0;
const result = [];
for (let i = messages.length - 1; i >= 0; i--) {
const cost = enc.encode(messages[i].content).length + MESSAGE_OVERHEAD;
if (total + cost > maxInputTokens) break;
result.unshift(messages[i]);
total += cost;
}
return result;
};
}截断函数的输入是完整消息列表,输出是保留后的列表。它不关心消息内容,只负责把 token 数控制在预算内。
压缩流水线需要调用 LLM,因此是异步的:
js
function shouldCompress(historyTokens, budget) {
return historyTokens > budget * 0.6;
}
function selectCutoff(messages, ratio = 0.7) {
// 将前 70% 的消息送入摘要,最近 30% 保留完整
const index = Math.floor(messages.length * ratio);
return {
toSummarize: messages.slice(0, index),
keepRecent: messages.slice(index)
};
}
// 压缩流程:
// 1. 检查总 token 数是否超过阈值
// 2. 将历史划分为“待压缩部分”和“最近保留部分”
// 3. 用一次独立 LLM 请求生成摘要
// 4. 构造新的消息列表,用摘要替换被压缩的历史压缩函数的返回结果是新消息列表,其中被压缩的部分已经替换为一条 system 消息。这意味着同样的代码在加入记忆管理后,不需要改动上层业务逻辑。
记忆管理的接入方式是在截断/压缩之前或之后,从外部存储检索相关内容,注入到消息列表前部:
js
async function withRetrievedMemory(messages, query) {
const related = await searchMemory(query, 3);
if (related.length === 0) return messages;
return [
{
role: 'system',
content: `以下是检索到的历史相关片段:\n${related.join('\n')}`
},
...messages
];
}整体的流水线顺序为:
用户请求 → 检索记忆 → 计数 → 是否需要压缩 → 截断到预算 → 发送给模型这个顺序中,检索发生在压缩之前,因为检索到的内容也需要占用 token 预算。
5 成本控制与优化
5.1 max_tokens 与单次请求成本
API 调用的费用由输入 token 与输出 token 共同决定。max_tokens 设置得越高,输出越长,费用越高。无论是否显式设置,模型都会返回一个输出长度。不设置时使用模型默认值,通常大于任务所需。
js
const res = await client.chat.completions.create({
model: 'gpt-4',
messages,
max_tokens: 1024
});max_tokens 提供了成本控制的第一层约束。它将输出 token 数限制在一个明确的范围内。合理的取值取决于任务类型:分类或提取可以设 256~512,代码生成通常需要 1024 以上,长文写作需要更大值。这些是经验范围,不是官方标准。设置过小会截断有效回复,设置过大会浪费输出成本。
成本估算可以用一个函数表达:
js
// 价格仅为示意,实际以厂商公布为准
const rates = {
'gpt-4': { input: 0.03, output: 0.06 }, // 单位:美元/千 token
};
function estimateCost(model, inputTokens, outputTokens) {
const r = rates[model];
if (!r) return null;
return (
(inputTokens / 1000) * r.input +
(outputTokens / 1000) * r.output
);
}在多轮对话中,每一轮都会重新发送全部上下文,同一段历史被反复计费。这正是总成本随会话轮数增长的根源。控制成本的思路不是单纯减少单次输入,而是减少“被反复发送但已经不再有用”的内容。截断、摘要和检索记忆,本质上都是在解决这个问题。
5.2 Prompt Caching、模型路由与用量监控
如果同一请求前缀出现在多次调用中(例如相同的系统提示、固定的工具定义),部分厂商支持 prompt caching:缓存命中的前缀 token 按较低价格计费。要利用缓存,需要保持请求前缀稳定,把可能变化的部分(时间戳、随机 id、最新消息)放在消息序列的靠后位置。
模型路由是另一种成本优化方式。不同模型的上下文窗口和单价差异很大 [1]。一个请求如果只需要短上下文和简单推理,用大窗口模型会造成浪费;如果输入很长、任务复杂,再选择更大窗口的模型。路由决策可以在发送前根据 token 计数和任务类型完成:
js
function selectModel(inputTokens) {
if (inputTokens < 8_000) return 'gpt-4o-mini'; // 低单价模型
if (inputTokens < 128_000) return 'gpt-4o';
return 'large-context-model'; // 超长上下文模型
}模型名和阈值是示意。实现时以当前可用模型及其上下文窗口为准 [1]。
用量监控是成本控制的前提。API 响应中会返回本次请求的实际 token 用量,调用方应记录这些字段:
js
async function callWithUsageRecord(model, messages, options = {}) {
const res = await client.chat.completions.create({ model, messages, ...options });
// usage 为 API 返回的实际 token 用量
const usage = res.usage;
console.log(JSON.stringify({
ts: new Date().toISOString(),
model,
input_tokens: usage.prompt_tokens,
output_tokens: usage.completion_tokens,
// total_tokens: usage.total_tokens
}));
return res;
}日志至少应包含时间戳、模型名、输入 token 数、输出 token 数。这类日志可以用于汇总每日消费、识别异常请求、评估不同模型的成本变化。
6 实践注意点与权衡
直接截断和滑动窗口实现简单、零额外延迟,适合对历史依赖不强的短任务。摘要压缩增加了信息保持能力,但引入了额外 LLM 调用。检索式记忆适合长期记忆场景,但需要维护向量索引,且检索质量会直接影响回复准确度。
几个值得注意的权衡:
- 不要用满上下文窗口。接近上限时出错概率增加,应为输出和控制 token 留出余量
[2]。 - 大窗口不等于高质量。即使 100k/200k 窗口的大模型,在上下文长度达到几万 token 时,回复质量也可能明显下降
[4]。不能只靠扩大窗口解决所有记忆问题。 - 关键信息放在开头或结尾。模型对上下文不同位置的注意力并不均匀,开头和结尾的信息通常更容易被利用,中部内容更容易被忽略。系统提示和关键约束放开头,最新指令放末尾,参考资料放中间。
- 摘要会丢失实体和待办。如果对话中涉及具体的订单号、人名、截止时间,单纯的语义摘要容易把它们省略。用结构化槽位单独保存这些状态。
- 消息不是等权的。截断时简单按时间顺序丢最旧的,可能丢掉仍然有效的任务约束。可以考虑给消息设置优先级:系统提示 > 用户最新指令 > 用户早期指令 > 助手历史回复 > 中间轮次细节。
7 小结与延伸
Token 管理可以拆成三个环节:
- 计量:用
tiktoken/js-tiktoken准确统计输入输出 token 数。 - 预算分配:在上下文窗口内同时考虑输入和输出的空间。
- 历史处理:用截断、摘要压缩、检索式记忆控制输入规模。
这三个环节共同决定一次请求能否成功、回复是否准确、成本是否可控。
进一步的方向包括:为消息增加优先级排序而不是简单按时间截断;在流式输出中动态调整长度;结合缓存策略降低多轮请求的重复计费;在 Agent 场景中把任务进度、工具调用结果和中间推理分开存储,而不是全部依赖摘要。
