最近我做了一个小工具,叫 cc-router。
它解决的问题其实很具体:我想继续用 Claude Code 的体验,但后端模型和服务我想自己选。
比如你已经有:
- OpenAI 兼容网关
- OpenRouter
- vLLM
- 公司内部代理
- 或者你自己的模型服务
这时候你会发现一件很现实的事:不是模型不能用,而是接口对不上。
Claude Code 这一侧期待的是 Anthropic 风格接口,但很多现成服务提供的是 OpenAI-compatible 接口。
于是我做了一个本地小 router,专门干这个事:
前面接 Claude Code,后面接 OpenAI 兼容接口,中间负责把请求和响应翻译一下。
这篇文章不讲套话,我就按真实做项目的思路,聊聊我是怎么想、怎么拆、怎么避坑的。
先说人话:cc-router 到底是干嘛的?
一句话解释:它是一个本地协议转换代理。
Claude Code 发请求给它,它再把请求转成 OpenAI Chat Completions 的格式发到上游;等上游回来了,它再翻译回 Claude Code 能认的格式。

所以它做的不是简单“转发”,而是:
- 请求协议翻译
- 响应结构翻译
- 流式 SSE 事件重组
- 模型名映射
- 错误格式统一
你可以把它理解成一个“翻译官”。前面说 Anthropic 这套话,后面说 OpenAI-compatible 那套话,cc-router 在中间负责把两边都哄明白。
我为什么会想做这个东西?
原因其实不复杂。
我很喜欢 Claude Code 这种工作方式。它不是单纯聊天,而是真的能进工程流:
- 读代码
- 改文件
- 跑命令
- 帮你分析问题
- 跟着上下文继续往下做
这种体验一旦顺手了,真的不太想换。
但问题是,现实世界里的模型后端并不总是那么统一。你可能会遇到这些情况:
- 公司统一走自己的 AI 网关
- 你想切不同 provider 比效果和成本
- 你已经有一套 OpenAI-compatible 接口
- 你希望本地模型或私有部署也能接进来
- 你不想把整个开发链路绑死在某一家服务上
这时候就很容易卡住。因为你会发现:工具体验和模型提供方,很多时候是强绑定的。
而我做 cc-router,本质上就是想把这件事拆开。
工具还是那个工具,后端归我自己决定。
这件事对工程师来说,其实挺有吸引力的。
真做之后我发现,这不是“代理”,是“协议适配器”
一开始很多人会觉得,这类工具不就是一个反向代理吗?
真不是。
普通代理解决的是:
请求从 A 转发到 B。
但 cc-router 要解决的是:
A 和 B 说的根本不是一种协议,你得在中间翻译。
比如看起来都是“聊天接口”,但细节差异一点也不少:
- 请求 body 结构不同
system 的表达方式不同
messages.content 组织方式不同
- 流式输出事件格式不同
- token usage 字段不同
- stop reason 语义也不同
所以这个项目最核心的地方,从来都不是 HTTP 转发。而是:
协议映射。
这个认识非常重要。因为一旦你把它当“协议翻译器”来设计,很多决策都会自然清晰:
- 类型要不要单独建
- 流式是不是能透传
- 错误要不要统一格式
- 模型映射该不该做成配置
- 模块怎么拆才不容易失控
我一开始就提醒自己:别做成“大而全”的 AI 网关
这种项目特别容易自我膨胀。
最常见的心路历程是这样的:
“既然都做 router 了,那顺手把 tool use 做了吧。”
“多模态以后肯定也要支持,不如现在先设计进去。”
“JSON mode 和 structured outputs 一起做是不是更完整?”
“provider-specific 兼容也先预留吧。”
结果就是:第一版还没跑通,你已经先把自己设计崩了。
所以我这次刻意控制范围,只做最小闭环。先完成下面这件事:
- 本地起服务
- 提供健康检查
- 提供
/v1/messages
- 接收 Anthropic 风格请求
- 转成 OpenAI 请求
- 请求上游
- 把结果转成 Anthropic 风格返回
- 支持非流式
- 再支持流式
你别小看这九步。只要这条链路跑通,cc-router 就已经是个能工作的东西了。
很多项目最后做不成,不是技术上做不到,而是第一版想做太多,把问题空间一下子放大了。
所以如果你也想自己做一个类似的工具,我非常建议:
先做最短可用链路,不要一上来做全量兼容。
项目结构怎么拆,我的原则就一句话:按职责,不按感觉
这种中间层项目,如果目录乱了,后面真的会很痛苦。
我比较喜欢的结构大概是这样:
src/
├── main.rs
├── config.rs
├── server.rs
├── error.rs
├── routes/
│ ├── mod.rs
│ ├── health.rs
│ └── messages.rs
├── upstream/
│ ├── mod.rs
│ └── openai_compat.rs
└── types/
├── mod.rs
├── anthropic.rs
└── openai.rs
这个结构不花哨,但够用,而且清楚。
config.rs
就专门管配置。这种工具很多问题都不是业务 bug,而是配置错了。
所以像这些东西,都适合集中处理:
- 监听地址
- 上游 base URL
- 上游 API key
- 默认模型
- 模型映射
这层单独拆出来,后面排查问题会轻松很多。
routes/messages.rs
负责接 Anthropic 风格请求。
注意,这层最好不要写成一个巨长 handler,把“接请求、验参数、调上游、拼 SSE、处理错误”全堆在一起。
那样第一版写起来也许快,后面看就会想骂自己。
upstream/openai_compat.rs
这一层就是“上游适配器”。专门负责:
- 生成 OpenAI-compatible 请求
- 调上游接口
- 收上游响应
- 处理流式和非流式两条路径
以后你想再接别的后端,基本也是从这里扩。
types/anthropic.rs 和 types/openai.rs
这层我很建议认真建。
很多人做这种项目,图快,全程 serde_json::Value 起飞。短期很爽,长期很痛苦。
协议转换最怕的是“我以为这个字段一定有”。有了明确类型,很多问题会更早暴露出来,也更容易做稳定映射。
我的经验是:
这类项目里,类型不是累赘,是安全带。
真正开干时,最重要的不是转发,而是模型映射
如果只让我选一个“必须从第一天就考虑清楚”的点,我会选 模型映射。
因为 Claude Code 发来的模型名,通常是这种:
claude-4-6-sonnet
claude-4-6-opus
但你后端能识别的,往往是别的名字:
gpt-5.4
kimi-k2.5
glm-5
qwen3.5-plus
你不做映射,中间层根本就没法用。
最省心的方式,就是把映射做成配置,例如:
export CC_ROUTER_MODEL_MAP='{
"claude-4-6-sonnet": "gpt-5.4",
"claude-4-6-opus": "gpt-5.4"
}'
这样做的好处很直接:
- 换上游不用改代码
- 不同环境可以独立配置
- 想临时切模型也方便
- 多个 Anthropic 模型名甚至可以映射到同一个后端模型
为什么我不建议写死?因为这种工具存在的意义,本来就是“解耦”。如果模型映射还写死在代码里,那你每次切 provider 都要重新改、重新编译,体验就很别扭。
一句话:
部署环境会变的东西,尽量不要写死。
非流式不难,真正会让你掉头发的是流式 SSE
如果只做非流式,其实整体难度还比较正常。
流程基本就是:
- 收到 Anthropic 请求
- 转成 OpenAI 请求
- 打上游
- 收完整 JSON
- 再转回 Anthropic 响应
这条路径虽然也要处理字段差异,但至少它是“完整响应 → 完整响应”,心智负担没那么高。
真正麻烦的地方,是 stream: true。
因为这时候上游不是一次性给你结果,而是源源不断给你 delta。
而 Anthropic 侧期待的 SSE 事件,大概是这种节奏:
message_start
content_block_start
content_block_delta
content_block_stop
message_delta
message_stop
问题在于,OpenAI-compatible 上游通常不是这套表达方式。所以你不能简单把上游流原样转发回来。你得在中间自己重组语义。
也就是说,你不是在“转字节流”,你是在“模拟一条 Anthropic 风格消息是怎么一点点生成出来的”。
这个事情最容易低估。因为它看起来只是 SSE,实际上你在处理中间状态:
- 什么时候发 message_start
- 什么时候开第一个 content block
- 每次 delta 怎么包
- 什么时候结束 block
- stop reason 什么时候补
- usage 在哪个阶段发更合适
如果这些节奏没理顺,就算字最后都出来了,客户端体验也会怪。
所以我的建议很明确:
先把非流式走通,再做流式。
先支持纯文本 delta,再考虑复杂内容块。
别在 streaming 还没搞定的时候,就开始设计一个“未来支持万物”的超级事件状态机。那很容易把自己做进去。
真正决定工具是否靠谱的,不是 happy path,而是 error path
这个我感触特别深。
很多项目成功路径都写得挺顺,一旦出错,就开始甩原始异常、吐 provider 自己的报错、返回格式前后不一致。
技术上也不是不能跑,但用起来很差。
因为如果你对外提供的是 Anthropic-compatible 接口,那成功时像 Anthropic,失败时也应该尽量像 Anthropic。
比如错误返回至少应该统一成类似:
{
"error": {
"type": "api_error",
"message": "upstream request failed: ..."
}
}
为什么这件事很重要?因为中间层不是单纯通一下就完了。它应该帮上层“收敛复杂度”。
如果上游报错长什么样、语义是什么、状态码怎么解释,全都原样甩给客户端,那这个 router 的价值就打折了。
我一直觉得,一个中间层项目是不是像样,不看它最顺的那条路,看它报错时有没有人话。
成功大家都会,失败时还能不能让人快速定位问题,才看得出有没有认真做。
配置体验别搞太复杂,真的会劝退人
这类工具很容易越做配置越多。最后变成作者看着很灵活,用户看着很窒息。
我自己的偏好是:配置项够用就行,不要追求“看起来特别全”。
最基础的几项,其实已经够覆盖主要场景:
export CC_ROUTER_UPSTREAM_BASE_URL="https://api.openai.com"
export CC_ROUTER_UPSTREAM_API_KEY="sk-..."
export CC_ROUTER_UPSTREAM_MODEL="gpt-5.4"
export CC_ROUTER_LISTEN_ADDR="127.0.0.1:8787"
export CC_ROUTER_MODEL_MAP='{"claude-4-6-sonnet":"gpt-5.4"}'
然后 Claude Code 只要指向本地:
export ANTHROPIC_BASE_URL="http://127.0.0.1:8787"
claude
这时候整条链路一眼就懂:
- Claude Code 照旧工作
- 本地
cc-router 接请求
cc-router 转给你指定的 OpenAI-compatible 上游
用户根本不需要理解你内部怎么组织模块,也不需要学一套新的复杂抽象。
一个好用的基础工具,真的不是靠配置项数量取胜的。而是:
第一次上手就能跑起来。
我会优先盯住这三个接口,不会乱扩范围
如果从零再来一遍,我依然会先盯住这三个点:
1. GET /health
这个接口虽然简单,但非常值。它的价值不在业务,而在排障。
很多时候问题根本不是协议转换,而是服务没起来、端口没监听、环境变量没读到。有了 health check,你能更快判断问题落在哪一层。
2. POST /v1/messages 非流式
这是最短主链路。只要这条通了,说明整体架子基本立住了。
Anthropic 请求 → 转换 → 上游调用 → 响应映射 → 返回
这条链一旦走通,你心里会踏实很多。
3. POST /v1/messages 流式
这是使用体验的关键。没有流式,也不是不能用。但只要你在命令行场景里用过一次,就会知道流式有多重要。
尤其是 Claude Code 这种交互工具,用户对“有没有持续往外出字”是非常敏感的。
做这种东西,有几个坑真的很真实
坑一:别以为 content 永远只是字符串
这是最容易想当然的地方。Anthropic 风格的 content 更像内容块数组;OpenAI-compatible 这边,不同 provider 兼容得也不完全一样。
所以哪怕第一版只支持 text-only,也最好在建模时承认一个事实:
content 本质上是结构化内容,不是永远简单 string。
这不是过度设计,而是给后面留活路。
坑二:stop reason 不能直接抄
上游可能返回:
stop
length
content_filter
tool_calls
但 Anthropic 侧理解的是另一套语义,比如:
这东西最烦的地方在于:错了不一定直接报错,但行为会变怪。
这种“表面上能跑,实际上语义不对”的问题,比直接 500 还难受。
坑三:日志一定要是为了排障服务
我很建议从第一天就把日志打清楚,至少要能看到:
- 服务监听地址
- 上游 base URL
- 当前请求是否 stream
- 模型映射结果
- 上游状态码
- 错误摘要
因为你迟早会遇到这种情况:“看起来都对,但就是没回。” 这时候日志如果只有一句 request failed,基本等于白打。
为什么我最后会用 Rust 来写?
说实话,这种工具不用 Rust 也能做。Go 可以,Node.js 也可以。如果只是验证 idea,脚本语言起手完全合理。
我最后用 Rust,不是为了“性能吹牛”,而是因为它确实适合这种场景:
第一,类型系统对协议转换很有帮助
这种项目的核心,不是业务逻辑有多复杂,而是协议边界很多。Rust 会逼你把字段、状态和边界条件想清楚。这对中间层项目是好事。
第二,做异步和流式处理更安心
SSE、异步 HTTP、状态维护,这些东西写起来虽然没有脚本语言那么随手,但边界会更清楚。
第三,最后可以落成单二进制
本地工具做成单 binary,体验真的很好。拿来就跑,不折腾运行时环境,这点在工具型项目里很加分。
当然,语言只是实现手段。别把“选型”变成拖延的理由。
真正重要的还是那句老话:
先把最短可用路径做出来。
如果让我再做一次,我会坚持这几个原则
做完回头看,我觉得有几条原则特别值得保留。
1. 第一版先只做 text-only
不要上来就碰多模态、tool use、structured outputs。先把聊天主链路做稳,这已经够难了。
2. 内部先有统一表示,再做双向转换
不要在 handler 里直接把 Anthropic 请求手搓成 OpenAI 请求。最好先转成一套内部统一结构,再分别适配上下游。这样以后你想接第二种 provider,不至于全拆。
3. 对外兼容,对内简单
外部协议可以复杂,内部流转尽量保持清晰。否则最后你不是做了一个 router,而是做了一团谁都不想碰的胶水层。
4. 别为“未来可能支持”提前设计太多
这是特别容易掉进去的坑。很多项目不是做不出来,而是提前抽象太多,把自己先绕晕了。
一个最小可用的 cc-router,我觉得做到这些就够发第一版了
如果只是出第一版,我觉得做到下面这些就已经很不错:
- 支持监听地址配置
- 支持上游 URL / API key / 默认模型配置
- 支持模型映射
- 提供
GET /health
- 提供
POST /v1/messages
- 支持非流式文本请求
- 支持流式文本请求
- 支持基础错误映射
- 支持基础 usage 映射
- 有可排障的结构化日志
而下面这些,我会明确放到后续版本:
- tool use / function calling
- 多模态
- JSON mode
- structured outputs
- 更复杂的内容块兼容
- provider-specific 深度适配
不是不能做,而是没必要在第一版一起做。基础设施项目最忌讳的,就是“看起来很全,但核心链路并不稳”。
最后我越来越觉得,这类工具真正有价值的地方,是“解绑”
表面上看,cc-router 只是一个小代理。
但从工程角度看,它真正做的事情其实是:
把工具体验和模型后端拆开。
一旦中间有了这样一层 router,你能做的事情就一下子多了很多:
- 前端继续用熟悉的 Claude Code
- 后端模型可以自由换
- 可以走统一网关,也可以走本地推理
- 可以加自己的鉴权、审计、路由策略
- 可以把“客户端工作流”和“模型供应商”解耦
我觉得这才是最有意思的地方。不是“我又造了一个轮子”,而是:原来绑死的一条链路,被拆开了。
这种能力,对工程师来说通常比多一个 feature 更值钱。
结尾
如果你也想自己做一个 cc-router,我的建议就一句话:
别一上来把它当成一个大而全的 AI 网关。先把它当成一个只解决眼前问题的协议翻译器。
先把消息链路跑通,先把流式做稳,先把错误处理做好,先让 Claude Code 真能接上你自己的后端。
剩下的能力,完全可以慢慢补。因为大多数时候,真正难的不是写代码,而是你能不能忍住,不在第一版里把自己做复杂。
如果你对这类解决实际工程问题的协议转换项目感兴趣,或者有自己独特的实现思路,欢迎来 云栈社区 的 开源实战 板块与大家一起交流探讨,那里汇聚了许多喜欢动手实践的开发者。