给 Claude Code 接一层视觉网关
记录 cc-vision-gateway 的由来:Claude Code 能发图片,但纯文本模型看不了图,于是中间加了一层本地网关。
这两天折腾了一个小项目:cc-vision-gateway。
起因其实很简单。我在 Claude Code 里贴了一张截图,想让它帮我看界面哪里不对。Claude Code 这边是能收图片的,但我后面接的是 DeepSeek 这种纯文本模型。结果问题就卡在这里:Claude Code 往外发的是 Anthropic Messages 请求,里面有 image block;纯文本模型那边并不知道这个东西该怎么处理。
一开始我的想法也挺直接:那是不是直接换一个多模态模型就好了?后来想了一下,不太对。
我真正想要的不是“所有问题都交给一个多模态模型”,而是继续用自己想用的代码模型,只是在有图片的时候,先有人帮它把图片讲清楚。
所以这个项目最后变成了一层很薄的本地代理:
Claude Code 发请求
-> 本地网关发现里面有图片
-> 调 Qwen-VL 看图
-> 把图片变成一段诊断文本
-> 再把请求发给 DeepSeek
如果没有图片,就尽量少折腾,只做必要的模型映射和转发。
不是为了做一个大路由器
这个边界我后来想了挺久。
现在 GitHub 上已经有一些 Claude Code proxy、universal router 之类的项目,功能会更大,provider 也更多。如果只是想做一个“什么模型都能接”的东西,那其实没必要再造一遍。
我这里的需求更窄一点:
- Claude Code 能继续用。
- DeepSeek 这类纯文本模型能接上。
- 有图片时不要直接炸。
- 普通文本请求不要变慢。
- Docker 镜像尽量小。
- 本地长期跑着不要太娇气。
所以第一版没有追求复杂配置,也没有做 Web UI。这个项目最核心的价值就是一件事:把图片请求降级成文本模型能理解的上下文。
模型名这个坑比想象中烦
真正写的时候,第一个烦人的地方不是图片,而是模型名。
Claude Code CLI 里你可能还能通过环境变量直接指定模型,但 Claude Code app/Desktop 这边更偏向 Claude 自家的命名体系。也就是说,你不能总指望客户端愿意直接发一个 deepseek-v4-pro 过来。
所以网关需要对外“装成”Claude 风格模型:
{
"claude-opus-4-7": "deepseek-v4-pro",
"claude-opus-4-6": "deepseek-v4-pro",
"claude-sonnet-4-6": "deepseek-v4-pro",
"claude-haiku-4-5": "deepseek-v4-flash"
}
客户端看到的是 claude-opus-4-7,真正转发给上游时再替换成 deepseek-v4-pro。
这里还有一个容易误会的点:Claude Code 界面里显示的上下文窗口,不一定等于上游真实模型的上下文窗口。比如 DeepSeek 可能支持更长上下文,但界面仍然按 Claude 别名的元数据展示。这一层显示并不完全可信,只能作为客户端自己的状态参考。
图片不能每次都扫全上下文
另一个坑是图片触发范围。
刚开始很容易想到:只要整个 messages 里有图片,那就走 Vision。后来发现这不适合作为默认策略。
Claude Code 的上下文会一直积累。如果历史里有一张旧截图,每次请求都重新拿去看图,就很浪费,而且还会把一次普通追问拖慢。更麻烦的是,视觉模型也不适合吃一大段 1M 上下文。它只需要知道这次用户贴的图和当前问题。
所以现在默认是:
IMAGE_SCAN_SCOPE=last_user
VISION_CONTEXT_SCOPE=last_user
只处理最后一条用户消息里的图片,也只把最后一条用户文本拿给视觉模型做上下文。
这个策略不完美,但比较符合 Claude Code 里“我刚贴了一张图,请你看这个”的使用方式。
Vision 失败时要不要直接报错
这个也纠结过。
如果是严格的代码修改任务,图片没解析出来就继续让文本模型猜,确实可能出问题。但实际用 Claude Code 的时候,如果 Qwen-VL 偶尔超时,整个会话直接失败也很烦。
第一版我最后选了 fallback:
VISION_FAILURE_MODE=fallback
也就是图片解析失败时,把失败信息作为文本注入给后面的模型,让它告诉用户“图片没看成,你可以补充描述或者重试”。如果你更在意严格性,也可以改成 error。
这个选择有点偏体验,不是绝对正确。后面可能会继续细分,比如代码修改默认严格,普通问答默认 fallback。
为什么用 Go
这类本地代理我不太想用 Node 或 Python 起一个服务长期挂着。不是不能做,而是运行时、依赖、镜像体积都会多一点。
Go 对这个场景刚好:
- 标准库 HTTP 够用。
- 单二进制好部署。
- scratch 镜像可以做得很小。
- 流式转发直接读一块写一块,然后 Flush。
- bbolt 做本地缓存也省事。
项目里没有上 Gin / Echo 之类的框架。不是因为框架不好,而是这里没必要。它就是一个协议适配层,依赖越少越安心。
实际踩到的小坑
DashScope endpoint 算一个。
如果你拿的是中国区 DashScope 的 key,就应该走:
https://dashscope.aliyuncs.com/compatible-mode/v1
一开始如果配到国际站 endpoint,表现可能就是鉴权失败或者请求卡很久。这个问题不复杂,但很容易浪费时间。
还有图片尺寸。测试时用过很小的 1x1 图片,结果 Qwen-VL 直接拒了。后来 smoke test 改成了正常尺寸的小图。真实用户截图当然不会这么极端,但测试用例会碰到。
再就是首 token 延迟。有图请求一定会比纯文本慢,因为在 DeepSeek 开始回答前,Qwen-VL 得先看完图。所以这里能做的不是消灭延迟,而是控制触发范围、做图片预处理、加缓存、超时后可控失败。
现在到什么程度了
目前 v0.1.0 已经发了:
https://github.com/ChenZengQing/cc-vision-gateway
现在能做的事情:
- 接 Claude Code 的
/v1/messages。 - 对外返回 Claude 风格模型别名。
- 把图片交给 Qwen-VL 解析。
- 把解析结果注入成文本 block。
- 转发给 DeepSeek Anthropic-compatible API。
- 支持 stream 响应逐块转发。
- 用 bbolt 缓存图片诊断结果。
- Docker Compose 本地跑起来。
快速启动大概是:
cp .env.example .env
# 填 TEXT_API_KEY 和 VISION_API_KEY
docker compose up -d --build
Claude Code 侧:
export ANTHROPIC_BASE_URL=http://127.0.0.1:8787
export ANTHROPIC_AUTH_TOKEN=local-anything
export ANTHROPIC_MODEL=claude-opus-4-7
claude
后面想补的东西
短期我不太想给它加复杂 Web UI。这个项目的价值就是小、稳、快,管理后台会带来认证、日志脱敏、前端构建和安全边界问题。
更应该先补的是这些:
- OpenAI-compatible 文本模型转换。
- tool call / tool result 的兼容处理。
- 更细的字段清理。
- 更好的日志和 debug 模式。
- 更多视觉 provider。
等这些基础能力稳定之后,再考虑一个可选的本地 dashboard 也不迟。
总之,cc-vision-gateway 不是一个宏大的东西。它只是把一个真实使用里的小断点补上:Claude Code 能发图,纯文本模型看不了图,那就在中间找一个视觉模型先把图说明白。