1. 为什么我要折腾这个路由层国内做 AI 应用开发的人最近一年应该都有同一个感受海外那几套 agent harness 的工程体验确实做得好任务拆解、工具调用、上下文管理、代码回退这些机制打磨得很成熟但真要把它们接到国内模型上麻烦事一堆。反过来国内模型的性价比又实在诱人尤其是长上下文和批量推理场景成本能压到海外方案的零头。这两头的好处我都想要中间就缺一个能扛事的路由层。我最初的做法很土就是在每个 harness 的配置文件里硬编码一个 base_url指向某个国内模型的兼容接口。单个工具用着还行一旦同时跑 Claude Code、Codex 这类工具再加上几个自研的 agent 脚本配置就开始互相打架。有的 harness 只认/v1/chat/completions有的走/responses端点有的对流式返回的字段格式特别挑剔还有的会在请求里塞一堆自定义 header。改一处崩三处调试成本高得离谱。cn-llm-router就是在这个背景下写出来的。它的定位很明确一个跑在本地的轻量路由服务对外暴露标准化的模型接口对内把请求分发到国内各家模型供应商同时处理协议转换、模型映射、失败重试、用量统计这些脏活。harness 那边完全不用改代码只要把 base_url 指过来就行。这篇文章我会把整个设计思路、核心实现、踩过的坑和排查技巧完整讲一遍适合正在做 agent 工程、被多模型接入折磨过的开发者参考也适合刚上手 Claude Code、Codex 想接国内模型的新手照着搭。2. 整体架构设计与选型考量2.1 为什么是本地路由而不是云端网关市面上做模型网关的方案不少litellm 就是其中比较成熟的一个支持多供应商、统一接口、成本追踪。我一开始也认真评估过直接用 litellm 当底座但最后选择自己写一层薄路由核心原因有三个。第一是延迟。agent harness 的特点是请求密集、单次请求小、对首 token 延迟极其敏感。云端网关多一跳网络在工具调用循环里会被放大成肉眼可见的卡顿。本地路由跑在127.0.0.1几乎零网络开销。第二是可控性。harness 发出来的请求经常带一些非标准字段比如某些工具会传metadata、system数组、自定义的tool_choice结构。云端网关为了兼容性会做严格校验直接拒掉本地路由我可以按需放行、改写、透传灵活得多。第三是隐私和离线。有些场景跑在内网或者离线环境请求根本出不去本地路由是唯一可行的形态。这一点在热词里也有人问“能不能在离线局域网使用”答案是可以只要模型服务本身在内网可达。不过我没有完全抛弃 litellm 的思路。cn-llm-router在供应商适配层借鉴了 litellm 的 provider 抽象把“协议转换”和“路由决策”拆开前者可以复用成熟库后者自己控制。这样既省事又不失灵活。2.2 分层结构拆解整个服务我分成四层从外到内依次是接入层、路由层、适配层、供应商层。接入层负责监听 HTTP暴露两类端点一类是 OpenAI 兼容的/v1/chat/completions另一类是/responses风格的端点专门伺候那些走新协议的 harness。接入层还要处理鉴权本地可以简化成固定 token、请求日志、CORS。路由层是核心做三件事模型名映射、供应商选择、失败转移。比如 harness 请求claude-sonnet这个逻辑名路由层根据配置把它映射到某个国内模型的具体型号再根据负载和健康状态选一个供应商实例。适配层处理协议差异。国内模型的接口大多兼容 OpenAI 格式但细节上有出入有的不支持tools字段的某种嵌套有的对stream_options处理不一致有的返回的finish_reason取值不同。适配层把这些差异抹平让上层看到统一的响应结构。供应商层就是各家模型的实际 HTTP 客户端负责拼请求、发请求、解析响应、处理错误码。2.3 模型映射表的设计映射表是整个路由的灵魂。我用一个 YAML 文件维护结构大概是这样models: claude-sonnet: provider: provider_a target: model-x-large context_window: 128000 supports_tools: true supports_stream: true gpt-4-class: provider: provider_b target: model-y-pro context_window: 64000 supports_tools: true supports_stream: true fallbacks: claude-sonnet: - provider_b:model-y-pro - provider_c:model-z这里有几个设计决策值得说。context_window不是给模型用的是给路由层做请求预检的——如果 harness 发来的上下文超过目标模型窗口路由层可以提前拒绝或者触发截断而不是等供应商返回一个含糊的错误。fallbacks是失败转移链主供应商超时或者返回 5xx 时按顺序尝试下一个。注意映射表里的逻辑名要和 harness 配置里写的模型名完全一致大小写敏感。我见过有人写Claude-Sonnet结果匹配不上排查了半天。2.4 为什么不用简单的反向代理有人会问nginx 做反向代理加个 rewrite 不就行了不行。反向代理只能改 URL 和 header改不了请求体里的 JSON 结构。harness 请求里的模型名在 body 里工具定义在 body 里流式响应的格式也在 body 里。这些都需要解析 JSON、按字段改写、再重新序列化。反向代理做不了这层语义转换所以必须是一个能理解协议的应用层服务。3. 核心细节解析与实操要点3.1 协议转换的关键差异点国内模型接口和海外 harness 期望的接口差异主要集中在几个地方我逐个说。工具调用格式。OpenAI 的tools字段是一个数组每个元素有type: function和function对象。有些国内模型要求function.parameters必须是合法的 JSON Schema有些则宽松处理。反过来模型返回的tool_calls里arguments是字符串化的 JSON有些模型会返回已经解析好的对象适配层要统一成字符串。流式响应的结束标记。标准 OpenAI 流式会在最后发一个data: [DONE]但部分国内模型发的是空行或者自定义的结束事件。harness 如果严格等待[DONE]就会一直挂着不结束。适配层要检测这种情况并补发标准结束标记。system 消息的位置。有的模型要求 system 消息必须在 messages 数组第一位有的允许穿插。harness 生成的对话历史里 system 可能出现在中间适配层要把它提到最前面。usage 字段。成本统计依赖 usage但不同供应商返回的字段名不一样有的叫prompt_tokens有的叫input_tokens。适配层统一映射成标准字段。3.2 请求预检与上下文管理agent harness 跑长任务时上下文会不断累积很容易超过模型窗口。路由层做预检能省很多事。我的做法是在转发前估算 token 数估算用简单的字符数除以系数中文按 1.5 字符一个 token英文按 4 字符一个 token混合内容取加权。这个估算不精确但足够触发预警。超过窗口时有两种策略拒绝并返回明确错误或者触发截断。我默认用拒绝因为 agent 场景下静默截断会导致模型丢失关键上下文产生更难排查的幻觉。返回的错误里带上当前 token 数和窗口大小方便 harness 侧做压缩。实操心得预检的阈值不要卡死在窗口上限留 10% 余量。因为模型实际能处理的 token 数往往比标称窗口小尤其是输出也要占额度的时候。3.3 失败重试与熔断agent 循环里一次请求失败可能导致整个任务中断所以重试策略很重要。我的配置是连接超时 10 秒读超时 120 秒长任务需要失败重试 2 次退避用指数加抖动。熔断这块我做得比较轻量。每个供应商维护一个滑动窗口的成功率连续失败超过阈值就临时标记为不健康后续请求直接走 fallback冷却一段时间后再试探性放行。这样避免某个供应商挂了之后所有请求都在那里干等超时。3.4 日志与可观测性调试 agent 问题日志是命根子。我记录每个请求的请求 ID、逻辑模型名、实际供应商、请求 token 估算、响应 token、首 token 延迟、总延迟、状态码、是否走了 fallback。这些字段落到结构化日志里方便后续用脚本分析。请求体和响应体默认不记录因为可能包含敏感内容需要时通过环境变量打开并且做脱敏处理。这一点在多人协作或者生产环境里特别重要。4. 实操过程与核心环节实现4.1 环境准备与依赖安装服务用 Python 写依赖尽量精简。核心依赖是fastapi、uvicorn、httpx、pyyaml、pydantic。httpx 用异步客户端因为 agent 场景并发高同步客户端会拖垮吞吐。python -m venv venv source venv/bin/activate pip install fastapi uvicorn httpx pyyaml pydantic如果你打算复用 litellm 的 provider 适配可以额外装litellm但要注意它的依赖比较重启动会慢一些。我自己的实现里只借鉴了它的抽象思路没有直接依赖。4.2 配置文件编写配置文件分两块供应商定义和模型映射。供应商定义里放 base_url、api_key、超时、并发上限。providers: provider_a: base_url: https://api.provider-a.example/v1 api_key: ${PROVIDER_A_KEY} timeout: 120 max_concurrency: 16 provider_b: base_url: https://api.provider-b.example/v1 api_key: ${PROVIDER_B_KEY} timeout: 120 max_concurrency: 8api_key 用环境变量占位不要写死在文件里。启动时读取环境变量替换缺失就报错退出避免带着空 key 跑起来。4.3 启动服务与接入 harness启动命令很简单uvicorn cn_llm_router.main:app --host 127.0.0.1 --port 8787然后到 harness 侧改配置。以 Claude Code 这类工具为例通常支持通过环境变量指定 base_url 和 api_keyexport ANTHROPIC_BASE_URLhttp://127.0.0.1:8787 export ANTHROPIC_API_KEYlocal-router-tokenCodex 这类工具一般在配置文件里指定找到base_url字段改成路由地址即可。改完重启 harness发一个简单请求验证连通性。4.4 验证与压测连通性验证用 curl 最直接curl http://127.0.0.1:8787/v1/chat/completions \ -H Content-Type: application/json \ -H Authorization: Bearer local-router-token \ -d { model: claude-sonnet, messages: [{role: user, content: 你好}], stream: false }返回正常就说明路由通了。压测我用hey或者自己写个异步脚本重点看首 token 延迟和并发下的错误率。并发上限要和供应商的限流匹配设太高会被供应商限流设太低浪费吞吐。4.5 参数选择与计算过程超时时间怎么定我的经验值是连接超时 10 秒读超时按最长任务的 P99 延迟乘以 1.5。比如长任务 P99 是 80 秒读超时设 120 秒。设太短会误杀正常的长请求设太长会让失败请求占用连接资源。并发上限怎么定看供应商的 RPM 和 TPM 限制。假设供应商给的是 60 RPM单请求平均耗时 5 秒那么理论并发是 60/60*55。实际设的时候留点余量设 4 到 5。超过这个数请求会排队排队时间算进总延迟反而拖慢整体。重试次数怎么定agent 场景下我设 2 次。因为 agent 循环本身有重试逻辑路由层重试太多会放大延迟。如果是幂等的查询类请求可以设 3 次写操作类请求设 1 次甚至 0 次避免重复副作用。5. 常见问题与排查技巧实录5.1 典型问题速查表现象可能原因排查方向harness 报连接失败路由没启动或端口不对检查进程和端口监听请求返回 401token 不匹配核对 harness 和路由的 token流式响应不结束结束标记格式不符看适配层是否补发 DONE工具调用报错tools 字段格式差异检查适配层转换逻辑上下文超限预检阈值太严调整阈值或加截断首 token 很慢供应商排队或并发过高降并发或换供应商走了 fallback 但没日志日志级别不够调高日志级别5.2 流式响应卡死的排查这是最常见也最烦的问题。harness 发流式请求路由转发了但 harness 一直等不到结束。排查步骤先在路由层打开原始响应日志看供应商实际发回的流是什么样。如果供应商发的是data: {done: true}而不是data: [DONE]那就是结束标记不匹配。适配层要检测供应商的结束事件转换成标准格式再转发。还有一种情况是供应商的流里夹杂了心跳或者注释行harness 解析器遇到不认识的格式会卡住。适配层要过滤掉非标准行。5.3 工具调用参数解析失败模型返回的tool_calls里arguments有时是合法 JSON有时是带 markdown 代码块包裹的有时干脆是残缺的。适配层要做容错解析先尝试直接json.loads失败就剥离代码块标记再试再失败就记录原始内容并返回一个明确的错误让 harness 侧决定怎么处理。不要静默吞掉否则 agent 会拿着空参数去执行工具产生莫名其妙的副作用。5.4 离线局域网部署的注意点离线环境部署路由本身没问题关键是模型服务要在内网可达。配置里把 base_url 指向内网地址api_key 用内网约定的值。要注意的是离线环境没法拉取依赖所有 Python 包要提前打包好用pip download下载 wheel 再离线安装。另外时间同步要做好否则日志时间戳会乱排查问题时很痛苦。5.5 独家避坑技巧第一个坑是模型名大小写。harness 配置里写的模型名和映射表里的 key 必须完全一致我建议全部用小写加连字符避免歧义。第二个坑是并发和限流的配合。路由层设了并发上限但 harness 侧也可能有自己的并发控制两者叠加可能导致实际并发远低于预期。调优时要两边一起看。第三个坑是日志里的敏感信息。请求体可能包含用户数据默认不要记录需要时做脱敏。我见过有人把完整请求打到日志里结果日志文件被误传出去很尴尬。第四个坑是版本升级。harness 升级后接口可能有变化路由的适配层要跟着更新。建议在路由层加一个版本探测遇到不认识的字段就记录警告方便提前发现兼容性问题。6. 后续可扩展的方向路由跑稳之后我陆续加了一些扩展。一个是按任务类型路由比如代码生成类请求走擅长代码的模型长文本总结走长上下文模型这个通过在映射表里加task_type字段实现。另一个是成本统计面板把每个供应商的 token 消耗和估算成本汇总方便做预算控制。还有一个比较有意思的扩展是请求缓存。agent 场景里有些请求是重复的比如固定的系统提示词加相同的工具定义这部分可以缓存供应商的响应省下不少 token。缓存 key 用请求体的哈希注意要排除掉随机性字段比如temperature之外的噪声。这些扩展都不是必须的核心路由跑通之后按需加就行。我的建议是先把基础链路跑稳把日志和排查手段做扎实再考虑优化。毕竟 agent 工程里能快速定位问题比多省几个 token 重要得多。