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_key:
export ANTHROPIC_BASE_URL="http://127.0.0.1:8787" export ANTHROPIC_API_KEY="local-router-token"Codex 这类工具一般在配置文件里指定,找到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*5=5。实际设的时候留点余量,设 4 到 5。超过这个数请求会排队,排队时间算进总延迟,反而拖慢整体。
重试次数怎么定?agent 场景下我设 2 次。因为 agent 循环本身有重试逻辑,路由层重试太多会放大延迟。如果是幂等的查询类请求可以设 3 次,写操作类请求设 1 次甚至 0 次,避免重复副作用。
5. 常见问题与排查技巧实录
5.1 典型问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| harness 报连接失败 | 路由没启动或端口不对 | 检查进程和端口监听 |
| 请求返回 401 | token 不匹配 | 核对 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 重要得多。