《互联网产品的 AI 架构·模型接入与能力边界》6.2 上下文预算与裁剪

「回声」摘要功能把历史、检索结果和工具输出一次性铺满窗口,提供方从尾部截断时把 system 规则一起切掉,模型开始编造。本节把上下文分成 system、历史、检索、工具四类,给出类别上限与全局裁剪顺序,并用可运行的 TypeScript 实现确定性预算规划与测试。

从一次上下文溢出说起

「回声」的私信智能回复在 6.1 之后已经用上了模板与变量,system 只保留角色和硬规则。但另一个功能没有跟上:长文摘要。

摘要的输入拼装逻辑是这样的:system 放角色与输出格式,中间放最近二十条历史,再放检索到的相关段落,最后放工具返回的订单与物流信息。团队为了让「指令离问题更近」,把 system 拼在了最末尾。

上线后遇到一篇特别长的帖子,检索结果把上下文撑到了模型窗口之外。提供方没有报错,而是从尾部截断。system 恰好就在尾部,于是角色、输出格式和「不得编造」的规则一起消失。模型仍然返回了一段摘要,只是开始补出不存在的物流节点。

复盘时发现三个问题:

  • 拼装时没有总预算,每一类内容各自只看自己的长度。
  • 没有区分内容类别,截断只看位置,system 和历史被同等对待。
  • 估算用的是字符数,与提供方的 token 计数不同,却没有留安全余量。

这三点合起来,就是本节要解决的上下文预算问题。

上下文不是越多越好

「把相关材料都放进去」听起来稳妥,实际会同时付出三种代价。

第一是成本。输入 token 按量计费,塞满窗口意味着每次请求都按上限付费,而多出来的材料未必被模型使用。

第二是延迟。输入越长,首字延迟通常越高;对私信回复这类交互,首字慢半秒就会明显影响体验。

第三是注意力稀释。上下文里无关内容越多,模型越容易抓错重点。这不是玄学,而是长上下文评测里反复出现的现象:关键信息放在中段时,召回率会下降。

所以问题不是「能不能塞下」,而是「在有限预算里放哪些、放多少、先放弃谁」。这是一个分配问题,不是压缩问题。

四类上下文与四个预算

「回声」把所有上下文片段分成四类。分类的依据不是格式,而是「谁产生、多久过期、裁掉之后产品会怎样」。

类别典型内容变化频率裁剪位置裁掉后的后果
system角色、语气、硬规则低永不裁剪规则丢失,输出不可控
history最近对话轮次每轮最后裁指代与上下文断裂
retrieval检索到的文章片段每请求中间裁事实依据缺失
tool订单、物流等工具结果每请求最先裁细节缺失,可降级提示

「永不裁剪」不是指 system 可以无限长,而是指 system 超预算时整个请求失败,而不是悄悄切掉一半。一个缺了规则的请求,比一个明确失败的请求更危险。

每类有一个硬上限,四类加起来还有一个全局上限。硬上限防止某一类挤占全部空间,全局上限防止四类同时膨胀。

为什么先分类再裁剪

统一的「按长度截断」有两个致命缺陷。

第一,它按位置决定生死。system 如果恰好排在后面,就会和历史消息一起被切掉;而这两类内容的价值完全不同。

第二,它无法表达类内优先级。十条历史里,最近两条通常比最早两条重要;二十条检索结果里,相关性最高的三条通常比其余十七条重要。按长度截断会把高价值片段和低价值片段一视同仁。

分类之后,裁剪可以分两步:先在各目的类别上限内按优先级保留,再在全局超预算时按类别顺序释放。每一步都有明确的排序键,结果可以复现。

估算口径

预算需要数字,数字需要口径。本节不引入真实 tokenizer,而是用一个可复现的近似:

  • CJK 与全角字符:1 字符按 1 token 计。
  • 其余字符:每 4 字符按 1 token 计,向上取整。
  • 逐段估算后相加,不做跨段合并。

这个口径比真实分词更保守,因为跨段拆开的拉丁字符串会被分别向上取整。保守的好处是留出余量,坏处是可能提前触发裁剪。对教学与回归测试来说,确定性比绝对精确更重要。

真实生产里,如果提供方返回了 usage,应该用实际 input tokens 校准;如果提供方提供 count API,应该在网关侧统一封装。不要在业务代码里各引一个 tokenizer,版本一升级,预算就对不上。

实现:两阶段裁剪

下面的实现把「类别上限」和「全局预算」拆成两个阶段。system 只检查、不裁剪;其余三类先按优先级保留,再按 trimOrder 释放。

// file: src/prompt/context-budget.ts
// tier: L1
// verify: node --test test/prompt/context-budget.test.ts

import { AiError } from "../core/errors.ts";

export type ContextKind = "system" | "history" | "retrieval" | "tool";
export type TrimmableKind = Exclude<ContextKind, "system">;

export const CONTEXT_KINDS: ContextKind[] = ["system", "history", "retrieval", "tool"];
export const TRIMMABLE_KINDS: TrimmableKind[] = ["history", "retrieval", "tool"];

export interface ContextSegment {
  id: string;
  kind: ContextKind;
  text: string;
  /** 同类内越大越先保留:历史用新旧序,检索用相关性,工具结果用新旧序。 */
  priority: number;
}

export interface BudgetPlan {
  maxTokens: number;
  /** 每类硬上限;system 超限直接失败,其余按优先级裁到上限以内。 */
  caps: Record<ContextKind, number>;
  /** 全局仍超预算时的裁剪顺序,必须恰好包含三个可裁剪类别。 */
  trimOrder: TrimmableKind[];
}

export interface DroppedSegment {
  id: string;
  kind: ContextKind;
  tokens: number;
  reason: "class-cap" | "global-budget";
}

export interface BudgetResult {
  kept: ContextSegment[];
  dropped: DroppedSegment[];
  usedTokens: number;
  byKind: Record<ContextKind, number>;
}

const CJK = /[ -〿㐀-䶿一-鿿豈-﫿＀-￯]/u;

/**
 * 可复现的近似口径:CJK 与全角字符按 1 字符 1 token,其余每 4 字符 1 token,
 * 逐段向上取整。它不是任何提供方的真实分词结果,只用于预算比较与回归测试。
 */
export function estimateTokens(text: string): number {
  if (text === "") return 0;
  let tokens = 0;
  let latin = 0;
  for (const char of text) {
    if (CJK.test(char)) {
      tokens += 1;
      if (latin > 0) {
        tokens += Math.ceil(latin / 4);
        latin = 0;
      }
    } else {
      latin += 1;
    }
  }
  if (latin > 0) tokens += Math.ceil(latin / 4);
  return tokens;
}

function fail(message: string): never {
  throw new AiError("invalid-request", `context-budget: ${message}`);
}

function validatePlan(plan: BudgetPlan): void {
  if (!Number.isInteger(plan.maxTokens) || plan.maxTokens <= 0) {
    fail("maxTokens must be a positive integer");
  }
  for (const kind of CONTEXT_KINDS) {
    const cap = plan.caps[kind];
    if (cap !== Number.POSITIVE_INFINITY && (!Number.isInteger(cap) || cap < 0)) {
      fail(`caps.${kind} must be a non-negative integer or Infinity`);
    }
  }
  const order = plan.trimOrder;
  if (
    order.length !== TRIMMABLE_KINDS.length ||
    new Set(order).size !== order.length ||
    TRIMMABLE_KINDS.some((kind) => !order.includes(kind)) ||
    order.some((kind) => !TRIMMABLE_KINDS.includes(kind))
  ) {
    fail("trimOrder must contain history, retrieval and tool exactly once");
  }
}

/**
 * 两阶段裁剪:先按类别上限保留高优先级片段,再按 trimOrder 从低优先级开始
 * 释放全局预算。system 只检查、不裁剪;它放不下时整个请求失败。
 */
export function planContext(plan: BudgetPlan, segments: ContextSegment[]): BudgetResult {
  validatePlan(plan);

  const seen = new Set<string>();
  for (const segment of segments) {
    if (segment.id.trim() === "") fail("segment id must not be empty");
    if (seen.has(segment.id)) fail(`duplicate segment id "${segment.id}"`);
    seen.add(segment.id);
    if (!CONTEXT_KINDS.includes(segment.kind)) fail(`unknown kind "${segment.kind}"`);
    if (!Number.isFinite(segment.priority)) fail(`priority for "${segment.id}" must be finite`);
  }

  const items = segments.map((segment, index) => ({
    segment,
    index,
    tokens: estimateTokens(segment.text),
  }));

  const systemTokens = items
    .filter((item) => item.segment.kind === "system")
    .reduce((sum, item) => sum + item.tokens, 0);
  if (systemTokens > plan.caps.system || systemTokens > plan.maxTokens) {
    throw new AiError(
      "budget-exceeded",
      `context-budget: system prompt needs ${systemTokens} tokens and is never trimmed`,
    );
  }

  const kept = new Set<string>();
  for (const item of items) {
    if (item.segment.kind === "system") kept.add(item.segment.id);
  }
  const dropped: DroppedSegment[] = [];

  for (const kind of TRIMMABLE_KINDS) {
    const candidates = items
      .filter((item) => item.segment.kind === kind)
      .sort((left, right) => right.segment.priority - left.segment.priority || left.index - right.index);
    let used = 0;
    for (const item of candidates) {
      if (used + item.tokens <= plan.caps[kind]) {
        kept.add(item.segment.id);
        used += item.tokens;
      } else {
        dropped.push({ id: item.segment.id, kind, tokens: item.tokens, reason: "class-cap" });
      }
    }
  }

  let total = items
    .filter((item) => kept.has(item.segment.id))
    .reduce((sum, item) => sum + item.tokens, 0);

  for (const kind of plan.trimOrder) {
    if (total <= plan.maxTokens) break;
    const candidates = items
      .filter((item) => item.segment.kind === kind && kept.has(item.segment.id))
      .sort((left, right) => left.segment.priority - right.segment.priority || right.index - left.index);
    for (const item of candidates) {
      if (total <= plan.maxTokens) break;
      kept.delete(item.segment.id);
      dropped.push({ id: item.segment.id, kind, tokens: item.tokens, reason: "global-budget" });
      total -= item.tokens;
    }
  }

  if (total > plan.maxTokens) {
    throw new AiError("budget-exceeded", `context-budget: context needs ${total} tokens after trimming`);
  }

  const position = new Map<string, number>();
  items.forEach((item) => position.set(item.segment.id, item.index));
  dropped.sort((left, right) => position.get(left.id)! - position.get(right.id)!);

  const byKind: Record<ContextKind, number> = { system: 0, history: 0, retrieval: 0, tool: 0 };
  const keptSegments: ContextSegment[] = [];
  for (const item of items) {
    if (!kept.has(item.segment.id)) continue;
    keptSegments.push(item.segment);
    byKind[item.segment.kind] += item.tokens;
  }

  return { kept: keptSegments, dropped, usedTokens: total, byKind };
}

实现里有几个刻意的选择。

第一,trimOrder 必须恰好包含 history、retrieval、tool 各一次。少写一个类别,那一类就会在全局裁剪时被遗忘;重复写一个类别,排序就失去意义。校验在入口处完成,不等到运行到一半才报错。

第二,类内排序用 priority 降序,相同优先级时保留更早出现的片段。这样历史消息可以按新旧赋权,检索结果可以按相关性赋权,工具结果可以按新鲜度赋权,而裁剪逻辑本身不需要知道业务含义。

第三,全局裁剪用 priority 升序,相同优先级时先丢更晚出现的片段。这一步保证「先牺牲最不重要、最旧」的片段,而不是从头开始切。

第四,返回的 kept 按原始输入顺序排列。裁剪改变的是「保留哪些」,不改变「保留下来的顺序」;对话历史和检索证据都需要保持原有叙事顺序。

第五,dropped 记录每个被裁片段的原因:class-cap 表示类别上限触发,global-budget 表示全局预算触发。日志里能区分这两者,才能判断是该调类别上限,还是该扩窗口。

第六,system 超限直接抛 budget-exceeded。它属于配置或输入错误,重试不会变好;调用方应该收到明确失败,而不是一段缺规则的输出。

测试与真实输出

预算逻辑必须是确定性的:同样的片段、同样的计划,每次得到同样的保留与丢弃结果。完整测试放在 test/prompt/context-budget.test.ts,覆盖估算口径、类别裁剪、全局裁剪、顺序保持与错误边界。

在实验工程根目录执行:

node --test test/prompt/context-budget.test.ts

本机一次运行的真实输出如下,耗时随机器变化:

✔ token 估算口径可复现 (1.054167ms)
✔ system 永远保留,先牺牲工具结果 (0.708375ms)
✔ 类别上限先裁掉低优先级片段 (0.108667ms)
✔ 全局超预算按 trimOrder 依次释放 (0.124625ms)
✔ 保留片段维持原始输入顺序 (0.110834ms)
✔ system 超限直接失败,不裁 system (0.257084ms)
✔ trimOrder 必须覆盖三个可裁剪类别 (0.081917ms)
✔ 同一输入重复运行结果完全一致 (0.103834ms)
ℹ tests 8
ℹ suites 0
ℹ pass 8
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 141.792

测试里所有片段都用 "a".repeat(tokens * 4) 构造,让每个片段恰好等于若干 token。这样断言可以写成精确数字,不需要依赖浮点或真实分词。

一次「检索结果挤掉系统规则」的复盘

回到开头的摘要事故。团队最初的拼装顺序是:历史、检索、工具、system。他们认为「指令放最后,模型更愿意遵守」。这个经验来自短提示,在长上下文里不成立,因为提供方的截断策略通常从尾部开始。

修复没有靠换模型,而是改了四件事。

第一,system 永远排在最前,并由预算器单独保护。它不参与类内排序,也不进入全局裁剪。

第二,拼装前先算总预算。窗口 8000,最大输出 800,安全余量 800,输入预算就是 6400(案例假设值)。四类片段的总和超过 6400 时,先在预算器里裁,不把超长请求发给提供方。

第三,设置类别上限。system 600、history 1800、retrieval 3200、tool 800,总和 6400(案例假设值)。这些数字不是拍脑袋,而是从评测里看「哪类内容删掉之后通过率下降最多」倒推出来的。

第四,记录每次请求的 usedTokens 与 dropped 原因。如果某类长期因为 class-cap 被裁,说明上限偏低;如果长期因为 global-budget 被裁,说明总预算或输入规模需要重新设计。

改完之后,同样的长文不再触发尾部截断,system 规则稳定保留。摘要里仍然可能缺少细节,但缺的是明确被裁掉的工具结果,而不是整段规则。

这个案例的教训是:上下文事故往往不是「模型记不住」,而是「系统在模型看到之前就把规则弄丢了」。预算器的价值,是让丢失发生在可控的位置,并留下记录。

预算参数从哪来

预算参数不能靠感觉,建议按下面的顺序推导。

先算输入预算:contextWindow - maxOutputTokens - safetyMargin。安全余量用来吸收估算误差和提供方计数差异,案例里取窗口的 10%。

再定类别上限:从评测集出发,逐类做消融,观察通过率变化。system 的下限由规则数量决定,通常最稳定;history 和 retrieval 的上限取决于任务类型;tool 结果最容易过期,适合给较小的上限和较早的裁剪顺序。

最后定 trimOrder:先裁「删掉之后还能用别的方式补救」的类别。工具结果缺失可以提示用户重试,检索片段缺失可以降低置信度,历史缺失只能让模型少一点指代能力。所以案例里的顺序是 tool、retrieval、history。

这些数字都要随模型和数据变化复核,不能一次写死。

与 6.1、6.3 的衔接

6.1 把提示变成有变量声明的产物,本节把「往产物里放多少内容」变成可计算的预算。两者结合,提示不再是一段无限增长的文本,而是一组有名字、有上限、有优先级的片段。

6.3 再往前一步:预算计划本身也会变。类别上限调整、裁剪顺序调整,都应该作为提示配置的一部分被版本化,而不是在代码里悄悄改一个常量。

小结

上下文预算不是「窗口够不够大」,而是「有限空间怎么分配」。system、历史、检索、工具四类内容的价值衰减速度不同,必须分开设上限,再按明确的顺序释放全局预算。

system 保底不裁,是这条规则里最重要的一条。一个缺了硬规则的请求,输出看起来仍然流畅,却可能在关键事实上编造;明确失败至少能让产品走降级路径。

下一节处理发布:提示和预算配置如何版本化、如何灰度、如何回滚,以及为什么要和模型快照绑定。

阅读导航:上一节:6.1 提示是产物,不是字符串 · 下一节:6.3 提示版本化与发布 。

常见错误与边界

  • 只设总长度,不分类别。system 和历史被同等对待,规则最容易先丢。
  • 把 system 放在末尾以「增强遵守」。长上下文里,尾部正是很多截断策略的起点。
  • 用字符数直接当 token 数,还不留余量。不同提供方的计数差异足以让预算失效。
  • 每类都设上限,但没有全局上限。四类同时膨胀时,总请求仍然超窗。
  • 裁剪顺序漏掉一类。没有校验的配置会让某一类永远不被释放。
  • 裁剪后重排片段。历史与证据的顺序本身携带信息,保序比“更紧凑”重要。
  • 用估算值代替真实 usage。估算用于事前规划,事后必须用实际计数校准。
  • 把 system 超限当成可重试错误。配置错误重试多少次都还是超限。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《移动游戏产品的架构·商业化与发行工程》附录 C 商业化与发行评审清单
  2. 《移动游戏产品的架构·商业化与发行工程》附录 B 双案例商业化事实表
  3. 《移动游戏产品的架构·商业化与发行工程》附录 A 商业化与发行术语速查