按字符长度截断提示词,会在中文、工具 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 写成普适系数。