上周我们把内部 LLM 网关从旧地址迁到新地址,全组其他人的工具都正常切过去了,唯独我 Windows 机器上的 Codex CLI 还在死心眼地向旧地址发请求。最诡异的是,config.toml里的base_url我明确改成了新地址,新网关的访问日志里却一直等不到我的请求,旧网关那边反而热热闹闹。如果你也遇到过“明明改过配置,工具却不认”的怪事,这篇文章应该能帮你省下和我一样多的排查时间。
这篇记录主要围绕 Windows 环境下 Codex CLI 误用旧base_url的问题展开,把完整链路讲清楚:配置到底有哪些来源、为什么文件里的值不是唯一生效值、daemon 进程为什么能让“新终端”也背负旧配置,以及怎么彻底清干净旧地址。适合三类人看:一是正在把 Codex CLI 从默认端点切到自定义网关的开发者,二是 Windows 上改了base_url但始终不生效的倒霉蛋,三是想系统理解 CLI 配置优先级、避免以后再踩坑的读者。
1. 现象与初步假设:我改的难道是“假”配置吗
1.1 问题现象
先交代背景。我们团队自建了一套兼容 OpenAI 接口格式的 API 网关,早前 Codex CLI 一直通过它来跑任务。这几天网关迁移,旧地址保留半小时后就要下线,新地址已经对外提供服务。其他工具比如curl、Python 脚本、同事的 Mac 环境都切换顺利,只有我这台 Windows 机器上的 Codex CLI 出了问题。
具体表现有两个:
- 旧网关上的访问日志里,还能看到来自我这台机器的 Codex CLI 请求,包括
/models、/chat/completions这类端点,时间戳持续更新。 - 新网关上完全没有我的请求记录。用 Codex CLI 发起一次简单对话,返回的报错是
404和401,错误信息里指向的地址明显还是旧域名。
这个现象很有意思。如果base_url配错了,通常两种情况:要么 CLI 根本连不上、立刻报网络错误;要么连上了但鉴权失败。而我的情况是它连得非常“顺滑”,只是连到了旧地址上。这说明 Codex CLI 并不是没有读取配置,而是读取到了一个我早已忘记的旧值。
1.2 我列出的三个怀疑对象
一开始我的第一反应是“config.toml 改错了”,但打开文件看了三遍,新地址明明躺在base_url字段里。于是我把怀疑范围扩大到三个地方:
- 环境变量:很久以前调试时可能设置过
OPENAI_BASE_URL之类的东西,且一直没清理。 - 多份配置文件:当前项目目录或用户目录下可能存在第二份 Codex 配置,覆盖了全局配置。
- daemon 进程缓存:Codex CLI 在 Windows 上并不是“每次终端启动都重新读文件”的轻量工具,后台 daemon 可能仍然持有旧配置。
这三个方向,几乎覆盖了所有 CLI 类工具“配置改了不生效”的常见原因。但真到排查时,麻烦在于它们往往同时存在,并且互相掩盖,必须按顺序逐个排除。
1.3 核心认知:base_url 不是“配置项”,而是“请求路由”
在动手之前,我需要先说清楚base_url到底是什么。很多第一次接触的人以为它是一个普通的字段,错了就改,改了就好。但它在 API 客户端里的真实角色是“请求路由”:客户端收到一个相对路径比如/v1/chat/completions后,会把它拼到base_url后面,组成完整 URL 再发出去。
用个生活中的类比:base_url相当于你外卖 App 里默认选中的门店地址。文件里写的门店和系统实际下单的门店不一致时,不管菜品怎么变,骑手永远跑错地方。base_url一旦被旧值“路由”了,后面所有环节——鉴权、模型列表、请求分发——全部建立在错误地址上。所以排查时不要把目光只放在“某个字段的值对不对”,而要弄清楚“CLI 进程最终拿到的路由是什么”。
1.4 整体排查思路
后面的内容就是实际操作过程。我按四个阶段推进:
- 确认 CLI 到底读取了哪些配置源。
- 逐个检查环境变量、配置文件、daemon 状态。
- 找出真正的旧值来源并清理。
- 制造一次复现实验验证思路,最后固化成长效习惯。
下面从第一步说起。
2. 配置源排查:环境变量、config.toml 与 daemon 的三层博弈
2.1 第一步:确认 CLI 版本、启动路径和常用命令
排查任何 CLI 问题之前,先确认你敲的codex到底是哪个程序。Windows 上最常见的坑之一,是系统里装了多个版本,Shell 的PATH优先命中了旧版。虽然这次问题不是版本导致,但这个检查不能跳过,因为它影响后面所有判断。
我在 PowerShell 里依次执行:
codex --version (Get-Command codex).Source where.exe codex第一行拿到版本号,第二行拿到当前 PowerShell 实际调用的程序路径,第三行列出 PATH 里能找到的所有codex。如果where.exe codex的输出不止一行,说明机器上存在多份安装,需要留意当前命中的是哪一个。
顺带一提,Codex CLI 交互会话里有一些常用命令,排查时可以用来辅助判断状态,比如/compact压缩上下文、/model查看或切换当前模型、/resume恢复历史会话。这些命令虽然不直接解决base_url问题,但能帮你确认会话本身是否健康,避免把“上下文坏了”误判成“配置没生效”。
2.2 第二步:环境变量检查,必须区分用户级和系统级
Windows 上环境变量的隐藏恶心程度,远超 Linux。Linux 用户通常只关心 shell 导出的变量和.bashrc,而 Windows 有进程级、用户级、系统级三个层级,且互相继承和覆盖。更麻烦的是,PowerShell 里直接敲$env:OPENAI_BASE_URL只能看到合并后的结果,如果系统级有一个值、用户级有另一个值,你根本分不清到底哪个生效。
所以我分别用三条命令查:
# 查看进程级(当前终端窗口临时设置的) Get-ChildItem Env:OPENAI_BASE_URL -ErrorAction SilentlyContinue # 查看用户级永久变量 [Environment]::GetEnvironmentVariable('OPENAI_BASE_URL','User') # 查看系统级永久变量 [Environment]::GetEnvironmentVariable('OPENAI_BASE_URL','Machine')如果用户级或系统级返回了值,那基本可以锁定一半。我的机器上用户级返回了一个旧域名,看到它的那一刻我就想起来了:大概半年前做接口联调时,我用setx写过一次OPENAI_BASE_URL,后来项目结束彻底忘了这回事。setx写入的是永久用户环境变量,它比config.toml更容易让程序读到,优先级也通常是环境变量优先于配置文件。
提示:Windows 环境变量名不区分大小写,所以
OPENAI_BASE_URL和openai_base_url是同一个变量。如果你用reg query HKCU\Environment直接看注册表,能更清楚地看到这类隐藏变量。
2.3 第三步:检查所有可能存在的 config.toml
环境变量有了嫌疑,但还不能直接下结论。Codex CLI 的配置文件也可能存在多份,最典型的是全局配置和项目级配置并存。全局配置文件路径在 Windows 上通常是:
%USERPROFILE%\.codex\config.toml实际展开后类似C:\Users\你的用户名\.codex\config.toml。如果系统开启了 OneDrive 文件夹重定向,%USERPROFILE%的解析结果可能与你预期不同,建议打印一下真实路径再打开:
Write-Output (Resolve-Path "$env:USERPROFILE\.codex\config.toml")除了全局配置,还要检查当前工作目录下有没有.codex目录或其他覆盖性配置。很多 CLI 都支持“当前目录配置优先于全局配置”,Codex 相关工具链也沿用了类似设计。我当时的检查方法是直接在当前项目根目录和上级目录里搜一遍:
Get-ChildItem -Path . -Filter "config.toml" -Recurse -Depth 2 -ErrorAction SilentlyContinue | Select-Object FullName这一圈查下来,我确认项目目录里没有第二份config.toml,全局配置里base_url是新的。于是矛盾集中到了环境变量和 daemon 进程上。
2.4 第四步:daemon 是否还持有旧配置
Codex CLI 在 Windows 上不是简单的单进程程序,它有一个后台 daemon 负责维护会话状态、共享上下文。这个设计让 CLI 能做到多窗口共用对话历史,但也带来一个副作用:daemon 生命周期比终端长得多,终端关了它不一定退出,配置改了它不一定会重新加载。
我打开任务管理器,按进程名筛出所有潜在相关进程:
Get-Process | Where-Object { $_.ProcessName -match 'codex|node' } | Select-Object Id, ProcessName, StartTime结果里果然有一个 codex 相关进程,启动时间是我改配置之前。也就是说,即使我改完config.toml、再开一个新终端,终端里的新请求依然通过旧 daemon 发出,而旧 daemon 使用的还是启动时加载的旧base_url。
Windows 上还有一个特有提示,如果你以前用管理员权限启动过 Codex CLI,再切回普通终端可能出现类似 “error: start the windows daemon from a non-elevated terminal; shared clients” 的报错。这个报错的常见背景就是:高权限终端先启动了 daemon,普通终端继承不了对应上下文,行为就会变得很怪。我这次排查时没遇到这条报错,但如果你遇到了,请先把所有相关进程结束,再用普通权限终端重新启动,否则后续任何配置修改都可能被“权限错位”干扰。
3. 定位真凶:旧地址从三个入口“复活”的常见路径
排查到这一步,我的结论其实已经出来了:环境变量残留 + daemon 缓存,两个因素叠加导致 Codex CLI 一直使用旧base_url。但为了写这篇文章时能给你一个真正可复用的经验,我特地把“项目里可能再遇到的其他成因”归纳了一遍,按我遇到的概率从高到低排列。
3.1 成因A:旧环境变量残留在用户级或系统级变量中
这是最常见、最隐蔽的一种。原因很简单:环境变量的优先级通常高于配置文件,而且一旦通过setx写入,它会永久存在于注册表里。你之后无论怎么改config.toml,只要环境变量里还有旧值,CLI 就以它为准。更阴险的是,很多人根本想不起自己设置过这个变量,因为在 Windows 上setx不打印警告,它只是默默替换掉你以后的默认值。
我这次的情况就是典型。前年某个项目联调时,临时把OPENAI_BASE_URL指到了旧网关,后来项目结束,我在代码层面清理了一堆东西,唯独漏掉了这个永久环境变量。直到这次网关迁移,它才重新“登场”。
3.2 成因B:项目目录下存在第二份配置文件
比环境变量再多一层的是项目级配置。很多工具的设计思路是:用户在项目根目录放一份config.toml,里面的字段覆盖全局配置,方便团队统一设置。这样是好事,但如果你以前在某个项目目录里调试过,写下过旧base_url,然后忘了删,换到新项目时——尤其是使用同一个终端会话时——CLI 可能会读取到你“当前所在目录”的配置,甚至向上递归找到父目录的配置。
这类问题最坑的点在于:你以为自己在改%USERPROFILE%\.codex\config.toml,实际上 CLI 读的是项目目录里那份。排查时不要只盯着全局文件,务必把当前目录、上一级目录都查一遍。最好的办法是用Get-ChildItem直接搜,不要靠肉眼回忆。
3.3 成因C:daemon 以旧配置启动且没有退出
这是最容易被“新终端”假象欺骗的成因。普通 CLI 用户会本能地认为:我关掉终端,再打开一个新终端,程序就会重新读取配置文件。但 Codex CLI 这类带 daemon 的工具不同——daemon 是独立于终端而存在的后台服务,关闭终端只会断开它的客户端连接,不会让 daemon 退出。
于是你看到的场景是:明明新开了一个干净的终端,明明config.toml里已经写了新地址,但发出去的请求还是走旧地址。这其实不是“新终端没生效”,而是“旧进程还在运行”。所以排查时必须先把所有相关进程找出来结束掉,再谈配置重载。
3.4 另一个隐蔽的坑:变量名拼写与版本差异
除了上面三种,还有一种比较少见但也会导致“误用旧值”的情况:不同 Codex CLI 版本对 base_url 相关变量和配置字段的读取规则有差异。早期版本可能只认OPENAI_BASE_URL,新版可能同时兼容OPENAI_BASE_URL、CODEX_BASE_URL或配置文件里的model_providers字段。如果你升级过 CLI,旧变量不会自动消失,仍然会被读取。
这类问题没有捷径,只能靠 CLI 自带的调试信息来确认。建议执行codex --help查看当前版本支持的参数,以及codex是否有debug、doctor之类的子命令,能直接输出“最终生效配置”。我自己排查时就是靠调试日志确认了进程实际请求的 URL。
4. 修复记录:清理残留配置并让 daemon 重新加载新地址
4.1 修正 config.toml 里的 provider 配置
先把配置文件整理干净。Codex CLI 支持多 provider,base_url一般是写在 provider 配置块里的。我的最终配置长这样:
model = "gpt-5" model_provider = "openai" [model_providers.openai] name = "OpenAI" base_url = "https://api.openai.com/v1" env_key = "OPENAI_API_KEY"如果你用的是自定义网关,可以新增一个 provider:
model = "gpt-5" model_provider = "internal" [model_providers.internal] name = "Internal Gateway" base_url = "https://gw.internal.example.com/v1" env_key = "INTERNAL_API_KEY"写完之后先不急着启动,把base_url的值再对一遍,尤其注意结尾的/v1。很多服务端要求 base_url 精确到版本前缀,多一个或少一个斜杠都会导致后续请求拼接出错。
注意:不要只在配置文件里改,还有一步很容易漏——确认环境变量里没有旧值。下面的清理操作才是关键。
4.2 清理环境变量中的旧值
根据前面的排查结果,我确定旧值来自用户级环境变量OPENAI_BASE_URL。永久清理用下面这条命令:
[Environment]::SetEnvironmentVariable('OPENAI_BASE_URL', $null, 'User')如果你在Machine级别也发现旧值,记得同样清理,但系统级变量修改需要管理员权限,PowerShell 要以管理员身份运行才行。想立刻让当前终端生效,再执行一次:
Remove-Item Env:OPENAI_BASE_URL -ErrorAction SilentlyContinue清理完别急着开新终端验证,先确认一下确实删干净了。重开一个 PowerShell 窗口,执行:
[Environment]::GetEnvironmentVariable('OPENAI_BASE_URL','User')返回值应该是空。如果仍有内容,检查是否在系统级变量或者注册表其他位置还有残留。
4.3 结束残留 daemon 并重新启动
配置清完之后,必须把旧 daemon 也结束掉,否则它还是可能按内存里的旧配置运行。我当时的操作是:
Get-Process | Where-Object { $_.ProcessName -match 'codex|node' } | Stop-Process -Force这会比较暴力,建议先列出确认一下进程名。如果你不想一次性全结束,也可以用进程 Id 精确操作:
Stop-Process -Id 12345 -Force结束之后再新开一个普通权限的 PowerShell 窗口,启动codex。如果 Windows 上弹出防火墙提示,允许即可。此时留意终端输出内容,正常启动后应该能看到它连接新网关时的初始化日志。
4.4 验证是否真正生效
修复是否成功,要看证据,不能靠“感觉”。我总结了一套三层验证方法:
| 验证项 | 命令/方法 | 期望结果 |
|---|---|---|
| 环境变量干净 | [Environment]::GetEnvironmentVariable('OPENAI_BASE_URL','User') | 返回空或新地址 |
| 请求确实到新地址 | 发起一次对话后查看新网关访问日志 | 能看到/chat/completions请求 |
| 旧地址彻底安静 | 查看旧网关访问日志 | 不再出现来自本机的 Codex CLI 请求 |
如果你不想看网关日志,也可以在发起请求前用curl先测试新base_url是否连通:
curl https://gw.internal.example.com/v1/models -H "Authorization: Bearer $env:INTERNAL_API_KEY"连通性没问题后,再进 Codex CLI 发一条最简单的消息,比如“回复 ok”。两条验证都通过,才叫真正修完。
5. 复现实验:人为制造一次“旧 base_url”,验证排查思路可复用
修完之后我没有直接收工,而是做了一次复现实验,目的是确认这套排查链路在以后还能用,顺便给自己留一份“反面教材”的记录。实验方法不复杂,全程用本地测试服务,不涉及任何对外流量。
5.1 实验设计
我故意在当前用户环境变量里设置一个错误旧地址,指向一个本地 mock 服务:
[Environment]::SetEnvironmentVariable('OPENAI_BASE_URL', 'http://localhost:9999/v1', 'User')同时把配置文件里的base_url保持为新网关地址。如果 Codex CLI 优先读环境变量,实验中的请求就会打到localhost:9999;如果优先读配置文件,就会打到新网关。
5.2 实验过程与结果
先用 Python 起一个极简的 HTTP 服务监听 9999 端口,打印收到的所有请求路径:
from http.server import HTTPServer, BaseHTTPRequestHandler class Handler(BaseHTTPRequestHandler): def do_GET(self): print("GET", self.path) self.send_response(404) self.end_headers() def do_POST(self): print("POST", self.path) self.send_response(404) self.end_headers() HTTPServer(('127.0.0.1', 9999), Handler).serve_forever()然后新开终端,清掉 daemon,启动codex发一条消息。控制台里立刻打印出了POST /chat/completions之类的请求路径。这说明环境变量确实压过了配置文件。接着依次执行前面几个检查步骤,最终定位到用户级变量,整个流程一气呵成,说明这套思路是可复现的。
5.3 实验后的清理与反思
实验做完后,我把 mock 服务停掉,并清掉刚才设置的变量:
[Environment]::SetEnvironmentVariable('OPENAI_BASE_URL', $null, 'User') Remove-Item Env:OPENAI_BASE_URL -ErrorAction SilentlyContinue再启动 Codex CLI 确认请求回到了新网关。整个过程让我印象深刻的是:问题本身不难,难的是“旧值藏得足够深”。如果没有系统性排查,我可能会去重装 CLI、删配置目录,最后仍然一头雾水。
6. Windows 下的长效预防:配置、环境变量和 daemon 的日常管理习惯
6.1 一张优先级速查表
这次踩坑之后,我把 Codex CLI 的配置优先级整理成一张表,贴在桌面备忘里。不同版本细节可能有差异,但大方向是一致的:
| 优先级 | 配置来源 | 特点 |
|---|---|---|
| 高 | 命令行参数 / 交互命令 | 临时生效,作用域最小 |
| 中高 | 环境变量 | 容易残留,最常引发“改文件不生效” |
| 中 | 项目目录配置 | 覆盖全局,团队协作时容易混淆 |
| 低 | 全局 config.toml | 日常修改的主要位置 |
| 最低 | 内置默认值 | 没有其他配置时兜底 |
有了这张表,以后遇到“配置不生效”,我会按“环境变量 → 项目配置 → daemon 缓存”的顺序快速排查,而不是从最底层的默认值猜起。
6.2 养成“看生效值,不看文件值”的习惯
配置文件里写什么,和程序实际读到什么,是两回事。现在我在改完base_url后,会优先用 CLI 自带的调试能力确认生效值,而不是反复打开文件对着看。如果你的版本支持debug或doctor类命令,建议第一时间使用。哪怕是简单地在交互会话里发一条消息,让服务端日志告诉你“我打到了哪里”,也比干瞪眼强。
6.3 环境变量的使用纪律
这一点我想重点强调:尽量不要用setx做临时调试。setx写入的是永久用户变量,今天临时用一下没问题,半年后它会变成一颗定时炸弹。如果你的调试只需要当前终端生效,用下面这种进程级变量就好:
$env:OPENAI_BASE_URL = "https://gw.internal.example.com/v1"终端关闭即失效,不会污染后续环境。如果确实需要长期自定义端点,优先写进config.toml,因为它是文件,可以被 Git 管理、可以被 review,至少团队里其他人还能看到。
顺带一提,如果你某天想彻底卸载 Codex CLI,光删安装目录是不够的,%USERPROFILE%\.codex目录和用户环境变量里的OPENAI_BASE_URL这类残留都得清理,否则重装之后它还会带着以前的“记忆”跑起来。
6.4 daemon 与终端权限习惯
Windows 上运行带 daemon 的 CLI 工具,我建议始终使用普通权限终端启动,不要动不动就用管理员。高权限启动的 daemon 会让后续普通终端会话出现各种莫名其妙的连接问题,比如前文提到的 “start the windows daemon from a non-elevated terminal” 报错。平时改完配置,顺手把所有 codex 相关进程结束再重启,能避免大部分“改了没生效”的误会。如果遇到脚本闪退、弹窗一闪而过的问题,可以在 PowerShell 里用-NoExit参数暂时保持窗口不关闭,方便看报错内容。
6.5 我在实际操作中的一个小习惯
最后分享一个我现在坚持用的习惯:每次改完base_url或任何 provider 相关配置,我不会立刻进入正式开发,而是先发一条最简单的测试消息,同时盯着新网关的访问日志。这个动作前后不过二十秒,却能确认“路由真的切换成功”。别嫌它多此一举,这次排查花了我大半个下午,根因就是当年一个顺手写入的旧变量。用二十秒换一个下午,这笔账很划算。