cn-llm-router:为Claude Code等harness接入国内大模型的本地路由方案
发布时间:2026/10/10 13:26:29 锦皓数字建站

1. 为什么我要折腾模型路由这件事用 Claude Code 这类 harness 工具写代码体验确实好但账单也是真让人肉疼。我平时主力开发环境在国内网络访问海外 API 本来就不算顺畅再加上按量计费一个月下来光是模型调用费用就够吃好几顿火锅了。更别提有时候网络抖动一个请求卡半天思路全断了。后来我就琢磨能不能让 harness 继续用它的交互逻辑和工具链但把底层模型换成国内的高性价比方案比如 DeepSeek、通义千问、Kimi 这些价格便宜量又足国内直连延迟低关键是很多场景下代码能力已经够用了。于是就有了cn-llm-router这个项目——一个跑在本地的小型路由层专门给国外 harness 配国内模型。它解决的问题很具体harness 默认只认某一家海外模型的 API 格式而国内模型的接口协议、鉴权方式、流式返回格式都不一样。cn-llm-router 做的事情就是在中间做协议转换和请求转发让 harness 以为自己在跟原来的服务说话实际上请求已经被悄悄转到了国内模型上。适合谁用适合那些想用 Claude Code、DeepSeek Harness 这类工具但又想控制成本、提升访问稳定性的开发者。哪怕你只是刚接触 LLM 工具链跟着本文的步骤也能跑起来。2. 整体设计思路与方案选型2.1 核心需求拆解harness 到底需要什么要理解 cn-llm-router 的设计先得搞清楚 harness 这类工具对后端模型服务的要求。我拆了一下大概有这么几层第一层是接口协议。Claude Code 走的是 Anthropic 的 Messages API 格式请求体里有model、messages、max_tokens、stream这些字段响应是 SSE 流式事件。而国内模型大多兼容 OpenAI 的 Chat Completions 格式字段名和事件结构都不一样。这是最硬的一层差异必须做转换。第二层是鉴权方式。海外服务用x-api-key头国内模型基本都用Authorization: Bearer token。路由层需要把鉴权信息重新组装。第三层是模型名称映射。harness 配置里写的是claude-sonnet-4-20250514这种名字但国内模型有自己的 model id比如deepseek-chat、qwen-max。路由层要维护一张映射表把请求里的模型名替换掉。第四层是流式响应格式。这是最容易踩坑的地方。Anthropic 的流式事件类型有message_start、content_block_delta、message_stop等而 OpenAI 格式是data: {choices:[{delta:{content:...}}]}。如果转换不完整harness 会解析失败表现为界面卡住或者报错。第五层是工具调用tool use。Claude Code 重度依赖 function calling 来做文件读写、命令执行。国内模型对 tool use 的支持程度参差不齐有的格式兼容有的需要额外适配。这一层如果处理不好harness 的核心功能就废了一半。2.2 为什么选择本地路由而不是改 harness 源码有人可能会问直接改 harness 的源码让它支持国内模型不就行了我试过不划算。原因有几个harness 更新频繁每次升级都要重新打补丁维护成本高。改源码容易引入 bug而且不好回退。本地路由层是独立的harness 升级不影响它反过来路由层要加新模型也不用动 harness。所以我的方案是harness 配置里把 API base URL 指向本地路由服务比如http://127.0.0.1:8787路由层收到请求后做协议转换转发给国内模型再把响应转回 Anthropic 格式。这样 harness 完全无感知该干嘛干嘛。2.3 技术栈选择Node.js Hono 的轻量组合路由层我用的是 Node.js 加 Hono 框架。选 Hono 的理由很简单体积极小启动快对 SSE 流式响应的支持很自然而且写起来跟 Express 类似但更现代。整个服务打包后不到 200KB跑在本地几乎不占资源。为什么不用 PythonFastAPI 也能做但 Node.js 在处理流式转发时的心智负担更低fetch原生支持 stream管道对接很顺。而且 harness 生态本身就跟 Node.js 亲近调试起来方便。数据库方面我没用模型映射表直接写在配置文件里用 JSON 管理。理由是这个东西变更频率低没必要上数据库增加复杂度。需要改的时候编辑文件重启服务就行。3. 核心细节解析与实操要点3.1 协议转换的关键字段对照协议转换是路由层的核心。我把 Anthropic Messages API 和 OpenAI Chat Completions 的关键字段做了对照这是写转换逻辑的基础Anthropic 字段OpenAI 对应字段转换说明modelmodel通过映射表替换为国内模型 idmessages[].rolemessages[].roleuser/assistant直接对应system需提取到单独字段messages[].contentmessages[].content字符串直接对应数组格式需展平max_tokensmax_tokens直接对应streamstream直接对应toolstools格式需转换Anthropic 用input_schemaOpenAI 用parameterstool_choicetool_choice语义基本一致枚举值需映射这里有个细节Anthropic 的system是顶层字段而 OpenAI 格式里 system 是 messages 数组里的一条。转换时要把顶层 system 插到 messages 最前面。反过来如果国内模型返回的响应里没有 system就不用管。注意有些国内模型对max_tokens的上限有自己的限制比如最大 8192。如果 harness 传了 16384路由层要截断否则请求会被拒绝。3.2 流式响应的逐事件转换逻辑流式转换是最容易出问题的地方。我的做法是收到国内模型的 SSE 流后逐行解析data:后面的 JSON提取choices[0].delta.content然后包装成 Anthropic 的content_block_delta事件。具体事件序列是这样的收到第一个 chunk 时先发message_start事件带上 message id 和 model 信息。然后发content_block_start表示内容块开始。每个 delta 发一个content_block_delta里面delta.type是text_deltatext是实际内容。流结束时发content_block_stop和message_stop。如果国内模型返回的是 tool call处理逻辑更复杂需要在content_block_start里把 type 设为tool_use然后 delta 里传input_json_delta。这块我踩过坑后面会细说。3.3 模型映射表的配置方式映射表我放在config/models.json里结构大概是这样{ claude-sonnet-4-20250514: { provider: deepseek, model: deepseek-chat, baseUrl: https://api.deepseek.com/v1, apiKeyEnv: DEEPSEEK_API_KEY, maxTokensLimit: 8192 }, claude-haiku-3-20240307: { provider: qwen, model: qwen-turbo, baseUrl: https://dashscope.aliyuncs.com/compatible-mode/v1, apiKeyEnv: QWEN_API_KEY, maxTokensLimit: 8192 } }apiKeyEnv指向环境变量名这样密钥不用写死在文件里。启动服务前export DEEPSEEK_API_KEYxxx就行。提示映射表的 key 要跟 harness 配置里写的模型名完全一致包括日期后缀。如果 harness 发来的模型名不在表里路由层应该返回一个明确的错误而不是静默失败。3.4 工具调用的适配难点Claude Code 的工具调用格式跟 OpenAI 有差异。Anthropic 的 tool 定义长这样{ name: read_file, description: Read a file, input_schema: { type: object, properties: { path: {type: string} } } }OpenAI 格式则是{ type: function, function: { name: read_file, description: Read a file, parameters: { type: object, properties: { path: {type: string} } } } }转换时要包一层function把input_schema改名成parameters。响应方向反过来国内模型返回的tool_calls要转成 Anthropic 的tool_usecontent block。这里有个大坑不是所有国内模型都支持 tool use。DeepSeek 的deepseek-chat支持得不错但有些模型会忽略 tools 字段直接返回文本。这种情况下 harness 会认为模型没有调用工具行为就异常了。我的做法是在配置里加一个supportsTools标志不支持的工具调用场景直接返回错误提示让用户换模型。4. 实操过程与核心环节实现4.1 环境准备与依赖安装先把基础环境搭起来。我假设你已经装了 Node.js 18 以上版本没装的话去官网下个 LTS 版就行。mkdir cn-llm-router cd cn-llm-router npm init -y npm install hono hono/node-serverHono 本身不带 Node.js 适配器所以要装hono/node-server。这两个包加起来依赖很少装起来很快。然后建目录结构mkdir -p src config touch src/index.js src/transform.js src/stream.js config/models.jsonindex.js是入口transform.js放请求转换逻辑stream.js放流式响应转换逻辑。分开放是为了后面好维护。4.2 请求转换模块的编写先写transform.js核心是把 Anthropic 请求转成 OpenAI 格式export function anthropicToOpenAI(body, modelConfig) { const messages []; if (body.system) { messages.push({ role: system, content: body.system }); } for (const msg of body.messages) { if (Array.isArray(msg.content)) { const textParts msg.content .filter(c c.type text) .map(c c.text) .join(); messages.push({ role: msg.role, content: textParts }); } else { messages.push({ role: msg.role, content: msg.content }); } } const result { model: modelConfig.model, messages, max_tokens: Math.min(body.max_tokens || 4096, modelConfig.maxTokensLimit), stream: body.stream || false }; if (body.tools modelConfig.supportsTools) { result.tools body.tools.map(t ({ type: function, function: { name: t.name, description: t.description, parameters: t.input_schema } })); } return result; }这段逻辑里max_tokens的截断很重要。我一开始没做结果 harness 传了 16384DeepSeek 直接返回 400 错误排查了半天才发现是超限。4.3 流式响应转换的实现stream.js是重头戏。我用 TransformStream 来做管道转换export function createAnthropicStream(modelName) { let buffer ; let messageStarted false; let contentBlockStarted false; const messageId msg_ Date.now(); return new TransformStream({ transform(chunk, controller) { buffer new TextDecoder().decode(chunk); const lines buffer.split(\n); buffer lines.pop(); for (const line of lines) { if (!line.startsWith(data: )) continue; const data line.slice(6).trim(); if (data [DONE]) { if (contentBlockStarted) { controller.enqueue(encodeSSE(content_block_stop, { index: 0 })); } controller.enqueue(encodeSSE(message_stop, {})); continue; } const parsed JSON.parse(data); const delta parsed.choices?.[0]?.delta; if (!delta) continue; if (!messageStarted) { controller.enqueue(encodeSSE(message_start, { message: { id: messageId, model: modelName, role: assistant } })); messageStarted true; } if (delta.content) { if (!contentBlockStarted) { controller.enqueue(encodeSSE(content_block_start, { index: 0, content_block: { type: text, text: } })); contentBlockStarted true; } controller.enqueue(encodeSSE(content_block_delta, { index: 0, delta: { type: text_delta, text: delta.content } })); } } } }); } function encodeSSE(event, data) { return new TextEncoder().encode( event: ${event}\ndata: ${JSON.stringify(data)}\n\n ); }这段代码的关键点是事件顺序。Anthropic 的流式协议要求message_start必须在所有content_block_delta之前message_stop必须在最后。如果顺序错了harness 的解析器会直接报错。注意buffer的处理不能省。SSE 数据可能被 TCP 分片一个完整的data:行可能跨两个 chunk。如果不做缓冲JSON.parse 会失败。4.4 主服务入口与路由注册index.js把上面两块串起来import { Hono } from hono; import { serve } from hono/node-server; import { readFileSync } from fs; import { anthropicToOpenAI } from ./transform.js; import { createAnthropicStream } from ./stream.js; const models JSON.parse(readFileSync(./config/models.json, utf-8)); const app new Hono(); app.post(/v1/messages, async (c) { const body await c.req.json(); const modelConfig models[body.model]; if (!modelConfig) { return c.json({ error: { message: Unknown model: ${body.model} } }, 400); } const apiKey process.env[modelConfig.apiKeyEnv]; if (!apiKey) { return c.json({ error: { message: Missing API key env: ${modelConfig.apiKeyEnv} } }, 500); } const openaiBody anthropicToOpenAI(body, modelConfig); const upstream await fetch(${modelConfig.baseUrl}/chat/completions, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${apiKey} }, body: JSON.stringify(openaiBody) }); if (!upstream.ok) { const err await upstream.text(); return c.json({ error: { message: err } }, upstream.status); } if (body.stream) { const stream upstream.body.pipeThrough(createAnthropicStream(body.model)); return new Response(stream, { headers: { Content-Type: text/event-stream, Cache-Control: no-cache, Connection: keep-alive } }); } const result await upstream.json(); return c.json(openAIToAnthropic(result, body.model)); }); serve({ fetch: app.fetch, port: 8787 }, () { console.log(cn-llm-router running on http://127.0.0.1:8787); });非流式响应的转换函数openAIToAnthropic我没展开逻辑跟流式类似只是把整个响应一次性转换。4.5 harness 侧的配置方法路由服务跑起来后harness 那边要改配置。以 Claude Code 为例设置环境变量export ANTHROPIC_BASE_URLhttp://127.0.0.1:8787 export ANTHROPIC_API_KEYdummy-key-not-usedANTHROPIC_API_KEY随便填一个因为真正的鉴权在路由层用国内模型的 key。但 harness 启动时会检查这个变量是否存在不填会报错。然后启动 harness它发的请求就会打到本地 8787 端口。你可以开另一个终端tail -f看路由层的日志确认请求和响应都正常。提示如果 harness 有配置文件而不是环境变量找到baseUrl或apiBase字段改成http://127.0.0.1:8787即可。不同版本字段名可能不同以实际为准。5. 常见问题与排查技巧实录5.1 流式响应卡住不动这是最常见的问题。表现是 harness 界面一直转圈没有内容输出。排查思路先看路由层日志确认有没有收到上游的 chunk。如果上游正常返回但 harness 没反应大概率是 SSE 事件格式不对。用curl直接打路由层curl -N -X POST http://127.0.0.1:8787/v1/messages \ -H Content-Type: application/json \ -d {model:claude-sonnet-4-20250514,messages:[{role:user,content:hi}],stream:true,max_tokens:100}看输出的 SSE 事件是否符合 Anthropic 格式。重点检查event:行和data:行之间有没有空行以及 JSON 是否合法。另一个可能原因是Content-Type没设成text/event-stream或者响应头里少了Cache-Control: no-cache。有些中间层会缓冲响应导致流式失效。5.2 工具调用返回格式错误如果 harness 报 invalid tool use 之类的错误说明 tool call 的转换有问题。国内模型返回的tool_calls结构是{ choices: [{ delta: { tool_calls: [{ index: 0, id: call_xxx, function: {name: read_file, arguments: {\path\:\a.txt\}} }] } }] }转成 Anthropic 格式时arguments是字符串需要原样放进input_json_delta的partial_json字段。而且 tool call 的流式传输是分片的arguments可能分多次到达要按index累积。我踩过的坑是一开始把arguments直接 JSON.parse 了结果分片时解析失败。正确做法是当作字符串透传让 harness 自己拼。5.3 模型名不匹配导致 400harness 有时会发一些内部模型名比如claude-3-5-sonnet-latest而你的映射表里只有带日期后缀的。解决办法是在映射表里加别名或者写一个模糊匹配逻辑如果精确匹配失败尝试去掉日期后缀再匹配。我现在的做法是映射表里同时保留精确名和通用名比如{ claude-sonnet-4-20250514: { ... }, claude-3-5-sonnet-latest: { ... } }两个 key 指向同一个配置。虽然有点冗余但省心。5.4 常见问题速查表现象可能原因解决方法界面一直转圈SSE 格式错误或响应头缺失用 curl 验证事件格式检查 Content-Type400 错误模型名不在映射表添加别名或模糊匹配401 错误国内模型 API key 无效检查环境变量是否正确导出工具调用失败模型不支持 tool use换支持工具调用的模型或关闭工具功能响应截断max_tokens 超限在配置里设置 maxTokensLimit 截断中文乱码编码问题确保全程 UTF-8TextDecoder 不指定编码5.5 几个实用的调试技巧第一个技巧在路由层加请求日志把转换前后的 body 都打印出来。我一开始没加排查问题时全靠猜加了日志后一目了然。可以用console.log(JSON.stringify(body, null, 2))。第二个技巧准备一个最小复现脚本。当 harness 出问题时用 curl 直接打路由层排除 harness 本身的干扰。如果 curl 正常但 harness 异常问题就在 harness 配置如果 curl 也异常问题在路由层。第三个技巧国内模型的 API 文档要常备。不同厂商对 OpenAI 兼容格式的支持程度不一样有的字段名有细微差异。比如某些平台的流式响应里delta可能叫message这种就得单独适配。6. 性能优化与扩展思路6.1 连接复用与超时控制路由层每次请求都新建 fetch 连接在高频调用下会有开销。Node.js 的 undici 默认会复用连接池但可以显式配置import { Agent, setGlobalDispatcher } from undici; setGlobalDispatcher(new Agent({ connections: 20, pipelining: 1, keepAliveTimeout: 30000 }));超时控制也很重要。国内模型偶尔会响应慢如果不设超时harness 会一直等。我设的是 60 秒连接超时、120 秒总超时超过就返回错误让 harness 重试。6.2 多模型负载均衡如果你配了多个国内模型可以在路由层做简单的轮询或按权重分配。我的做法是在映射表里加一个weight字段请求时按权重随机选一个 provider。这样某个模型限流时请求会自动落到其他模型上。不过要注意不同模型的输出风格差异较大混用可能导致 harness 的行为不稳定。我的建议是主力用一个模型备用模型只在主力失败时兜底。6.3 缓存高频请求有些请求是重复的比如 harness 启动时的握手、固定的系统提示词。可以在路由层加一层内存缓存对相同 body 的请求直接返回缓存结果。但要注意流式请求不能缓存因为每次的 message id 不同。缓存用 Map 实现就行设置一个 TTL比如 5 分钟。超过就清除。这个优化能省不少 token尤其是调试阶段反复发相同请求的时候。6.4 扩展到其他 harness 工具cn-llm-router 的设计是通用的不只服务于 Claude Code。任何走 Anthropic Messages API 格式的 harness 都能用。如果你的工具走的是 OpenAI 格式那更简单直接改 baseUrl 指向国内模型就行连转换都不用。我后来把这个路由层也接给了 DeepSeek Harness 用只需要在它的配置里把 API 地址改过来。因为 DeepSeek Harness 本身可能就兼容 OpenAI 格式所以路由层甚至可以省掉转换逻辑只做转发和鉴权替换。6.5 安全与密钥管理密钥绝对不能写死在代码或配置文件里。我用的是环境变量加.env文件的方式.env加到.gitignore里。启动脚本里source .env再跑服务。另外路由层只监听127.0.0.1不要监听0.0.0.0。否则局域网内其他机器也能访问你的路由服务等于把你的 API key 暴露了。这个细节很多人会忽略我特意在代码里写死了127.0.0.1。注意如果你在容器里跑路由层127.0.0.1可能不通需要改成0.0.0.0并配合防火墙规则。但一定要确保只有可信网络能访问。7. 我踩过的坑和实际使用体会这个项目我从起意到跑通大概花了一个周末中间踩的坑比预想的多。最大的一个坑是流式事件的顺序问题。我一开始图省事把所有事件都塞进一个content_block_delta里发结果 harness 直接报解析错误。后来对着 Anthropic 的文档一个事件一个事件地对才发现message_start和content_block_start一个都不能少。另一个坑是 tool call 的分片累积。国内模型返回 tool call 时arguments字段是分多次流式传输的每次只给一小段 JSON 字符串。我一开始每次都尝试 parse结果自然是失败。正确做法是维护一个 buffer按 index 累积等流结束再整体处理。实际用下来DeepSeek 的deepseek-chat在代码场景下表现相当不错响应速度快工具调用也稳定。通义千问的qwen-max在中文理解和长文本处理上更强但工具调用偶尔会抽风。我的建议是主力用 DeepSeek遇到复杂中文任务再切千问。成本方面之前用海外模型一个月大概两三百块现在用国内模型同样的调用量只要三四十块省了将近九成。而且国内直连延迟低harness 的响应明显更跟手了。唯一需要注意的是国内模型的上下文窗口和输出长度限制跟海外模型不完全一样配置的时候要把maxTokensLimit设对不然容易报错。最后分享一个小技巧路由层可以加一个/health端点返回当前配置的模型列表和各自的状态。这样 harness 出问题时先 curl 一下/health就能快速判断是路由层挂了还是上游模型的问题。这个端点我加完之后排查效率提升了不少。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。