按字符长度截断提示词,会在中文、工具 schema 和多模型分词器下系统性错位:轻则浪费窗口,重则切碎半个 JSON,或误伤系统前缀。把上下文做成可测试的会计层——按通道预留、按 token 计数、在消息与结构边界上取舍,超限时显式降级。网关和 Agent 运行时可以用同一套夹具回归不同模型的计数差异,而不是在发请求前拍脑袋
slice。
为什么按字符砍会对不齐
很多网关和 Agent 运行时仍在用 prompt.slice(0, 8000) 这类写法。它默认「字符数和 token 之间存在稳定比例」,在短英文里偶尔能蒙对;一旦混入中文、代码和工具 JSON,偏差会沿着整条请求放大。分词器看到的不是字符,是子词。同一段中文换一套编码,长度就会变;JSON 的括号、引号、转义、字段名会被切成和「看上去的长度」无关的碎片。String.prototype.slice 还作用在 UTF-16 码元上,既可能切断代理对,也可能把一段合法 JSON 砍成语法错误。
更隐蔽的是入账单位不一致。Chat 模型真正扣除的,除了正文,还有 role 标记、special tokens、tools 序列化等模板开销。你在请求外数的字符,和窗口里扣掉的 token,本来就不是同一个账本。若先把系统提示、检索片段、历史对话拼成一根大字符串再切开,系统前缀和工具定义往往在最前面:从尾部切看起来安全,但拼接顺序一变,或把 tool 结果插进中间,就可能切到 schema,或切出半条 tool_calls。问题不在切多切少,而在截断单位和模型入账单位错位。
所以要处理的对象不是「字符串的某个下标」,而是一组带优先级、可独立计数的上下文单元。
把窗口拆成可记账的通道
上下文窗口是上限,不是 prompt 的目标长度。输入和输出通常共享同一段窗口:必须先为 completion 留出 outputReserve,再为模板、special tokens 留 safetyMargin,剩下的才是可分配输入。不要用一个笼统的 maxChars 覆盖所有东西。
至少拆四条通道:
- 系统通道:角色、不可变指令。默认钉死,不被对话挤掉。
- 工具通道:function schema。体积稳定但不可碎,按整份 JSON 计数,走 API 的
tools字段,而不是塞进 prompt 再一起切。 - 资料通道:检索片段、文件摘录。可丢、可压缩,以 chunk 为单元。
- 对话通道:user / assistant / tool 消息。按整段后缀保留,优先丢最旧的一轮,而不是从某条消息的肚子中间切开。
通道之间不要互相「借」而不记账。常见事故是检索把当前用户问题挤出窗口,或历史对话把系统提示卷进截断逻辑。会计层职责很窄:给定窗口和各通道约束,输出一份不超过输入预算、且边界完整的 messages;超限时返回原因,而不是 silently 变短。
配额可以是硬上限(系统 + 工具合计不超过 N),也可以是优先级(先保证系统与最后一轮用户,再填资料,再填历史)。无论哪种,数字都要来自分词器,而不是 length。
按分词器计数,按单元装箱
下面用 js-tiktoken 对齐 OpenAI 系常见编码。o200k_base 用于 gpt-4o 家族,cl100k_base 用于 gpt-4 / gpt-3.5 一类模型。它不是万能分词器:Claude、Gemini 等不要硬套这套 BPE,应换成对应官方编码或厂商的计数接口。会计结构可以复用,替换的是 countText()。
环境:Node.js 18+,npm install js-tiktoken,package.json 里设置 "type": "module"。消息开销因模板而异,代码里的常数只是保守余量,不是精确公式;不确定的部分用 safetyMargin 吸收,按目标模型实测再调。
import { getEncoding } from "js-tiktoken";
import assert from "node:assert/strict";
const enc = getEncoding("o200k_base");
const PER_MESSAGE_OVERHEAD = 4;
export function countText(text) {
if (text == null || text === "") return 0;
return enc.encode(String(text)).length;
}
export function countMessage(message) {
const content =
typeof message.content === "string"
? message.content
: JSON.stringify(message.content ?? "");
let n = PER_MESSAGE_OVERHEAD;
n += countText(message.role);
n += countText(content);
if (message.name) n += countText(message.name);
if (message.tool_call_id) n += countText(message.tool_call_id);
if (message.tool_calls) n += countText(JSON.stringify(message.tool_calls));
return n;
}
export function countMessages(messages) {
return messages.reduce((sum, m) => sum + countMessage(m), 0);
}
export function allocateInputBudget(contextWindow, outputReserve, safetyMargin) {
const inputBudget = contextWindow - outputReserve - safetyMargin;
if (inputBudget <= 0) {
throw new Error("outputReserve + safetyMargin 已超过 contextWindow");
}
return inputBudget;
}
export function truncateTextToTokens(text, maxTokens) {
if (maxTokens <= 0) return "";
if (countText(text) <= maxTokens) return text;
const parts = text.split(/(\n+)/);
let out = "";
for (const part of parts) {
const next = out + part;
if (countText(next) > maxTokens) break;
out = next;
}
if (out) return out;
return enc.decode(enc.encode(text).slice(0, maxTokens));
}
function chunkToMessage(chunk, text = chunk.text) {
return { role: "system", content: `[${chunk.id}]\n${text}` };
}
export function assemblePrompt({
contextWindow,
outputReserve,
safetyMargin = 64,
systemMessage,
toolSchemas = [],
chunks = [],
history = [],
}) {
const inputBudget = allocateInputBudget(
contextWindow,
outputReserve,
safetyMargin
);
const protectedMsgs = [{ role: "system", content: systemMessage }];
const toolsCost = toolSchemas.length
? countText(JSON.stringify(toolSchemas))
: 0;
const protectedCost = countMessages(protectedMsgs) + toolsCost;
if (protectedCost >= inputBudget) {
return {
ok: false,
reason: "protected_overflow",
protectedCost,
inputBudget,
messages: null,
tools: toolSchemas,
};
}
let remaining = inputBudget - protectedCost;
const last = history.at(-1) ?? null;
const older = history.length > 1 ? history.slice(0, -1) : [];
if (last) {
const lastCost = countMessage(last);
if (lastCost > remaining) {
return {
ok: false,
reason: "last_message_overflow",
lastCost,
remaining,
messages: null,
tools: toolSchemas,
};
}
remaining -= lastCost;
}
const sortedChunks = [...chunks].sort(
(a, b) => (a.priority ?? 0) - (b.priority ?? 0)
);
const keptChunkMsgs = [];
for (const chunk of sortedChunks) {
let msg = chunkToMessage(chunk);
if (countMessage(msg) > remaining) {
const headerTokens = countText(`[${chunk.id}]\n`);
const room = remaining - PER_MESSAGE_OVERHEAD - headerTokens;
const cut = truncateTextToTokens(chunk.text, room);
if (!cut) continue;
msg = chunkToMessage(chunk, cut);
if (countMessage(msg) > remaining) continue;
}
keptChunkMsgs.push(msg);
remaining -= countMessage(msg);
}
let keptOlder = [];
for (let i = older.length - 1; i >= 0; i--) {
const candidate = [older[i], ...keptOlder];
if (countMessages(candidate) > remaining) break;
keptOlder = candidate;
}
const messages = [
...protectedMsgs,
...keptChunkMsgs,
...keptOlder,
...(last ? [last] : []),
];
const total = countMessages(messages) + toolsCost;
if (total > inputBudget) {
return {
ok: false,
reason: "assemble_overflow",
total,
inputBudget,
messages: null,
tools: toolSchemas,
};
}
return {
ok: true,
reason: "ok",
total,
inputBudget,
remaining: inputBudget - total,
droppedHistory: older.length - keptOlder.length,
keptChunkCount: keptChunkMsgs.length,
messages,
tools: toolSchemas,
};
}
const mixed = "请调用工具:" + JSON.stringify({ q: "上下文截断", limit: 3 });
const charCut = mixed.slice(0, 18);
let charCutIsJson = true;
try {
JSON.parse(charCut.slice(charCut.indexOf("{")));
} catch {
charCutIsJson = false;
}
const result = assemblePrompt({
contextWindow: 720,
outputReserve: 220,
safetyMargin: 40,
systemMessage: "你是问答助手。禁止改写系统前缀。",
toolSchemas: [
{
type: "function",
function: {
name: "search",
description: "按关键词检索",
parameters: {
type: "object",
properties: { q: { type: "string" } },
required: ["q"],
},
},
},
],
chunks: [
{
id: "low",
priority: 2,
text: "低优先级资料。\n".repeat(60),
},
{
id: "high",
priority: 1,
text: "高优先级:截断必须停在换行处。\n第二段尽量保留。",
},
],
history: [
{ role: "user", content: "最早的问题" },
{ role: "assistant", content: "最早的回答" },
{ role: "user", content: "请根据资料作答,不要丢掉系统前缀。" },
],
});
assert.equal(result.ok, true);
assert.equal(result.messages[0].content, "你是问答助手。禁止改写系统前缀。");
assert.equal(result.messages.at(-1).role, "user");
assert.ok(result.total <= result.inputBudget);
console.log({
chars: mixed.length,
tokens: countText(mixed),
charCut,
charCutIsJson,
total: result.total,
inputBudget: result.inputBudget,
droppedHistory: result.droppedHistory,
keptChunkCount: result.keptChunkCount,
});
把这段存成 token-budget.mjs 后执行 node token-budget.mjs。同一句中英混合 JSON,length 和 countText 的比值不是常数;字符切开后往往不再是合法 JSON。不要把某次打印结果写成「中文 token 系数」——换语料、换 encoding,数字就会变。
装箱顺序是会计策略,不是模型能力:先扣 protected(系统 + tools JSON),再锁定最后一条消息,再按 priority 填资料,最后保留历史的连续后缀。会计层不管「该不该检索」,只管「已经排好序的单元还放不放得下」。
对齐消息与 JSON 边界
有了计数还不够,截断必须停在结构边界上。系统前缀和工具 schema 放入 protected:装箱前先扣它们的 token。生产路径里 schema 应留在 tools 参数,示例用 JSON.stringify(toolSchemas) 单独入账,就是为了避免「和用户历史拼成一根再切」。若 protected 自身超窗,直接失败并打出 protected_overflow——这通常是系统提示膨胀或 schema 过大,靠删对话解决不了,失败比悄悄砍系统前缀更安全。
对话通道以连续后缀为最小保留单位。从最旧的消息删到预算内,比跳着删某几条更不容易拆散 assistant.tool_calls 与随后的 tool 结果。半条带 tool_calls 的 assistant 比整段丢掉更危险。最后一条用户消息视为准保护:它是当前问题,资料通道不该把它挤出窗口;若连它都放不下,应报 last_message_overflow,而不是交一条没有问题的请求。
资料通道可以在单元内部再切,但只切纯文本,并且先按换行对齐。truncateTextToTokens 先贪心拼段落,只有第一段就超限时才退回按 token 硬切。JSON、代码块、HTML 不要走硬切;超长对象应在序列化前丢字段或丢整个 chunk。切完必须再 countMessage 一次,因为 header、role 开销不会出现在正文的 encode 结果里。
超限降级,并用夹具回归计数差异
降级顺序要写死,避免每次超限都即兴 slice。推荐顺序是:丢掉更旧的历史后缀 → 丢掉或压缩低优先级 chunk → 仍超限则拒绝发送。不要在前两步失败后回头切系统提示。返回值里的 reason、droppedHistory、keptChunkCount 是给网关打日志和给产品侧做降级提示用的,不是可有可无的调试字段。
同一套夹具还用来回归分词器差异。不要断言「两套 encoding 的计数必须相等」,那不是正确性;要断言的是不变量:protected 前缀还在、最后一条用户还在、总账不超过输入预算、字符截断可以破坏 JSON 而单元装箱不行。把窗口故意收小,才能在 CI 里稳定触发降级。
import assert from "node:assert/strict";
import { getEncoding } from "js-tiktoken";
import { assemblePrompt, countText } from "./token-budget.mjs";
const samples = [
"Hello, tokenizer.",
"会计层要同时覆盖中文与工具 JSON。",
JSON.stringify({ name: "lookup", parameters: { q: "预算" } }),
];
const o200k = getEncoding("o200k_base");
const cl100k = getEncoding("cl100k_base");
for (const s of samples) {
const row = {
chars: s.length,
o200k: o200k.encode(s).length,
cl100k: cl100k.encode(s).length,
countText: countText(s),
};
assert.ok(row.o200k > 0 && row.cl100k > 0);
console.log(row);
}
const tight = assemblePrompt({
contextWindow: 380,
outputReserve: 160,
safetyMargin: 32,
systemMessage: "你是问答助手。禁止改写系统前缀。",
toolSchemas: [{ type: "function", function: { name: "search" } }],
chunks: [{ id: "only", priority: 1, text: "资料\n".repeat(40) }],
history: [
{ role: "user", content: "旧问题" },
{ role: "assistant", content: "旧回答" },
{ role: "user", content: "当前问题" },
],
});
assert.equal(tight.ok, true);
assert.equal(tight.messages[0].content, "你是问答助手。禁止改写系统前缀。");
assert.equal(tight.messages.at(-1).content, "当前问题");
assert.ok(tight.droppedHistory >= 1);
assert.ok(tight.total <= tight.inputBudget);
换模型时,替换 encoding 或厂商计数接口,夹具不用改。若某次回归里 protected 前缀变了,那是截断逻辑回归,而不是「token 数和上次不一样」——后者在多模型下是预期现象。
总结
字符截断把不同通道、不同结构揉成一根字符串,再用和分词器无关的下标去砍,错位是必然的。可替换的做法很具体:窗口先减去输出预留和安全余量,系统与 tools 单独入账且默认不可切,资料按 chunk、对话按连续后缀,超长文本才在换行处按 token 收缩。超限要有固定降级和明确失败,而不是继续 slice。把这些做成可运行的会计层之后,网关上的「凭感觉截断」可以拿掉;同一套夹具用来锁不变量,并观察不同分词器的计数差异,而不是把某次 length / tokens 写成普适系数。
评论