折腾 6 版之后,Vision Skill 路由终于能用了
“vision routing” 功能从提出到修复的完整过程。
相关项目: claude-code-vision-skill
从一个 issue 说起
事情始于 issue #4。提出者的场景很具体:这个 skill 原本是为纯文本模型(比如 DeepSeek)外挂视觉能力的——截图交给外部视觉模型(豆包/Qwen/GPT-4o)分析,再把文字描述喂回主模型。但用户会通过 CC Switch 之类的代理,在纯文本模型和原生多模态模型之间来回切换。问题是:项目当时的机制是把一段”必须用截图+视觉模型分析”的强制规则合并进 ~/.claude/CLAUDE.md,这段规则合并进去之后就是无条件生效的——不管背后到底是不是原生多模态模型,主模型都会被迫调用外部视觉工具,造成重复分析、多余延迟和成本。
Issue 里已经预判了一个关键陷阱:不要指望让模型自己判断”我有没有原生视觉能力”,因为真实的模型路由和代理能力,模型自己是看不到的。这句话后来在整个设计过程里反复被验证、反复被违反、又反复被纠正。
六次推翻
如果只看最终合并的代码,这个功能看起来是一次性设计好的。但实际过程是连续六次被推翻重来,每一次推翻都对应一个真实的架构漏洞,而不是风格偏好。
第一版:环境变量 + install.py 开关。 最初的方案是 VISION_ROUTING=external/native/auto 三态开关,配合 install.py 加 --enable-vision-rules/--disable-vision-rules 两个 CLI flag,复用已有的 marker 标记块增删逻辑。auto 模式被否决——Claude Code 是按 Anthropic API 协议写的客户端,代理(CC Switch)会把请求伪装成 Anthropic API 格式转发,这层伪装对 Claude Code 完全不可见。模型没有任何可靠途径确认自己实际连的是谁,不管是读环境变量、自我认知,还是拿一张图片去”测试”自己看不看得懂,都不可靠。
第二版被推翻: install.py 方案本身的问题。 用户安装 skill 后,通过 CC Switch 切换厂商,就不会再使用 install 了。install.py 是一次性安装脚本,CC Switch 切换厂商是运行时的高频动作,把开关做成需要重新运行安装脚本的形式,从使用习惯上是错的。
第三版:把判断挪进 vision.py 运行时。 改成 vision.py 的 main() 里读 VISION_ROUTING 环境变量,不碰 install.py。这个方案又被推翻,因为 vision.py 仍然建立在 skill 被激活的前提下,我们要解决的是,在非纯文本模型下,不会激活 skill。这是最关键的一次纠偏:路由(该用哪个 provider)和激活(模型会不会决定去调用这个工具)是两个不同阶段,vision.py 内部的检查只在工具已经被调用之后才生效,而”要不要调用”这个决定,在那之前就已经由 CLAUDE.md 的无条件强制指令做出了。等 vision.py 判断”其实不用做”,激活早就发生了,只是变成一次白跑的空调用。
第四版:CLAUDE.md 本身变成条件判断文本。 于是把判断逻辑往前挪,写进 CLAUDE.md 的指令文本里(“先检查 VISION_ROUTING,若 native 则跳过”)。这一步引出了一个问题:VISION_ROUTING 会自动修改吗?答案是不会,之前设想的”它会跟着 CC Switch 一起被带上”只是没验证过的假设。查了 CC Switch 的实际机制后确认它不提供这种自定义变量透传的保证,这个坑被记录下来,而不是继续往前堆设计。
第五版:黑名单思路 + vision.py --check-routing。 与其维护一直膨胀的多模态白名单,不如维护黑名单,名单小得多,并且关键是能拿到 ANTHROPIC_MODEL 环境变量做匹配。这个思路本身是对的,但藏着一个陷阱:如果黑名单被用来”推断出 native”,一旦漏判一个新出的纯文本模型,就会静默跳过整个视觉检查流程。于是设定一条规则:黑名单只能把结果推向更保守的 external,永远不能推出 native,也就是命中时强制外部分析,没命中时什么也不改变。顺便把判断逻辑收进 vision.py 一个 --check-routing 命令,让 CLAUDE.md 直接调用它。
第六版修正:CLAUDE.md 不该引用任何 skill 内部细节。 CLAUDE.md 是无条件、每条消息都在场的全局文件,Skill 系统本身才是设计给”按需加载、按相关性触发”的机制。把 vision.py 的路径和 flag 硬编码进 CLAUDE.md,等于让一个全局文件依赖某个具体 skill 的实现细节,skill 一旦改名/迁移,CLAUDE.md 里就留下死引用。于是把职责重新拆开:CLAUDE.md 只保留”必须实际看渲染截图,不能只读代码猜布局”这条与工具无关的通用策略;”要不要调用这个工具”的判断整段移进 vision/SKILL.md 自己的文档里——这原本就是它的分内事,它本来就已经在正文里直接引用同级的 vision.py。
灵感来自 SessionStart hook
到第六版为止,还有一个根本缺口没解决——VISION_ROUTING 不会自动变,用户忘记切换就会一直错下去。也许可以去查询 Claude Code 和 CC Switch 有没有什么特性(hooks 等)参考?
Claude Code 的 SessionStart hook 会在每次会话开始或恢复时重新触发,把命令的 stdout 当作上下文注入给模型。这意味着路由判断根本不需要指望”某个静态状态一直不变”,而是可以让判断逻辑每次会话都从当前环境变量重新算一遍。
问题从”怎么让一个开关自动保持正确”变成了”怎么让判断在每次需要时自动重新发生”,这是 Claude Code 自带的能力,不需要 CC Switch 配合。
最终定型的结构是四层,从自动到兜底:
SessionStarthook(主路径):安装时注册一次,之后每次会话自动用当时的环境变量重算路由,不需要用户做任何事。vision/SKILL.md:”若不确定,运行--check-routing“ 的兜底指令,防止 hook 未注册或上下文被压缩掉。vision.py内部检查:真正发起外部请求前的最后一道防线。- 黑名单:贯穿以上三层,单向把结果推向
external,永不推向native。
三层共用同一个 resolve_routing() 函数,判断逻辑只有一份。
这一版实现完之后合并为 PR #5。
手动安装测试:一个漏掉的边界
代码合并之后,在真实使用中很快暴露了一个问题:一个完全没有经过 CC Switch、直接连官方 API 跑 Sonnet 5 的会话,--check-routing 依然报告 external。手动调试发现这台机器这次会话里 ANTHROPIC_BASE_URL 完全没有被设置过,也就是说根本没有代理介入,但路由逻辑还是落到了保守的默认值。
根本原因是此前的设计只写了”如何识别已知的纯文本模型”(黑名单),却完全没写”如何识别确定没有代理介入的情况”。两者都不命中时,只能落到 VISION_ROUTING 未设置时的默认值 external,跟实际连的是不是 Sonnet 5 毫无关系。
解决思路:Anthropic 官方 API 不会提供纯文本模型,只要没有代理把这个地址改写指向别处,后端就一定是原生多模态的。这和黑名单是对称的两端:黑名单证明一定不是 native,新加的这层证明一定是 native,中间那段代理指向其他地址的情况才保留原来的保守默认 external。主机名比较用 urlparse(...).hostname 而不是用子串匹配,避免像 api.anthropic.com.evil.example 这样的拼接域名绕过检测。
这版修复对应 PR #7,补了 8 个新测试覆盖官方 URL/未设置 URL 判定为 native、代理 URL 判定为 external、显式设置在两个方向上都能覆盖自动推断、主机名子串攻击防护等场景。修复后直接在本地验证:不设任何环境变量运行 python vision.py --check-routing,输出从 external 变成了 native。手动安装也验证通过。
总结一下
回头看整个过程,有几条经验更值得记录:
- 安全默认值需要两个方向的信号,不能只做一个方向。
- “模型能不能自己判断”这个问题,答案几乎总是不能,但要分清楚判断的是什么。判断”我是不是原生多模态”是自我认知,不可靠;判断”一个人为设置好的环境变量等不等于某个值”是确定性检查,可靠。不要把这两者混为一谈。
- 框架/平台自带的机制,通常比自己造的更可靠。折腾了五版之后才想起去查 Claude Code 自己有没有现成的 hook 机制,而
SessionStart恰好精准地解决了”如何让判断自动保持新鲜”这个自己怎么设计都设计不圆的问题。