cn-llm-router:给国外Harness配国内大模型的轻量兼容路由层
1. 先说清楚到底给 harness 配国内模型难在哪里如果你最近在折腾 LLM 相关的东西大概率已经发现一个现实国外那些做得相当成熟的 harness 框架——就是负责调度大模型、管理上下文窗口、调用外部工具的 agent 运行外壳——用起来是真的顺手但默认绑定模型这件事也是真的让人头疼。我写的 cn-llm-router就是为了解决给国外 harness 配国内高性价比 LLM 模型这个具体问题而诞生的。先说个我在踩坑过程中的直观感受你花大半天时间把一个海外 harness 框架跑起来插上各种插件然后它告诉你请使用官方指定模型。这个模型按 token 计费价格并不便宜而且每次请求的延迟偶尔还很感人。于是很多人会想能不能把模型换成国内的大模型毕竟现在的国产模型能力已经相当能打价格还便宜了好几倍。理论上讲主流 harness 都宣称兼容 OpenAI 协议那直接改 base_url 不就行了我告诉你没有那么简单。1.1 harness 眼中的模型边界harness 这类项目之所以叫 harness是因为它把 agent 的骨架和模型本身解耦了。一个标准 harness 通常会负责管理多轮对话历史包括压缩和裁剪把用户指令拆解成任务调用内置工具接收模型输出中的 function call 参数渲染最终回复处理流式输出。它是套在模型外面的一套缰绳所以你换模型的本质不是换一个 API 地址那么简单。模型返回的格式稍有差异harness 对结果的处理逻辑就会出问题。比如有的模型返回的 function call 字段嵌套两层有的只嵌套一层有的流式返回里 reasoning_content 是单独通道有的混合在 content 里。这些差异在普通 API 调试工具里看不清但 harness 会直接读崩。也就是说你面对的真正问题不是地址改成哪而是harness 的输入输出协议和国内模型服务之间缺一个翻译层。1.2 三个绕不开的兼容性问题以我接过的几个海外 harness 为例换国内模型时会遇到以下三类问题。第一模型名写死。很多 harness 在配置里允许你填 model 字段但内部还是会用预设的模型名去判断行为。比如它知道你用的是 A 模型就走 A 模型的 prompt 模板和温度策略当你填了一个不在它预设列表里的模型名它就退回默认模板。这个退回动作本身可能没问题但有些 harness 会把未知模型直接判定为不支持然后拒绝发送请求。第二工具调用格式不一致。这是最致命的一点。OpenAI 的 function calling 格式是tools - tool_calls - arguments国内不少模型服务在接口上宣称兼容但工具调用的返回结构总是有细微差别。有些把参数放在arguments里但内容不是合法的 JSON 字符串有些把type字段写成了function_call而不是function。harness 拿不到它期望的字段就直接报错。第三内容审核与安全前缀。这倒不是接口层面的问题而是实际运营中出现的一些国内模型服务会在输出的某些特定场景下返回拒绝内容harness 收到这种响应会认为模型失效反复重试最后把整个任务卡死。1.3 为什么不用改源码的方案而是中间层面对这些兼容性问题你当然可以直接 fork 一个 harness 改源码。我也试过但很快就放弃了。原因很简单harness 的更新频率很高上游一升级你 fork 的版本就要手动合并冲突维护成本极高。而且很多使用场景下你并不需要动 harness 的逻辑只是希望把模型的请求地址替换掉。所以我选择了另一种思路在 harness 和本地模型服务之间加一个轻量路由层。它对外模拟出一个 harness 熟悉的标准服务接口对内负责把请求翻译成目标模型服务能理解的格式。harness 不需要知道背后连的是哪家大模型它只需要看到一个符合预期的响应。这个思路就是 cn-llm-router 的雏形。它的具体做法是harness 本来要发一个带有某种模型名、某种鉴权头、某种参数结构的请求cn-llm-router 截获之后改写成目标模型服务能够识别的样子再转发出去收到响应之后再做一次反向转换把响应降级成 harness 期望的格式返回。整个过程对 harness 完全透明。2. cn-llm-router 的路由设计一边伪装成标准服务一边对接国产模型2.1 整体数据流cn-llm-router 本质上是一个本地 HTTP 服务监听在某个端口上。harness 那边把 base_url 指向http://127.0.0.1:7765/v1然后一切照旧。数据流是这样的harness - cn-llm-router (本地端口) - 改写请求体 (模型名映射/参数修正/鉴权头替换) - 国内模型服务 API - 拉取流式/非流式响应 - 改写响应体 (格式还原/tool_calls 字段纠正) harness - 标准格式响应这个链路看起来简单但落地时需要处理很多细节。路由层本身不做任何模型推理它只是个翻译官。正因为此它的额外延迟非常低仅在请求和返回时各做一次 JSON 解析和改写通常能控制在 20ms 以内。对于模型本身几秒钟的响应时长来说这点开销可以忽略。我当时的目标是让路由器本身足够薄不引入数据库不引入消息队列不引入复杂的插件体系。一个进程、一份配置文件、一条命令行就完成整个部署。这也是它能快速上手的原因。2.2 模型名映射表配置的第一项就是模型映射。harness 那边发来的模型名通常是它官方模型的标识比如gpt-xxx这种而我们实际上要调用的国内模型可能是deepseek-chat或者qwen-max这类标识。这时路由器需要做一张映射表。model_mapping: - alias: gpt-xxx-default target: deepseek-chat max_tokens_default: 2048 temperature_override: null - alias: gpt-xxx-large target: qwen-max max_tokens_default: 4096这里有个细节很多人会忽略max_tokens_default很重要。国外模型服务的上下文长度和国内模型的默认输出上限并不一致。有些 harness 会在请求体里显式带上max_tokens: 4096但目标国内模型的单次最大输出只有 2048超限就会报错。映射表里可以针对不同 alias 设定不同的默认值同时在后面的参数修正环节把超限的值压到目标模型允许的范围内。还要考虑 prompt 模板的差异。有些 harness 会在系统提示词里写上你是某某模型的官方助手如果你换模型后不处理这句话目标模型会误判自己的身份回答风格也容易跑偏。所以我在路由器里加了一个strip_system_keywords开关默认会把系统提示词中出现原始模型名的地方替换成目标模型名保持身份一致性。2.3 请求头与认证字段的改写这一步比较容易理解但也最容易出问题。国内模型服务通常使用Authorization: Bearer key作为鉴权这个和国外标准是一致的但 key 的来源不同。你必须确保路由器拿到的 key 是目标模型服务的 key而不是 harness 原本使用的 key。实际操作中我会把目标的 key 直接放在路由器的配置文件里upstream: api_key: sk-国内模型服务的key base_url: https://api.example-llm.com/v1 timeout_seconds: 300然后在转发请求前把Authorization头整体替换掉。这个过程有一点要注意有些国内模型服务对OpenAI-Organization、OpenAI-Project这类头也无所谓但有些网关会做严格的 header 白名单校验遇到不认识的头会直接拒绝。稳妥的做法是除了Authorization、Content-Type两个头之外其余请求头全部剥离什么都不带。这样不仅能减少兼容问题还能避免一些 token 信息泄露。2.4 流式响应的兼容处理流式响应是个大坑。使用 SSEServer-Sent Events传输时每行都是一个data: {...}格式的 JSON。很多模型服务会把多个事件混合输出比如content的增量、reasoning_content的增量、tool_calls的增量交替出现。而 harness 通常只认它自己预设的那套事件流结构。我的处理方式是路由器在流式模式下边接收边解析收到目标模型服务的增量事件后重组成 harness 期望的块再写入标准响应流里。每个块都包含id、object、created、model、choices这些字段保证 harness 不会因为字段缺漏而中断解析。特别要注意的是finish_reason那次事件。很多国内模型服务在最后一个数据块里会把finish_reason置为stop但有些服务不写这个字段而是另外发一个[DONE]标记。我在路由器里做了一层兜底如果流已经结束但还没收到finish_reason就往下游补发一个finish_reason: stop的块。这层兜底解决了不少莫名其妙的截断问题——对那根本不是截断只是 finish 信号没对齐。3. 实现过程里踩过的几个坑3.1 参数落地的那些小差异别看所有服务都宣称兼容 OpenAI 格式实际跑起来你会发现各种小差异。举几个我在真实运行中遇到的例子。第一个是temperature的取值范围。国外标准是 0 到 2有些模型甚至支持小数位更多国内不少模型的官方取值是 0 到 1超过 1 就返回参数错误。路由器在转发前要做一个 clamp 操作超过 1 就拉回 1。如果你想要更强的随机性可以增加top_p而不是提升温度。第二个是response_format。有些模型服务支持{type: json_object}有些只支持{format: json}。一个不小心就会得到 400。我的做法是做一个白名单配置只有当目标模型确认支持 JSON 模式时才透传这个参数否则直接删除。第三个是stop参数。国外的 stop 可以是一个字符串列表也可以包含多个自定义停止符号部分国内模型服务对 stop 的长度有限制或者完全忽略这个参数。如果 harness 传了一个很长的 stop 列表路由器应该评估目标模型能力必要时截断或移除。这些问题单独看都很小但叠加起来就能让一次简单的模型替换变成崩溃现场。整理一张表可能更直观差异点国外服务国内常见服务路由器处理策略temperature 范围0-20-1超限自动 clampresponse_formatjson_object不统一按白名单决定是否透传stop 长度无限制有限制过长则截断或移除system 提示词可任意传部分服务限制必要时精简超时上限较长各有不同取 min 值避免任务未完成即超时3.2 工具调用格式不一致怎么解决工具调用是 harness 里面最关键的能力也是格式差异的高发区。我拿一个具体例子来说。海外 harness 发送的tools定义长这样{ type: function, function: { name: search, description: 搜索接口, parameters: {type: object, properties: {}} } }国内某模型服务要求它在请求里长这样{ name: search, description: 搜索接口, parameters: {type: object, properties: {}} }注意多了一层或多去了一层function包裹。如果你直接把 harness 的请求转发过去模型服务可能不认识这种结构觉得你没有传任何工具然后该调用的不调用白白丢失功能。我的解决办法是在路由器里做 tools 结构的浅转换发起请求时如果检测到目标模型需要展开结构把tools数组里的每一项做一次拍平或套壳处理拿到响应中的tool_calls时再做一次反向拍平。转换逻辑不复杂但必须覆盖所有嵌套情况否则就会遇到第一个参数正常、第二个参数解析失败的诡异问题。另外一个比较隐蔽的问题是arguments字段的内容。有的模型服务返回的arguments是一个已经解析好的 JSON 对象而 OpenAI 标准要求它是字符串。我在路由器里统一把它们序列化成字符串再传给 harness这样不管源头是什么格式harness 端永远拿到它认识的东西。3.3 超时、重试与并发策略可能有人会觉得路由器只是转发超时策略交给 harness 不就行了其实不是。你替换模型之后目标服务的响应速度分布会有变化尤其是遇到推理能力较强的模型时一个复杂任务可能几十秒都没有增量输出。harness 自带的超时时间往往按照它官方模型的均值来设定你换完模型之后很容易被这个超时坑到。我在 cn-llm-router 里提供了一套独立的超时控制它比 harness 的等待更像上游感知:连接超时10 秒用于快速暴露网络问题读取超时300 秒给大模型充足的思考时间首字节超时60 秒首字节迟迟不来就用重试逻辑。重试也要讲究策略。盲目重试不仅浪费 token还会让 harness 收到重复的工具调用。我在重试时自动丢弃已经产生的增量输出只在请求尚未进入实际生成阶段时才重试。怎么判断是否已经生成简单粗暴如果流式响应里已经收到第一个content块就不再重试直接返回错误让 harness 层面做决策。并发方面路由器采用按请求 ID 隔离的策略。每一个传入请求都分配一个唯一的request_id在转发给上游时透传便于排查。多实例部署时由于路由器本身是无状态的可以随便横向扩容前面套一层负载均衡就行。3.4 日志与调试写路由器的过程中我养成的一个习惯是把每次请求的改写前和改写后完整地记录到日志里。这听起来有点笨重但在排错时价值极高。很多兼容性问题看一眼改写前后的 JSON 差异立刻就知道问题出在哪一层。日志格式我设计了几种级别debug记录请求体、响应体的全量改动info记录请求来源、模型映射、延迟、token 消耗warn记录参数修正、超时重试、字段补齐等异常error记录上游报错的具体状态码和响应体。尤其是 warn 级日志我后来才发现它比 error 更有价值。因为大部分兼容性问题不会直接让请求失败而是让结果变得不那么对。举个例子某次我把temperature从 1.3 压到 1如果没有 warn 日志你根本不会注意到这个动作但那一次生成结果的随机性可能就差了不少。有了 warn 日志你就能反向检查自己到底做了哪些非预期修改。4. 实际跑通的配置与使用效果4.1 一份可以抄作业的配置下面是我当前正在用的一份简化配置可以配合大多数常见 harness 使用。你需要先安装 cn-llm-router然后写入以下配置server: host: 127.0.0.1 port: 7765 log_level: warn model_mapping: - alias: gpt-xxx-default target: 国内模型服务 max_tokens_default: 2048 upstream: base_url: https://api.example-llm.com/v1 api_key: sk-xxxx timeout_seconds: 300 exclude_headers: [OpenAI-Organization, OpenAI-Project] param_guard: clamp_temperature: true max_stop_entries: 4 response_format_whitelist: [json_object] tool_style: request_style: flattened response_style: nested启动命令也很简单python -m cn_llm_router --config ./config.yaml然后在 harness 侧把 base_url 改成http://127.0.0.1:7765/v1模型名填你映射表里的 alias 值比如gpt-xxx-defaultAPI key 随便填一个占位符就行路由器在转发时会用配置里的真实 key 替换。这一步很关键不然 harness 自带的鉴权校验可能直接拦住请求。4.2 实测的延迟、成本与稳定性我连续跑了大概一周覆盖了代码生成、文档问答、工具调用三种典型场景。先说延迟。使用国内模型服务后首字节延迟比海外服务本地直连稍高一些但影响不大。整体上一个包含 3 到 4 次工具调用的任务从 harness 发出首个请求到最终拿到回复耗时在 20 到 40 秒之间属于可接受范围。成本方面的差异是立竿见影的。以同样的代码生成任务为例使用海外付费模型按官方计价每百万 token 的价格和国内模型服务大约是几十倍的差距。在大量 agent 任务、尤其是需要多轮调用工具的负载下这个差距会直接体现在账单上。我自己跑完一周的测试任务换算下来比原来至少降了一个数量级而生成质量在核心任务上没有明显差距部分结构化输出甚至更稳定。稳定性方面我印象最深的是有一次连续跑了 12 个小时的自动化任务中间出现过三次上游限流返回 429但路由器重试之后全部恢复harness 那边零感知。这就是中间层存在的价值它不只是翻译还是一个韧性提供者。5. 后续想扩展的方向与个人总结5.1 多模型路由策略现阶段 cn-llm-router 的映射是一对一一个别名对应一个目标模型。接下来的一个扩展方向是支持池化路由把同一个任务按规则分发给多个模型比如简单任务走更便宜的模型复杂任务走更强的模型。硬件条件允许时还可以引入本地部署的开源模型把极其高频的简单任务完全离线消化。我设想的路由规则大概包含几个维度任务长度、是否包含工具调用、是否要求 JSON 输出、预期的 token 量。这些维度可以组合成一个简单的评分然后路由器根据评分将请求分发到不同的上游。配置层面就是多张映射表加一个规则引擎不至于太复杂但盈利能力很强。5.2 安全与审计能力还有一块我也在完善那就是请求审计。既然所有模型请求都经过路由器那在这里做数据脱敏和合规检查是最合适的位置。比如检测请求中是否包含敏感字段决定要不要放行或者脱敏后再发给模型服务。响应侧也可以做一遍简单的 PII 扫描防止模型生成的文本里意外泄露人员信息。这个功能一旦加上路由器就不只是技术工具还能承担一部分安全责任。5.3 给还在观望的人几句实话最后说几句体己话。如果你只是在本地调试一个小 demo那完全没必要引入 cn-llm-router直接改环境变量指向国内服务地址能跑就跑跑不通再说。但如果你像我一样需要在 harness 上跑长时间任务、接多种插件、频繁更换模型供应商那一个轻量路由层带来的收益绝对超过它的维护成本。真正让我觉得这个方向没错的时刻是一次深夜调试harness 反复报错我排查了一个多小时都没定位到问题最后打开 cn-llm-router 的 warn 级日志瞬间就看到了参数被压制的记录。那一瞬间你会明白工具的价值不在于你写了多少行代码而在于它帮你省了多少无意义的排查时间。希望这次的分享能帮你少走一些弯路。