面向长对话的 LLM 上下文窗口管理
上下文窗口有两种成本:溢出错误和逐轮计费。如何统计 token、裁剪与摘要历史、缓存稳定前缀,并使用服务端压缩。
一场不断增长的对话会以两种不同的方式失败,把它们混为一谈就会导向错误的修法。响亮的那种失败是溢出:提示词加上请求的输出超过了模型的上下文窗口,请求被直接拒绝。安静的那种失败是成本:你保留在上下文里的一切都会在每一轮被重新计费,因此一场从不溢出的聊天也会随着历史堆积而线性地变贵。管理一个上下文窗口意味着同时抵御两者——既让 token 计数保持在硬上限之下,又让它足够小,以致你不必为永远重发同一份历史而付费。本文覆盖如何测量这份预算、能把它控制住的裁剪与摘要策略,以及降低一场长对话成本的缓存与服务端压缩。
一份预算,由输入、输出和推理共享
上下文窗口是整个请求都从中支取的单一 token 预算。它不是"输入 token 用到上限、然后输出再叠加在上面"——输入 token、你要求模型生成的 token,以及(在推理模型上)隐藏的推理 token 都在争夺同一个天花板。两个后果立刻随之而来。第一,一个长提示词会压缩留给答案的空间,因此一个塞得过满的上下文即便没有硬性溢出错误,也可能逼出一个被截断或空的响应。第二,在推理模型上,看不见的推理 token 也会啃食那个天花板,这正是 推理模型 API 参数强调预留输出余量的原因。把窗口作为一个整体来做预算:预先决定你会让历史占用多少 token、为推理预留多少、为可见答案保证多少。
默认情况下,超出窗口会触发硬错误——在禁用截断时,提供方会返回上下文长度错误,而不是静默丢弃 token——所以溢出通常是你需要预防的问题,而不是交给模型吸收。这里有一个值得了解的显式例外:Responses API 接受 truncation: auto,允许提供方从输入的中间丢弃条目,让过大的请求适配窗口,而不是直接拒绝。这会把清晰失败换成你未选择的静默内容丢失,因此应有意识地启用,并记录它何时触发;自动截断的行为及丢弃内容会因提供方而异。无论采用哪种方式,可靠的防线都一样:发送前先知道 token 计数。
发送前先测量
你无法管理一份你不测量的预算。每一个历史管理决定都取决于对你即将发出的那个请求的准确 token 计数,而"大约每个 token 四个字符"是一个在代码、非拉丁文字和结构化数据上偏差严重的经验法则。在你能做到的地方用提供方自己的分词器来统计,并统计整个请求——系统指令、工具、每一条消息,以及任何附带的文档——而不只是最新的用户轮次。
统计也必须对模型看到的东西诚实。工具定义、用于 结构化输出的 JSON schema,以及图片都会消耗 token,而它们很容易被遗忘,因为它们看起来不像"对话"。一个大的工具目录会在每次调用时悄悄占据窗口中相当可观的一块。一旦你有了一个可靠的计数,下面的策略就只是把那个数字保持在你想要之处的政策而已。
裁剪策略,从粗糙到精巧
有四种常见的方式能把历史保持在预算内,而真实系统会把它们组合起来。
截断最早的轮次。 最简单的政策:保留系统提示词加上最近的 N 轮,丢弃其余。它便宜且可预测,是那种旧上下文很少要紧的客服式聊天的正确默认。它的弱点是失忆——模型会忘掉一场长会话早期确立的事实。
摘要并替换。 与其丢弃旧轮次,不如把它们压缩。周期性地让一个模型把对话最早的部分摘要成一段紧凑的笔记,然后用这段摘要替换那些轮次。这以极小一部分 token 保留了要点,代价是一次额外调用和一些保真度损失。对于必须记住一小时前所做决定的长期助手,这个交换通常是值得的。
检索而非保留。 对于活在文档里、而非活在对话里的知识,完全不要把它带进上下文。把它存在外部,并在每一轮只检索与当前问题相关的段落、把那些注入进去。无论源材料有多少,检索都让窗口保持小而恒定——这是"每一轮为整本手册付费"和"只为要紧的那两段付费"之间的区别。
保持一个稳定前缀、变化尾部。 把对话组织成不变的部分(系统提示词、工具、参考材料)坐在前面,只有最近的轮次在末尾变化。这本身就是好的卫生习惯,也是缓存的先决条件,下面就讲。
大多数耐用的聊天系统会把这些分层:一个固定的系统前缀、对中间部分的一份滚动摘要、最后几轮的逐字保留,以及对任何事实性内容的检索。确切的配比取决于你的优先项是召回、延迟还是成本。
缓存那些你无法避免重发的前缀
有些上下文确实必须每一轮都在场——一段详尽的系统提示词、一套固定的工具集、一份正在讨论的文档。你无法在不破坏任务的前提下裁剪它,但你可以停止为重新处理它付全价,而这正是提示词缓存所做的。当稳定前缀被缓存时,提供方会复用它,并把那些 token 按陡峭的折扣、而非全额输入费率计费。管理上的动作是把对话安排成可缓存区域最大化且字节稳定——里面没有时间戳或被重排的工具——并让易变的尾部落在缓存边界之后。逐家提供方的机制在 OpenAI、Claude 与 Gemini 三家的提示词缓存中;这里的要点是,缓存和上下文管理是从两个角度看到的同一门纪律。裁剪减少你发送多少 token;缓存减少那些不可避免的 token 花费多少。
服务端状态并不会让历史变免费
Responses API 让你能用 previous_response_id 续接一个线程,而不必重发整个历史,这是一个实实在在的人体工学胜利——你只发送新的一轮,服务器把它缝接到已存储的上下文上。人们很容易把这读成"早先的上下文现在免费了"。它不是。链中所有先前的输入 token 在每次续接时仍作为输入 token 计费;链式续接省的是你把字节从线路上发出去,而不是省你为模型仍要处理的上下文付费。所以服务端对话状态改变的是谁持有历史,而不是你是否为它付费。你仍然需要上面的裁剪和缓存来控制成本;previous_response_id 与那件事是正交的。
压缩:在服务端缩小一场对话
当一场对话长到连你这边的摘要都变得别扭时,一些 Responses 风格的工作流会暴露一个服务端压缩步骤,把运行中的上下文凝缩成一个更小的、你可以从中续接的表示。这个网关把它作为一个专门的端点 POST /v1/responses/compact 呈现出来,它把一个压缩请求转发给一个支持它的上游。该请求携带要凝缩的对话——model、input、instructions,以及可选的 previous_response_id、tools 和一个 reasoning 对象——而响应返回一个被压缩的 output 加上一个 usage 对象,记录压缩本身所消耗的输入和输出 token。
curl https://your-gateway.example.com/v1/responses/compact \
-H "Authorization: Bearer AVE_GATEWAY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "gpt-5.6",
"previous_response_id": "resp_abc123",
"instructions": "Condense the conversation so far, preserving decisions and open tasks."
}'
有两件事要记住。压缩本身就是一次模型调用——它读取对话并写出一个凝缩版本,因此消耗的 token 会像任何其他请求一样出现在使用日志中;它用一次性压缩成本换取后续轮次更低的成本。其次,它只对实现了压缩端点的上游有意义,因此这是一项透传能力,不是网关能为缺少该能力的提供方凭空合成的功能。在可用时,它相当于服务端的“摘要并替换”,把压缩工作移到上游,而不是在客户端自行执行。
为答案预留空间
无论你选择哪种组合,都要为可见答案留下足够空间。设置显式输出上限,并确保 context_used + reserved_output 充分低于窗口上限;在推理模型上,这份预留还必须覆盖隐藏的推理 token。否则会出现一种令人沮丧的失败:请求成功、为大量输入计费,却因为没有空间完成答案而返回截断结果。对超时也应用同一原则:主动留出余量。
把它们串起来
一个长期运行助手的上下文政策通常长这样:钉住一个可缓存的系统前缀、把最后几轮逐字保留、维护对较旧中间部分的一份滚动摘要(或一次服务端压缩)、从外部存储检索事实而非把它们随身携带、在每次发送前统计 token,并预留一份固定的输出预算。每一块都瞄准两成本问题的某个具体部分——溢出或逐轮计费——而它们合在一起,让一场对话既在硬上限之下、又不落在一条不断上升的成本曲线上。网关的角色是让这个效果可见:一个逐请求盯住 token 计数的处所,去确认对你历史政策的一次改动是否真的移动了那个数字,这与 降低 LLM API 成本的更广工作相连。
常见问题
当一个请求超出上下文窗口时会发生什么?
默认情况下,提供方会用上下文长度错误拒绝请求,而不是静默截断提示词,因此你需要在发送前统计 token 来预防溢出。例外是 Responses 可选的 truncation: auto:它会丢弃输入中间的条目,把过大的请求压进窗口。这个功能方便,但会静默丢失内容,因此应明确启用,而不要把它当作安全网。由于输入、输出和推理 token 共用一份预算,即使没有超过硬上限,过长的提示词也可能因为剩余生成空间不足而得到截断答案。
使用 previous_response_id 会降低我的 token 账单吗?
不会。它省下你把历史从线路上重发出去,但链中所有先前的输入 token 在每次续接时仍作为输入计费。服务端状态是一个人体工学和延迟特性,不是一次成本削减。要真正削减上下文成本,你仍然需要裁剪、摘要,以及对稳定前缀的提示词缓存。
摘要历史还是截断历史——我该用哪个?
截断(保留最后 N 轮)最简单,且在旧上下文很少要紧时没问题,但它会忘掉早期的事实。摘要把旧轮次压缩成一段紧凑的笔记,以每次压缩的额外成本保留了要点。必须记住早先决定的长期助手通常两者都跑:逐字的近期轮次加上对较旧中间部分的一份滚动摘要,再对任何事实性内容用检索。
/v1/responses/compact 端点做什么?
它把一个压缩请求转发给一个支持把 Responses 对话凝缩成一个更小、你可以从中续接的表示的上游。你传入对话(通过 input/instructions,以及可选的 previous_response_id),它返回一个被压缩的 output 和一个针对压缩所消耗 token 的 usage 计数。它是一个透传能力——只有当上游实现了它时才可用——而且它是"摘要并替换"的服务端版本。
先画出曲线,再让它变平
为一场长期运行的对话添加监测,在每一轮记录总 token 数并画出曲线。如果曲线持续上升,就在某个阈值加入滚动摘要或压缩步骤,再观察使用日志中的曲线是否变平。然后把系统前缀标记为可缓存,并确认每轮输入费用下降。这两组前后曲线——token 数和输入成本——会根据你自己的流量说明上下文策略是否真正生效,而不是停留在理论上。