1. 项目缘起与整体设计思路
1.1 为什么要折腾这件事
事情起因很简单:团队里几个开发同学一直在用 Codex 做代码补全和对话式编程辅助,效果确实不错,但每个月的调用账单越滚越大。尤其是我们有几个内部项目需要频繁做代码审查、单元测试生成、注释补全这类高频低延迟的操作,如果全部走官方接口,成本会非常夸张。
于是就有了一个很自然的需求:能不能把 Codex 的底层模型换成国内的低成本大模型?毕竟现在国内几家主流厂商的 API 价格已经卷到离谱,有些模型每百万 token 只要几块钱甚至更低,而且响应速度在国内网络环境下反而更快。
这个项目的核心目标就是:让 Codex 这个客户端工具,能够接入国内低成本大模型的 API,在保持可用性的前提下把成本压下来。适合有一定开发经验、愿意折腾配置、对成本敏感的个人开发者或小团队参考。
1.2 整体方案选型
在动手之前,我先梳理了几条可行路径:
| 方案 | 思路 | 优点 | 缺点 |
|---|---|---|---|
| 直接改客户端源码 | 找到 Codex 的 API 调用层,替换 base_url 和模型名 | 最彻底,无中间层 | 客户端更新后要重新改,维护成本高 |
| 本地代理转发 | 起一个本地服务,把 Codex 的请求转发到国内 API | 客户端无感,灵活可控 | 需要处理协议差异 |
| 使用兼容层工具 | 借助已有的 API 兼容转换工具 | 省事 | 依赖第三方,可控性差 |
我最终选了本地代理转发这条路。原因有三个:第一,Codex 的请求格式和国内大部分模型的 API 格式虽然都是类 OpenAI 风格,但细节上有差异,比如字段名、流式返回的格式、function calling 的支持程度等,需要一个中间层做适配;第二,本地代理可以加日志、做缓存、限流,方便调试和成本控制;第三,客户端升级不影响代理层,维护起来更省心。
提示:如果你只是想快速试一下,也可以先用兼容层工具跑通流程,确认效果后再决定要不要自己写代理。
1.3 核心难点预判
在方案确定之后,我预判了几个可能会卡住的地方:
- 协议差异:Codex 可能使用了 OpenAI 的某些特定字段,比如
max_completion_tokens、tool_choice的特定取值等,国内模型不一定完全支持。 - 流式返回格式:SSE(Server-Sent Events)的 chunk 结构可能有细微差别,处理不好会导致客户端解析失败。
- 认证方式:Codex 可能用的是 Bearer Token,国内厂商有的用 API Key 放在 header,有的放在 query 参数,需要统一。
- 模型名称映射:Codex 内部可能硬编码了模型名,需要在代理层做映射。
- 上下文长度和 token 计算:不同模型的上下文窗口不一样,超长请求需要截断或报错处理。
这些难点在后面实操中确实都遇到了,我会在对应章节详细展开。
2. 核心细节解析与实操要点
2.1 协议适配的关键字段
国内主流大模型的 API 大多兼容 OpenAI 的 Chat Completions 接口格式,但兼容不等于完全一致。我在代理层重点处理了以下几个字段:
请求侧:
model:Codex 发过来的模型名可能是gpt-4、gpt-3.5-turbo之类的,需要在代理层映射成国内模型的实际名称,比如qwen-plus、glm-4、deepseek-chat等。max_tokensvsmax_completion_tokens:新版 OpenAI 接口用后者,国内很多模型只认前者,需要做字段转换。tools和tool_choice:如果 Codex 用了 function calling,要确认国内模型是否支持,不支持的话需要降级处理,比如把工具调用转成普通文本提示。stream:流式开关,国内模型基本都支持,但返回格式要仔细比对。
响应侧:
choices[].delta的结构:国内模型的流式返回大多也是这个结构,但有的会在最后一个 chunk 里多带一些字段,需要过滤。finish_reason:有的模型返回stop,有的返回eos,需要统一成 Codex 能识别的值。usage字段:用于成本统计,国内模型有的返回,有的不返回,不返回的话需要自己估算。
我在代理层写了一个字段映射表,用配置的方式管理,这样换模型的时候只需要改配置,不用动代码。
2.2 认证与安全处理
认证这块看起来简单,但有几个坑:
- API Key 的存放:绝对不要硬编码在代码里,我用的是环境变量加本地配置文件的方式,配置文件权限设为 600,避免被其他用户读到。
- 请求头透传:Codex 发过来的
Authorization头需要替换成国内厂商的格式。有的厂商用Bearer sk-xxx,有的用X-Api-Key: xxx,代理层要做转换。 - HTTPS:本地代理默认跑 HTTP 就行,因为只在 localhost 通信。但如果代理要部署到远程服务器,必须上 HTTPS,否则 API Key 在传输过程中可能泄露。
注意:如果你把代理部署在公网,一定要加访问控制,比如 IP 白名单或额外的鉴权 token,否则你的 API Key 可能被别人盗用。
2.3 流式返回的处理细节
流式返回是体验的关键。Codex 这类工具对首 token 延迟很敏感,如果代理层处理不好,用户会感觉明显卡顿。
我的处理方式是:
- 收到国内模型的 SSE 流后,逐行解析
data:开头的内容。 - 跳过
[DONE]标记之前的所有空行和注释行。 - 把每个 chunk 的 JSON 解析出来,做字段映射。
- 重新组装成 Codex 期望的格式,再以 SSE 形式写回。
- 确保
Content-Type: text/event-stream和Cache-Control: no-cache正确设置。
这里有个细节:Node.js 的res.write()默认会缓冲,需要手动调用res.flushHeaders()并确保没有启用压缩中间件,否则流式效果会变成一次性返回。
2.4 模型选择与成本测算
国内低成本模型的选择很多,我对比了几个主流选项:
| 模型 | 大致价格(每百万 token) | 上下文长度 | 代码能力 | 流式支持 |
|---|---|---|---|---|
| 模型A | 1-2 元 | 32K | 中等 | 支持 |
| 模型B | 4-8 元 | 128K | 较强 | 支持 |
| 模型C | 0.5-1 元 | 16K | 一般 | 支持 |
| 模型D | 8-15 元 | 200K | 强 | 支持 |
我的策略是分级使用:代码补全这种高频低要求的场景用最便宜的模型C,代码审查和复杂对话用模型B或D。代理层根据请求里的模型名做路由,这样既能控制成本,又不牺牲关键场景的体验。
成本测算举个例子:假设团队每天有 5000 次代码补全请求,平均每次 500 token,一天就是 250 万 token。用模型C的话,一天成本不到 3 块钱,一个月不到 100 块。同样的量走官方接口,可能是这个数字的几十倍。
3. 实操过程与核心环节实现
3.1 环境准备与依赖安装
我用的技术栈是 Node.js + Express,原因是生态成熟、写代理方便、流式处理有现成的库。
mkdir codex-proxy && cd codex-proxy npm init -y npm install express axios dotenv目录结构如下:
codex-proxy/ ├── config/ │ └── models.json ├── src/ │ ├── index.js │ ├── adapter.js │ └── stream.js ├── .env └── package.json.env文件存放敏感信息:
PORT=8787 PROVIDER_API_KEY=your_api_key_here PROVIDER_BASE_URL=https://api.example-provider.com/v13.2 模型映射配置
config/models.json用来管理模型映射和路由规则:
{ "gpt-4": { "target": "model-b", "maxTokens": 4096, "temperature": 0.7 }, "gpt-3.5-turbo": { "target": "model-c", "maxTokens": 2048, "temperature": 0.5 }, "default": { "target": "model-a", "maxTokens": 2048, "temperature": 0.7 } }这样 Codex 发过来的模型名会被映射到国内模型的实际名称,同时可以针对不同模型设置不同的默认参数。
3.3 代理核心逻辑实现
主入口src/index.js:
const express = require('express'); const axios = require('axios'); const dotenv = require('dotenv'); const { adaptRequest, adaptResponse } = require('./adapter'); const { handleStream } = require('./stream'); dotenv.config(); const app = express(); app.use(express.json({ limit: '10mb' })); app.post('/v1/chat/completions', async (req, res) => { const adapted = adaptRequest(req.body); if (adapted.stream) { return handleStream(adapted, res); } try { const response = await axios.post( `${process.env.PROVIDER_BASE_URL}/chat/completions`, adapted, { headers: { 'Authorization': `Bearer ${process.env.PROVIDER_API_KEY}`, 'Content-Type': 'application/json' }, timeout: 60000 } ); res.json(adaptResponse(response.data)); } catch (err) { console.error('Proxy error:', err.message); res.status(500).json({ error: { message: err.message } }); } }); app.listen(process.env.PORT, () => { console.log(`Proxy running on port ${process.env.PORT}`); });src/adapter.js负责字段映射:
const models = require('../config/models.json'); function adaptRequest(body) { const mapping = models[body.model] || models.default; const adapted = { model: mapping.target, messages: body.messages, temperature: body.temperature ?? mapping.temperature, max_tokens: body.max_tokens || body.max_completion_tokens || mapping.maxTokens, stream: body.stream || false }; if (body.tools && Array.isArray(body.tools)) { adapted.tools = body.tools; adapted.tool_choice = body.tool_choice || 'auto'; } return adapted; } function adaptResponse(data) { if (!data.choices) return data; data.choices = data.choices.map(choice => ({ ...choice, finish_reason: normalizeFinishReason(choice.finish_reason) })); return data; } function normalizeFinishReason(reason) { const map = { 'eos': 'stop', 'length': 'length', 'stop': 'stop' }; return map[reason] || 'stop'; } module.exports = { adaptRequest, adaptResponse };src/stream.js处理流式返回:
const axios = require('axios'); async function handleStream(adapted, res) { res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); res.flushHeaders(); try { const response = await axios.post( `${process.env.PROVIDER_BASE_URL}/chat/completions`, { ...adapted, stream: true }, { headers: { 'Authorization': `Bearer ${process.env.PROVIDER_API_KEY}`, 'Content-Type': 'application/json' }, responseType: 'stream', timeout: 120000 } ); let buffer = ''; response.data.on('data', chunk => { buffer += chunk.toString(); const lines = buffer.split('\n'); buffer = lines.pop(); for (const line of lines) { if (!line.startsWith('data:')) continue; const payload = line.slice(5).trim(); if (payload === '[DONE]') { res.write('data: [DONE]\n\n'); continue; } try { const parsed = JSON.parse(payload); res.write(`data: ${JSON.stringify(parsed)}\n\n`); } catch (e) { // 忽略解析失败的行 } } }); response.data.on('end', () => res.end()); response.data.on('error', err => { console.error('Stream error:', err.message); res.end(); }); } catch (err) { console.error('Stream setup error:', err.message); res.status(500).end(); } } module.exports = { handleStream };3.4 Codex 客户端配置
代理跑起来之后,需要让 Codex 把请求发到本地。具体配置方式取决于 Codex 的版本和配置项,一般是在设置里找到 API Base URL 或自定义端点的地方,填上http://localhost:8787/v1,然后把 API Key 随便填一个非空值(因为真正的 Key 在代理层注入)。
如果 Codex 不支持自定义端点,那就需要看它是否支持环境变量覆盖,比如OPENAI_BASE_URL之类的。不同版本差异较大,建议先查一下对应版本的文档或配置文件。
提示:配置完成后,先用一个简单的对话测试,确认请求能正常返回。如果报 401,检查代理层的 Key 是否正确;如果报 404,检查路径是否匹配。
3.5 日志与成本监控
我在代理层加了一个简单的日志中间件,记录每次请求的模型、token 数(如果返回里有 usage)、耗时和状态码。这些数据写到本地文件,每天轮转一次。
function logRequest(model, usage, duration, status) { const line = JSON.stringify({ time: new Date().toISOString(), model, promptTokens: usage?.prompt_tokens || 0, completionTokens: usage?.completion_tokens || 0, duration, status }); fs.appendFileSync('logs/requests.log', line + '\n'); }有了这些日志,就能算出每天的实际消耗,跟预算做对比。如果发现某个模型消耗异常,可以及时调整路由策略。
4. 常见问题与排查技巧实录
4.1 流式返回中断或卡顿
这是最常见的问题。表现是 Codex 那边显示正在输入,但内容半天不出来,或者出来一半就停了。
排查思路:
- 先看代理层日志,确认请求是否成功发到了国内 API。
- 如果请求成功但流式没数据,检查
responseType: 'stream'是否设置正确。 - 如果数据有但客户端收不到,检查
res.flushHeaders()是否调用,以及是否有压缩中间件干扰。 - 如果流到一半断了,可能是国内 API 的超时设置太短,或者网络抖动,需要在代理层加超时和重试。
我遇到过一次是因为 Express 默认开启了etag,导致流式响应被缓冲。关掉之后就好了:
app.set('etag', false);4.2 模型不支持某些字段导致报错
国内模型对 OpenAI 接口的兼容程度参差不齐。有的模型不支持tools字段,传过去直接报 400。有的不支持response_format,有的对temperature的取值范围要求不同。
解决办法是在适配层做能力检测,用一个配置表标记每个模型支持哪些字段,不支持的字段直接丢弃或转换。
| 问题字段 | 常见报错 | 处理方式 |
|---|---|---|
| tools | 400 Bad Request | 降级为文本提示 |
| response_format | 400 | 丢弃该字段 |
| temperature > 1 | 400 | 截断到 1 |
| max_tokens 过大 | 400 | 按模型上限截断 |
| stream_options | 400 | 丢弃 |
4.3 上下文超长被截断
Codex 发过来的对话历史可能很长,国内模型上下文窗口小的话会报错。我的处理方式是在代理层做一个简单的 token 估算,超过阈值就丢弃最早的消息,保留 system prompt 和最近几轮对话。
估算 token 的粗略方法是:英文按 4 个字符 1 token,中文按 1.5 个字符 1 token。这个精度对于截断判断够用了。
注意:丢弃历史消息可能导致模型丢失上下文,影响回答质量。如果业务对上下文要求高,建议选上下文窗口大的模型,或者做摘要压缩。
4.4 成本突然飙升
有一次发现某天成本是平时的 5 倍,查日志发现是某个脚本在循环调用,而且每次都是长上下文。后来在代理层加了限流,每个 IP 每分钟最多 60 次请求,超过就返回 429。
限流用express-rate-limit就能搞定:
const rateLimit = require('express-rate-limit'); app.use(rateLimit({ windowMs: 60 * 1000, max: 60, message: { error: { message: 'Too many requests' } } }));4.5 常见问题速查表
| 现象 | 可能原因 | 快速排查 |
|---|---|---|
| 401 Unauthorized | API Key 错误或未注入 | 检查 .env 和代理日志 |
| 404 Not Found | 路径不匹配 | 确认 base_url 和端点路径 |
| 400 Bad Request | 字段不兼容 | 对比请求体和模型文档 |
| 流式无输出 | 缓冲或压缩干扰 | 关 etag,检查 flushHeaders |
| 响应极慢 | 模型负载高或网络差 | 换模型或加超时重试 |
| 成本异常 | 循环调用或长上下文 | 查日志,加限流 |
| 中文乱码 | 编码问题 | 确保 UTF-8 全程一致 |
4.6 几个实操心得
第一,不要一上来就追求完美适配。先把最基本的对话跑通,再逐步加流式、加工具调用、加路由。每加一个功能就测一遍,出问题容易定位。
第二,日志要详细但不要记录敏感内容。请求体和响应体里可能包含代码和业务信息,日志里只记 token 数和耗时就行,不要全文记录。
第三,准备一个降级方案。如果国内模型某天挂了或者限流,代理层可以自动切回官方接口,保证业务不中断。这个切换逻辑可以用一个简单的健康检查加熔断器实现。
第四,定期对比效果。低成本模型在代码补全这种场景够用,但在复杂推理上可能不如官方模型。建议每周抽几个典型 case 做对比,如果质量下降明显,及时调整路由策略。
第五,关注国内厂商的免费额度。很多厂商对新用户有免费 token 赠送,合理利用可以进一步降低成本。但要注意免费额度通常有有效期和并发限制,不适合生产环境依赖。
这套方案我跑了几个月,整体稳定,成本控制在预算的 20% 以内。中间踩过的坑基本都写在上面了,如果你也在做类似的事情,希望能帮你少走点弯路。后面我打算再加一个基于语义缓存的层,把重复的代码补全请求直接命中缓存,进一步压成本,到时候有新的体会再分享。