上个月我把 Claude Code 的底层模型切成了 DeepSeek、通义千问和 GLM 几个国产模型,过程中顺手折腾出一套智能路由接口。起因不太复杂:不是所有任务都值得用最贵的模型跑,日常改配置、批量处理日志、写一次性脚本,完全可以用更便宜的模型扛,省下的成本不是小数目。但这条路比想象中颠簸,我前后踩了 5 个坑,最终把所有逻辑收拢成一个统一接口。如果你也在给 Claude Code 接第三方模型,或者想把多模型能力统一到一个入口做按需分发,这篇文章应该能帮你省下至少两天的调试时间。
1. 为什么非要给 Claude Code 做智能路由
1.1 需求来源:不是炫技,是成本压力
我先说场景。我平时用 Claude Code 做两类事情:一类是写业务代码、做架构设计,这类任务要模型有足够推理深度,出错重试代价很高;另一类是改配置文件、刷日志、整理 JSON、跑重复性命令,这类活要的是快,模型稍微笨一点问题不大。
官方模型很聪明,但每一轮都在烧 token。我统计过一周的用量,大约 60% 的调用都属于“轻活”,如果把这部分流量切到便宜模型,费用直接砍掉一大截。理想状态是有一个路由层,能根据请求内容自动判断该用哪个模型,做到“重活用好模型,轻活用便宜模型”,而不是靠我手动切配置。
另外还有个现实问题:团队里不是每个人都愿意折腾环境变量、每个会话手动选模型。如果我只在自己电脑上换个 key,谁也复用不了。所以最合理的做法是把模型选择逻辑从客户端抽出来,做成一个统一的 HTTP 接口,Claude Code 只对接这一个端点,端点背后自动做模型分配。
1.2 路由层到底做了什么
这里的“智能路由”不是一个抽象的网关概念,它的工作链路很具体:
- 接收 Claude Code 发来的请求,请求格式是 Anthropic Messages API 风格。
- 读取请求里的模型标识或自定义路由头,也可以根据提示词内容与任务类型做规则匹配。
- 把 Anthropic 格式翻译成目标模型厂商的格式(OpenAI 兼容格式为主)。
- 调用上游模型,拿到流式响应。
- 把上游的事件流再翻译回 Anthropic 格式,原样推给 Claude Code。
从 Claude Code 的视角看,它只是在和 Anthropic API 对话,浑然不知背后已经换了一个模型。这个透明层做得好,客户端完全无感知;做得不好,就是你接下来要看到的这 5 个坑。
2. 第一个坑:协议差异比你想象的更碎
2.1 两种 API 结构根本对不上
我先天真了一把,以为 Anthropic Messages API 和 OpenAI Chat Completions API 都是“发一组消息、返回一段文本”而已,结构上做个字段映射就行。真上手才发现,差异是结构性的。
Anthropic 的请求长这样:
{ "model": "claude-3-5-sonnet", "max_tokens": 4096, "system": [{"type": "text", "text": "你是一个编程助手"}], "messages": [ {"role": "user", "content": "帮我看看这个报错"}, {"role": "assistant", "content": [{"type": "text", "text": "我先看一下日志"}]} ] }而 OpenAI 兼容格式是:
{ "model": "deepseek-chat", "messages": [ {"role": "system", "content": "你是一个编程助手"}, {"role": "user", "content": "帮我看看这个报错"}, {"role": "assistant", "content": "我先看一下日志"} ] }表面看只是 system 换了个位置,但实际翻译时我踩了三个暗坑。第一,Anthropic 的 content 字段可以是数组,里面塞了 text、image、tool_use、tool_result 多种内容块,而很多 OpenAI 兼容接口的 content 只收字符串,数组形式的 content 会被拒绝或忽略。第二,Claude Code 是重度多轮对话使用者,它会交替发送 user、assistant 消息,但有些上游模型要求严格轮转,连续出现两条 user 会报错或者把上下文切断。第三,Anthropic 的 tool 定义里参数格式叫 input_schema,OpenAI 体系叫 parameters,字段名完全不同,不处理的话工具直接不识别。
我把这些问题整理成一张对照表,方便你对照排查:
| 项目 | Anthropic Messages | OpenAI 兼容格式 |
|---|---|---|
| system 提示词 | 顶层 system 字段 | messages 里的 role=system |
| 图片输入 | content 块里 type=image | content 里带 image_url 对象 |
| 工具参数 | input_schema | parameters |
| 多轮限制 | 允许连续 assistant、穿插 tool 块 | 部分模型要求 user/assistant 交替 |
| 停止原因 | stop_reason(end_turn、tool_use) | finish_reason(stop、tool_calls) |
2.2 正确姿势:不要做字段映射,要做结构转换
我在第一版里写了一个递归字段映射函数,结果被各种边界情况打脸。比如某个模型返回的 tool_calls 里 function 字段缺失,或者 content 块里的 type 被写成大写,这些都要单独处理。
后来我换了个思路:不写映射器,写一个规范化中间层。任何上游请求先进 Pydantic 模型,转成内部统一的 Message 结构,再根据目标模型渲染成对应的 JSON 结构。这样每个模型写一个渲染器,互不干扰。虽然前期代码量更大,但后续加新模型时只需写十几行渲染逻辑,不用动主流程。
这里给一个建议:不要相信网上说的“OpenAI 兼容就等于能直接用”,实际每家都有微调。DeepSeek 的兼容度还算高,GLM 对工具调用的格式有额外要求,Qwen 的流式事件里偶发空 delta 块,这些都要在中间层里做防御性处理。
3. 第二个坑:工具调用(Tool Use)是重灾区
3.1 症状:模型开始自言自语,就是不动手
Claude Code 能自己改文件、执行命令、搜索代码,靠的是工具调用。它是这么运作的:Claude Code 发出请求后,模型返回一个 tool_use 块,里面带工具名称和 JSON 参数;Claude Code 执行完工具后,再发一个 tool_result 块把结果回传;模型继续生成下一步回复。
这个循环对官方模型毫无压力,但换成第三方模型后,我先是发现模型返回了一大段“我帮你检查一下当前目录的文件结构”,然后就没有下文了,它把工具调用用自然语言描述出来,而不是结构化的 tool_use 块。更严重的问题是,有些模型在第二轮对话中忘了带上一次 tool_use 的 id 字段,Claude Code 找不到对应的工具调用,直接停在原地等待,整个对话像死锁一样。
定位这个问题花了我半天时间。我打开了 debug 日志,发现下游模型确实返回了 tool_calls,但返回的 tool_call id 是随机生成的新 id,没有沿用 Claude Code 发来的 tool_use id。Claude Code 在下一轮请求中传的 tool_result 配的是旧 id,两边对不上,自然无法继续。
3.2 我后来写的“工具调用修复器”
这个修复器其实做三件事:
- 如果模型返回的内容里嵌了 JSON 格式的工具调用文本(看起来像函数调用但没走原生 tool_calls),用正则把它捞出来,补成结构化的 tool_use 块。
- 强制保留上游请求中 tool_use 的 id,在下游模型的 tool_calls 里原样回填,绝不生成新 id。
- 对参数做二次 JSON 校验。有些模型会把参数对象转成字符串,或在大括号里塞了非法缩进,我会在转发前用 json.loads 试一遍,失败就尝试修复再试。
一句话总结:在路由层做工具调用的“对齐和兜底”,而不是把这个责任交给模型自觉。模型换得越杂,修复逻辑就越要重,宁可多写几行防御代码,也不要在凌晨三点看它卡死。
4. 第三个坑:SSE 流式输出没有你想的那么简单
4.1 事件流名字都不一样,更别提格式
Claude Code 和 Anthropic API 之间走的是 SSE 流式传输,每次响应都是多个事件拼接。Anthropic 的事件流有一堆类型:message_start、content_block_start、content_block_delta、content_block_stop、message_delta、message_stop。而 OpenAI 风格的事件基本只有一种:chat.completion.chunk,结构是 choices[0].delta 里不断追加 content 或 tool_calls。
问题在于,Claude Code 的流式解析逻辑是比较严格的。它收到请求后,解析器会等待 content_block_delta 事件来更新界面文本,如果你返回的流里是 OpenAI 风格的事件,解析器直接忽略,造成一个非常诡异的症状:接口有数据返回,网络没断,但 Claude Code 界面一直显示“思考中”,像卡死了一样。
还有一个隐藏大坑:thinking 块。部分推理模型在正式输出前会先返回 reasoning_content,这是给用户看思维链的内容。如果不加处理,直接把这些内容塞进 content_block_delta 的 text 里,Claude Code 会以为这是模型的最终回答,把一堆“问题分析、拆解步骤”当成正文输出。我第一次接 DeepSeek 时就被灌了一大段推理过程,回答的正文反而被截断。
4.2 我的 SSE 转换方案
我写了一个事件转换器,核心逻辑只有一条:把所有上游事件递归遍历,只要发现 text 类型内容,就包装成 content_block_delta 事件;只要发现 tool_calls 结构,就包装成 content_block_start 事件,type 标记为 tool_use。每个 content_block 组都按顺序发 stop 事件,最后补 message_delta 和 message_stop。
对于 thinking 内容,我的处理是明确丢弃,不放行到客户端。不要想着保留给用户看,Claude Code 没有对应的渲染通道,放了只会破坏界面信息流。
调试这块有一个好习惯:用curl -N直连路由层接口,原样观察事件流。如果你是 Python 环境,也可以用 sseclient 库逐条打印事件,一眼就能看出是哪个事件没对上。别一上来就怀疑 Claude Code 本身的问题,先确认事件流格式是否符合 Anthropic 规范。
5. 第四个坑:系统提示词是给 Claude 写的,不是给所有模型写的
5.1 官方提示词换个模型就“水土不服”
Claude Code 在启动时会往请求里注入一大段系统提示词,里面全是复杂规则、XML 标签、责任链分层,比如“你是 Claude Code,由 Anthropic 训练”“开始任务前先思考规划”“涉及敏感操作时需确认”。这些提示词对 Claude 模型效果很好,因为它在底层已经对齐过这套指令协议。
但换成 DeepSeek、Qwen、GLM 之后,问题就来了。这些模型没有经过同样的协议对齐,面对满屏 XML 标签时会表现得很奇怪。我记得第一次切换后,模型在回答开头自称“我是 DeepSeek,很高兴为你服务”,这倒还好,但后面它就表现得过于谨慎,用户让它改文件,它总是先输出十几行分析,就是不执行工具,操作指令被 XML 标签里的约束给“吓退”了。
5.2 我给提示词做了分层压缩
我没有完全删掉 Claude Code 的系统提示词,而是按模块重新组织。保留的模块包括:权限边界(哪些目录可写、哪些命令需要确认)、任务执行协议(先读后写、修改前备份)、输出格式要求(回答简洁、不用客套)。砍掉的包括:大量人格化描述、针对 Claude 模型特化的推理引导、那套冗长的 XML 责任链标签。
实操中我发现,把系统提示词压缩成两段效果最好。第一段用自然语言告诉模型“你是编码助手,负责完成用户的开发任务”,第二段用列表说明工作规则。总长度控制在 1500 字以内,超过这个量,廉价模型容易在长提示词里“迷路”,注意力分散到无关规则上。
如果你希望模型在切换后行为更稳定,还可以按模型做提示词微调:给 Qwen 一个偏好结构化输出的提示版本,给 GLM 一个偏好谨慎确认的版本。这些变体可以挂在路由表里,跟随模型一起切换。
6. 第五个坑:超时、并发、鉴权这些“隐性成本”
6.1 上游模型的脾气各不相同
响应延迟这件事,不实测真的没概念。同一台机器上,我用相同请求分别打三个模型,数值差别很大。DeepSeek 在长上下文场景下首字延迟偏高,最长一次等了快 8 秒;Qwen 的响应速度最稳,基本在 2 秒内出字;GLM 的流式返回很积极,字出得快,但遇到工具调用时会停顿几秒再做决策。
如果路由层给所有上游配同一个超时时间,就会陷入两难:超时设短了,DeepSeek 长请求经常被打断;超时设长了,Qwen 本该快速返回的请求也被拖住。我最后给每个上游模型单独配了超时窗口,并在路由表里标注“慢模型倍率”,比如 DeepSeek 超时 90 秒,Qwen 超时 30 秒。
6.2 鉴权和并发也要管,不然会被供应商限流
我遇到过两个低级问题,写出来给大家提个醒。一个是鉴权,Claude Code 在环境变量里配了 ANTHROPIC_AUTH_TOKEN,路由层用它校验请求是否来自本机;但转发到上游时,必须换成对应的厂商 key,不能把本机校验用的 token 直接透传出去,否则泄漏风险很大。另一个是并发,Claude Code 单个会话对工具调用是串行的,但如果你开了多个会话或者多人共用同一个路由层,上游并发会瞬间拉满,触发限流。
我的处理方案是给路由层加一个简单的并发控制:按上游模型分桶排队,同时限制每个桶的最大并发数。比如 DeepSeek 桶最大并发 4 个,Qwen 桶最大并发 8 个。等队列满了,后续请求原地等待,而不是直接打爆上游接口。实测下来,加了这层控制之后,再也没出现过 429 限流错误。
7. 把 5 个坑总结成一个接口
7.1 接口定义和路由规则
所有坑踩完之后,我终于得到一个能稳定运行的统一接口。对外只暴露一个端点/v1/messages,接收 Anthropic 格式请求,返回 Anthropic 格式 SSE 流。路由规则我用三层判断:
- 如果请求头带
X-Route-Model: deepseek之类的显式标识,直接用指定模型。 - 如果没有显式标识,按任务类型匹配:请求里出现“执行命令”“批量修改”“整理日志”这类关键词,走廉价快模型;出现“设计架构”“解释原理”“代码审查”这类词,走强模型。
- 都不匹配时走默认模型,我默认配的是 Qwen,因为均衡性最好。
这个设计其实是模仿了 cc-switch 这类工具的切换思路,但更进一步:cc-switch 是固定切换,我这是按请求动态分发。如果你只是偶尔换模型用,cc-switch 完全够用;但如果你想自动分流、多人共享、按任务选模型,就需要有一个自己的路由层。
7.2 一个可以跑起来的最小实现
这是去掉异常处理后的精简版,但完整展示了路由层的主干逻辑:
from flask import Flask, request, Response, jsonify import httpx, os app = Flask(__name__) ROUTES = { "deepseek": { "base_url": "https://api.deepseek.com/v1", "api_key": os.getenv("DEEPSEEK_API_KEY"), }, "qwen": { "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", "api_key": os.getenv("QWEN_API_KEY"), }, "glm": { "base_url": "https://open.bigmodel.cn/api/paas/v4", "api_key": os.getenv("GLM_API_KEY"), }, } @app.route("/v1/messages", methods=["POST"]) def route_messages(): body = request.get_json(force=True) model = request.headers.get("X-Route-Model", "qwen") cfg = ROUTES.get(model, ROUTES["qwen"]) # 这里省略了 Anthropic -> OpenAI 格式转换 # 以及 tool_use id 对齐、SSE 事件转换等逻辑 async def forward(): async with httpx.AsyncClient(timeout=90) as client: async with client.stream( "POST", f"{cfg['base_url']}/chat/completions", headers={"Authorization": f"Bearer {cfg['api_key']}"}, json=translated_body, ) as resp: async for line in resp.aiter_lines(): yield translate_sse_line(line) return Response(forward(), mimetype="text/event-stream")这个版本核心思路就是:接收请求 → 按路由表选模型 → 做格式转换和修复 → 流式转发 → 做事件回译。生产环境里还应该加上缓存、限流、日志、健康检查,但主干逻辑跑通后,其他都是锦上添花。
7.3 接入 Claude Code 的具体姿势
接入方式很简单,核心就是改两个环境变量。一个是ANTHROPIC_BASE_URL,指到你的路由层地址,比如http://localhost:8000;另一个是ANTHROPIC_AUTH_TOKEN,填一个自定义 token,路由层用它校验身份即可。
在 Ubuntu 或 macOS 上,可以写进 shell 配置文件:
export ANTHROPIC_BASE_URL="http://localhost:8000" export ANTHROPIC_AUTH_TOKEN="my-router-token"然后在 Claude Code 里正常对话,流量就会自动走你的路由层。如果你在 VSCode 里用 Claude Code 插件,同样配置生效,因为插件底层读取的也是这两个环境变量。实测下来,切换模型后工具调用、文件编辑、终端命令执行都能正常工作,界面表现和官方模型没有明显区别。
8. 常见问题速查与避坑清单
8.1 问题对照表
我把踩坑过程中的典型症状和排查思路整理成一张表,遇到问题可以直接对照:
| 症状 | 可能原因 | 排查方向 |
|---|---|---|
| 界面一直“思考中”但无输出 | SSE 事件流格式不是 Anthropic 风格 | 用 curl 观察返回的事件类型 |
| 模型说话但不动手 | 工具调用未走结构化 tool_use | 检查回复里是否有 tool_calls 字段 |
| 工具执行后对话中断 | tool_use id 和 tool_result id 对不上 | 在 debug 日志里比对两轮请求的 id |
| 回答混入大段推理过程 | thinking 内容被当成正文输出 | 在路由层丢弃 reasoning_content |
| 请求报 404 或 401 | 上游 base_url 或 key 配错 | 检查路由表里的 URL 和 key 是否匹配 |
| 偶尔超时或限流 | 未按模型区分超时、未做并发控制 | 给慢模型单独加大超时,加并发队列 |
8.2 几个值得记住的结论
先说工具调用修复器这个事。它是我这个项目里投入产出比最高的代码,50 行不到的防御逻辑,省去了大量无意义的排查时间。模型换了三种之后,我才意识到:模型返回格式不稳定才是常态,路由层应该默认“所有模型都会出幺蛾子”,而不是默认“换了模型一切正常”。
再说事件转换。如果你只接一个模型,可以在路由层写死事件映射;但要按任务分流、随时换模型,事件转换器就要做成通用的。因为不同的模型在流式事件里塞的内容不太一样,有的会带空块,有的会提前发 stop,只有统一转换逻辑处理过这些边界,才能真正做到无感切换。
最后说一句我的体会。给 Claude Code 做智能路由,真正的难点不在“接口”本身,而在接口背后那些看不见的兼容层。协议转换、工具调用对齐、事件流重构、提示词压缩、超时与并发控制,这五件事单独看都不难,但它们叠加在一起,就成了压垮第一次尝试的 5 座大山。希望这篇文章能帮你少走这几段弯路。