昨天我解决了“怎么一键切 Provider”。今天发现真正麻烦的不是切过去,而是切过去以后,上一段会话的记忆不见了。
昨天那篇文章里,我给 Codex.app 写了一个 cx 命令。
终端输入 cx,选一个 Provider,脚本自动改 ~/.codex/config.toml 和 ~/.codex/auth.json,再重启 Codex.app。原本要手动改文件、复制 Key、重启应用的事,变成了一个菜单。
我以为这就够了。
用了两天才发现:Provider 是切过去了,但 Agent 的记忆没跟过去。
为什么这个问题必须修
最典型的场景是这样的。
你在官方账号下面做一个开发任务。比如排查一个 Tauri 打包问题,前面已经让 Codex 看过 workflow、脚本、日志和失败原因。它知道现在卡在哪里,也知道上一轮分析留下了哪些线索。
做到一半,官方账号额度快用完了。

这时候你不想停。最自然的办法是切到第三方 Provider,比如 MiniMax、Fucheers、Kimi 或 DeepSeek,继续让 Agent 干活。
但如果切完以后历史不见了,问题就来了。
新的 Provider 进入会话时,像一个刚进项目的人。它不知道你前面查过什么,不知道哪个文件已经看过,不知道哪个方案刚被否掉,也不知道你现在真正要它接着做哪一步。
你只能重新解释一遍:
- • 我们刚才在修什么
- • 哪些路径已经排过了
- • 现在失败点在哪里
- • 哪些猜测已经证伪
- • 下一步应该沿着哪个方向继续
这很烦。更糟的是,开发任务里的上下文通常不是一两句话,而是一整段已经被压缩过的工作记忆。

上面这张图里,Codex 已经把前面的排查过程压缩成上下文继续往下走。这个上下文如果断掉,新模型进来就会像没看过前情提要一样,接力失败。
所以今天继续优化 cx。
目标很明确:切 Provider 的同时,保留上一段会话历史,让新的 Provider 能接着旧上下文继续干活。
一开始我以为只是列表没刷新
昨天的脚本只动两个文件:
~/.codex/config.toml
~/.codex/auth.json这两个文件负责 Provider、模型和凭证。
但切到第三方 Provider 后,我打开 Codex.app,左侧历史列表空了。上午在官方账号里做的任务没有出现。
我一开始以为只是 UI 没刷新,或者历史被藏在另一个列表里。后来发现不是。
对话历史根本不在 config.toml 和 auth.json 里。
我顺着 ~/.codex/ 看了一圈,真正可疑的是这些文件:
| 文件/目录 | 作用 |
|---|---|
sessions/2026/06/10/*.jsonl | 活跃会话原文 |
archived_sessions/rollout-*.jsonl | 归档会话 |
session_index.jsonl | 会话索引 |
state_5.sqlite | Codex.app UI 状态 |
state_5.sqlite-wal / state_5.sqlite-shm | SQLite 的 WAL 文件 |
history.jsonl | 顶层输入历史 |
其中最关键的是 state_5.sqlite。
这不是配置文件,而是 Codex.app 的状态数据库。左侧历史列表,很大概率就是从这里读出来的。
打开 SQLite 后,问题变清楚了
我看了一下 threads 表结构:
CREATE TABLE threads (
id TEXT PRIMARY KEY,
rollout_path TEXT NOT NULL,
...
model_provider TEXT NOT NULL,
...
cli_version TEXT NOT NULL DEFAULT '',
first_user_message TEXT NOT NULL DEFAULT '',
...
preview TEXT NOT NULL DEFAULT ''
);
CREATE INDEX idx_threads_provider ON threads(model_provider);这个 model_provider 字段一下就很可疑。
它还有一个专门的索引:
CREATE INDEX idx_threads_provider ON threads(model_provider);这说明 Codex.app 很可能会按当前 Provider 过滤历史。
我查了一下分布:
SELECT model_provider, COUNT(*)
FROM threads
GROUP BY model_provider;结果大概是这样:
model_provider n
-------------- ---
Fucheers 74
openai 44上午在官方账号里做的任务,model_provider = 'openai'。
下午切到 Fucheers 后,当前 Provider 变成了 Fucheers。如果 UI 查询类似这样:
SELECT *
FROM threads
WHERE model_provider = ?
ORDER BY updated_at DESC;那 openai 的历史自然就被过滤掉了。
所以问题不是历史丢了,而是 Codex.app 按 Provider 分桶显示历史。
第一版修法:把所有历史都改成当前 Provider
最直接的想法是:既然 UI 按 model_provider 过滤,那我就把所有 thread 的 model_provider 改成当前 Provider。
比如切到 Fucheers 时:
UPDATE threads
SET model_provider = 'Fucheers'
WHERE model_provider != 'Fucheers';这样左侧列表查询 Fucheers 时,所有历史都能出现。
但不能直接这么做。
原因很简单:如果把原始 Provider 覆盖掉,以后就不知道这条会话最开始到底是 openai 还是 Fucheers 创建的了。脚本异常退出,数据也会变得很难恢复。
所以我加了一个影子列:
ALTER TABLE threads ADD COLUMN original_provider TEXT;
UPDATE threads
SET original_provider = model_provider
WHERE original_provider IS NULL;original_provider 保存真实来源,model_provider 用来配合 UI 显示。
这就像给每条会话贴了两张标签:
- •
original_provider:它原来属于谁 - •
model_provider:现在让 UI 把它显示在哪个 Provider 下
切 Provider 时,只改 model_provider。以后想恢复,再用 original_provider 改回去。
SQLite 不能随便改,要先让它停稳
这里还有一个坑:state_5.sqlite 是 SQLite,而且启用了 WAL。
WAL 可以简单理解成 SQLite 的“临时账本”。应用还在退出时,有些写入可能还在 state_5.sqlite-wal 里,没有完全合并回主库。
所以脚本不能刚发完退出命令就立刻改 DB。
我做了几层保险:
osascript -e 'tell application "Codex" to quit'
sqlite3 "$STATE_DB" "PRAGMA wal_checkpoint(TRUNCATE);"
prev=-1
for i in 1 2 3 4 5; do
cur=$(stat -f%z "$STATE_DB-wal" 2>/dev/null || echo 0)
[ "$cur" = "$prev" ] && break
prev="$cur"
sleep 0.3
done然后再备份三件套:
cp -p state_5.sqlite state_5.sqlite.bak.$ts
cp -p state_5.sqlite-wal state_5.sqlite-wal.bak.$ts
cp -p state_5.sqlite-shm state_5.sqlite-shm.bak.$ts这一步不酷,但重要。
改用户本地状态库,第一原则不是写得漂亮,而是坏了能救回来。
第一次跑:看起来成功,其实没成功
我把脚本改完,跑了一次:
cx选择 Fucheers。
脚本显示:
DB: 已更新 44 条 thread 到 model_provider=Fucheers再查数据库,所有历史确实都变成了 Fucheers。original_provider 也保留得好好的。
我以为成了。
打开 Codex.app,上午那条官方账号里的会话还是没出现。
这就很离谱。
我又查了一次数据库,发现刚刚改成 Fucheers 的几条会话,竟然又变回 openai 了。
不是脚本没改成功。是 Codex.app 启动后,把它们改回去了。
真正的事实之源不是 DB,而是 rollout JSONL
我顺着 threads.rollout_path 找到对应的会话文件。
比如:
head -1 ~/.codex/sessions/2026/06/10/rollout-2026-06-10T09-20-23-*.jsonl第一行是 session_meta:
{
"timestamp": "2026-06-10T01:20:23.xxxZ",
"type": "session_meta",
"payload": {
"id": "019eaf1d-...",
"model_provider": "openai"
}
}这下真相出来了。
state_5.sqlite 不是最终事实之源。它更像一个索引。
真正记录这条会话属于哪个 Provider 的,是 rollout JSONL 第一行里的:
"model_provider": "openai"Codex.app 启动时会做一件事:
- 1. 从 SQLite 读 thread
- 2. 根据
rollout_path找到 JSONL - 3. 读取第一行
session_meta - 4. 如果 DB 和 JSONL 不一致,就以 JSONL 为准,把 DB 修正回去
所以我只改 DB 没用。
Codex.app 一启动,就会“自愈”,把我改过的 model_provider 改回 rollout 里的原值。
这也是这次排障最重要的发现:DB 管显示索引,JSONL 管会话事实。
终极修法:DB 和 rollout JSONL 一起改
最终方案变成了两步:
- 1. 改所有相关 rollout JSONL 第一行里的
model_provider - 2. 再改 SQLite 里的
threads.model_provider
顺序不能反。
因为 rollout 改写有可能失败,比如磁盘满、权限不够、某个文件正在被占用。如果先改 DB,rollout 没改成功,Codex.app 启动后还是会把 DB 改回去。
先改 rollout,再改 DB。最坏情况是 rollout 没改成,DB 也不动,原状态还在。
改 JSONL 我没有用 sed -i。
原因是这些文件可能很大,而且 JSONL 是一行一个事件。我要改的只有第一行 session_meta,不能全文件盲替换。
我用了 Python 流式改写:
import os
import re
import sys
path, new_provider = sys.argv[1], sys.argv[2]
with open(path, "r", encoding="utf-8") as f:
first = f.readline()
if '"type": "session_meta"' not in first:
sys.exit(0)
new_first = re.sub(
r'("model_provider"\s*:\s*)"[^"]*"',
rf'\1"{new_provider}"',
first,
count=1,
)
if new_first == first:
sys.exit(0)
tmp = path + f".tmp-{os.getpid()}"
with open(tmp, "w", encoding="utf-8") as out:
out.write(new_first)
for line in f:
out.write(line)
os.replace(tmp, path)这里有几个关键点:
- • 只读第一行,因为只有第一行是
session_meta - •
count=1,只改第一个model_provider - • 剩余内容流式复制,不把整个大文件塞进内存
- • 先写临时文件,再
os.replace()原子替换
这不是为了优雅,是为了别把几百 MB 的历史文件写坏。
第二次跑:终于成了
这次再跑:
cx切到 Fucheers,脚本输出类似这样:
DB: 已更新 44 条 thread 到 model_provider=Fucheers
Rollout: 改写 38 个 JSONL
Launched Codex打开 Codex.app,上午那条官方账号里的任务终于出现在左侧列表里。
点进去,继续发消息。
新消息走的是当前激活的 Fucheers,而不是原来的 openai。也就是说:
- • 历史能看见
- • 上下文能接上
- • 新请求走当前 Provider
这才是我真正想要的效果。
回滚也要设计成一键
这种脚本只写“前进”是不够的。
因为它改的是本地历史状态。如果哪天我不想合并历史,或者 Codex.app 升级后行为变了,我必须能一键退回去。
所以我加了 --restore-providers:
cx --restore-providers它做的事是:
- 1. 读取
original_provider - 2. 把
threads.model_provider改回原值 - 3. 把 rollout JSONL 第一行里的
model_provider也改回原值 - 4. 再重启 Codex.app
我还加了一个临时开关:
cx --no-unify这次只切 Provider,不合并历史。
如果想长期关掉,也可以在脚本顶部设:
UNIFY_THREADS=0dry-run 让状态可见
这类脚本最怕黑盒。
所以我加了一个 dry-run:
cx --dry-run它不会改任何东西,只打印当前分布:
threads 表当前 model_provider 分布:
model_provider n
-------------- --
Fucheers 119
original_provider 影子列分布:
original_provider n
----------------- --
Fucheers 74
openai 44这两列放在一起很有用。
model_provider 告诉我 UI 现在会把历史显示在哪个 Provider 下。original_provider 告诉我这些历史最初来自哪里。
一个管“现在怎么看”,一个管“原来是谁”。
这次学到的东西
第一,GUI 应用和 CLI 的状态模型完全不同。
CLI 更像一次性命令,环境变量、参数、配置文件进来,结果出去。GUI 应用有长时间运行的状态,有 SQLite,有索引,有归档文件,还有启动时的自我修正。
所以调 GUI 问题,不能只盯配置文件。
第二,“看起来成功”和“真的成功”之间,隔着一个启动周期。
我第一次改完 DB,SQL 查出来没问题,以为成了。结果 Codex.app 一启动,又按 JSONL 把 DB 改回去了。
以后遇到类似问题,不能只看静态文件,要看应用重启后是否还保持这个状态。
第三,索引不是事实之源。
state_5.sqlite 很像会话索引。真正的会话源数据在 rollout JSONL 里。你改索引,应用会用源数据把它校正回来。
这件事其实挺合理。只是当我从外部写脚本去改它时,必须尊重这个隐式规则。
第四,回滚不是锦上添花,是工具的一部分。
original_provider、DB 备份、JSONL 原子替换、--restore-providers,这些都不是为了显得工程化,而是为了让我敢用这个脚本。
如果一个工具只在一切顺利时好用,那它还不够好。真正需要设计的是出错时怎么退。
结语
昨天的 cx 解决了“怎么切 Provider”。
今天的优化解决了“切完以后怎么不断片”。
这两个问题连在一起,才算真的把 Codex.app 的多 Provider 工作流跑通:
官方账号额度快用完
→ cx 切到第三方 Provider
→ 旧会话还在
→ 新 Provider 接着旧上下文继续干活对开发任务来说,这比单纯切模型重要得多。
因为 Agent 真正值钱的地方,不只是它能回答,而是它能沿着前面的工作继续走。上下文一断,它就又变回一个刚进项目的人。
这次改完以后,我终于可以放心一点:额度不够了,就切;Provider 想换,就切;任务上下文还在,工作不用从头讲起。
代码现在 526 行。
听起来有点长,但换来的是一个很舒服的动作:
cx选一下,继续干活。
夜雨聆风