雨天小六

读懂 Codex(1.1):Token、上下文窗口与下一 Token 预测

· 更新于 2026-08-01 · 专栏:读懂 Codex

#Codex#Agent Runtime#Token#Context Window#软件架构

在 Codex 里运行 /status,界面会显示当前模型和剩余上下文容量。这里的“上下文”并不是一份 无限增长的聊天记录。每次调用模型时,固定指令、对话历史、工具说明、工具结果和当前输入都要被 编码成有限长度的 Token 序列。序列接近预算后,Codex 必须压缩历史或切换上下文窗口,否则下一次 请求可能没有足够空间完成。

“模型有 128k 上下文”也不足以解释 Runtime 的实际行为。Codex 内部至少还存在配置覆盖上限、 有效窗口比例、自动压缩阈值、当前活动上下文占用和界面显示比例。它们都以 Token 为单位,但不是 同一个值。

本文从模型与 Runtime 的分界开始,再沿 Codex 的用量记录路径回答四个问题:

  1. Token 是什么,为什么模型通过连续预测 Token 生成回答;
  2. 上下文窗口装下的是什么,输出为什么也会消耗空间;
  3. Codex 怎样结合服务端统计和本地估算判断当前占用;
  4. 达到阈值后,为什么有时继续结束当前 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。

Codex 先把固定指令、历史、工具和当前输入编排为模型请求;模型服务在不可见边界内循环预测并追加下一个 Token;Codex 再消费流式增量、完整响应项和 Token Usage 的边界图
图 1.1-1:Codex 管理模型调用的输入与输出,但下一 Token 的概率计算和采样位于模型服务内部。研究客户端源码不能反推出具体模型的 logits 或 Tokenizer。

这条边界也解释了工具调用。模型仍然在生成受协议约束的输出序列;当序列形成一个 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 同时比较两条上限:

  1. 完整有效窗口:无论配置怎样,全部活动上下文都不能越过这条线;
  2. 自动压缩窗口:可以计算全部活动上下文,也可以只计算固定前缀之后的增长。

body_after_prefix 适合压缩后携带了较大固定前缀的场景。假设新窗口一开始已有 30k Token 前缀, 当前活动上下文增长到 70k:total 作用域计 70k,body_after_prefix 只计 40k。

前缀基线可能先由恢复历史的本地估算建立。第一次获得服务端输入 Usage 后,Codex 用服务端观测值 替换估计基线;后续本地估算不能反过来覆盖服务端值。这样既能在网络统计尚未到达时工作,又避免 长期漂移。

模型目录窗口经过配置上限和有效比例得到硬上限;最近一次服务端总 Token 与尚未被服务端看到的历史尾部估算合成活动占用;Runtime 再按 total 或 body_after_prefix 作用域比较自动压缩阈值和完整窗口,并决定是否触发上下文换窗的流程图
图 1.1-2:窗口决策由“上限解析”和“占用观测”两条数据流汇合而成。服务端 Usage 是基线,本地估算只补尚未被服务端观察的部分。

完整判断可以写成下面的设计伪代码:

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 为什么不是四个普通标签。

延伸阅读

评论


← 返回文章列表