1. 为什么要在命令行 AI 工具里接自建网关
把 Codex CLI 或者 Claude Code 这类命令行 AI 编程助手接到自建网关,本质上解决的是一个"统一入口"的问题。默认情况下,这些工具会直连官方服务,但实际团队使用时会遇到几个很现实的痛点:多个项目要分别管理密钥、不同模型要切换不同供应商、调用量需要统一统计、内网环境要走自己的出口。自建网关就是在这中间加一层转发,把请求统一收口,再按规则分发到后端。
我最初动这个念头,是因为团队里几个人各自配了不同的 key,月底对账一团乱。后来搭了一个自建网关,所有 CLI 工具都指向同一个base_url,密钥只在网关侧配置一次,客户端不再持有真实凭证。这个改动看起来简单,但真正动手时踩的坑比想象中多——base_url到底该填到哪一层、协议是走 OpenAI 兼容格式还是 Anthropic 原生格式、流式响应为什么断流、工具调用为什么报错,每一个都能卡你半天。
这篇文章适合两类人:一是已经会用 Codex CLI 或 Claude Code,想把它接到自己网关的开发者;二是正在搭 AI 网关,需要知道客户端到底会发什么请求、期望什么响应的后端同学。我会把base_url的拼接规则、两套协议的差异、流式与工具调用的坑,以及排查方法完整讲一遍。文中涉及的具体域名、密钥、项目名都用代称,你按自己的环境替换即可。
先说结论性的认知:Codex CLI 和 Claude Code 虽然都是命令行 AI 工具,但它们默认走的协议不一样。Codex CLI 更偏向 OpenAI 的 Chat Completions / Responses 风格,Claude Code 走的是 Anthropic 的 Messages API。你的自建网关如果只兼容一种协议,另一个工具就会出问题。这是所有坑的根源,理解了这一点,后面的问题基本都能推导出来。
2. base_url 到底该填到哪一层
2.1 路径拼接规则是第一个大坑
很多人第一次配base_url时,会下意识填成https://gateway.example.com/v1/chat/completions这种完整路径,结果请求直接 404。原因在于:客户端会在你填的base_url后面自动拼接它认为的路径。你填得越"完整",重复拼接就越严重。
以 OpenAI 兼容协议为例,客户端内部通常会这样拼:
最终URL = base_url + "/chat/completions"所以正确的base_url应该填到/v1这一层:
# 正确 export OPENAI_BASE_URL="https://gateway.example.com/v1" # 错误:会变成 .../v1/chat/completions/chat/completions export OPENAI_BASE_URL="https://gateway.example.com/v1/chat/completions"而 Anthropic 协议这边,Claude Code 期望的路径是/v1/messages,所以它的base_url同样填到/v1:
export ANTHROPIC_BASE_URL="https://gateway.example.com"注意这里有个微妙差异:有些版本的 Claude Code 会在base_url后自动补/v1/messages,有些则要求你把/v1也带上。我实测下来,最稳妥的做法是先看工具版本对应的文档,然后用一个最简单的curl去验证网关到底收到了什么路径。
2.2 用日志反推真实请求路径
与其猜,不如让网关把收到的请求路径打出来。我在网关侧加了一行访问日志,记录method + path + 关键 header,然后跑一次 CLI 工具,立刻就能看到真相:
# 网关侧日志示例(伪代码) [2024-xx-xx] POST /v1/chat/completions host=gateway.example.com [2024-xx-xx] POST /v1/messages host=gateway.example.com看到这个日志,你就知道客户端实际请求的是哪个路径,base_url该填到哪一层也就一目了然了。这个"先看日志再改配置"的习惯,帮我省下了大量反复试错的时间。
提示:如果你用的是反向代理(如 Nginx),记得把
proxy_pass的路径重写规则也一起检查,否则网关内部转发时可能又拼错一次。
2.3 环境变量与配置文件优先级
Codex CLI 和 Claude Code 都支持环境变量和配置文件两种方式。环境变量优先级通常更高,但不同版本行为不一致。我的建议是:只保留一种配置来源,避免环境变量和配置文件打架。
常见的环境变量名有:
| 工具 | 常用环境变量 | 说明 |
|---|---|---|
| Codex CLI | OPENAI_BASE_URL/OPENAI_API_KEY | OpenAI 兼容协议 |
| Claude Code | ANTHROPIC_BASE_URL/ANTHROPIC_API_KEY | Anthropic 协议 |
| 通用 | HTTPS_PROXY | 走代理时用,注意别和 base_url 冲突 |
配置文件一般在用户目录下,比如~/.codex/config或~/.claude/settings.json之类。改完记得重启终端,环境变量不会自动刷新。
3. 两套协议的核心差异与适配
3.1 OpenAI 兼容格式 vs Anthropic Messages
这是整个接入过程中最需要理解的部分。两套协议的请求体和响应体结构差异很大,网关必须做转换,或者分别提供两个端点。
先看请求体的核心字段对比:
| 维度 | OpenAI Chat Completions | Anthropic Messages |
|---|---|---|
| 系统提示 | messages里 role=system | 顶层system字段 |
| 消息角色 | system/user/assistant/tool | user/assistant |
| 工具调用 | tools+tool_calls | tools+tool_useblock |
| 最大长度 | max_tokens | max_tokens(必填) |
| 流式 | stream: true | stream: true |
| 结束原因 | finish_reason | stop_reason |
最典型的差异是系统提示的位置。OpenAI 把它当成一条 role=system 的消息塞进messages数组,Anthropic 则要求单独放在顶层system字段。如果你的网关只做简单透传,Claude Code 发来的请求里system字段就会被丢掉,模型行为立刻变得奇怪。
3.2 工具调用格式的映射
工具调用是第二个重灾区。OpenAI 的响应里,工具调用长这样:
{ "choices": [{ "message": { "tool_calls": [{ "id": "call_abc", "type": "function", "function": {"name": "read_file", "arguments": "{\"path\":\"a.txt\"}"} }] } }] }Anthropic 的响应则是 content block 数组:
{ "content": [ {"type": "text", "text": "..."}, {"type": "tool_use", "id": "toolu_abc", "name": "read_file", "input": {"path": "a.txt"}} ], "stop_reason": "tool_use" }注意两个关键点:一是arguments是字符串,input是对象;二是 Anthropic 用stop_reason: tool_use表示要调工具,OpenAI 用finish_reason: tool_calls。网关做协议转换时,这两处必须正确映射,否则客户端要么解析失败,要么根本不知道要执行工具。
我踩过的一个坑是:网关把arguments字符串原样塞进了input,结果客户端拿到的是一个字符串而不是对象,工具参数解析直接报错。正确做法是JSON.parse(arguments)之后再放进input。
3.3 流式响应的 SSE 事件格式
流式响应两边都用 SSE(Server-Sent Events),但事件结构完全不同。
OpenAI 的流式 chunk:
data: {"choices":[{"delta":{"content":"你"}}]} data: {"choices":[{"delta":{"content":"好"}}]} data: [DONE]Anthropic 的流式事件:
event: content_block_delta data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"你"}} event: message_stop data: {"type":"message_stop"}差异点在于:Anthropic 有明确的事件类型(event 字段),OpenAI 只有 data。而且 Anthropic 的流式有完整的生命周期事件:message_start、content_block_start、content_block_delta、content_block_stop、message_delta、message_stop。网关如果只转发data而丢掉event行,Claude Code 会解析不出内容。
注意:做协议转换时,流式部分是最容易出 bug 的。建议先用非流式跑通,再开流式,逐段对比原始响应和转换后响应。
4. 实操:从零搭一个能同时接两个工具的网关
4.1 网关的最小可用结构
我用一个轻量 HTTP 服务做演示,核心逻辑就三件事:识别请求路径、转换协议、转发到后端。下面是一个简化的伪代码结构:
# 伪代码,仅示意逻辑 def handle_request(req): path = req.path if path.endswith("/chat/completions"): # OpenAI 协议入口 body = convert_openai_to_backend(req.json) resp = forward(body) return convert_backend_to_openai(resp) elif path.endswith("/messages"): # Anthropic 协议入口 body = convert_anthropic_to_backend(req.json) resp = forward(body) return convert_backend_to_anthropic(resp) else: return 404关键设计点是:两个入口分别做各自的协议转换,后端只认一种内部格式。这样后端供应商换起来也方便,不用改客户端。
4.2 参数计算:max_tokens 与上下文窗口
接入时经常遇到max_tokens相关的报错。原因是不同模型对max_tokens的上限要求不同,而客户端默认值可能超出后端限制。
我的处理方式是:在网关侧做一次 clamp(截断)。比如后端模型最大输出 8192,客户端传了 16384,网关就把它压到 8192,而不是直接报错。计算逻辑很简单:
MAX_OUTPUT = 8192 def clamp_max_tokens(requested): if requested is None: return MAX_OUTPUT return min(requested, MAX_OUTPUT)另外要注意上下文窗口。如果客户端把整个对话历史都发过来,加上系统提示可能超过模型上下文,网关需要做截断或摘要。我一般保留最近 N 轮对话,N 根据模型窗口和平均消息长度估算,比如窗口 128k、平均每条消息 500 token,那保留 200 条左右是安全的。
4.3 密钥与鉴权头的处理
客户端发来的鉴权头,OpenAI 用Authorization: Bearer xxx,Anthropic 用x-api-key: xxx。网关需要统一识别,然后替换成后端真实密钥。
def extract_key(headers): if "authorization" in headers: return headers["authorization"].replace("Bearer ", "") if "x-api-key" in headers: return headers["x-api-key"] return None这里有个安全细节:网关侧要校验客户端传来的 key 是否合法,不能无脑转发。我一般维护一个允许列表,只有列表内的 key 才放行,否则直接 401。这样即使客户端 key 泄露,也能快速吊销。
4.4 完整请求链路验证
搭好之后,用curl分别验证两个协议入口:
# 验证 OpenAI 兼容入口 curl -s https://gateway.example.com/v1/chat/completions \ -H "Authorization: Bearer test-key" \ -H "Content-Type: application/json" \ -d '{"model":"gpt-x","messages":[{"role":"user","content":"hi"}]}' # 验证 Anthropic 入口 curl -s https://gateway.example.com/v1/messages \ -H "x-api-key: test-key" \ -H "anthropic-version: 2023-06-01" \ -H "Content-Type: application/json" \ -d '{"model":"claude-x","max_tokens":100,"messages":[{"role":"user","content":"hi"}]}'两个都返回正常内容后,再把 CLI 工具的base_url指过来。这个顺序很重要:先验证网关本身,再验证客户端,否则出问题你分不清是哪一层的锅。
5. 常见问题与排查速查表
5.1 报错速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 404 Not Found | base_url 路径拼错 | 看网关访问日志的真实 path |
| 401 Unauthorized | 鉴权头格式不对 | 检查 Bearer / x-api-key |
| 400 max_tokens 超限 | 客户端默认值过大 | 网关侧 clamp |
| 流式无输出 | SSE event 行丢失 | 检查是否转发 event 字段 |
| 工具调用报错 | arguments 未解析成对象 | 检查 JSON.parse |
| 响应截断 | 上下文超窗口 | 做历史截断或摘要 |
| 系统提示失效 | system 字段被丢弃 | 检查协议转换逻辑 |
5.2 流式断流的排查思路
流式断流是最烦的问题,表现是输出到一半突然停住。我的排查顺序是:
- 先看网关日志,确认后端是否正常返回了完整流。
- 抓包对比,看客户端收到的 SSE 事件是否完整。
- 检查缓冲,有些反向代理会缓冲响应,导致流式变成一次性返回。Nginx 需要关掉
proxy_buffering。 - 检查超时,长响应可能触发网关或代理的超时设置。
# Nginx 关闭缓冲,保证流式 proxy_buffering off; proxy_cache off; proxy_read_timeout 300s;5.3 独家避坑经验
几个文档里不会写、但实际很关键的技巧:
- 版本锁定:CLI 工具更新频繁,协议细节可能变。生产环境建议锁定版本,升级前先在测试环境验证。
- 保留原始请求日志:出问题时,能回放原始请求是最快的定位方式。注意脱敏,别把密钥打进日志。
- 双协议端点分开:不要试图用一个端点同时兼容两种协议,靠路径区分最清晰。
- 健康检查:网关加一个
/health端点,CLI 工具启动时可以先探活,避免配置错误时一脸懵。 - 超时分层设置:连接超时短一点(如 5s),读取超时长一点(如 300s),适配长响应。
提示:如果你在团队内推广这套方案,建议写一份"配置模板 + 排查手册",把 base_url 填法、环境变量、常见报错都列清楚,能省下大量重复答疑。
6. 协议转换中的几个细节陷阱
6.1 stop_reason 与 finish_reason 的映射
前面提过这两个字段,但实际映射时还有细节。OpenAI 的finish_reason有stop、length、tool_calls、content_filter等值,Anthropic 的stop_reason有end_turn、max_tokens、tool_use、stop_sequence。映射关系大致是:
| OpenAI | Anthropic |
|---|---|
| stop | end_turn |
| length | max_tokens |
| tool_calls | tool_use |
| content_filter | (无直接对应,需自定义) |
如果映射错了,客户端可能误判响应是否结束,导致对话提前终止或死循环。
6.2 多模态内容的处理
如果客户端发送图片等多模态内容,两套协议的结构差异更大。OpenAI 用content数组里的image_url,Anthropic 用imageblock 加 base64。网关做转换时要注意:图片数据体积大,转换时别做无谓的拷贝,直接透传或流式处理。
6.3 错误响应的格式统一
后端出错时,两套协议的错误格式也不同。OpenAI 是:
{"error": {"message": "...", "type": "..."}}Anthropic 是:
{"type": "error", "error": {"type": "...", "message": "..."}}网关需要把后端错误转换成客户端期望的格式,否则客户端可能显示一个无意义的解析错误,掩盖了真实问题。
7. 性能与稳定性的一些实测体会
7.1 连接复用能省不少时间
CLI 工具会频繁发请求,如果网关每次都新建到后端的连接,握手开销累积起来很可观。我在网关侧启用了 HTTP 连接池,实测下来整体延迟能降 20% 到 30%。配置上就是复用同一个 client 实例,别每次请求都 new 一个。
7.2 重试要谨慎
网络抖动时重试是必要的,但流式请求重试要特别小心,因为可能已经输出了一部分内容,重试会导致重复输出。我的做法是:非流式请求可以自动重试 2 到 3 次,流式请求只在连接建立阶段重试,一旦开始输出就不再重试。
7.3 限流与配额
团队使用时,网关侧加一层限流很有必要。可以按 key 或按用户维度限制每分钟请求数,避免某个人跑批量任务把配额打满。实现上用一个简单的滑动窗口计数器就够,不必上重型方案。
8. 写在最后的一点个人经验
这套东西我从第一次配到跑顺,前后折腾了差不多一周,大部分时间都花在"猜客户端到底发了什么"上。后来养成习惯:任何接入问题,第一步永远是看网关收到的原始请求。日志一打,路径、header、body 全清楚了,剩下的就是对照协议文档做映射。
另外一个深刻体会是,别迷信"兼容"两个字。很多网关号称兼容 OpenAI 协议,但实际只兼容了最基础的对话,工具调用、流式、多模态这些高级特性一测就露馅。选网关或者自己写的时候,一定要用真实的 CLI 工具去跑一遍完整流程,而不是只测一个curl就完事。
最后分享一个小技巧:如果你同时用 Codex CLI 和 Claude Code,可以在网关侧给它们分配不同的路径前缀,比如/openai/*和/anthropic/*,这样日志和限流都能分开统计,排查问题时一眼就能看出是哪个工具在报错。这个改动很小,但后期维护省心很多。