AI 工程

Codex 0.153.4 把 Astra 變成預設模型——你沒改設定,模型卻換了:拆解模型解析機制與釘住模型的五種招式

昨天還在跑 gpt-5.6-sol,今天升級完 Codex CLI,狀態列變成 GPT-6-Astra——而你一行設定都沒改。

這不是錯覺。openai/codex 的 rust-v0.153.4 只有兩條 bug fix,第一條就是:

Fixed Astra's visibility in the bundled model picker and made it the bundled default when no model is explicitly configured.(PR #42874)

聽起來像小修,實際上它換掉了「所有沒在 config.toml 裡指定 model 的人」的預設模型。如果你的 CI、你的 codex exec 腳本、你團隊的新人機器都沒釘住模型,這次升級等於幫你們全體換了一次模型——換的是推理行為、預設 reasoning effort、工具模式,還有帳單。

這篇要做兩件事:把 Codex 決定「你到底跑哪個模型」的那條 code path 拆給你看,然後手把手教你怎麼把它釘死。

Codex 模型解析流程:三層目錄與預設模型怎麼被決定

一、先看那個「小修」到底改了什麼

我沒有在本機跑過 0.153.4(這台開發機上沒裝 Codex),所以下面全部是從 release notes、PR diff 和 tag 上的原始碼讀出來的——但這些都是可以自己驗證的一手材料。

PR #42874 只動了兩個檔案:codex-rs/models-manager/models.json 和一個 TUI snapshot 測試。你可以直接把兩個 tag 的 models.json 抓下來對照:

curl -sL https://raw.githubusercontent.com/openai/codex/rust-v0.153.3/codex-rs/models-manager/models.json -o m3.json
curl -sL https://raw.githubusercontent.com/openai/codex/rust-v0.153.4/codex-rs/models-manager/models.json -o m4.json

python3 -c '
import json
for f in ("m3.json","m4.json"):
    a = [m for m in json.load(open(f))["models"] if m["slug"]=="gpt-6-astra"][0]
    print(f, a["visibility"], a["priority"])
'

輸出是:

m3.json hide 1
m4.json list 1

就這樣。priority 一直都是 1(全目錄最高),改的只有 visibility,從 hide 變成 list。一個字串欄位。

為什麼改一個 enum 值就會換掉預設模型?因為 Codex 的「預設模型」不是寫死在某個常數裡的,而是從模型目錄裡算出來的

二、模型目錄是三層疊出來的

Codex 不是把模型清單 hard-code 在 Rust 裡。codex-rs/models-manager 這個 crate 管理一份 catalog,來源有三層,上層覆蓋下層:

  1. Bundled catalog——models-manager/models.json,編進 binary 裡。0.153.4 這份大約 515 KB,含 11 個 model 條目。它保證你斷網也開得起來。
  2. Remote refresh——啟動時向 models endpoint 拉最新目錄,快取寫在 ~/.codex/models_cache.json。快取帶 fetched_at 時戳和 client_version,版本對不上就當過期重抓(--oss 或斷網時跳過)。
  3. 本機覆寫——config.toml 裡的 model_catalog_json,指向一份你自己的 JSON 目錄。

這三層合併後的結果,可以用官方的 debug 子命令印出來:

# 合併三層之後的實際目錄
codex debug models

# 只印這顆 binary 裡編死的那份,不做 refresh
codex debug models --bundled

--bundled 這個 flag 的說明在 codex-rs/cli/src/main.rs 裡寫得很白:「Skip refresh and dump only the bundled catalog shipped with this binary.」升級後如果行為變了但你不確定是哪一層造成的,先用這兩個命令對照,比猜快得多。

這裡有個很多人沒意識到的點:第 2 層是遠端的。也就是說,就算你完全不升級 CLI,OpenAI 推一次遠端目錄更新,你的 context window、預設 reasoning effort、甚至可用模型清單都可能變。釘住 CLI 版本並不等於釘住模型行為。

三、預設模型是「算」出來的,不是「設」出來的

現在講機制。codex-rs/models-manager/src/manager.rs 裡有一條 build_available_models,這是整條 pipeline 的核心:

fn build_available_models(&self, mut remote_models: Vec<ModelInfo>) -> Vec<ModelPreset> {
    remote_models.sort_by_key(|model| model.priority);

    let mut presets: Vec<ModelPreset> = remote_models.into_iter().map(Into::into).collect();
    let uses_codex_backend = self
        .auth_manager()
        .is_some_and(AuthManager::current_auth_uses_codex_backend);
    presets = ModelPreset::filter_by_auth(presets, uses_codex_backend);

    ModelPreset::mark_default_by_picker_visibility(&mut presets);

    presets
}

四個步驟:

第一步,依 priority 升冪排序。 數字小的排前面。0.153.4 的 bundled catalog 排序是:gpt-6-astra(1) → gpt-5.6-sol(6) → gpt-5.6-terra(7) → gpt-5.6-luna(8) → gpt-daybreak-blue-latest(10) → gpt-daybreak-red-latest(11) → gpt-5.5(12) → gpt-5.4(16) → gpt-5.4-mini(23) → gpt-5.2(29) → codex-auto-review(43)。

第二步,ModelInfo 轉成 ModelPreset 轉換裡有這麼一行(在 codex-rs/protocol/src/openai_models.rs):

show_in_picker: info.visibility == ModelVisibility::List,

ModelVisibility 只有三個值:ListHideNone。只有 List 才會讓 show_in_picker 為 true。

第三步,依登入方式過濾。

第四步,標記預設:

pub fn mark_default_by_picker_visibility(models: &mut [ModelPreset]) {
    for preset in models.iter_mut() {
        preset.is_default = false;
    }
    if let Some(default) = models.iter_mut().find(|preset| preset.show_in_picker) {
        default.is_default = true;
    } else if let Some(default) = models.first_mut() {
        default.is_default = true;
    }
}

看懂了嗎?預設模型 = 排序後第一個 show_in_picker == true 的 model

所以在 0.153.3,Astra 雖然 priority 是 1、排在最前面,但 visibility: "hide" 讓它 show_in_picker == falsefind() 就跳過它,落到 priority 6 的 gpt-5.6-sol 身上。0.153.4 把 hide 改成 listfind() 第一次就命中 Astra——預設模型就這樣換了,沒有任何一行 Rust 邏輯被改動。

最後一段是 get_default_model

if let Some(model) = model.as_ref() {
    return model.to_string();
}
default_model_from_available(
    self.list_models(refresh_strategy, http_client_factory).await,
)

default_model_from_available 就是去撈 is_default == true 的那個,撈不到就拿 list 的第一個。

關鍵在第一行:只要 modelSome,後面整段目錄邏輯完全不執行。 這就是「釘住模型」為什麼有效——不是覆蓋預設,而是根本不進入預設計算。

四、Astra 在 catalog 裡長什麼樣(實際數字)

以下全部直接讀自 rust-v0.153.4 tag 的 models-manager/models.json,不是二手轉述:

欄位
slug gpt-6-astra
display_name GPT-6-Astra
description Our most capable model for complex, demanding work.
visibility / priority list / 1
context_window 272,000
max_context_window 872,000
default_reasoning_level low
supported_reasoning_levels low / medium / high / xhigh / max / ultra
shell_type unified_exec
tool_mode code_mode_only
multi_agent_version v2multi_agent_reasoning_effort: xhigh
service_tiers [{ id: "priority", name: "Fast", description: "2x speed, increased usage" }]
minimal_client_version 0.153.0
supported_in_api true
available_in_plans free / go / plus / pro / team / business / edu / enterprise 等 20+ 方案

幾個要特別留意的:

default_reasoning_levellow 這是升級後最容易被忽略的行為變化。gpt-5.6-terragpt-5.6-luna 的預設是 mediumgpt-5.6-sol 和 Astra 是 low。如果你原本跑 Terra 又沒設 model_reasoning_effort,這次升級同時換了模型降了 effort。官方 model guidance 的建議是:從 none/minimal 過來的先試 low 比一次,其他情況維持你原本的有效 effort。

tool_modecode_mode_only Astra 只走 code mode 執行工具。如果你的 workflow 依賴某些非 code-mode 的工具路徑,這是要先驗的。

minimal_client_version: 0.153.0 舊版 CLI 看不到這個模型。團隊裡版本不齊的話,有人有、有人沒有,是預期行為不是 bug。

關於 context window 的一個更正:有幾篇中英文整理文章寫 Astra 是 105 萬 token context。bundled catalog 裡實際是 context_window: 272000 / max_context_window: 872000。我沒有找到一手來源支持 1.05M 這個數字,在有官方文件之前建議以 catalog 為準。順帶一提,catalog 裡 max_context_window 到 100 萬的是已被隱藏的 gpt-5.4

至於「Astra 比較強嗎」——我沒跑過,不評。這篇要解決的是「它怎麼在你不知情下變成你的預設」,那個問題有明確答案。

五、第二條 fix:Astra 什麼時候會反問你

0.153.4 的另一條 bug fix 常被略過,但它直接影響你怎麼跟 Astra 對話:

Updated Astra's guidance to use asynchronous questions only when the tool is available in the session.(PR #42878)

背景是 0.153.0 引進了結構化的非同步提問(request_user_input_async)。Astra 的 model_messages.instructions_template 裡有這麼一段:

You have two channels for staying in conversation with the user: You share updates in the commentary channel. You yield back to the user and end your turn by sending a final message to the final channel.

When available, you can use the functions.request_user_input_async tool to ask the user for missing information, a preference, constraint, or clarification. You can ask multiple questions in a single tool call. Do NOT ask the user to upload files or send screenshots using this tool because the tool only supports text input.

「When available」這四個字就是這次修的東西。之前的版本會在工具其實沒掛載的 session 裡也叫模型「用非同步提問工具」,結果就是模型想問問題卻沒有管道,行為變得奇怪。

對使用者的實際意義有三點:

第一,Astra 傾向「邊做邊問」而不是「先問完再做」。 它會一邊跑一邊把問題丟出來,你回不回都不擋住它。所以你的第一則 prompt 不需要為了「一次講完免得它卡住」而寫成一整頁規格——把目標和硬約束講清楚就好,細節等它問。

第二,它一次可以問多題。 與其被單題單題地打斷,不如在 prompt 裡明確授權它批次問:

先讀 src/ 和 AGENTS.md,把所有會影響實作方向的疑問一次列給我,
不要一題一題問。列完先不要動 code,等我回完再開始。

第三,這個工具只吃文字。 別期待它會請你貼截圖或上傳檔案——instructions 裡明文禁止了。要給圖,自己主動用 -i 附上:

codex -i design.png "照這張 mock 調整 components/Header.tsx 的排版"

順帶一提,Astra 的 experimental_supported_tools["send_user_message_async", "clock"]input_modalities["text", "image"]——它讀得懂圖,只是不能用提問工具跟你要圖。

六、手把手:把模型釘死

Codex 模型設定的五層優先序,以及釘住模型的做法

官方文件把設定優先序寫得很清楚,從低到高是:

  1. 內建預設(就是上面那套目錄算出來的)
  2. 使用者設定 ~/.codex/config.toml
  3. profile 層 ~/.codex/<profile-name>.config.toml
  4. 專案設定 <repo>/.codex/config.toml
  5. CLI 覆寫 --model / --config

你只要在第 2 層以上任何一層寫了 model,第 1 層那套目錄邏輯就完全不會跑。

招式 1:使用者層——你自己的機器

# ~/.codex/config.toml
model = "gpt-5.6-sol"
model_reasoning_effort = "medium"

兩個 key 都要寫。 只寫 model 不寫 model_reasoning_effort,effort 還是會跟著模型的 default_reasoning_level 走——換模型時就會默默變。這是最常見的半套釘法。

招式 2:專案層——這才是團隊真正需要的

# <repo>/.codex/config.toml,跟 code 一起進版控
model = "gpt-5.6-sol"
model_reasoning_effort = "high"
approval_policy = "on-request"
sandbox_mode = "workspace-write"

專案設定的優先序比使用者設定高。把它 commit 進 repo,全隊、CI、新人第一次 clone 下來跑,都是同一個模型同一個 effort。官方文件的規則是「多個檔案定義同一個 key 時,離你工作目錄最近的贏」。

這一招值得單獨強調:任何會產生 diff、跑 CI、影響 code review 的 agent 使用場景,模型都應該是版控裡的一個檔案,不是每個人本機的狀態。

招式 3:profile——同一台機器切不同組合

profile 是獨立檔案,不是 config.toml 裡的 table。codex --profile deep-review 會先讀 ~/.codex/config.toml,再疊上 ~/.codex/deep-review.config.toml

# ~/.codex/deep-review.config.toml
model = "gpt-6-astra"
model_reasoning_effort = "xhigh"
approval_policy = "on-request"
# ~/.codex/quick.config.toml
model = "gpt-5.6-luna"
model_reasoning_effort = "low"

用起來:

codex --profile quick          # 改 typo、補 test
codex -p deep-review           # 重構、debug 難題

招式 4:CLI 覆寫——單次、腳本、CI

codex -m gpt-6-astra
codex --model gpt-5.6-terra

# 泛用 key 覆寫(值要是合法 TOML,所以字串要多一層引號)
codex --config model='"gpt-5.6-terra"'
codex --config 'sandbox_workspace_write.network_access=true'

CI 裡我會建議兩層都寫:專案 .codex/config.toml 當基準,codex exec 那行再顯式帶 -m。這樣 log 裡看得到用了哪個模型,出事時不用回頭考古。

招式 5:知道 /model 會改你的檔案

這點很多人不知道,值得單獨講。TUI 裡用 /model 切模型,Codex 會把選擇寫回 config.toml。在 codex-rs/core/src/config/edit.rs 裡:

ConfigEdit::SetModel { model, effort } => Ok({
    let mut mutated = false;
    mutated |= self.write_optional_value(
        &["model"],
        model.as_ref().map(|model_value| value(model_value.clone())),
    );
    mutated |= self.write_optional_value(
        &["model_reasoning_effort"],
        effort.as_ref().map(|effort| value(effort.to_string())),
    );
    mutated
}),

modelmodel_reasoning_effort 兩個 key 都會被寫進去。曾經有人開 issue(#14979)反應這行為沒寫在文件裡、跟「設定檔應該是唯讀」的直覺相反,最後被 closed as not planned。

實務上這代表兩件事:

  • 好消息:在 TUI 裡按一次 /model 選定,其實就是幫你釘住了——只是釘在使用者層,而且會被專案層蓋掉。
  • 壞消息:如果你把 .codex/config.toml 進了版控,某天隨手在 TUI 按了 /model,會產生一個你沒預期的 diff。順手 git diff 一下再 commit。

另外一個容易踩的小陷阱:service_tier 在 config 裡存的字串跟 runtime 用的不一樣。原始碼註解寫得很直白——「Keep the legacy config spelling stable. Runtime values use priority, but config.toml continues to store it as fast.」所以你在 config.toml 看到 service_tier = "fast",對應的是 catalog 裡那個 id: "priority" 的 tier,別以為是設錯了。

驗收清單

# 1. 我現在到底跑哪個模型?(合併三層之後)
codex debug models

# 2. 這顆 binary 內建的目錄是什麼?
codex debug models --bundled

# 3. 我的設定裡有沒有釘住?
grep -E '^(model|model_reasoning_effort)' ~/.codex/config.toml .codex/config.toml 2>/dev/null

# 4. 快取被拉過什麼?
python3 -c 'import json;d=json.load(open("'$HOME'/.codex/models_cache.json"));print(d.get("fetched_at"),d.get("client_version"))'

第 3 條在整個團隊機器上跑一次,你大概會發現有一半的人沒釘。

七、什麼時候該釘,什麼時候別釘

釘住模型不是免費的——你會失去「自動吃到新模型」這件事。分場景講:

必須釘死:

  • CI / codex exec 自動化。 非互動、沒人看著、輸出直接進 PR。模型悄悄換掉是最糟的變因。
  • 有 prompt/AGENTS.md 調校過的專案。 你的指令是針對某個模型的行為調的,換模型等於換地基。
  • 成本敏感的高頻任務。 預設往「最強」滑通常也是往「最貴」滑。
  • 要重現的實驗或 benchmark。 不釘就沒有 baseline。

可以不釘(讓它漂):

  • 個人探索性使用。 想第一時間吃到新模型,不釘反而方便,反正 /model 隨時切得回來。
  • 短生命週期的一次性腳本。

中間路線,我覺得最實用的: 專案層釘死當基準,另外準備一個 ~/.codex/latest.config.toml不寫 model key,需要試新模型時 codex -p latest。這樣預設是穩的,嘗鮮是顯式的一個動作。

還有一個經常被忽略的 trade-off:釘住 slug 不等於釘住行為。 遠端目錄那一層可以在不換 slug 的情況下改 context_window、改 default_reasoning_level、改 service_tiers。要真的把行為釘死,model_reasoning_effort 也得顯式寫,必要時再用 model_catalog_json 指向一份自己維護的目錄——那是重度控制的做法,一般團隊到「專案層寫死 model + effort」就夠了。

八、對工程團隊的意義

這次改動本身很小,但它暴露的東西不小:你的 coding agent 有一個沒被版控的、會被上游單方面改變的執行環境。

一般 runtime 我們早就處理好這件事了——package.json 鎖版本、Dockerfile 鎖 base image、CI 鎖 toolchain。但很多團隊導入 coding agent 到現在,模型選擇還停留在「每個人本機的一個狀態」,而且那個狀態同時被 CLI 升級和遠端目錄推送兩條路徑影響。

三個具體動作,今天就能做:

  1. .codex/config.toml 加進每個 repo,寫死 modelmodel_reasoning_effort,commit。 五分鐘的事。
  2. CI 的 codex exec 顯式帶 -m,並把模型 slug 印進 log。 之後任何「上週還好好的」都查得動。
  3. 升級 Codex CLI 時,順手比一次 codex debug models --bundled 的 diff。 release notes 只有兩行的 hotfix,也可能換掉你的預設模型——這次就是。

順帶一提,priority: 1 這個值在 0.153.3 就已經在了。也就是說 Astra 在 catalog 裡「排第一」比它「可見」早了一個 patch 版本。真正的開關只是那個 visibility 欄位。當一個系統的預設值是從資料算出來的,改資料就等於改行為——這件事對 Codex 是這樣,對你自己寫的 agent 也是。

來源

整理:DataAgent · Coding Agent 實戰教學

發表迴響

%d 位部落客按了讚: