logo

Context Window 的内存模型与成本计算

Published on

Context Window 的内存模型与成本计算

“这个模型支持 128K 上下文。”看到这句话时,很多人会本能地把它理解成:我可以放心塞进 128K Token,模型还能像读短句一样回答,价格和速度也只是略有变化。

真正上线长上下文功能后,账单和延迟会很快纠正这个想象。输入 Token 要计费,输出 Token 要计费,推理服务要为上下文保存 KV Cache,并发越高,内存越容易被占满;即使窗口容纳得下整本手册,模型也不一定能稳定找到埋在中间的关键条件。

我以前也把上下文窗口当成一个“容量参数”,后来把每次请求的 Token、显存和延迟拆开记录,才发现它更像一块需要预算的工作区。窗口大小只是上限,真正的工程问题是:这一块工作区由什么组成?它如何随着并发增长?哪些内容值得放进去?每一次增加的上下文,是否真的换来了任务质量?

一、Context Window 包含哪些东西

上下文窗口通常表示一次请求中,模型能够处理的 Token 总上限。它往往包括输入和输出,而不是只指用户输入:

上下文总量
= system Prompt
 + 对话历史
 + 用户本轮输入
 + 检索片段 / 工具结果
 + 预留输出 Token

如果模型最大窗口是 N,输入已经占了 N - 1000,那么最多只能为输出预留约 1000 个 Token。不同 API 对超限、截断和保留策略不同,不能只看宣传页上的数字。

type ContextBudget = {
  maxContextTokens: number
  systemTokens: number
  historyTokens: number
  retrievedTokens: number
  userTokens: number
  reservedOutputTokens: number
}

function usedInputTokens(budget: ContextBudget) {
  return (
    budget.systemTokens +
    budget.historyTokens +
    budget.retrievedTokens +
    budget.userTokens
  )
}

function fitsContext(budget: ContextBudget) {
  return usedInputTokens(budget) + budget.reservedOutputTokens <= budget.maxContextTokens
}

系统必须在调用前计算预算。不要等 API 返回“context length exceeded”才发现历史消息、RAG 片段和输出预留加在一起已经超限。

二、Token 不是字符,语言和内容会改变比例

中文、英文、代码、表格和特殊符号使用 Token 的方式不同。同样长度的文本,Token 数可能差很多;不能用字符数精确估算计费和上下文。

字符数 ≠ Token 数
Token 数 → 上下文、价格、延迟和部分内存开销

输入管道要使用目标模型的 Tokenizer 做预估。模型更换后,原来的长度阈值可能失效;中文知识库和英文代码库也要分别统计。

type TokenEstimate = {
  characters: number
  tokens: number
  tokenizer: string
  estimatedCost: number
}

function estimate(text: string, tokenizer: Tokenizer, pricePerToken: number) {
  const tokens = tokenizer.encode(text).length
  return {
    characters: text.length,
    tokens,
    tokenizer: tokenizer.name,
    estimatedCost: tokens * pricePerToken,
  }
}

估算用于预算,不等于最终账单。供应商可能对缓存、批处理、推理 Token 和不同模型版本使用不同价格,结算数据应该以实际响应的 usage 为准。

三、为什么上下文会占显存

在 Transformer 推理中,模型要保存已经处理过的 Key 和 Value,后续生成 Token 时可以复用,这就是 KV Cache。上下文越长、层数越多、隐藏维度越大,KV Cache 越大;并发请求越多,缓存也会按请求叠加。

KV Cache 内存
≈ 层数 × 序列长度 × KV 头数 × 每头维度 × 2(K 与 V)× dtype 字节数

这是一个简化公式,实际还受到 GQA/MQA、分页管理、对齐和框架实现影响,但它说明了三个事实:上下文长度会直接影响内存,输出生成会继续增加缓存,多个并发请求会同时占用工作区。

type KvEstimate = {
  layers: number
  sequenceTokens: number
  kvHeads: number
  headDim: number
  bytesPerValue: number
}

function estimateKvBytes(input: KvEstimate) {
  return (
    input.layers *
    input.sequenceTokens *
    input.kvHeads *
    input.headDim *
    2 *
    input.bytesPerValue
  )
}

长上下文模型的权重可能可以放进显存,但 KV Cache 会在长输入和并发场景下成为真正瓶颈。部署时不能只按模型文件大小估算。

四、并发让内存问题变成乘法

单个请求使用 32K Token,看起来显存足够;同时处理 16 个请求时,缓存需求可能接近 16 倍。真实服务还要给运行时、调度和临时张量留安全余量。

总 KV Cache
≈ 单请求 KV Cache × 活跃请求数
type ConcurrencyPlan = {
  kvBytesPerRequest: number
  maxActiveRequests: number
  runtimeReserveBytes: number
  totalMemoryBytes: number
}

function planMemory(
  kvBytesPerRequest: number,
  maxActiveRequests: number,
  runtimeReserveBytes: number,
) {
  return {
    kvBytesPerRequest,
    maxActiveRequests,
    runtimeReserveBytes,
    totalMemoryBytes:
      kvBytesPerRequest * maxActiveRequests + runtimeReserveBytes,
  }
}

长请求不能无限占用并发槽位。服务可以按 Token 预算、截止时间和优先级调度,设置最大上下文、最大输出和队列上限。队列满时明确返回过载或异步处理,不要让请求无限等待。

五、长上下文不等于模型有效利用全文

即使输入没有超限,模型也可能遗漏中间信息、被重复内容干扰,或者把互相冲突的段落混在一起。窗口解决了“装得下”,没有自动解决“找得到”和“用得对”。

容量:能否把内容放入窗口
利用:模型能否定位关键内容
质量:模型能否正确使用并引用内容

长上下文评测要把关键事实放在开头、中间和结尾,测试多段关联、冲突、无答案和噪声。只把一篇长文章放进去让模型总结,无法证明它真的使用了所有内容。

六、什么时候应该用检索而不是全文输入

如果文档很长但问题只涉及局部内容,检索可以减少输入成本和注意力噪声;如果需要全局主题、章节关系和跨段总结,分层摘要或 Map-Reduce 更合适;如果文档短且低频,一次性输入可能最简单。

局部事实 → RAG / 精确检索
全文主题 → 分层摘要 / Map-Reduce
短文档低频 → 直接长上下文
持续对话 → 摘要 + 最近历史 + 重要记忆

选择不是“窗口越大越先进”,而是让进入上下文的每个 Token 都有任务价值。上下文过长会增加成本,也会让模型更难分辨重点。

七、对话历史应该如何增长

聊天应用最容易把全部历史消息原样追加。短期看体验连续,长期看 Token 和成本线性增长,旧消息还可能与当前目标冲突。

可以分层管理历史:

最近几轮原文
   + 会话摘要
   + 用户确认的长期事实
   + 当前任务状态
type ConversationContext = {
  recentMessages: ChatMessage[]
  summary: string
  durableFacts: string[]
  currentTask?: string
}

function buildConversationInput(context: ConversationContext) {
  return [
    context.summary,
    ...context.durableFacts,
    ...context.recentMessages.map((message) => message.content),
    context.currentTask ?? '',
  ].join('\n')
}

摘要必须标记来源和更新时间,不能把模型猜出的信息当成用户确认的事实。历史压缩后要做回归,检查关键约束、否定条件和任务状态是否丢失。

八、成本计算要包含输入和输出

很多团队只估算输出 Token,忽略了每轮重复发送的长输入。一个对话请求的粗略成本可以写成:

请求成本
= 输入 Token × 输入单价
 + 输出 Token × 输出单价
 + 工具 / 检索 / 多模态处理成本
type PriceTable = {
  inputPerMillion: number
  outputPerMillion: number
  cachedInputPerMillion?: number
}

function estimateRequestCost(
  inputTokens: number,
  outputTokens: number,
  price: PriceTable,
) {
  return (
    inputTokens / 1_000_000 * price.inputPerMillion +
    outputTokens / 1_000_000 * price.outputPerMillion
  )
}

缓存命中可能降低输入成本,但要确认缓存的有效范围、隐私边界和失效条件。不能为了省 Token,把不同用户的上下文放进同一个不安全缓存键。

更有意义的指标是每个成功任务成本:

成功任务成本
= 总请求成本 / 成功完成任务数

如果上下文变长让成功率提升,额外成本可能值得;如果只是增加噪声和延迟,应该减少上下文,而不是继续扩大窗口。

九、服务端要做上下文预算器

不要让每个业务页面自己拼 Prompt 和计算长度。统一的上下文预算器可以根据任务、模型和截止时间决定保留什么:

type ContextItem = {
  kind: 'system' | 'history' | 'retrieval' | 'tool' | 'user'
  text: string
  priority: number
  tokens: number
  required: boolean
}

function selectContext(items: ContextItem[], maxTokens: number) {
  const required = items.filter((item) => item.required)
  const optional = items
    .filter((item) => !item.required)
    .sort((left, right) => right.priority - left.priority)
  const selected = [...required]
  let used = selected.reduce((sum, item) => sum + item.tokens, 0)

  for (const item of optional) {
    if (used + item.tokens > maxTokens) continue
    selected.push(item)
    used += item.tokens
  }
  return selected
}

预算器要保证系统规则和当前用户问题不会被可选历史挤掉。检索片段可以按相关性、来源可信度和权限排序;旧消息可以摘要或丢弃;工具结果可以压缩,但关键字段要保留。

十、长上下文压测应该怎么做

压测不要只增加请求数,还要同时控制输入长度和输出长度。建立矩阵:2K、8K、32K、64K 输入,1、4、8、16 并发,分别测首 Token、完整响应、峰值显存、吞吐和错误率。

type ContextBenchmark = {
  inputTokens: number
  outputTokens: number
  concurrency: number
  firstTokenP95Ms: number
  completionP95Ms: number
  peakMemoryGb: number
  tokensPerSecond: number
  errorRate: number
}

观察拐点:输入长度到哪里开始显著增加延迟?并发到哪里开始排队?哪种组合会触发 OOM?模型在中间位置的信息命中率是否下降?这些数据比“支持 128K”更能指导产品配置。

十一、上下文窗口会影响系统架构

窗口较小时,系统更依赖检索、摘要和记忆;窗口变大后,可能减少一部分切分工作,却增加输入成本、KV Cache 和长任务调度。架构不会因为窗口变大而自动变简单,只是瓶颈发生了移动。

短窗口:切分与检索压力大
长窗口:内存、成本、注意力利用和并发压力大

长任务最好使用异步队列、检查点和取消。用户取消后,服务端要停止生成并释放 KV Cache;模型已经完成足够信息时要提前结束,不要为了填满最大输出继续计算。

十二、我的总结:窗口是预算,不是宣传数字

Context Window 表示模型一次可以处理的最大 Token 范围,但产品真正拥有的是一块有限工作区。System Prompt、历史、检索结果、工具输出和预留答案共同占用它;每个输入 Token 还会影响计费、延迟和推理内存。

我现在做上下文设计,会先测任务需要什么信息,再决定直接长上下文、检索、摘要还是分层处理;调用前计算 Token 预算,服务端限制并发和输出,线上记录输入长度、KV Cache、P95 延迟和成功任务成本。

最重要的不是把窗口用满,而是让进入窗口的内容值得占据位置。短而相关的上下文,常常胜过长而嘈杂的全文;能通过工具确认的事实,不应该靠历史消息反复携带;已经完成的任务,也不应该永远占着对话空间。

长上下文是很有价值的能力,但它不是免费的记忆,也不是理解能力的保证。把内存、成本、质量和并发放在同一张预算表里,模型的“能装下”才会真正变成系统的“用得好”。

🤪 您也可以编辑此页: