《互联网产品的 AI 架构·推理性能与成本工程》3.1 上下文窗口的成本结构

从「回声」一次账单翻三倍的扩容说起,拆解输入 token 的四类来源、历史每轮重发带来的近似二次增长,以及上下文长度对成本与首字延迟的不同曲线;文中给出可运行的 TypeScript 统计模块、示例样本上的运行结果,并说明它与卷一上下文预算的分工。

同一段对话,账单为什么翻了三倍

「回声」的私信助手在双十一前做了一次扩容。请求量涨了 40%,账单涨了 190%。产品经理以为是模型涨价,工程同学把单次请求拆开看,发现模型单价没变,变的是输入:平均输入 token 从 780 涨到 2,310。

增长不是均匀的。系统提示只改过两次,检索片段从 3 条加到 8 条,工具结果从一段物流摘要变成完整 JSON,历史从 4 轮涨到 12 轮。四类内容一起膨胀,最后都按输入 token 计价。

这一节不讨论“怎么不超预算”(那是卷一 6.2 的问题),而是回答三个更细的问题:输入 token 由什么构成、它随对话怎样增长、长度对延迟与成本的边际代价如何变化。文中数字来自 ai-lab 的示例请求,标注为“示例样本上的运行结果”,不是生产实测。

输入 token 的四个来源

把上下文按产生者分类,比按格式分类更有用,因为每一类的增长方式和裁剪后果不同。

类别典型内容变化频率增长方式裁掉之后
system角色、语气、硬规则低固定输出不可控
retrieval检索片段、知识条目每请求随召回条数线性事实依据缺失
tool订单、物流、库存 JSON每请求随工具数量线性细节缺失,可降级
history最近对话轮次每轮随轮次线性,但每轮重发指代断裂
question当前问题每请求固定无法回答

前四类里有三类会被“每轮重发”放大。system 和 question 虽然固定,也一样在每一轮重复计费。真正只付一次的是模型输出,输入几乎从不只付一次。

一份示例请求的构成

示例请求是一条售后对话:system 规则一段,检索三条(退款、物流、补偿),工具返回一条订单 JSON,历史六轮,最后一句是当前问题。用 composeContext 统计:

角色片段数token占比
system16620.6%
history610733.3%
retrieval310733.3%
tool1309.3%
question1113.4%
合计12321100%

这张表打破一个常见直觉:system 提示不是成本大头。它只占两成,历史和检索各占三分之一。真正该先审的是“历史带了多少轮”和“检索召回了几条”,而不是反复压缩 system 的措辞。

历史每轮重发,累计成本接近二次增长

如果每轮新增 60 token 的对话,历史从 107 开始,到第 10 轮是 647。单轮输入从 321 涨到 861,历史占比从 33.3% 涨到 75.1%。

轮次历史 token输入 token历史占比
110732133.3%
534756161.9%
1064786175.1%

单轮看只是线性增长,累计账单不是。十轮对话一共发送 5,910 个输入 token,而把所有历史只算一次,只需要 921 个输入 token。按示例模型(输入 250,000 微元/百万 token、输出 1,000,000 微元/百万 token、每轮输出 120 token)计算,十轮总成本 2,685 微元,只发一次的口径是 1,431 微元,差了 1.88 倍。

这个差额就是“历史管理”的经济价值。卷一 6.2 讨论的是历史超预算时先裁谁;这里要问的是:既然历史每轮都重发,是否应该在第 5 轮就把它摘要掉,而不是等到第 20 轮窗口放不下。3.3 会给出压缩与验收方法。

长度对延迟与成本不是同一种曲线

成本按 token 线性计价,延迟不是。预填充要处理全部输入,注意力部分的代价随长度超线性增长。用一个明确标注为参考模型的公式(固定 40ms + 每千 token 8ms + 每千 token 平方 0.06ms)算出来的曲线是:

输入 token输入成本(微元)预填充延迟(ms)边际延迟(ms/千 token)
4,0001,00073.0—
8,0002,000107.88.7
16,0004,000183.49.5
32,0008,000357.410.9
64,00016,000797.813.8
128,00032,0002,047.019.5

两个结论要分开说。第一,成本随 token 线性增长,从 4k 到 128k 正好 32 倍,没有隐藏的非线性。第二,延迟的边际代价从 8.7ms/千 token 涨到 19.5ms/千 token,最后一段的每千 token 边际延迟是前一段的 2.24 倍。把上下文从 64k 压到 32k,省下的不只是 8,000 微元,还有约 440ms 的首字时间。

“成本非线性”还有另一层:同样一次回答,输出长度不变,输入越长,单位有效输出的成本越高。128k 输入、200 token 输出的一次请求,成本 32,200 微元;4k 输入只要 1,200 微元。如果长上下文没有带来更好的答案,这部分钱就是纯损耗。

与卷一 6.2 的分工

卷一 6.2 解决“怎么不超预算”:四类上下文、类别上限、全局裁剪顺序、system 永不裁剪。它的输出是一份能放下的 BudgetResult。

本节解决“同样放得下,钱花在哪”:每类占多少、随轮次怎样涨、长度的边际代价是多少。它不决定裁谁,只提供做决定所需的成本曲线。两者的接口很清晰:先用本节的口径测出构成与曲线,再用 6.2 的预算规划决定保留哪些片段。

实现

src/perf/context.ts 只做四件事:按角色统计 token、投影轮次增长、给出成本与首字延迟曲线、计算多轮累计成本。它复用卷一的 estimateTokens,不重新定义分词口径。

// file: src/perf/context.ts
// tier: L2
// verify: node --test test/perf/context.test.ts

import { AiError } from "../core/errors.ts";
import { estimateTokens } from "../prompt/context-budget.ts";
import type { ModelProfile } from "../core/types.ts";

export const CONTEXT_ROLES = ["system", "history", "retrieval", "tool", "question"] as const;
export type ContextRole = (typeof CONTEXT_ROLES)[number];

/** 拼装前的上下文片段。role 决定成本归属,不影响发送顺序。 */
export interface ContextPiece {
  id: string;
  role: ContextRole;
  text: string;
}

export interface RoleRow {
  role: ContextRole;
  pieces: number;
  tokens: number;
  /** 占全部输入 token 的比例,0 到 1。 */
  share: number;
}

export interface ContextComposition {
  totalTokens: number;
  rows: RoleRow[];
  byRole: Record<ContextRole, number>;
}

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

function requireInteger(value: number, field: string, min: number): void {
  if (!Number.isInteger(value) || value < min) fail(`${field} 必须是 >= ${min} 的整数`);
}

function emptyByRole(): Record<ContextRole, number> {
  return { system: 0, history: 0, retrieval: 0, tool: 0, question: 0 };
}

/**
 * 输入 token 的构成。行按固定顺序输出,零片段也保留,
 * 这样两次统计可以逐行对比,不会因为缺行错位。
 */
export function composeContext(pieces: readonly ContextPiece[]): ContextComposition {
  if (pieces.length === 0) fail("至少需要一个上下文片段");
  const seen = new Set<string>();
  const tokens = emptyByRole();
  const counts = emptyByRole();
  for (const piece of pieces) {
    if (piece.id.trim() === "") fail("片段 id 不能为空");
    if (seen.has(piece.id)) fail(`片段 id 重复:${piece.id}`);
    seen.add(piece.id);
    if (!CONTEXT_ROLES.includes(piece.role)) fail(`未知角色:${String(piece.role)}`);
    tokens[piece.role] += estimateTokens(piece.text);
    counts[piece.role] += 1;
  }
  const totalTokens = CONTEXT_ROLES.reduce((sum, role) => sum + tokens[role], 0);
  return {
    totalTokens,
    byRole: tokens,
    rows: CONTEXT_ROLES.map((role) => ({
      role,
      pieces: counts[role],
      tokens: tokens[role],
      share: totalTokens === 0 ? 0 : tokens[role] / totalTokens,
    })),
  };
}

export interface GrowthInput {
  /** 不含历史的固定部分:system、检索、工具结果与当前问题。 */
  base: readonly ContextPiece[];
  initialHistoryTokens: number;
  /** 每轮新增的用户消息与助手回复合计 token。 */
  perTurnHistoryTokens: number;
  turns: number;
}

export interface GrowthRow {
  turn: number;
  historyTokens: number;
  totalTokens: number;
  historyShare: number;
}

/** 历史按轮次线性增长,但它在输入里的占比会随轮次上升。 */
export function projectGrowth(input: GrowthInput): GrowthRow[] {
  requireInteger(input.initialHistoryTokens, "initialHistoryTokens", 0);
  requireInteger(input.perTurnHistoryTokens, "perTurnHistoryTokens", 0);
  requireInteger(input.turns, "turns", 1);
  const base = composeContext(input.base).totalTokens;
  const rows: GrowthRow[] = [];
  for (let turn = 1; turn <= input.turns; turn += 1) {
    const historyTokens = input.initialHistoryTokens + input.perTurnHistoryTokens * (turn - 1);
    const totalTokens = base + historyTokens;
    rows.push({
      turn,
      historyTokens,
      totalTokens,
      historyShare: totalTokens === 0 ? 0 : historyTokens / totalTokens,
    });
  }
  return rows;
}

/** 成本沿用网关的整数微元口径,向上取整,避免一次请求被算成零成本。 */
export function inputCostMicros(model: ModelProfile, inputTokens: number): number {
  requireInteger(inputTokens, "inputTokens", 0);
  requireInteger(model.inputMicrosPerMTok, "inputMicrosPerMTok", 0);
  return Math.ceil((inputTokens * model.inputMicrosPerMTok) / 1_000_000);
}

export function outputCostMicros(model: ModelProfile, outputTokens: number): number {
  requireInteger(outputTokens, "outputTokens", 0);
  requireInteger(model.outputMicrosPerMTok, "outputMicrosPerMTok", 0);
  return Math.ceil((outputTokens * model.outputMicrosPerMTok) / 1_000_000);
}

/**
 * 首字延迟的参考模型:固定开销 + 线性预填充 + 注意力二次项。
 * 系数是示例值,不是任何提供方的实测曲线,只用于比较相对代价。
 */
export interface PrefillModel {
  fixedMs: number;
  perKTokMs: number;
  quadraticMsPerKTok2: number;
}

export const SAMPLE_PREFILL: PrefillModel = { fixedMs: 40, perKTokMs: 8, quadraticMsPerKTok2: 0.06 };

export function estimatePrefillMs(tokens: number, model: PrefillModel = SAMPLE_PREFILL): number {
  requireInteger(tokens, "tokens", 0);
  const k = tokens / 1000;
  return Math.round((model.fixedMs + model.perKTokMs * k + model.quadraticMsPerKTok2 * k * k) * 10) / 10;
}

export interface CurveRow {
  tokens: number;
  costMicros: number;
  prefillMs: number;
  /** 相对上一行的增量;首行为 0。 */
  marginalCostMicros: number;
  marginalMs: number;
  marginalMsPerKTok: number;
}

/** 同一份提示在不同长度下的成本与首字延迟,用来暴露边际代价的变化。 */
export function contextCurve(
  model: ModelProfile,
  lengths: readonly number[],
  prefill: PrefillModel = SAMPLE_PREFILL,
): CurveRow[] {
  if (lengths.length === 0) fail("lengths 不能为空");
  const sorted = [...lengths].sort((left, right) => left - right);
  return sorted.map((tokens, index) => {
    requireInteger(tokens, "lengths[]", 0);
    const costMicros = inputCostMicros(model, tokens);
    const prefillMs = estimatePrefillMs(tokens, prefill);
    if (index === 0) {
      return { tokens, costMicros, prefillMs, marginalCostMicros: 0, marginalMs: 0, marginalMsPerKTok: 0 };
    }
    const previous = sorted[index - 1];
    if (tokens === previous) fail("lengths 不能包含重复值");
    const marginalMs = Math.round((prefillMs - estimatePrefillMs(previous, prefill)) * 10) / 10;
    return {
      tokens,
      costMicros,
      prefillMs,
      marginalCostMicros: costMicros - inputCostMicros(model, previous),
      marginalMs,
      marginalMsPerKTok: Math.round((marginalMs / ((tokens - previous) / 1000)) * 10) / 10,
    };
  });
}

export interface ConversationInput {
  model: ModelProfile;
  /** 每轮都要重发的固定部分:system、检索、工具、当前问题。 */
  fixedTokens: number;
  initialHistoryTokens: number;
  perTurnHistoryTokens: number;
  turns: number;
  outputTokensPerTurn: number;
}

export interface ConversationRow {
  turn: number;
  inputTokens: number;
  costMicros: number;
  cumulativeMicros: number;
}

export interface ConversationCost {
  rows: ConversationRow[];
  totalInputTokens: number;
  totalOutputTokens: number;
  totalMicros: number;
  /** 把历史当成“只发一次”的错误口径,仅用于展示差额。 */
  noResendMicros: number;
}

/** 多轮对话每轮都重发固定部分与历史,累计成本近似二次增长。 */
export function conversationCost(input: ConversationInput): ConversationCost {
  requireInteger(input.fixedTokens, "fixedTokens", 0);
  requireInteger(input.initialHistoryTokens, "initialHistoryTokens", 0);
  requireInteger(input.perTurnHistoryTokens, "perTurnHistoryTokens", 0);
  requireInteger(input.turns, "turns", 1);
  requireInteger(input.outputTokensPerTurn, "outputTokensPerTurn", 0);

  const rows: ConversationRow[] = [];
  let cumulative = 0;
  let totalInputTokens = 0;
  for (let turn = 1; turn <= input.turns; turn += 1) {
    const historyTokens = input.initialHistoryTokens + input.perTurnHistoryTokens * (turn - 1);
    const inputTokens = input.fixedTokens + historyTokens;
    const costMicros =
      inputCostMicros(input.model, inputTokens) + outputCostMicros(input.model, input.outputTokensPerTurn);
    cumulative += costMicros;
    totalInputTokens += inputTokens;
    rows.push({ turn, inputTokens, costMicros, cumulativeMicros: cumulative });
  }
  const totalOutputTokens = input.outputTokensPerTurn * input.turns;
  const sentOnce = input.fixedTokens + input.initialHistoryTokens + input.perTurnHistoryTokens * input.turns;
  return {
    rows,
    totalInputTokens,
    totalOutputTokens,
    totalMicros: cumulative,
    noResendMicros: inputCostMicros(input.model, sentOnce) + outputCostMicros(input.model, totalOutputTokens),
  };
}

composeContext 固定输出五行,即使某类为零也保留,避免两次统计错位。contextCurve 把边际值单独算出来,是为了让“加一段上下文值不值”可以被逐行比较。conversationCost 里的 noResendMicros 不是一个可执行方案,而是一个对照口径:它回答“如果历史只付一次,账单会是多少”。

测试与运行结果

node --test test/perf/context.test.ts

本机一次运行的输出摘要:

ℹ tests 8
ℹ pass 8
ℹ fail 0
ℹ duration_ms 177.843209

测试逐条断言了 321 的构成、第 10 轮 861 的输入、5,910 的累计输入、2,685 与 1,431 的两个成本口径,以及延迟边际从 8.7 涨到 19.5。数字随机器和 Node 版本略有浮动,断言的是函数关系而不是墙上时钟。

小结

上下文成本由四类内容构成,system 通常不是大头;历史每轮重发,累计成本接近二次增长;成本随 token 线性,延迟的边际代价随长度上升。把这三件事测出来,才能判断该压历史、减检索,还是缩短工具结果。

下一节讨论另一种省钱方式:不改变内容,只改变提示的顺序,让稳定的前缀被缓存复用。

阅读导航:上一节:2.3 多租户公平调度 · 下一节:3.2 前缀缓存与提示结构 。

常见错误与边界

  • 只算输出 token。输入通常远多于输出,且每轮重发。
  • 把 system 当成成本大头。多数请求里历史和检索才是主要部分。
  • 用单轮输入估算总成本。十轮对话的累计成本接近二次增长。
  • 把 token 成本和延迟当成同一条曲线。成本线性,延迟的边际代价上升。
  • 只看每百万 token 单价,不看单位有效输出的成本。
  • 长上下文没有带来更好答案,却仍然按满窗口付费。
  • 把参考延迟模型当成提供方承诺。系数是示例值,换模型必须重测。
  • 用字符数代替 token。中英文、代码与 JSON 的比例完全不同。
  • 等窗口放不下才处理历史。那时成本已经付了很多轮。
  • 把本节方法当成裁剪策略。它只测量,裁谁仍要由预算与验收决定。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查