把本地模型接入 codex

如今本地模型已经很能打了,比如 Qwen3.8-27B,稠密模型,已经可以和 Opus-4.6 一争高下。如果用的是 Uncensored 版本,那更有种种奇妙的功能,很难用语言表达。但本地模型使用起来却有诸多不便。比如,用 ollama 部署,它默认没有联网功能,需要额外配置;而且如果只用它来聊天,显然大材小用了。

实际上,它缺的无非是一套完善的 Harness。远在天边、近在眼前,世界上最优秀的 Harness 不就是 Codex 吗?很明显 ollama 也是一种 Provider,那么,当然可以将 ollama 服务接入 Codex。

结果思易行难,从“简单换个 provider”,一路折腾到了上下文长度、自动压缩、KV 缓存、模型模板、工具参数、流式事件、失败接续和进程隔离。最终还是在 Codex 和 Ollama 之间,写了一个专门的中间接口程序。熟悉的剧情又发生了:一件原以为顺手就能做完的小事,渐渐长出了自己的项目文件夹。

本地模型能回答问题只是跑通的第一步,要让它在 Codex 里持续干活,还有不少事情需要处理。

先说明一下环境:我的主机是 Windows 11 + RTX 4090,Ollama 运行在本地,版本是 0.34.0。Codex 也就是 ChatGPT Desktop,最新版本。强烈建议将 PowerShell 升级到 7.4+。另外本机还需要 Python 环境(废话)。

一、GPU 已经忙起来了,Codex 却一直“正在思考”

本次要嫁接的具体模型是 hf.co/JonathanColetti/Qwen3.8-27B-Uncensored-GGUF:Q4_K_M。选择 Q4 量化是不得已的选择,显存就那么大,Q8 的话显存放不下,一定会溢出到系统内存,基本无法使用。

做一个切换接口的脚本很容易,于是做了两个桌面入口:一个切到 Ollama,一个恢复正常的 Codex。

真正开始干活的时候问题来了。任务管理器里能看到 GPU 很忙,本地模型显然在跑;Codex 窗口却没什么动静,只显示“正在思考”。十几分钟过去,等来一句 “We are experiencing high demand” 的提示。这显然不对头,模型在自己电脑里跑,怎么还会碰上服务繁忙?

原来,思考强度的 “xhigh” 在接口转换过程中变成了模型模板不接受的 “max”,Codex 传来的多条 system/developer 消息也不符合该模板的要求。这些本地错误最后被包装成了客户端的通用报错。“high demand” 是个很让人误解的报错信息。

这时我已经意识到:从测试消息成功返回,到让一个模型接管 Codex 的工具工作流,中间还隔着很多东西。

二、为什么还得加一个中间接口程序

Ollama 本身支持 “/v1/responses”。Ollama 官方明确说,它支持的是 OpenAI API 的一个子集。但接口名字相同,实际能接受的消息、工具和返回事件仍有差异。

为了避免 Codex 调用 Ollama 是设置的参数影响到其他本地模型的行为,最终设计了一个专用接口结构:

Codex 桌面端
↓ Responses 请求
127.0.0.1:11435 Python 兼容层
↓ 整理后的 Responses 请求
127.0.0.1:11436 Codex 专用 Ollama

专用模型别名 + 本机 GPU

127.0.0.1:11434 上的原有 Ollama 服务继续保留,11436 是为 Codex 单独启动的 Ollama,11435 则是 Codex 实际连接的兼容层。Codex 的 “base_url” 使用“http://127.0.0.1:11435/v1”。

Python 兼容层负责这些事情:

首先是整理请求。把这套模板不接受的多条 system/developer 文本合并进 instructions,转换思考参数,移除不适用的云端字段。Codex 传过来的函数工具可能带命名空间,Ollama 这条接口要的是扁平函数列表,就先展开,收到调用后再把名字还原。历史里的工具调用,也必须做对应转换。

其次是整理历史。我们遇到过一种中断:上一轮留下了一段思考,却没有后续的助手回答。再次续接时,Ollama 的处理会把这段孤立思考挪到新用户消息之后。于是用户已经说了“继续”,模型却像还在接着上一段自言自语。桥接层需要固定这段历史的位置,同时保留原有内容。

最后是管理响应的生命周期。什么时候算完成,什么时候算截断,哪些失败能接续一次,什么时候必须报错,用户点停止以后怎样让上游也停下来,都由它协调。

这不是一个把 URL 换掉就完事的透明转发器。它包含对特定模型行为的适配,也包含任务执行策略。比如合并消息角色就无法原样保留所有角色结构,必须验证转换后的行为;支持函数工具,也不等于兼容所有自由格式工具和插件。换到另一套原生兼容得更好的模型,未必需要同样的桥接,但对于当前这个模型,这些都是必要的。

三、上下文长度,原来要在好几处对齐

最早的一次上下文报错很直接:请求已经有约 7 万 token,实际运行的模型却只有 32K 上下文。

我明明没打多少字,怎么就 7 万了?因为 Codex 发给模型的,远不止聊天框里的 Prompt。系统指令、项目说明、工具定义、历史对话、读文件的结果、命令输出,都可能占用空间。有工具的 Agent,上下文增长得比普通聊天快得多。

调整到 96K 后,一次 75K token 的请求跑通了。经过反复测试,最后把上下文上限设置在了 144K。需要注意的是,不能只改一个地方。Ollama 中的模型别名对应的 num_ctx、专用 Ollama 进程中的 OLLAMA_CONTEXT_LENGTH、Codex配置中的 model_context_window、Codex 模型目录中的 context_window,4个地方都需要设置。

在 4090 上部署实测:上下文 147456,66/66 层在 GPU,服务报告模型占用约 20.44 GiB,Q8 KV 缓存约 4896 MiB。这可能是我这张 4090 上的“甜点”配置,显存占用还能留一点给其他应用。

四、自动压缩要提前

接着又遇到一个容易混淆的概念:上下文窗口和自动压缩阈值。

前者是模型这次推理能容纳多少内容;后者是 Codex 什么时候开始整理历史,把已经发生的事情压缩成摘要,以便继续工作。压缩本身也需要模型配合,它总是会丢失一部分细节,不可能做到完全无损。

经过反复测试,最终设置是:144K 窗口,104K 开始触发自动压缩。为什么空出这么多?因为当前输入之外,还要给这一轮思考、回答、工具参数,以及必要时的失败接续留空间。

模型采用 xhigh 时,首次最多生成 16K;满足条件时,还允许一次最多 16K 的接续。按名义窗口算,144K 减去 104K,留下 40K。模型目录还设置了 95% 的有效窗口比例,按这个折扣计算,阈值之上的余量约为 32.8K。恢复提示词、计数差异和新工具返回,也会消耗空间。因此,这组数字只能理解为工程上的预算安排。不同模型的 tokenizer、工具返回大小和一次生成的长度,都需要观察。

如果迁移到更小的窗口,压缩阈值和输出预算要一起缩小。

五、显存怎么省?

为了容纳长上下文,这次给 Codex 专用 Ollama 开启了 Flash Attention,KV 缓存用 `q8_0`,单请求并发,最多加载一个模型,空闲 60 秒卸载。

对应的运行时配置如下:

OLLAMA_HOST=127.0.0.1:11436
OLLAMA_CONTEXT_LENGTH=147456
OLLAMA_FLASH_ATTENTION=1
OLLAMA_KV_CACHE_TYPE=q8_0
OLLAMA_NUM_PARALLEL=1
OLLAMA_MAX_LOADED_MODELS=1
OLLAMA_KEEP_ALIVE=60s

脚本用 PowerShell 7.4+ 的 Start-Process -Environment,把它们只传给新建的子进程。这里补充说明一下,KV 缓存精度是服务级选项,直接改共用服务,那个服务加载的其他模型也会受到影响。另开 11436,独立设置,就不会影响原来 Ollama 上执行的其他模型。需注意 KV 缓存保存的是推理时复用的信息,把它改成 Q8,并不会改变硬盘上的模型量化。官方给出的 Q8 缓存占用约为 F16 的一半,通常精度损失较小,而任务质量影响有限。

并发请求显然会增加缓存需求。所以强行设置为单并发,也是争取更大的长上下文。

考虑到本地 Ollama 和专属进程 Ollama 本质上仍共用同一张显卡,所以脚本还会检查原 11434 是否有模型驻留,有就拒绝新的本地 Codex 推理,等待原模型释放。

六、“卡壳”现象

最折腾人的现象,是它刚开始工作正常,过一阵就停了。再说“继续”,还是停。有时等很久,有时一下就结束,看上去都像模型不肯干活。

后来一条条对日志,才发现这些现象背后存在多种原因。

第一种,是程序自己把仍在工作的模型掐断了。早期桥接有一个 600 秒总时长上限。日志里有请求在 600.03 秒终止,当时上游仍在返回数据。对本地模型来说,长输入加上长生成,十分钟未必意味着故障。因此去掉了“满十分钟就停”的规则,保留了“连续多久没有收到上游数据”的空闲超时。

第二种,是模型在写很长的工具参数,接口还没反馈信息。这比第一种更隐蔽。我专门测过让模型生成一大段工具 JSON。Ollama 内部仍在生成,但在参数解析完成之前,Responses 接口可能迟迟没有首个工具事件。旧的 240 秒等待把这样的合法请求中止了。最后采用的配置是:桥接层 → Ollama:连续无数据最多等待 900 秒;Codex → 桥接层:流空闲最多等待 960 秒。客户端多留一分钟,目的是让桥接层有机会先给出明确错误。Codex 对应配置为 stream_idle_timeout_ms = 960000。

第三种,是输出额度全部用来思考,没来得及干活。有一次请求生成了 16384 个 token,用了约 518 秒,输出却只有思考,没有回答,也没有工具调用。它确实在算,只是预算花光了。这个具体模型模板支持的是 low、medium、xhigh 三档,当前首次生成额度是:low / medium:8192 token;xhigh:16384 token。

第四种,是工具参数写到一半,额度用完了。普通回答写半句,至少还看得出意思。工具调用的 JSON 被截断,可能连解析都过不了,更谈不上执行。一次早期故障恰好停在旧版给 xhigh 分配的 12288 token 边界;另外的真实模型测试也复现了工具参数截断导致解析错误或缺少完成事件的情况。

第五种,是响应流结束了,却没有正常的完成事件。HTTP 200、GPU 停下来了、窗口不转圈了,都不能单独证明任务完成。桥接层还要核对响应状态和 response.completed。确认不了,就向 Codex 发出它能识别的 `response.failed`,把本地原因展示出来。最怕的是活没干完,界面上却什么都没发生。

现在才试用两天,卡壳问题仍然不能说完全解决,后续还需要继续研究。

七、失败以后自动“继续”

现在的桥接策略是:如果这一段生成还没有向客户端发出可用回答或工具动作,却发生了空响应、纯思考耗尽、工具参数截断或缺少完成事件,允许最多接续一次。保留已有历史和可用的思考内容,追加一条要求:给出一个小的下一步工具调用,或者简短回答。

接续阶段用 think=false,额外最多 16384 token。首次生成加接续,low/medium 最多 24K,xhigh 最多 32K。这些都是输出预算,不能忘记还有输入要占上下文。关闭接续阶段的思考,是为了让这次尝试收敛到实际输出。它会影响这一段的推理深度。早期曾经试过把固定额度拆成 6K+2K、12K+4K,后来换成当前做法:首次给完整额度,只有满足条件的失败才追加一次接续。如果客户端明确传了 max_output_tokens,桥接保留较小的上限、最多允许 16K,而且不自动扩额接续。

另一个更重要的限制是:只要已经发出文字或工具调用,就不自动重放。因为模型可能已经要求写文件,客户端也可能已经执行了动作。这时再把整段请求重来一次,就可能重复修改。即使还没确认工具执行,保守地停止,也比猜测“再来一遍应该没事”更容易核对。

传输层的 request_max_retries 和 stream_max_retries 都设为 0,避免叠加看不见的重试。受条件约束的一次接续,由桥接层自己管理,并统一两阶段的响应 ID、事件顺序、工具调用 ID 和用量统计。缺失的统计不编造。

用户点停止,也不能只让 Codex 窗口停下来。桥接层增加了独立的断连监视,客户端取消后,关闭相应的上游连接,避免模型仍在后台憋那一大段 JSON。一次实际取消测试里,下一条请求在 0.84 秒返回了正确结果。至于权重何时卸载,仍按前面的 60 秒空闲策略处理。

这一圈下来,接口程序里最费心的部分,已经变成了“失败以后怎样交代清楚”。

八、切换脚本的总体设计

最初还想把本地模型直接加进现有的模型菜单,随时切一下就好,但实际上无法实现,因为 Provider 不同。于是采用了两个入口:

Codex-O.cmd:切换到本地模式并启动 Codex。
Codex-C.cmd:恢复切换前的模型设置并启动 Codex。

本地模式的核心 TOML 大致如下。

model = "codex-qwen38-27b-144k"
model_provider = "codex_desktop_ollama"
model_reasoning_effort = "low"
model_catalog_json = 'D:\Tools\CodexLocal\CodexModelSwitcher\ollama-models.json'
model_context_window = 147456
model_auto_compact_token_limit = 106496
model_supports_reasoning_summaries = true
model_reasoning_summary = "auto"
web_search = "disabled"

[model_providers.codex_desktop_ollama]
name = "Local Ollama"
base_url = "http://127.0.0.1:11435/v1"
wire_api = "responses"
requires_openai_auth = false
supports_websockets = false
request_max_retries = 0
stream_max_retries = 0
stream_idle_timeout_ms = 960000

我还担心:如果 Codex 后来升级了,切回默认时,会不会拿旧 `config.toml` 整份覆盖回来,把新配置弄没了?

通常来说不会。现在的实现以当前文件为基础,只恢复切换器管理的十个字段,包括模型、provider、思考强度、目录、上下文、压缩阈值等,其他项目和插件配置保留。虽然最后会写回文件,但内容不是直接拿历史备份覆盖。启动程序也动态查找当前安装包,不固定指向旧版路径。

两个 CMD 正式切换会关闭并重新打开 Codex,所以先结束正在执行的任务,切换后新建任务。

为了健壮性,我让脚本先做预检,再临时启动专用运行时,检查真实回复和上下文,结束后清理本次启动的进程。它不改 Codex 配置、不重启应用,但会刷新专用模型别名的元数据。确认这些通过,再正式运行 Codex-O.cmd。

具体脚本不再赘述。

九、小试牛刀

用 Codex + Uncensored Qwen3.8-27B,对朋友搭建的网站进行了“友好的安全检测”。模型没有拒绝,吭哧吭哧开始干活。换作原版 Codex,这种“有悖伦常”的任务,早就红牌警告了,但本地模型不会。

联网功能正常,本地文件夹读写文件也正常。虽然干 Hacker 的事情,它的能力肯定不如 Astra,但最后还是给出了比较令人满意的结果。

又一扇新世界的大门打开了。