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 再逐步加功能。路由层这东西,够用就好,过度设计反而增加维护负担。