news 2026/10/21 13:35:55

给 Claude Code 和 Codex 配国内模型路由:协议转换与成本优化实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
给 Claude Code 和 Codex 配国内模型路由:协议转换与成本优化实践

1. 为什么我要给国外 harness 配一个国内模型路由

先说清楚我遇到的实际问题。我日常主力用 Claude Code 和 Codex 这两套 agent harness 做开发,它们本身是很好的“驾驶舱”——负责把任务拆解、调用工具、读写文件、跑命令、维护上下文。但它们的默认模型通道都指向国外服务,国内直连的体验大家心里有数:延迟高、偶发超时、按量计费换算下来成本不低,尤其是长时间跑 agent 任务,token 消耗是成倍往上翻的。

我一开始的做法很土:手动改配置文件,把 base_url 和 model 换成国内某家的兼容接口。改一次能用,但问题很快暴露出来——Claude Code 和 Codex 的配置格式不一样,一个用环境变量,一个用 TOML;切换模型要改好几处;有时候想 A 任务用便宜模型、B 任务用强模型,来回改配置改到崩溃。更麻烦的是,某些 harness 对接口协议有隐含要求,比如 Codex 走的是/responses端点,而很多国内模型只提供/chat/completions,直接对接会报错。

所以我写了个cn-llm-router,本质是一个本地运行的轻量路由层。它对外暴露一套 OpenAI 兼容接口,对内把请求转发到国内各家模型服务,同时做协议适配、模型映射、失败重试和用量统计。harness 那边只需要把 base_url 指向http://127.0.0.1:端口,剩下的脏活累活交给路由器。这样一来,Claude Code、Codex、以及任何支持自定义 OpenAI 端点的工具,都能用同一套配置接入国内模型。

这篇文章我会把整个思路、实现细节、踩过的坑、以及实际跑下来的经验完整讲一遍。适合两类人看:一是想给 agent harness 换国内模型但被配置折磨过的开发者;二是想理解“路由层”这个中间件到底解决什么问题的人。不需要你精通网络编程,但最好对 HTTP 接口和 JSON 有点基本概念。

2. 路由层到底解决了哪些具体问题

2.1 协议差异:/responses和/chat/completions不是一回事

这是最核心的一个坑。Codex 这类 harness 使用的是较新的 Responses API 风格,请求体结构和/chat/completions有明显区别:它的输入不是简单的 messages 数组,而是带input字段的结构化内容,工具调用、推理过程的表达方式也不一样。而国内绝大多数模型服务商提供的是标准的/chat/completions。

如果你直接把 Codex 的 base_url 指向国内服务,大概率会遇到类似cc switch local proxy failed while handling codex endpoint /responses这样的报错——因为对方根本没有/responses这个路由。

cn-llm-router的做法是在中间做一层转换:接收 harness 发来的/responses请求,解析出实际的对话内容和工具定义,转换成/chat/completions格式发给上游,拿到结果后再转回 Responses 格式返回。这个双向转换是整个项目里最费心思的部分,后面我会单独讲。

2.2 模型映射:让 harness 里的模型名和真实模型解耦

harness 的配置里通常写死了模型名,比如claude-sonnet-4或者gpt-5-codex。但国内模型的名字完全是另一套体系。如果每次换模型都要去改 harness 配置,非常低效。

路由器里我设计了一张映射表,harness 请求什么名字,路由器查表决定实际调用哪个上游模型。这样我可以在不改 harness 任何配置的前提下,把“sonnet”这个逻辑名随时指向不同的国内模型,做 A/B 对比或者成本切换。

[model_map] "claude-sonnet-4" = "deepseek-chat" "gpt-5-codex" = "deepseek-reasoner" "claude-haiku" = "qwen-turbo"

2.3 成本与稳定性:为什么值得多这一层

有人会问,多一层转发不是多一个故障点吗?我的实测结论是:值得。原因有三个。

第一,统一重试。国内不同服务商的稳定性有差异,路由器可以在上游返回 5xx 或超时时自动重试,甚至切换到备用服务商,harness 那边完全无感知。

第二,用量可见。所有请求都经过路由器,我可以记录每个任务消耗了多少 token、花了多少钱。跑长任务时这个数据非常关键,能帮我判断是不是某个环节在疯狂烧 token。

第三,配置收敛。API key、base_url、超时参数全部集中在路由器一个配置文件里,harness 那边永远是干净的本地地址。换服务商只改一处。

提示:路由层不是必须的,如果你只用一家国内模型且 harness 原生支持,直接配也行。但只要涉及多模型、多 harness、协议转换中的任意两项,这层中间件的价值就体现出来了。

3. 核心实现:请求转换与转发的完整链路

3.1 技术选型:为什么用 Python + FastAPI

选型上我没纠结太久。路由层本质是 IO 密集型的转发服务,Python 的异步生态足够成熟,FastAPI 自带请求校验和异步支持,写起来快。相比 Node 方案,Python 在处理 JSON 结构转换时更顺手,尤其是嵌套的 tool_calls 结构。相比 Go,开发速度更快,这个项目不需要极致性能。

核心依赖就三个:fastapi做服务框架,httpx做异步 HTTP 客户端,pydantic做请求体校验。没有引入任何重型框架。

pip install fastapi uvicorn httpx pydantic

启动命令也很简单:

uvicorn cn_llm_router.main:app --host 127.0.0.1 --port 8787

我特意绑127.0.0.1而不是0.0.0.0,因为这是本地个人工具,不需要对外暴露,绑本地更安全。

3.2 Responses 到 Chat Completions 的字段映射

这是整个项目最核心的转换逻辑。我拿一个真实请求举例。Codex 发过来的/responses请求大概长这样:

{ "model": "gpt-5-codex", "input": [ {"role": "user", "content": [{"type": "input_text", "text": "帮我读一下 config.py"}]} ], "tools": [ {"type": "function", "name": "read_file", "parameters": {...}} ] }

转换到/chat/completions需要做几件事:

  • input数组里的content是分块结构,要拍平成字符串或标准 content 数组
  • tools里的type: function和name字段,要转成{"type": "function", "function": {"name": ...}}的嵌套结构
  • 工具调用的返回结果,在 Responses 里是独立的function_call_output类型,要转成role: tool的消息

我写了一个convert_responses_to_chat函数专门处理这个。反向转换convert_chat_to_responses则要把上游返回的tool_calls重新包装成 Responses 期望的output数组格式。

def convert_responses_to_chat(body: dict) -> dict: messages = [] for item in body.get("input", []): if item.get("type") == "function_call_output": messages.append({ "role": "tool", "tool_call_id": item["call_id"], "content": item["output"], }) continue content = item.get("content") if isinstance(content, list): text = "".join(c.get("text", "") for c in content if c.get("type") == "input_text") else: text = content or "" messages.append({"role": item.get("role", "user"), "content": text}) return { "model": body["model"], "messages": messages, "tools": normalize_tools(body.get("tools", [])), "stream": body.get("stream", False), }

这段代码看着简单,但实际调试花了我不少时间,因为 Responses 的字段命名和 Chat 差异比想象中大,尤其是流式返回时的增量结构。

3.3 流式响应的处理:SSE 事件类型要对齐

Agent harness 几乎都依赖流式输出,因为要实时显示模型在“思考”和调用工具。Responses API 的流式事件类型和 Chat Completions 完全不同。Chat 那边是data: {"choices": [{"delta": {...}}]},Responses 那边是一系列带type字段的事件,比如response.output_text.delta、response.function_call_arguments.delta。

我的处理方式是:在转发层拦截上游的 SSE 流,逐块解析,把 Chat 的 delta 事件重新组装成 Responses 的事件序列再吐给 harness。这里有个细节——Responses 要求先发response.created,再发内容增量,最后发response.completed,顺序错了 harness 会解析失败。

async def stream_bridge(upstream_stream): yield sse_event("response.created", {"response": {"id": "resp_local"}}) async for line in upstream_stream: if not line.startswith("data: "): continue payload = line[6:].strip() if payload == "[DONE]": break chunk = json.loads(payload) delta = chunk["choices"][0].get("delta", {}) if "content" in delta and delta["content"]: yield sse_event("response.output_text.delta", {"delta": delta["content"]}) yield sse_event("response.completed", {"response": {"id": "resp_local"}})

注意:流式转换里最容易出错的是工具调用的参数增量。Chat 的tool_calls是分片下发的,arguments字段会一段段拼接,必须按 index 累积后再整体输出,否则 harness 拿到的 JSON 是残缺的。

3.4 配置文件的组织方式

我把所有配置放在一个router.toml里,分三块:上游服务商定义、模型映射、路由规则。

[providers.deepseek] base_url = "https://api.deepseek.com/v1" api_key_env = "DEEPSEEK_KEY" protocol = "chat" [providers.qwen] base_url = "https://dashscope.aliyuncs.com/compatible-mode/v1" api_key_env = "QWEN_KEY" protocol = "chat" [model_map] "gpt-5-codex" = { provider = "deepseek", model = "deepseek-reasoner" } "claude-sonnet-4" = { provider = "deepseek", model = "deepseek-chat" } "claude-haiku" = { provider = "qwen", model = "qwen-turbo" }

API key 通过环境变量读取,不写进配置文件,避免误提交。这个习惯是从踩坑里养成的——早期我把 key 直接写在配置里,结果同步到云盘差点泄露。

4. 把 Claude Code 和 Codex 接进来的实操步骤

4.1 Claude Code 的接入配置

Claude Code 支持通过环境变量指定自定义端点。启动前设置两个变量即可:

export ANTHROPIC_BASE_URL="http://127.0.0.1:8787" export ANTHROPIC_API_KEY="local-router-key"

这里有个容易忽略的点:Claude Code 走的是 Anthropic 的消息格式,和 OpenAI 的/chat/completions又不一样。所以路由器还需要额外提供一个/v1/messages端点,把 Anthropic 格式转成内部统一格式。我在项目里复用了同一套转换核心,只是入口不同。

如果你在 VS Code 里用 Claude Code 插件,环境变量要在插件启动配置里设置,而不是在终端里 export,否则插件进程读不到。这个坑我踩过,排查了半天才发现是环境变量作用域问题。

4.2 Codex 的接入配置

Codex 的配置在~/.codex/config.toml里。关键是把 provider 指向本地路由器:

model_provider = "local_router" model = "gpt-5-codex" [model_providers.local_router] name = "Local Router" base_url = "http://127.0.0.1:8787/v1" env_key = "LOCAL_ROUTER_KEY" wire_api = "responses"

wire_api = "responses"这行很关键,它告诉 Codex 用 Responses 协议发请求,路由器那边正好有对应的转换逻辑。如果这里配错成chat,请求会走错端点。

配好之后启动 Codex,如果看到cc switch local proxy failed while handling codex endpoint /responses这类报错,八成是路由器没启动,或者端口对不上。先确认路由器进程在跑,再确认 base_url 里的端口一致。

4.3 验证链路是否打通

接好之后别急着跑复杂任务,先用一个最小请求验证。我一般用 curl 直接打路由器:

curl http://127.0.0.1:8787/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{"model": "claude-haiku", "messages": [{"role": "user", "content": "说一句话"}]}'

如果返回正常,说明路由器到上游是通的。然后再在 harness 里发一个简单指令,比如“列出当前目录文件”,看工具调用能不能正常往返。工具调用是最容易出问题的环节,因为涉及双向的格式转换。

验证层级检查内容常见失败原因
路由器存活端口能否访问进程未启动、端口占用
上游连通能否拿到模型回复API key 错误、余额不足
协议转换工具调用能否往返字段映射错误、流式拼接问题
harness 集成任务能否完整执行环境变量作用域、配置路径错误

5. 实测中暴露的问题和我的处理方式

5.1 工具调用参数被截断

这是最隐蔽的一个 bug。跑一个需要写文件的任务时,harness 报“参数解析失败”。我抓包发现,上游返回的tool_calls里arguments是分多个 chunk 下发的,我早期版本每个 chunk 直接转发,导致 harness 收到的是碎片。

修复方式是维护一个按 index 索引的缓冲区,把同一工具的 arguments 片段累积起来,等finish_reason出现时再整体输出。这个逻辑在流式场景下必须做,非流式反而没这个问题。

5.2 长上下文被上游拒绝

国内部分模型对上下文长度限制比国外模型严格。跑长任务时,harness 累积的对话历史很容易超过上游上限,直接返回 400。我的处理是在路由器里加一个上下文裁剪逻辑:当估算 token 超过阈值时,保留 system 提示和最近若干轮对话,中间的历史做摘要压缩。

这个裁剪策略要小心,不能把工具调用的上下文裁掉,否则模型会“忘记”自己刚才调用了什么工具。我的做法是保留所有tool角色的消息,只裁剪早期的普通对话。

5.3 超时和重试的边界

agent 任务动辄跑几分钟,超时设置很讲究。设太短,正常的长推理会被误杀;设太长,真卡住了要等很久。我最终设的是连接超时 10 秒、读取超时 300 秒,配合最多 2 次重试。

重试有个陷阱:如果请求已经部分执行(比如模型已经开始输出),重试会导致重复。所以我的重试只针对“还没收到任何响应”的情况,一旦开始流式输出就不再重试,避免副作用。

提示:涉及写文件、执行命令这类有副作用的工具调用,重试一定要谨慎。宁可让任务失败让用户重跑,也不要自动重试导致重复操作。

5.4 离线局域网场景的适配

有朋友问能不能在离线局域网用。可以,但前提是上游模型服务本身部署在内网。路由器只是个转发层,它不产生智能,模型在哪它就把请求发到哪。把base_url指向内网的模型服务地址即可,其余逻辑不变。这种场景下路由器反而更有价值,因为内网模型服务的接口格式往往更不统一,正好靠路由器抹平差异。

6. 关于成本、模型选择和长期维护的几点体会

跑了一段时间后,我对模型选择有了更实际的认识。不是所有任务都需要最强模型。像代码补全、简单重构这种,用便宜的快模型完全够;只有涉及复杂推理、多步规划的任务,才值得上强模型。路由器让这种“按任务分级用模型”变得可行——我甚至可以根据请求里的工具数量、上下文长度自动路由到不同模型。

成本上,我做了个粗略统计:同样的 agent 任务量,全走国外模型和走国内模型加路由,后者成本大概能压到前者的三分之一到五分之一,具体取决于任务类型。省下来的钱不是重点,重点是延迟明显降低,交互体验顺畅很多。

维护方面,路由器本身代码量不大,核心逻辑就几百行。真正需要持续跟进的是各家模型服务的接口变化——偶尔会有服务商调整字段或端点,这时候改一处映射就行,不用动 harness。这也是分层带来的好处。

最后分享一个我常用的调试技巧:在路由器里加一个--log-requests开关,把每个请求的原始体和转换后的体都打到日志里。协议转换出问题时,对比这两个体,问题基本一眼就能定位。这个日志我平时关着,只在排查时开,避免日志文件膨胀。

如果你也在用 agent harness 并且被模型接入折腾过,我建议先别急着上复杂方案,从最小可用的转发开始,跑通一个 harness 再逐步加功能。路由层这东西,够用就好,过度设计反而增加维护负担。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/21 13:35:48

Matlab实现概率潮流:蒙特卡洛与半不变量法全解析

概率潮流这个东西,说实话,我刚接触的时候心里是有点打鼓的。做电力系统潮流分析,常规做法是把风电出力和负荷当成确定值,牛拉法一跑,出来一组节点电压和支路功率,完事了。但现实中风电和负荷是天生不确定的…

作者头像 李华
网站建设 2026/10/18 20:04:49

跨平台桌面应用开发复盘:从命名到发布的完整旅程

"Lucas_coming"看起来像是一个名叫 Lucas 的人正在赶来的路上,但在开发者眼里,它只是一个项目的代号——一个功能极简的跨平台专注工具,核心就三件事:待办清单、番茄钟、每周统计。名字里那个 coming 是故意的&#xff…

作者头像 李华
网站建设 2026/10/20 23:36:42

GeoGebra GGB5与GGB6选型指南:稳定vs功能的实操决策

1. 这不是“下一个软件下载链接”,而是数学教育工具选型的实操决策现场GeoGebra——这三个音节在中学数学教师、师范生、教育技术研究者和自学数学的成年人口中,出现频率高得惊人。它不是PPT插件,也不是临时演示小工具,而是一个把…

作者头像 李华
网站建设 2026/10/21 6:45:29

为Claude构建记忆层:提取、存储、检索与注入的完整实践指南

如果你用过带记忆的AI助手,再回头看那些"聊完就忘"的裸模型,真的会很不适应。"claude-mem"这个名字,我第一次听到就觉得直捣痛点:Claude本身的对话能力已经很强了,但你要把它接到真实应用里&#…

作者头像 李华
网站建设 2026/10/21 5:02:01

2026版Java架构师面试指南:核心考点与实战备战策略

这两年Java后端圈子的风向变了不少,但“架构师”这三个字依然是很多人的职业里程碑。网上突然流传的《2026版Java架构师面试参考指南》,说是全网首次公开,很多朋友跑来问我到底值不值得刷,说实话,这类资料每年都有新版…

作者头像 李华
网站建设 2026/10/19 1:18:21

局域网组网综合实验:VLAN划分、IP规划与排错实战指南

简介:《局域网组网的综合实验.doc》是一份面向网络工程、计算机及相关专业学生和实验人员的综合性实验文档,以局域网规划设计与组网实践为核心目标。内容从实验目的、原理入手,完整覆盖需求分析、逻辑设计、物理设计与设备选型等流程&#xf…

作者头像 李华