在 Codex 里运行 /status,界面会显示当前模型和剩余上下文容量。这里的“上下文”并不是一份
无限增长的聊天记录。每次调用模型时,固定指令、对话历史、工具说明、工具结果和当前输入都要被
编码成有限长度的 Token 序列。序列接近预算后,Codex 必须压缩历史或切换上下文窗口,否则下一次
请求可能没有足够空间完成。
“模型有 128k 上下文”也不足以解释 Runtime 的实际行为。Codex 内部至少还存在配置覆盖上限、 有效窗口比例、自动压缩阈值、当前活动上下文占用和界面显示比例。它们都以 Token 为单位,但不是 同一个值。
本文从模型与 Runtime 的分界开始,再沿 Codex 的用量记录路径回答四个问题:
- Token 是什么,为什么模型通过连续预测 Token 生成回答;
- 上下文窗口装下的是什么,输出为什么也会消耗空间;
- Codex 怎样结合服务端统计和本地估算判断当前占用;
- 达到阈值后,为什么有时继续结束当前 Turn,有时才触发 Compact。
消息角色、历史规范化和压缩算法会在后续细节章展开。本文只建立后面分析都会使用的计量模型。
Token 不是字符、单词或字节
语言模型不会直接接收字符串。输入先经过 Tokenizer,被编码为一列整数 ID;每个 ID 对应词表中的 一个 Token。英文单词可能被拆成词根和后缀,中文字符可能单独成 Token,也可能与相邻字符组合。 代码里的缩进、空格、括号和标点同样参与编码。
因此下面三个长度没有固定换算关系:
| 长度 | 由谁定义 | 能回答什么 |
|---|---|---|
| 字符数 | 文本编码与语言 | 人看到多少字符 |
| UTF-8 字节数 | 字符编码 | 文本序列化后占多少字节 |
| Token 数 | 具体模型的 Tokenizer | 模型实际接收多少离散单元 |
同样是 1000 个字符,英文、中文、连续空格和 Base64 数据的 Token 数可以明显不同。把“一个 Token 等于四个字符”当成精确公式,会在非英文文本和结构化载荷上产生系统性误差。
Codex 源码确实使用了“约 4 bytes / token”的计算,但它被明确放在本地粗估和输出截断路径中, 不是模型分词器。这个区别会在后文解释。
下一 Token 预测发生在模型服务内
自回归语言模型每一步计算的是:在已有 Token 序列成立的条件下,下一个 Token 可能是什么。可以 把抽象过程写成:
P(next_token | token_1, token_2, ..., token_n)
模型产生候选 Token 的分数后,解码规则从分布中选择一个 Token,把它追加到序列末尾,再进行下一 步计算。循环遇到结束标记、长度限制或协议定义的完整输出项时停止。
下面的伪代码是通用模型服务的概念模型,不是 Codex 仓库中的一段实现:
def generate_next_tokens(context: ModelContext, stop: StopRules) -> OutputStream:
token_ids = tokenizer.encode(context)
decoder_state = DecoderState()
while True:
# 模型根据全部已有 Token 计算下一位置的条件分布。
distribution = model.predict_next(token_ids)
# 温度、候选截断和协议约束属于模型服务的解码策略。
next_id = decoder.select(distribution, decoder_state)
token_ids.append(next_id)
yield tokenizer.decode_incrementally(next_id)
if stop.matches(next_id, token_ids, decoder_state):
return
真实服务会使用 KV Cache、批处理和并行计算降低重复计算成本,Tokenizer 与解码策略也因模型和 Provider 而异。这里真正重要的只有两条性质:下一步依赖此前序列;新 Token 会被追加回同一序列, 所以生成长度会持续消耗容量。
Codex 客户端不执行这段循环,也拿不到每一步的 logits。它能观察的是请求前的上下文,以及服务端 返回的文本增量、Reasoning 增量、Tool Call、完整响应项、错误和 Token Usage。
这条边界也解释了工具调用。模型仍然在生成受协议约束的输出序列;当序列形成一个 Function Call 响应项时,Codex 才把它解释为工具调用。Tool Call 为什么能被稳定表示,以及 Tool Result 为什么要 进入第二次采样,会在后续单元展开。
上下文窗口限制的是一次模型调用可处理的序列
对 Coding Agent 而言,当前上下文通常不只有用户刚输入的一句话。一次请求可能包含:
- 模型基础指令和 Developer Instructions;
- 当前工作区的规则、环境与权限说明;
- 之前的用户消息和模型回复;
- 工具名称、说明和参数 Schema;
- 已发生的 Tool Call 与 Tool Result;
- 图片、音频或其他模型支持的输入;
- 当前用户输入;
- 为本次输出和必要控制操作预留的空间。
上下文窗口描述的是模型一次能够处理的 Token 容量。输入先占用一部分,模型生成的输出再占用一 部分。对下一次采样来说,刚刚生成的回复或工具调用又会成为历史输入。因此,“输入还能塞进去”并 不代表“一定有足够空间生成可用回答”。
上下文过长也不只有硬错误。即使没有超过上限,大量重复日志、无关搜索结果和过期假设仍会稀释 重要信息。容量问题可以通过计数检测,信息质量问题却需要 Runtime 控制工具输出、压缩历史,并让 任务保持清晰的状态。这也是 Codex 不把模型目录中的窗口数字直接当成全部可用空间的原因。
Codex 中的四种窗口量
以目录声称 128,000 Token 的模型为例,Codex 不会只保存一个 128000。实际决策需要区分四层:
| 层次 | 作用 | 典型计算 |
|---|---|---|
| 目录窗口 | 模型元数据声明的原始容量 | 128,000 |
| 配置后的解析窗口 | 应用用户覆盖,但不超过目录允许的最大值 | min(用户值, 最大值) |
| 有效窗口 | Codex 用于完整活动上下文判断的上限 | 解析窗口 × 有效比例,默认 95% |
| 自动压缩阈值 | 提前触发上下文换窗的预算 | 默认取解析窗口 90%,可按作用域配置 |
如果解析窗口是 128,000,默认有效窗口是 121,600,默认自动压缩阈值是 115,200。后者更早到达, 使系统能在碰到完整窗口限制前处理历史。
假设用户把窗口配置成 200,000,但目录规定覆盖最大值为 128,000。解析阶段会先把 200,000 截到 128,000,再计算有效窗口。用户配置不能凭空扩张模型能力。
这段设计可以提炼为:
def resolve_context_limits(model: ModelMetadata, config: Config) -> ContextLimits:
requested = config.context_window
if requested is not None:
resolved = min_if_present(requested, model.max_context_window)
else:
resolved = first_present(model.context_window, model.max_context_window)
effective = None
if resolved is not None:
effective = saturating_multiply(resolved, model.effective_percent) // 100
ninety_percent = None if resolved is None else resolved * 90 // 100
declared_compact = first_present(
config.auto_compact_limit,
model.auto_compact_limit,
ninety_percent,
)
total_scope_limit = min_if_both(declared_compact, ninety_percent)
if config.auto_compact_scope == "body_after_prefix" \
and config.auto_compact_limit is not None:
compact_limit = config.auto_compact_limit
else:
compact_limit = total_scope_limit
return ContextLimits(full_context=effective, auto_compact=compact_limit)
这里把多个生产分支合并成一个设计函数。total 作用域使用的模型阈值会被限制在解析窗口的 90%
以内;body_after_prefix 的显式配置代表“前缀之后允许增长多少”,但无论它怎样设置,完整有效窗口
的硬判断仍然存在。
有效比例和自动压缩阈值不能合并。有效比例回答“Codex 最多把多少活动上下文视为可用”,自动压缩 阈值回答“什么时候应该提前换窗”。把两者写成同一个 90% 常量,会丢失模型目录的保留空间策略, 也无法支持只计算窗口增长的配置。
Token Usage 同时记录累计值和最近值
模型完成一次响应时,Provider 可以返回六类 Token 统计:
| 统计项 | Runtime 中的用途 |
|---|---|
| 输入 Token | 描述本次请求输入规模,也可建立压缩窗口的前缀基线 |
| 缓存输入 Token | 标识输入中命中缓存的部分 |
| 缓存写入输入 Token | 表示缓存写入相关计数,缺失时按零兼容 |
| 输出 Token | 描述本次生成的输出规模 |
| Reasoning 输出 Token | 单列 Provider 报告的推理输出 |
| 总 Token | 作为最近一次服务端上下文样本 |
缓存命中不会让输入从上下文窗口中消失。它可能改变计算复用、延迟或计费属性,但模型仍然要在当前 请求中以这些 Token 为条件生成后续内容。所以判断窗口占用时,不能简单执行“输入减缓存输入”。
Codex 为会话同时保留累计 Usage 与最近一次 Usage:
- 累计 Usage把历次响应逐字段相加,适合报告会话总消耗;
- 最近 Usage保存最近一次完成事件的统计,适合作为当前活动上下文的服务端基线。
这两个值经常被误读。假设三次模型调用分别报告 20k、32k 和 45k,总累计量可能是 97k;但当前 上下文占用不是把三次上下文再叠加一次,它更接近最近一次的 45k,再加上服务端尚未看到的本地 历史尾部。
界面还有两种派生值:用于显示单个消耗数字的“非缓存输入 + 输出”,以及用于显示剩余百分比的 归一化计算。它们都不是触发完整窗口限制的权威值。
为什么服务端计数之后还要本地估算
完成事件中的 Token Usage 只覆盖刚刚发送给服务端的那次请求。响应完成后,Runtime 可能立即追加 新的用户输入、Tool Result 或上下文更新。这些历史项还没有进入下一次模型请求,自然也没有出现在 最近一次服务端统计中。
Codex 因此使用“权威基线 + 本地尾部”的混合算法:
def estimate_active_context(history: History, model: ModelMetadata) -> int:
server_sample = history.last_server_usage
active = 0 if server_sample is None else max(server_sample.total_tokens, 0)
# 只补最近模型输出之后追加的项,避免与服务端已统计内容重复相加。
for item in history.items_after_last_model_output():
active = saturating_add(active, estimate_item(item))
# 某些服务端不会把较早的加密 reasoning 计回上下文,此时才补估。
if not model.server_counts_historical_reasoning:
for item in history.historical_reasoning_before_last_turn():
active = saturating_add(active, estimate_item(item))
return active
关键不在“加法”,而在分界点:本地只估算最近一个模型生成项之后的历史尾部。如果粗暴估算全部
历史,再与服务端 total_tokens 相加,同一批消息会被计算两次。
当历史因恢复、回滚或压缩被整体替换时,旧的最近 Usage 也可能失去代表性。Codex 会使用当前 Session 的基础指令和完整历史重新粗估,把结果写入“最近 Usage 的总量”位置,并更新有效窗口。 累计 Usage 不会被这次重算伪造为新的 Provider 消耗。
4 bytes / token 只是一个廉价传感器
本地估算器首先把普通历史项序列化,再用下面的近似换算:
def approximate_tokens(serialized_bytes: int) -> int:
return ceil_div(serialized_bytes, 4)
它很快、无需绑定某个模型的 Tokenizer,也适合判断量级。但误差来源明确存在:
- 不同语言的 UTF-8 字节密度不同;
- 标点、缩进和重复字符串的分词方式不同;
- JSON 字段名与协议包装也会成为模型输入;
- 模型版本可能使用不同词表;
- Base64 的序列化长度与图片、音频的模型成本不是一回事。
所以 Codex 源码把它称为粗略下界,而不是 Tokenizer 精确计数。服务端 Usage 可用时,后者应当作为 更权威的样本;粗估器负责填补时间差和缺失值。
图片与音频为什么不能按 Base64 长度算
一张图片以 Data URL 放进 JSON 时,Base64 可能包含数百万字符。如果直接除以 4,估算器会假定 模型逐字符阅读这段 Base64,窗口占用被严重夸大。实际多模态模型按图像编码规则处理图片。
Codex 对可识别的内联媒体先减去 Base64 载荷,再加上模态专用代理值:
- 默认或缩放图片使用约 1,844 Token 的固定代理;
detail: original图片按 32×32 patch 数量估算,最多计 10,000 个 patch;- 音频交给音频估算器;
- 加密 Reasoning、压缩内容和工具输出按估计的模型可见长度替换;
- 无法识别 Data URL、解码 Base64 或读取图片尺寸时,回退到固定代理或原始序列化估算。
这仍然不是精确账单。它解决的是更基础的问题:不要让传输编码的膨胀倍数冒充模型上下文成本。
当前活动上下文怎样变成一次决策
得到活动占用后,Runtime 同时比较两条上限:
- 完整有效窗口:无论配置怎样,全部活动上下文都不能越过这条线;
- 自动压缩窗口:可以计算全部活动上下文,也可以只计算固定前缀之后的增长。
body_after_prefix 适合压缩后携带了较大固定前缀的场景。假设新窗口一开始已有 30k Token 前缀,
当前活动上下文增长到 70k:total 作用域计 70k,body_after_prefix 只计 40k。
前缀基线可能先由恢复历史的本地估算建立。第一次获得服务端输入 Usage 后,Codex 用服务端观测值 替换估计基线;后续本地估算不能反过来覆盖服务端值。这样既能在网络统计尚未到达时工作,又避免 长期漂移。
完整判断可以写成下面的设计伪代码:
def inspect_context_window(
active_tokens: int,
limits: ContextLimits,
scope: Literal["total", "body_after_prefix"],
prefill_tokens: int | None,
fallback_buffer: int,
) -> WindowStatus:
if scope == "total":
scoped_tokens = active_tokens
else:
baseline = active_tokens if prefill_tokens is None else prefill_tokens
scoped_tokens = max(active_tokens - baseline, 0)
compact_remaining = subtract_or_unknown(limits.auto_compact, scoped_tokens)
full_remaining = subtract_or_unknown(limits.full_context, active_tokens)
visible_remaining = minimum_known(compact_remaining, full_remaining)
buffered_compact_limit = add_if_known(limits.auto_compact, fallback_buffer)
full_limit_reached = reached(limits.full_context, active_tokens)
token_limit_reached = (
reached(buffered_compact_limit, scoped_tokens)
or full_limit_reached
)
return WindowStatus(
active_tokens=active_tokens,
scoped_tokens=scoped_tokens,
remaining=max_if_known(visible_remaining, 0),
full_limit_reached=full_limit_reached,
token_limit_reached=token_limit_reached,
)
剩余量取两条约束中的较小值,因为较早到达的上限才是实际约束。所有减法都要夹到零,计数相加和 窗口乘法则使用饱和运算,防止异常的大输入把整数绕回负数。
Fallback buffer 只在确实配置了备用 Prompt 时参与压缩触发。它不是永久从界面扣除的一块容量, 而是为达到基础阈值后的恢复动作保留余地。
达到阈值不等于立刻压缩
Codex 在两个时间点检查预算。
采样前,如果当前历史已经达到自动压缩阈值或完整有效窗口,Runtime 会先执行 Compact,再创建普通 采样 Step。这样可以避免明知上下文已满仍发出注定失败的请求。
采样后,Runtime 先判断是否还需要 follow-up。模型可能已经给出最终回答,也可能返回工具调用、 要求继续采样,或者用户在 Turn 中追加了输入。只有仍需继续,并且达到 Token 限制或显式请求新 窗口时,才在 Turn 中途 rollover/compact。
result = await sample_model(current_context)
status = inspect_context_window(...)
needs_follow_up = result.requests_follow_up or input_queue.has_pending_input()
should_roll_over = needs_follow_up and (
context_window.was_explicitly_requested()
or status.token_limit_reached
)
if should_roll_over:
await compact_and_rebuild_context()
continue_agent_loop()
elif needs_follow_up:
continue_agent_loop()
else:
emit_turn_complete(result)
这条条件避免了一个无意义动作:模型已经结束 Turn 时,即使统计值刚好触线,也没有必要为了一个 不存在的下一次采样立刻压缩。若用户以后提交新 Turn,采样前检查仍会处理它。
本文只解释“何时触发”。Compact 怎样生成摘要、远端 Compact 怎样返回替代历史,以及新窗口怎样 重建 World State,会在后续上下文章节展开。
界面显示为什么又减去 12k
Codex 的剩余上下文百分比不是简单的:
100% × (窗口 - 总量) / 窗口
界面希望显示用户还能影响多少空间。固定系统 Prompt、工具说明和发起 Compact 所需空间并不是用户 通过缩短当前问题就能消除的。当前实现从窗口和使用量两边都减去 12,000 Token 基线,再计算剩余 比例:
def remaining_percent_for_ui(total_tokens: int, context_window: int) -> int:
display_baseline = 12_000
if context_window <= display_baseline:
return 0
controllable_window = context_window - display_baseline
controllable_used = max(total_tokens - display_baseline, 0)
remaining = max(controllable_window - controllable_used, 0)
return round(clamp(remaining / controllable_window, 0.0, 1.0) * 100)
12k 是 UI 归一化基线,不是第二条 Runtime 硬上限,也不是对所有模型都精确成立的固定 Prompt Token 数。把它混进自动压缩算法,会让压缩过早发生两次。
失败与误差如何表现
| 情况 | Runtime 仍知道什么 | 风险 | 处理原则 |
|---|---|---|---|
| 模型窗口元数据缺失 | Usage 或本地估算值 | 无法提前算出可靠的硬剩余量 | 继续记录用量,由 Provider 在越界时返回错误;不能虚构窗口 |
| 完成事件没有 Usage | 旧服务端样本和新历史项 | 当前占用可能漂移 | 在历史变换时重算;后续样本到达后重新校准 |
| 4 bytes/token 对当前文本误差大 | 字节量级 | 可能过早或过晚接近阈值 | 把它限制为本地估算,优先采用服务端样本 |
| 图片或音频载荷不可解码 | 原始 JSON 或固定代理 | 多模态估算偏差 | 安全回退,不因估算失败中止整个 Turn |
| Provider 是否计入历史 Reasoning 不同 | 模型能力元数据 | 重复计算或漏算 | 仅在服务端未覆盖时补估历史 Reasoning |
| 异常负值或极大值 | 已接收的计数字段 | 百分比越界或整数溢出 | 负数夹零,算术使用饱和操作 |
| 达到阈值但本轮已结束 | 完成结果和窗口状态 | 无谓生成摘要 | 把 rollover 与“是否需要下一次采样”联合判断 |
这些分支体现了一个取舍:客户端若要精确计数,就必须绑定每个模型的 Tokenizer、多模态计费规则和 Provider 历史语义,更新成本很高;完全依赖服务端,又看不到两次请求之间刚追加的历史。Codex 选择 服务端样本与廉价本地传感器组合,用硬窗口与提前压缩两条约束兜底。
怎样验证这套设计
这里不靠打印某个内部函数的返回值证明,而是验证可观察的行为契约:
| 行为 | 构造方式 | 预期结果 |
|---|---|---|
| 当前窗口优先于最大覆盖值 | 同时提供 273k 当前值和 400k 最大值 | 解析窗口为 273k |
| 当前窗口缺失时回退 | 只提供 400k 最大值 | 解析窗口为 400k,默认压缩阈值为 360k |
| 有效比例生效 | 100×50% 与 200×25% | 两者有效窗口都为 50 |
| 本地尾部不丢失 | 服务端样本 100,随后追加用户消息和工具结果 | 活动占用为 100 加两个尾部项估算 |
| 重算使用 Session 指令 | 修改会话基础指令后替换历史 | 新估算反映会话指令,而不是目录默认指令 |
| 服务端前缀覆盖估算前缀 | 先恢复并估计,再收到第一次 input usage | 后续作用域以服务端观测基线为准 |
| original 图片随尺寸变化 | 输入两张不同尺寸图片 | patch 估算不同,超过上限时封顶 |
| UI 基线不是硬限制 | 相同用量分别计算决策状态和显示百分比 | 只有显示公式减去 12k |
Mini Codex 当前不复刻模型 Tokenizer,也不应该伪装成精确计数器。最小实现只需保留三个接口:记录 最近服务端样本、估算未发送的历史尾部、比较完整窗口与压缩阈值。这样可以测试 Agent Loop 在预算 边界的控制流,又不会把 4 bytes / token 错写成模型能力。
本文已经建立后续源码分析需要的计量框架:模型在服务端按下一 Token 循环生成;Codex 负责请求 边界与流式结果;上下文上限经过目录、配置、有效比例和压缩阈值四层解析;当前占用来自服务端样本 与本地历史尾部估算;压缩触发还要与下一次采样需求联合判断。
下一篇 1.2 将进入消息协议,解释 System、Developer、User 和 Assistant 为什么不是四个普通标签。
评论
登录后即可评论