1. 为什么我要折腾一个模型路由层国内做 AI 辅助开发的朋友大概率都经历过这样一个尴尬局面工具本身很好用但默认绑定的模型要么贵得离谱要么网络链路不稳定要么干脆没法用。我自己用 Claude Code 和 Codex 这类 agent harness 工具已经有一段时间了说实话它们在代码理解、多轮对话、工具调用上的体验确实比传统补全强出一大截。但问题也很明显——默认走的那套模型服务成本高延迟波动大有时候一个复杂任务跑下来token 消耗看得人心疼。于是我开始琢磨能不能让这些 harness 工具继续用它们擅长的交互方式但底层推理换成国内高性价比的模型比如 DeepSeek 系列在代码任务上的表现已经相当能打价格却只有国外头部模型的零头。这个想法听起来简单但真正动手才发现中间隔着一层“协议适配”的鸿沟。harness 工具通常按自己的接口规范发请求而国内模型服务商的 API 格式、鉴权方式、流式返回结构都不完全一样。直接改 harness 源码不现实升级一次就白干。手动改配置每个工具一套写法维护成本太高。所以我写了一个叫cn-llm-router的小项目定位很明确在本地跑一个轻量路由层把 harness 发出来的请求翻译成国内模型能听懂的格式再把返回结果翻译回去。它不碰 harness 的核心逻辑也不依赖任何特定厂商的 SDK就是一个纯粹的“协议转换中间人”。这篇文章我会把整个设计思路、核心实现、踩过的坑和实操步骤全部摊开讲适合正在用 Claude Code、Codex 或者类似 agent harness 工具、又想控制成本的开发者参考。哪怕你只是想搞清楚“harness 和模型之间到底发生了什么”这篇也能给你一个完整的视角。2. 整体架构设计与选型考量2.1 为什么不做成插件而是独立路由最开始我考虑过给每个 harness 写插件。Claude Code 有它的扩展机制Codex 也有自己的配置入口理论上可以分别适配。但很快我就放弃了这个方向原因有三个。第一插件机制不统一。不同 harness 的插件接口差异很大有的用配置文件有的用代码钩子有的干脆没开放底层请求拦截。为每个工具单独维护一套适配代码工作量成倍增加而且工具一升级就可能失效。第二调试困难。插件跑在 harness 进程内部出问题时日志混在一起很难判断是 harness 本身的问题还是适配层的问题。独立进程就不一样了请求进出一目了然抓包、打日志、改逻辑都不影响主程序。第三复用性差。我可能今天用 Claude Code明天试 Codex后天又换别的。如果适配逻辑绑死在某个工具上换工具就得重写。而独立路由层只要暴露一个标准接口任何 harness 只要能把请求指向本地地址就能接进来。所以最终架构是这样的harness 配置里把 API base URL 指向http://127.0.0.1:端口路由层收到请求后解析出模型名、消息体、工具定义等字段转换成目标模型服务的格式发出去拿到响应后再转回 harness 期望的结构。整个过程对 harness 透明它以为自己还在跟原来的服务对话。2.2 协议转换的核心难点在哪里很多人以为协议转换就是改改 JSON 字段名实际远不止如此。我总结下来主要有四个层面的差异需要处理。鉴权方式不同。有的服务用Authorization: Bearer xxx有的用自定义 header有的把 key 放在 query 参数里。路由层需要统一收口对外暴露一种鉴权方式对内按目标服务的要求重新组装。消息结构不同。OpenAI 风格的messages数组里每条消息有role和contentcontent 可以是字符串也可以是数组。而有些模型服务对 system 消息的位置、多模态内容的格式有额外要求。工具调用tool calls的字段命名和嵌套层级也经常不一样。流式返回格式不同。SSEServer-Sent Events是主流但每个服务商在事件类型、数据分片方式、结束标记上都有自己的习惯。harness 通常按某一种格式解析如果路由层不做好转换前端就会卡住或者解析报错。错误码和重试语义不同。429 限流、500 服务错误、上下文超长这些在不同服务里的返回结构不一样。路由层需要识别这些情况决定是直接透传、转换后返回还是自己重试。我在设计时把这些问题拆成独立的转换模块每个模块只负责一个维度这样新增一个模型服务商时只需要写对应的适配器不用动核心逻辑。2.3 为什么选 litellm 作为底层参考热词里提到了 litellm这确实是一个绕不开的项目。它做的事情和我有重叠都是统一多家模型服务的接口。但我没有直接用它而是参考了它的设计思路原因如下。litellm 功能很全支持的服务商非常多但这也意味着它的抽象层次比较高配置项复杂。对于我这种只想在本地跑一个轻量路由、专注国内几个高性价比模型的场景有点杀鸡用牛刀。而且它的某些默认行为比如重试策略、超时设置不一定符合 harness 工具的预期改起来反而麻烦。不过 litellm 的适配器模式很值得借鉴。它把每个服务商的差异封装在独立的 provider 类里对外暴露统一的 completion 接口。我在 cn-llm-router 里采用了类似的结构但做了大幅简化只保留我实际用到的几个模型服务代码量控制在很小的规模方便自己维护和调试。提示如果你需要支持非常多的模型服务商litellm 仍然是更成熟的选择。但如果你跟我一样主要在国内几个模型之间切换自己写一个轻量路由反而更可控。3. 核心细节解析与实操要点3.1 请求入口的设计与模型名映射路由层对外暴露的接口我选择兼容 OpenAI 的/v1/chat/completions格式。原因很简单绝大多数 harness 工具都支持自定义 OpenAI 兼容端点这是事实上的通用语言。只要我把入口做成这个格式Claude Code、Codex 以及各种基于 OpenAI SDK 的工具都能直接接进来。模型名映射是第一个要解决的问题。harness 配置里通常会写一个模型名比如claude-sonnet或者gpt-4但国内模型服务不认识这些名字。我的做法是在路由层维护一张映射表model_mapping: claude-sonnet: deepseek-chat gpt-4: deepseek-chat gpt-3.5-turbo: deepseek-chat default: deepseek-chat这样 harness 那边不用改任何模型名路由层自动把它翻译成实际要调用的国内模型。映射表支持热加载改完配置文件不用重启服务方便快速切换测试。这里有个细节要注意有些 harness 会在请求里带上模型的能力声明比如是否支持工具调用、上下文窗口多大。如果映射后的国内模型能力不完全一致可能会导致 harness 做出错误假设。我的处理方式是在路由层做一层能力过滤把不支持的字段去掉避免目标服务报错。3.2 消息体转换的关键字段处理消息体转换是工作量最大的部分。我以 OpenAI 格式为中间标准因为 harness 大多按这个格式发请求国内模型服务也大多兼容这个格式。但“兼容”不等于“完全一样”以下几个字段需要特别处理。system 消息的位置。OpenAI 允许 system 消息出现在 messages 数组的任意位置但有些模型服务要求 system 必须放在最前面。路由层需要检测并重新排序同时保持其他消息的相对顺序不变。content 的数组格式。当消息包含图片或复杂内容时content 会是一个数组每个元素有type字段。国内部分模型服务只支持纯文本遇到数组格式会直接报错。我的做法是检测到数组时提取其中的文本部分拼接成字符串图片等非文本内容暂时丢弃并记录警告。这不是完美方案但能保证请求不失败。工具调用的字段名。OpenAI 用tool_calls有些服务用function_call字段结构也不同。路由层需要做双向转换请求发出前把 harness 的格式转成目标服务的格式响应返回后再转回去。max_tokens 和 temperature 的默认值。不同 harness 对这些参数的默认值不一样有的不传有的传得很激进。路由层可以设置一层兜底默认值避免因为参数缺失导致目标服务使用不合理的默认行为。下面是一个简化的转换逻辑示意def convert_messages(messages, target_provider): # 1. 重排 system 消息 system_msgs [m for m in messages if m[role] system] other_msgs [m for m in messages if m[role] ! system] messages system_msgs other_msgs # 2. 展平 content 数组 for msg in messages: if isinstance(msg.get(content), list): msg[content] .join( part[text] for part in msg[content] if part.get(type) text ) # 3. 工具调用字段转换 if target_provider some_provider: for msg in messages: if tool_calls in msg: msg[function_call] convert_tool_calls(msg.pop(tool_calls)) return messages这段代码只是示意实际实现里还要处理更多边界情况比如空 content、多轮工具调用、并行工具调用等。3.3 流式响应的解析与重组流式响应是体验的关键。harness 工具通常期望边生成边显示如果路由层把整个响应缓冲完再返回用户会感觉卡顿。所以路由层必须支持 SSE 透传同时做好格式转换。我的实现方式是路由层向目标服务发起流式请求逐行读取 SSE 数据解析出每个 chunk转换成 harness 期望的格式再逐行写回给 harness。整个过程保持流式不缓冲完整响应。这里有几个坑我踩过。第一chunk 边界问题。SSE 数据可能被 TCP 分片切断不能假设每次读取都能拿到完整的一行。需要维护一个缓冲区按换行符切分不完整的部分留到下次处理。第二结束标记不一致。OpenAI 用data: [DONE]有些服务用空行或者特定事件类型。路由层需要识别目标服务的结束标记转换成 harness 认识的格式。第三心跳和注释行。有些服务会发送注释行或心跳包保持连接这些不应该透传给 harness否则可能干扰解析。async def stream_proxy(response, target_format): buffer async for chunk in response.aiter_bytes(): buffer chunk.decode(utf-8) while \n in buffer: line, buffer buffer.split(\n, 1) line line.strip() if not line or line.startswith(:): continue # 跳过空行和注释 if line data: [DONE]: yield data: [DONE]\n\n return if line.startswith(data: ): data json.loads(line[6:]) converted convert_chunk(data, target_format) yield fdata: {json.dumps(converted)}\n\n这段逻辑看起来简单但实际调试时花了我不少时间尤其是处理各种异常断开和超时的情况。3.4 配置文件结构与热加载机制路由层的配置我放在一个 YAML 文件里结构清晰方便手动修改。主要包含四块监听地址和端口、模型映射表、各模型服务商的连接信息、日志和重试策略。server: host: 127.0.0.1 port: 8787 model_mapping: claude-sonnet: deepseek-chat gpt-4: deepseek-chat default: deepseek-chat providers: deepseek: base_url: https://api.deepseek.com/v1 api_key: ${DEEPSEEK_API_KEY} timeout: 120 max_retries: 2 logging: level: info file: ./logs/router.log retry: max_attempts: 3 backoff_factor: 1.5API key 用环境变量注入避免明文写在配置文件里。热加载的实现是监听文件修改时间检测到变化后重新解析配置替换内存中的配置对象。正在处理的请求继续用旧配置新请求用新配置避免切换时的竞态问题。注意热加载虽然方便但如果你改了 provider 的 base_url 或 api_key正在进行的流式请求可能会中断。建议在低峰期做这类修改或者加一个优雅重启的机制。4. 实操过程与核心环节实现4.1 环境准备与依赖安装路由层我用 Python 写主要原因是生态成熟异步 HTTP 客户端和 SSE 处理都有现成的库。依赖不多核心就几个pip install fastapi uvicorn httpx pyyamlFastAPI 用来暴露 HTTP 接口uvicorn 作为 ASGI 服务器httpx 负责向后端模型服务发请求pyyaml 解析配置文件。没有引入任何重型框架整个项目跑起来内存占用很小在开发机上常驻完全没压力。如果你习惯用 Node.js逻辑完全一样用 express 或 fastify 加 axios 也能实现。我选 Python 纯粹是个人习惯加上调试异步流式代码时 Python 的写法比较直观。安装完成后目录结构大概是这样cn-llm-router/ ├── config.yaml ├── main.py ├── adapters/ │ ├── base.py │ └── deepseek.py ├── converters/ │ ├── messages.py │ └── stream.py └── logs/adapters 目录放各模型服务商的适配器converters 目录放协议转换逻辑main.py 是入口。结构简单新增一个服务商只需要加一个 adapter 文件改一下配置。4.2 启动路由服务并验证连通性配置写好后启动服务python main.py --config config.yaml看到监听日志后先用 curl 验证一下基本连通性curl http://127.0.0.1:8787/v1/chat/completions \ -H Content-Type: application/json \ -H Authorization: Bearer any-value \ -d { model: claude-sonnet, messages: [{role: user, content: 你好}], stream: false }如果返回了正常的 JSON 响应说明路由层到模型服务的链路是通的。注意这里的 Authorization 值可以随便填因为路由层对外不校验鉴权真正的 key 在配置文件里注入。这样做是为了简化 harness 那边的配置不用把真实 key 暴露给每个工具。如果返回错误先看路由层日志。我设计了分级日志info 级别记录请求摘要debug 级别记录完整请求和响应体。排查问题时把日志级别调到 debug基本能定位到是哪个环节出的问题。4.3 在 Claude Code 中接入路由层Claude Code 支持通过环境变量指定 API 端点。在启动前设置export ANTHROPIC_BASE_URLhttp://127.0.0.1:8787 export ANTHROPIC_API_KEYany-value然后正常启动 Claude Code。它发出的请求会先到路由层路由层把 Anthropic 格式转换成 OpenAI 格式再转发给国内模型。这里有个关键点Claude Code 用的是 Anthropic 自己的消息格式和 OpenAI 格式有差异比如 system 是独立参数而不是消息content 块的结构也不同。所以路由层需要额外做一个 Anthropic 到 OpenAI 的转换适配器。我实测下来转换后 Claude Code 的核心功能包括多轮对话、文件读取、代码生成都能正常工作。工具调用部分需要额外适配因为 Anthropic 的 tool use 格式和 OpenAI 的 function calling 格式差异较大我花了一些时间做字段映射和结果回传的转换。4.4 在 Codex 中接入路由层Codex 的配置方式不太一样它通常通过配置文件指定模型和端点。找到 Codex 的配置文件修改 base URL 指向本地路由{ api_base: http://127.0.0.1:8787/v1, model: gpt-4, api_key: any-value }Codex 本身就用 OpenAI 格式所以路由层不需要做额外的格式转换直接走标准的 OpenAI 适配器即可。这也是我选择 OpenAI 格式作为中间标准的原因——大部分工具都能对接省去很多转换工作。接入后我跑了一个实际任务让 Codex 帮我重构一个中等规模的 Python 模块。整个过程流畅模型响应速度比默认服务快不少成本更是降了一个数量级。唯一需要注意的是上下文窗口国内模型的上下文长度可能和 Codex 默认假设的不一样如果任务涉及大量文件可能需要调整 Codex 的上下文管理策略。4.5 参数调优与成本控制实测路由层跑通后我开始做一些参数调优。主要调整三个地方超时时间、重试策略、并发限制。超时时间我设成 120 秒因为国内模型在生成长代码时可能需要较长时间设太短会导致请求被切断。重试策略是失败后重试两次退避系数 1.5避免瞬间大量重试打爆后端。并发限制我设成 5因为个人使用场景下同时发起的请求不会太多限制并发可以避免触发后端限流。成本方面我做了个简单对比。同样一个代码生成任务用默认国外模型跑一次的成本换成国内高性价比模型后大约只有原来的十分之一到五分之一。具体数字取决于任务复杂度和输出长度但量级上的差异是明显的。对于每天都要用 agent harness 干活的开发者来说这个节省是实打实的。实操心得不要一上来就把并发调得很高。国内模型服务通常有 QPS 限制并发太高反而容易触发限流导致请求失败率上升。建议从低并发开始观察日志里的失败率逐步调整。5. 常见问题与排查技巧实录5.1 请求发出后无响应或超时这是最常见的问题可能的原因有好几个。先检查路由层日志看请求有没有到达路由层。如果日志里没有记录说明 harness 那边配置的地址不对或者网络不通。如果日志里有请求记录但没有响应记录说明路由层到模型服务的链路有问题检查 base_url 和 api_key 是否正确。还有一种情况是流式请求卡住。这通常是因为路由层在等待后端返回完整响应但后端已经断开了连接。我的处理方式是给流式请求加一个空闲超时如果超过一定时间没有收到新数据就主动关闭连接并返回错误。harness 那边收到错误后可以决定是否重试。5.2 工具调用失败或结果解析错误工具调用是 agent harness 的核心能力也是最容易出问题的环节。常见症状是模型返回了工具调用请求但 harness 解析失败或者工具执行结果回传后模型无法理解。排查时先看路由层日志里工具调用的字段结构。对比 harness 发出的格式和目标服务返回的格式确认转换逻辑是否正确。我遇到过一个坑某个模型服务返回的工具调用 ID 格式和 OpenAI 不一样导致 harness 在回传结果时匹配不上。解决办法是在路由层做一层 ID 映射把后端返回的 ID 转换成 harness 期望的格式回传时再转回去。另一个常见问题是并行工具调用。有些模型支持一次返回多个工具调用但 harness 可能只处理第一个。路由层需要检测这种情况要么把多个调用拆成多轮要么确保 harness 支持并行处理。5.3 流式输出中断或乱码流式输出中断通常和 chunk 边界处理有关。如果路由层没有正确维护缓冲区一个多字节字符被 TCP 分片切断解码时就会出错。解决办法是用字节缓冲区而不是字符串缓冲区按字节累积遇到完整行再解码。乱码的另一个来源是编码不一致。确保路由层、后端服务、harness 三方都用 UTF-8。我在配置文件里显式指定了编码避免依赖系统默认值。还有一种情况是 SSE 事件格式不标准。有些服务返回的 data 字段不是合法 JSON或者包含额外的空白字符。路由层需要做容错解析遇到解析失败的行记录警告并跳过而不是直接崩溃。5.4 常见问题速查表症状可能原因排查方向解决办法请求无响应地址配置错误检查 harness 配置的 base URL改为本地路由地址请求超时后端服务慢或网络问题查看路由层到后端的日志增加超时时间检查网络工具调用失败字段格式不匹配对比请求和响应的工具字段增加字段映射转换流式输出中断chunk 边界处理错误检查缓冲区逻辑改用字节缓冲区返回乱码编码不一致检查三方编码设置统一使用 UTF-8频繁限流并发过高查看后端返回的 429 错误降低并发增加退避上下文超长模型窗口不匹配检查请求的 token 数调整 harness 上下文策略5.5 几个我踩过的坑和独家技巧第一个坑是配置文件里的环境变量替换。我一开始用${VAR}语法但解析时没有做替换导致 api_key 变成了字面量字符串。后来加了一个预处理步骤在解析 YAML 之前先把环境变量替换掉。这个细节很小但排查起来很费时间。第二个坑是日志里的敏感信息。debug 级别日志会记录完整请求体里面可能包含 api_key。我在日志输出前做了一层脱敏把 key 替换成掩码。如果你也要做类似的路由层建议一开始就把脱敏逻辑加上避免事后补救。第三个技巧是用 curl 模拟 harness 请求。当 harness 本身出问题时很难判断是 harness 的 bug 还是路由层的问题。我习惯用 curl 手动构造一个和 harness 一样的请求直接打到路由层看返回是否符合预期。这样能快速定位问题边界。第四个技巧是保留原始请求和响应的快照。在调试阶段我会把每个请求的原始体和转换后的体都写到单独的文件里方便对比。问题解决后关掉这个功能避免磁盘占用。这个做法帮我省了很多来回猜测的时间。6. 后续可以怎么扩展路由层跑稳之后我又陆续加了一些小功能。比如按请求内容自动选择模型——简单问答走便宜的小模型复杂代码任务走能力强的大模型。实现方式是在路由层加一个简单的规则引擎根据消息长度、是否包含代码块、是否有工具调用等特征做判断。这个策略帮我进一步压低了成本同时保证了关键任务的质量。另一个扩展方向是加一层缓存。对于重复的请求比如相同的系统提示词加相同的问题可以直接返回缓存结果省去一次模型调用。缓存 key 用请求体的哈希设置合理的过期时间。这个在调试阶段特别有用因为调试时经常重复发同样的请求。还有一个想法是做多后端负载均衡。如果你有多个模型服务的 key可以在路由层做轮询或按权重分发提高可用性。不过这个我还没实际做因为个人使用场景下单后端基本够用。如果你也在用 agent harness 工具并且对成本敏感我建议先从一个最简单的路由层开始把基本链路跑通再逐步加功能。不要一上来就追求大而全那样很容易在细节里迷失。先把核心的协议转换做扎实后面的扩展都是水到渠成的事。