1. 为什么我要折腾 cn-llm-router 这件事国内用 Claude Code、Codex 这类 agent harness 的人越来越多但真正卡住大多数人的不是工具本身而是模型接入这一环。harness 本身只是个壳它负责把任务拆解、把工具调用串起来、把上下文管理好真正干活的是背后那个大模型。问题在于官方默认绑定的模型要么贵要么在国内网络环境下调用体验一言难尽。于是很多人开始琢磨能不能让 harness 继续用但把后端模型换成国内高性价比的选择我就是在这个背景下写了 cn-llm-router。它的定位很明确一个跑在本地的轻量路由层夹在 harness 和模型服务之间把 harness 发出来的请求翻译、转发、适配到国内各家模型 API 上。你可以把它理解成一个协议转换插头——墙上的插座harness是固定形状的电器国内模型的插头形状不一样中间加个转换器两边就都能用了。这个项目解决的核心问题有三个。第一是协议适配Claude Code 和 Codex 各自有一套请求格式国内模型的 API 格式又各不相同router 负责做双向翻译。第二是成本控制国内模型在同等能力下价格往往只有海外模型的几分之一尤其是长上下文场景差距更明显。第三是可用性本地路由可以做一些重试、降级、缓存的事情让整个链路更稳。适合谁来参考如果你已经在用或者准备用 Claude Code、Codex 这类 agent 工具手头有国内模型的 API key又不想被官方绑定模型的价格和网络问题困扰那这篇内容就是写给你的。哪怕你只是想搞清楚 harness 和模型之间到底是怎么通信的看完也能有个清晰的认识。2. 先搞懂 harness 和模型之间到底在聊什么2.1 harness 的本质是一个请求编排器很多人把 Claude Code 当成一个AI 编程工具这个理解不算错但不够准确。它更本质的身份是一个agent harness也就是智能体的驾驭框架。它做的事情是接收你的自然语言指令把它拆成一系列可执行的步骤每一步决定是调用工具读文件、写文件、跑命令还是问模型要下一步决策然后把结果拼回上下文继续下一轮。在这个循环里模型扮演的是大脑角色harness 扮演的是手脚和神经角色。大脑每轮都要接收当前状态输出下一步动作。所以 harness 和模型之间的通信非常频繁一次任务可能产生几十甚至上百次请求。这就解释了为什么模型的价格和延迟如此关键——单次便宜没用乘以几十次就是实打实的成本。Codex 的定位类似只是它的工具集和交互风格跟 Claude Code 有差异。两者对模型的要求都是支持工具调用tool use / function calling、支持较长的上下文、响应要稳定。这三条是硬指标缺一条体验就会明显下降。2.2 请求格式的差异才是真正的坑Claude Code 走的是 Anthropic 风格的 Messages API请求体里messages数组、system字段、tools定义都有固定结构工具调用的返回格式是tool_use和tool_result这种块状结构。Codex 走的是 OpenAI 风格的 Chat Completions 或者 Responses API工具调用用的是tool_calls数组字段命名和嵌套方式完全不同。国内模型的 API 大多兼容 OpenAI 格式但兼容程度参差不齐。有的支持tools参数但返回格式有细微差别有的对system角色的处理不一样有的在流式输出时delta结构对不上。这些差异单看文档看不出来必须实际跑一遍才会暴露。cn-llm-router 要做的就是把这些差异全部吃掉。harness 发出来什么格式router 就按什么格式解析国内模型需要什么格式router 就转成什么格式。对 harness 来说它以为自己在跟一个标准模型说话对国内模型来说它以为自己在跟一个标准客户端说话。两边都无感这就是路由层的价值。2.3 为什么不用现成的方案市面上已经有 litellm 这类成熟的模型网关功能很全支持几十家厂商。那为什么还要自己写一个我的考虑有几点。litellm 确实强大但它的定位是通用网关配置项多、依赖重跑起来是个不小的服务。对于只想在本地给 harness 换个后端这个单一场景来说有点杀鸡用牛刀。而且 litellm 的协议转换主要面向 OpenAI 格式对 Anthropic 风格的原生支持需要额外配置遇到 Claude Code 这种原生 Anthropic 客户端时适配链路会变长出问题时排查也麻烦。cn-llm-router 走的是另一条路只做我需要的转换代码量小逻辑透明出问题一眼能定位。它不追求支持所有厂商而是把国内几个主流模型服务适配好把 Claude Code 和 Codex 这两个 harness 的请求格式吃透。这种窄而深的做法在个人使用场景下反而更省心。当然如果你需要的是企业级的统一网关litellm 依然是更合适的选择。工具没有绝对好坏只有场景匹配度。我写 cn-llm-router 的初衷就是解决自己的痛点顺便把思路分享出来。3. cn-llm-router 的核心设计拆解3.1 整体架构三层结构各司其职router 的内部结构我分成了三层从外到内依次是接入层、转换层、适配层。接入层负责监听本地端口接收 harness 发来的 HTTP 请求。它要做的第一件事是识别请求来源和类型——是 Claude Code 的 Messages 请求还是 Codex 的 Responses 请求。识别方式主要看路径和请求体结构比如/v1/messages大概率是 Anthropic 风格/v1/responses是 Codex 风格/v1/chat/completions是通用 OpenAI 风格。转换层是核心负责把请求体从源格式翻译成目标格式再把响应翻译回去。这里最复杂的是工具调用部分的映射因为 Anthropic 的tool_use块和 OpenAI 的tool_calls数组在语义上等价但结构差异很大需要仔细处理嵌套和字段名。适配层负责跟具体的国内模型服务通信。每个厂商一个适配器处理各自的鉴权方式、endpoint 路径、参数差异。适配器对外暴露统一接口转换层不需要关心底层是哪家。这种分层的好处是扩展成本低。想加一个新模型厂商只需要写一个适配器想支持一个新的 harness只需要在接入层加识别逻辑。各层之间通过明确定义的数据结构通信互不干扰。3.2 协议转换的关键工具调用的双向映射工具调用是 agent harness 的命脉转换错了整个任务就废了。我重点说说这块怎么处理。Anthropic 风格里模型返回的工具调用长这样content数组里有一个type为tool_use的块包含id、name、input三个字段。工具执行结果回传时是一个type为tool_result的块包含tool_use_id和content。OpenAI 风格里模型返回的工具调用在message.tool_calls数组里每个元素有id、type固定为function、function.name、function.arguments注意是 JSON 字符串不是对象。结果回传时是一条独立的role为tool的消息带tool_call_id。转换时要处理的细节包括input对象要序列化成 JSON 字符串塞进argumentstool_result要拆成独立的 tool 消息多个工具调用并行时顺序要保持一致流式输出时工具调用的delta要正确拼接。这些点任何一个处理不好harness 就会报解析错误或者工具调用丢失。我的做法是定义一个中间表示IR源格式先转成 IRIR 再转成目标格式。这样 N 个源格式和 M 个目标格式之间只需要 NM 个转换函数而不是 N×M 个。这个思路借鉴了编译器的做法实际写下来确实清爽很多。3.3 流式输出的处理策略agent harness 基本都用流式输出因为要实时显示模型在思考和打字。流式处理的难点在于事件边界可能被切断。SSEServer-Sent Events的每个事件以\n\n分隔但网络传输时一个 chunk 可能只包含半个事件需要缓冲拼接。router 在转发流式响应时不能简单地把字节流转发因为格式要转换。我的做法是先按 SSE 规则解析出完整事件转成目标格式再重新序列化成 SSE 事件发出去。中间维护一个缓冲区遇到不完整的事件就等下一个 chunk。这里有个容易踩的坑不同厂商的流式结束标志不一样。有的发data: [DONE]有的直接关闭连接有的发一个特定的 finish 事件。router 要能识别这些情况统一转成 harness 期望的结束信号否则 harness 会一直等下去表现为卡住不动。提示调试流式问题时建议先把 router 的日志级别调到 debug把每个 SSE 事件原样打出来。肉眼比对源格式和目标格式的差异比盯着代码猜要快得多。4. 实操从零把 router 跑起来4.1 环境准备与依赖安装router 我用 Python 写的主要是考虑到生态成熟、改起来快。运行环境要求 Python 3.10 以上因为用到了些较新的类型语法。依赖不多核心就是fastapi、uvicorn、httpx这三个分别负责 web 框架、ASGI 服务器和异步 HTTP 客户端。安装步骤很直接python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate pip install fastapi uvicorn httpx pydantic用虚拟环境是必须的别图省事直接装全局。我见过太多因为全局包版本冲突导致莫名其妙的报错排查半天最后发现是环境问题。虚拟环境隔离干净出问题直接删了重建成本极低。配置文件我放在项目根目录的config.yaml用 YAML 是因为可读性好改起来不用数括号。配置结构大概是这样server: host: 127.0.0.1 port: 8787 providers: deepseek: base_url: https://api.deepseek.com/v1 api_key: ${DEEPSEEK_API_KEY} model_map: claude-sonnet: deepseek-chat qwen: base_url: https://dashscope.aliyuncs.com/compatible-mode/v1 api_key: ${QWEN_API_KEY} model_map: claude-sonnet: qwen-max routing: default_provider: deepseek fallback_provider: qwenAPI key 用环境变量注入不要硬编码在配置文件里。这是基本的安全习惯配置文件万一被同步到什么地方key 泄露了就是真金白银的损失。4.2 启动 router 并验证连通性配置写好之后启动命令就一行uvicorn cn_llm_router.main:app --host 127.0.0.1 --port 8787 --reload--reload是开发时用的改代码自动重启方便调试。正式跑的时候去掉省资源。启动后先别急着接 harness用 curl 单独测一下 router 能不能正常转发curl http://127.0.0.1:8787/v1/messages \ -H Content-Type: application/json \ -H x-api-key: dummy \ -d { model: claude-sonnet, max_tokens: 100, messages: [{role: user, content: 说一句你好}] }如果返回了正常的响应说明 router 到国内模型的链路是通的。如果报错看日志定位是哪一层的问题。这一步很重要先把 router 单独跑通再接 harness否则出了问题你分不清是 router 的锅还是 harness 配置的锅。4.3 把 Claude Code 指向本地 routerClaude Code 支持通过环境变量指定 API 端点。设置ANTHROPIC_BASE_URL指向 router 的地址即可export ANTHROPIC_BASE_URLhttp://127.0.0.1:8787 export ANTHROPIC_API_KEYdummy-key这里的 API key 填什么都行因为真正的鉴权在 router 到国内模型那一层做。router 收到请求后会用配置里的真实 key 去调用国内模型。设置完环境变量重新打开一个终端跑 Claude Code观察 router 的日志。正常情况下你会看到请求进来、转换、转发、响应回来的完整链路。如果 Claude Code 报连接错误先确认 router 还在跑再确认端口没被占用。Codex 的配置类似它读的是OPENAI_BASE_URL和OPENAI_API_KEY。注意 Codex 可能用的是 Responses API 路径router 要能识别/v1/responses并做对应转换。4.4 模型映射与参数调优model_map这个配置项值得单独说说。harness 发请求时会带一个模型名比如claude-sonnetrouter 根据映射表把它换成国内模型的实际名称。这样做的好处是 harness 侧的配置不用动换后端只改 router 配置。参数调优方面有几个点需要根据实际模型调整。max_tokens国内模型的上限各不相同超了会直接报错router 里可以做个截断保护。temperature对 agent 任务建议调低一些0.1 到 0.3 之间比较合适太高了模型容易发散工具调用会变得不稳定。top_p一般保持默认就行。还有个容易被忽略的参数是超时时间。agent 任务里模型可能要思考很久尤其是复杂推理超时设太短会频繁中断。我一般把读超时设到 120 秒连接超时 10 秒。这个值可以根据你用的模型实际响应速度调整。5. 踩过的坑和排查经验5.1 工具调用丢失或格式错误这是最常见也最致命的问题。表现是 harness 显示模型在调用工具但实际没执行或者执行了但结果传不回去。排查思路分三步。第一步看 router 日志里模型返回的原始响应确认工具调用信息在不在。如果原始响应里就没有那是模型的问题可能这个模型对工具调用支持不好换一个。第二步如果原始响应里有看转换后的格式对不对重点检查arguments是不是合法的 JSON 字符串id有没有正确传递。第三步看 harness 侧的日志确认它有没有正确解析。我遇到过一个典型情况某个国内模型返回的arguments里带了多余的换行和空格虽然 JSON 本身合法但某些 harness 解析时会出问题。解决办法是在转换时做一次 JSON 的 parse 再 dumps规范化输出。注意不同模型对工具调用的支持程度差异很大。选模型时一定要实测工具调用别只看它宣传的支持 function calling。有些模型号称支持实际用起来格式各种不对调到你怀疑人生。5.2 流式响应中断或卡死流式卡死通常有两个原因。一是前面提到的结束标志没识别对router 以为流还没结束一直等。二是某个 chunk 解析失败异常没被捕获整个流就断了。我的处理方式是在流式转发外面包一层 try-except任何异常都记录日志并主动发送一个结束事件让 harness 能正常收尾而不是干等。同时加一个空闲超时如果超过 N 秒没有新数据也主动结束。还有个细节是心跳。有些场景下模型思考时间长中间没有输出连接可能被中间层掐断。router 可以定期发送注释行以:开头的 SSE 行作为心跳保持连接活跃。5.3 常见问题速查表现象可能原因排查方向harness 报连接拒绝router 没启动或端口不对检查 router 进程和端口占用请求返回 401API key 配置错误检查环境变量和配置文件工具调用不执行格式转换错误对比原始响应和转换后格式流式输出卡住结束标志未识别检查 SSE 事件解析逻辑响应特别慢模型本身慢或超时设置换模型或调大超时上下文超限报错max_tokens 设置过大调小或做截断保护中文乱码编码处理问题确认全程 UTF-85.4 几个提升稳定性的实操心得第一加请求日志但要脱敏。完整记录请求和响应对于排查问题极有帮助但 API key、用户内容这些敏感信息要过滤掉。我一般只记录结构、长度、耗时这些元信息内容按需开启。第二做重试但要有上限。国内模型偶尔会有瞬时故障重试一两次能救回来。但重试次数不能多否则一个失败请求会拖很久。我设的是最多重试 2 次间隔 1 秒。第三降级要有兜底。主模型不可用时自动切到备用模型这个在配置里配好 fallback 就行。虽然备用模型可能能力弱一点但总比整个任务失败强。第四定期看成本。router 可以统计每个模型的调用次数和 token 消耗跑一段时间后看看哪个模型性价比最高据此调整路由策略。这个数据比拍脑袋选模型靠谱得多。6. 关于模型选型和后续扩展的一些想法国内模型这两年进步很快在代码和 agent 场景下的表现已经能满足大部分日常需求。选型时我建议重点看三个维度工具调用的稳定性、长上下文的表现、以及单位 token 的成本。这三个维度里工具调用稳定性是一票否决项不稳定的话其他再好也没用。从我的实测来看不同模型适合不同任务。简单的文件读写、命令执行这类任务用便宜快速的模型就行复杂的重构、推理任务再上能力更强的模型。router 的model_map支持按请求特征做路由比如根据上下文长度或者任务类型自动选模型这个后面可以继续扩展。扩展方向上我还在考虑加本地缓存。同样的请求如果短时间内重复出现直接返回缓存结果能省不少钱。另外就是加一个简单的 Web 面板可视化查看调用统计和日志比翻命令行舒服。这个项目本身不复杂代码量不大但把 harness 和国内模型之间的这层适配做扎实了日常使用体验的提升是很明显的。如果你也在用类似的方案欢迎交流踩坑经验尤其是不同模型在工具调用上的那些个性多攒一些案例对大家都是好事。