类比嵌入式内存限制,context window 应在设计阶段显式规划而非事后补救;超限时的截断/错误行为应在设计评审时明确。
上下文窗口作为架构层面的约束
任何系统中任何固定容量的资源都会有一条明确的策略:什么可以进入,什么会被拒绝,达到上限时会发生什么。上下文窗口通常不会得到这样的待遇,这就是为什么它们会在生产环境中失效,而不是在设计评审阶段被发现。
一个更有启发性的类比是受内存约束的嵌入式系统。没有人会在为 64KB RAM 编写固件时把限制当作偶尔的烦恼。它是设计的首要前提:数据结构的大小为此而选,内存分配提前规划,内存耗尽时的行为是预先指定好的,而不是事后才发现的。
上下文窗口就是这样的约束,但它有两个不同之处,而且这两个不同之处都让情况变得更糟。它是按量计费的——你为每个使用的 token 付费,而不是为每个可用的 token 付费;而且它是软性的,即超出时会在请求时产生截断或错误,而不是编译失败。软性正是导致它被忽视的原因。系统在开发环境正常,在预发布环境正常,在生产环境的前三周正常,然后用户发起了一次长对话。
把它当作一种设计好的容量来对待,意味着在写代码之前要先回答三个问题:这个系统必须运行的最小窗口是多少,预留给系统之后的保证可用预算是多少,当请求超出这个预算时会发生什么。这三个问题都有答案,但大多数系统并没有把它们写下来。
每个工具返回值在 schema 层面就有界限。不是在调用点作为安全网进行截断——而是在签名中设计好界限,带有分页。一个能返回无限输出的工具就是一个能让会话终结的工具,没有任何下游的预算分配能够完全弥补这一点。如果一个工具能返回一百万行数据,它必须接受一个限制参数并返回一个句柄;句柄模式就是通用形式。
状态是一份文档,而不是一份对话记录。任何系统必须记住的内容都存在于一个具有定义好的字段和更新语义的机构中,而不是存在于一条在后台被压缩的消息历史中。这就是系统在第八十轮能优雅降级与系统忘记需求之间的区别。
循环既有步骤预算也有 token 预算。一个能执行无限步骤的 agent 最终会执行足够多的步骤来填满任何窗口。这两种限制是相关的但不可互换,两者都属于设计范畴——停止条件是机制所在。
长文档需要一个接口,而不是被包含进来。如果任何输入都可能超出预算,架构需要一个读取接口——搜索、大纲、分页——而不是计划把它包含进去。尽早做这个决定是便宜的;在一切都假设可以访问完整文档之后再返工就不便宜了。
Prompt 有一个最大大小,而且它是被断言的。一个渲染最大现实上下文并断言它有边距地容纳的测试,能够在引入的当天而不是三周后捕获到有人添加了三个工具 schema 时的回归。
这是最能区分有设计的系统和一个仅有分配器的系统的决策。当组装好的上下文超出预算时,应该精确地发生以下四种情况之一,按块预先选择:
绝对不能出现在这个列表中的一种行为是静默截断。它在数量惊人的框架代码路径中是默认行为:输入被裁剪以适应,请求成功,模型在缺少本应拥有的材料的情况下作答。没有错误,没有日志行,输出中也没有任何信号——只有一个微妙错误的答案,其原因在事后无法恢复。如果你的系统能截断,它至少应该记录它丢弃了什么。
不同的块应该有不同的行为,这正是按块决策的意义所在。工具结果:驱逐。历史:压缩。安全策略:永不——如果装不下,就拒绝,因为一个不能携带自身约束的请求是一个不应该被发出的请求。
普遍存在的诱惑是等待更大的窗口。它们不断到来,而且确实有帮助。但它们不会移除这个约束,原因有四个,且彼此独立。
成本与你使用的内容成正比,而不是与你可用的内容成正比。一个你填满的一百万 token 的窗口,在每一轮都会被按一百万 token 计费,而一个长会话的二次增长适用于任何规模。更大的窗口改变的是你何时撞墙,而不是账单是否随对话的平方增长。
可用长度落后于宣传长度。两者之间的差距是有文档记录的,合理的规划假设是,无论规格表上怎么写,整个窗口并非均匀可用。
更多的空间意味着更多的矛盾。更长的窗口容纳更多的过时状态,而会话衰减背后的矛盾问题是由积累引起的,更大的窗口只是使积累成为可能,而不是解决了它。
预填充有延迟成本。读取 400,000 个 token 在第一个输出 token 出现之前需要可测量的时间。对于任何交互式的东西来说,一个填满的窗口就是一个慢窗口,而缓存命中只对稳定的前缀有帮助。
更大的窗口意味着更大的预算,而这里的每一条规则都是关于如何良好地支出一个预算。这种约束不会变得不必要;它只是得到了更多的资金。
第一个设计问题——这个系统必须运行的最小窗口是多少——只有当这个数字在每个模型上是可见的时候才能回答,而且它会随着模型的添加和退役而移动。上下文长度和最大输出是按模型列出的,这就是你的测试套件中的可移植性断言可以针对它来编写的内容。