ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

CC Switch本地路由404排查:路径、配置与规则十步自查

CC Switch本地路由404排查:路径、配置与规则十步自查

上个月帮一位做客户端联调的朋友看一个特别典型的问题:他在 CC Switch 里把本地路由开关拨到"开启",端口也填了,规则也加了,结果 Codex 那边一发请求就是 404。他前后折腾了两个多小时,重装了工具、换了端口、甚至把本地路由规则全删了重写,问题依旧。

我接手后做的第一件事不是看配置,而是先确认一件事——这个 404 到底是谁返回的。五分钟定位,十分钟解决。事后我复盘了一下,这类问题之所以难查,是因为"404"这个状态码把链路上三个完全不同的环节混成了一个表象:客户端拼错了路径、本地路由没匹配上规则、上游端点真的没有这个资源,三种情况的返回码一模一样,但根因天差地别。

这篇就把我当时用的那条自查链路完整拆开讲一遍:从请求离开客户端的那一刻起,到本地路由端口,再到上游端点,每一环该看什么、怎么验证、常见坑在哪。适合正在用 CC Switch 做本地路由、或者任何在客户端和上游 API 之间架了一层本地服务的人看。不管你是刚配好第一次跑不通,还是配好了隔几天突然失效,这套顺序都能用。

1. 先分清 404 是谁发出的:三段链路各有一张"身份证"

很多人一看到 404 就本能地去改路由规则,这是最常见的误判起点。实际上请求从 Codex 发出到拿到响应,中间至少经过三个独立的"关卡",每一关都可能吐出 404,而且它们的 404 长得不一样。搞清楚当前这个 404 的出处,后面的排查才有方向,否则你就是在三个可能的故障域之间瞎撞。

1.1 请求经过的三个关卡与各自的职责

第一关是客户端。Codex 这类命令行或插件形态的工具,本身会读取一份配置(可能是环境变量、可能是项目下的配置文件、可能是全局配置),拿到一个 base URL 之后,自己再往后面拼上它认为正确的接口路径,比如/responses或者/chat/completions之类。这一关的职责是"把地址拼对"。它出错的表现是:地址拼错了,但请求照样发出去了。

第二关是本地路由层,也就是 CC Switch 开出来的那个本地端口。它的职责是"接住请求,按规则决定往哪送"。这一关有两种典型失败:一是请求根本没到达(端口不对、进程没起、监听地址不对),二是到达了但没有规则命中,于是它自己造一个 404 回给你。

第三关是上游端点。请求被转发出去之后,对面服务器判断"你要的这个路径我不提供",返回 404。这一关的 404 会被本地路由层原样透传回来,看起来和第二关的 404 几乎是同一个东西。

三层里任何一层出问题,你在终端里看到的都是404。所以第一件要做的事,是给当前这个 404 做"身份鉴定"。

1.2 用响应头和响应体做身份鉴定

最省事的办法是看响应头和响应体的"指纹"。我一般用带-i的 curl 复现一遍,把响应头也打出来:

curl -i -X POST http://127.0.0.1:8787/v1/responses \ -H 'Content-Type: application/json' \ -H 'Authorization: Bearer test-key-000' \ -d '{"model":"your-model-name","input":"ping"}'

拿到输出后按下面的特征对号入座:

观察到的情况大概率出处说明
响应体是纯文本,比如Cannot POST /v1/responses本地路由层典型的框架级未匹配提示,说明请求到了但没规则接住
响应体是 JSON,含route not found、no matching rule一类字段本地路由层路由层自己构造的结构化错误
响应体是 JSON,含error对象、request_id、type字段上游端点透传特征明显,字段风格属于服务端
响应头里有本地路由注入的自定义头(如x-route-id、x-upstream)已转发说明请求穿过了第二关
响应头里Server字段是本地框架名本地路由层上游一般不会暴露这种信息
完全连不上,报连接被拒第二关没起来这其实不是 404,但常被混为一谈

还有个更直接的办法:看本地路由的日志。请求有没有进来,日志里一定有痕迹。如果日志里空空如也,那问题在第一关或第二关的"接住"环节,跟上游一点关系都没有;如果日志里有一条记录后面跟着 404,就去看那条记录匹配到了哪条规则、转发到了什么 URL。

提示:把本地路由的日志级别临时调到 debug,让它把"收到的原始路径"和"最终转发的目标 URL"都打出来。这两个值一对比,90% 的路径类问题当场现形。

1.3 "开关打开了"和"路由生效了"是两件事

这里要说一个特别容易让人卡住的认知偏差。CC Switch 里那个本地路由开关,语义上只是"启用了本地路由这个能力",它不等于"有一条规则会匹配你现在发的这个请求"。开关是总闸,规则是分路,总闸合上了不代表你要走的那条分路有电。

我见过不止一次的情况是:开关开着,规则列表里有一条看起来很合理的规则,但那条规则的匹配条件是精确路径/responses,而 Codex 实际发的是/v1/responses。差了一个前缀,规则就是没命中,路由层老老实实返回 404。用户盯着那个开关看了半天,怎么也想不通"明明开了呀"。

所以接下来几节,我们按链路顺序往回推:先确认路径拼得对不对,再确认配置有没有真生效,然后才轮到规则匹配本身。

2. base_url 与接口路径的拼接是最常见的 404 源头

如果只能查一个地方,我会先查路径拼接。原因很简单:这里的错误最隐蔽,因为每一段单独看都是对的,拼起来才错。客户端认为自己拼的是"base + 接口路径",路由规则认为自己匹配的是"某个前缀",上游认为自己的接口挂在"某个位置",三方各有一套理解,谁都没错,但结果就是 404。

2.1 三种典型的拼接错位

第一种:/v1被写了两次。你在 CC Switch 里把本地路由的上游地址填成https://upstream.example/v1,同时 Codex 的 base URL 也填成http://127.0.0.1:8787/v1,客户端本身还会再追加一段/responses。如果路由规则是"前缀透传"——也就是收到什么路径就往后拼什么——最终上游收到的可能是/v1/v1/responses。上游当然没有这个路径,404。

第二种:规则匹配的层级比实际请求浅一层。规则写的是精确匹配/responses,实际请求是/v1/responses。这种情况在那些"规则列表里看着有一条,但就是不生效"的场景里出现频率极高。判断方法很简单:把 debug 日志里收到的原始路径抄下来,逐字符和规则比对,别凭记忆。

第三种:尾部斜杠制造的幽灵路径。base URL 结尾多写一个/,客户端拼接时又加一个/,就出现了/v1//responses。有些路由框架会自动把连续斜杠规范化成一个,有些不会;有些上游会规范化,有些不会。只要链路上任何一环不做规范化,这个双斜杠就会原封不动地传下去,然后 404。

下面这张表是我实际整理过的对照,把常见写法、客户端最终发出的路径、上游实际收到什么列在一起,照着对一遍基本能定位:

base URL 写法客户端请求路径上游收到(前缀透传模式)结果
http://127.0.0.1:8787/responses/responses取决于上游接口是否挂在根路径
http://127.0.0.1:8787/v1/v1/responses/v1/responses上游 base 也带/v1时会重复
http://127.0.0.1:8787/v1//v1//responses/v1//responses未规范化则 404
http://127.0.0.1:8787/v1/responses/v1/responses/responses同左一定 404

看表能发现一个规律:base URL 只应该写到"版本层",不要写到"接口层"。写到接口层,客户端再追加一次,必然重复。

2.2 用一条最小请求把路径钉死

配置层面争论不清的时候,直接绕过客户端,手写 curl 打本地端口。这一步的价值在于:它把第一关(客户端拼接)排除掉了,你测的是纯粹的路由层行为。

# 测根路径是否可达 curl -i http://127.0.0.1:8787/ # 测目标路径是否被规则接住 curl -i -X POST http://127.0.0.1:8787/v1/responses \ -H 'Content-Type: application/json' \ -H 'Authorization: Bearer test-key-000' \ -d '{"model":"your-model-name","input":"ping"}' # 同样内容,换成规则里写的那个精确路径再打一次 curl -i -X POST http://127.0.0.1:8787/responses \ -H 'Content-Type: application/json' \ -H 'Authorization: Bearer test-key-000' \ -d '{"model":"your-model-name","input":"ping"}'

如果第二条 404、第三条通,问题就锁死在"规则路径和客户端路径差一层前缀"上,改一边即可。如果两条都 404 但日志里有记录,说明规则匹配上了、是上游不认这个路径,那就去查上游的接口定义。如果两条都 404 且日志里什么都没有,那根本不是路径问题,跳到下一节。

注意:手动 curl 时一定要带上和客户端一致的Content-Type和Authorization。有些路由规则会按请求头做匹配,头不对也会静默不命中,这时候你测出来的是"头不对导致的 404",却误以为是路径问题,方向就跑偏了。

2.3 一条命令看清路由到底往哪转

大多数本地路由工具都支持在 debug 日志里打印最终转发的完整 URL。如果你的工具没这个能力,退而求其次的办法是在上游侧看访问日志——如果上游是你自己可控的服务,直接看它收到了什么path,比在客户端这边猜快一百倍。

我自己的习惯是:联调期先在上游侧挂一个最小服务,把收到的所有请求的 method、path、headers 打出来,什么都不做就回 200。等链路完全打通,再换成真实上游。这个"假上游"的写法大概二十行,但能省掉一整晚的猜测。

from http.server import BaseHTTPRequestHandler, HTTPServer class Echo(BaseHTTPRequestHandler): def _handle(self): print("METHOD:", self.command) print("PATH:", self.path) print("HEADERS:", dict(self.headers)) self.send_response(200) self.send_header("Content-Type", "application/json") self.end_headers() self.wfile.write(b'{"ok":true}') do_GET = _handle do_POST = _handle HTTPServer(("127.0.0.1", 9099), Echo).serve_forever()

把上游地址临时指向http://127.0.0.1:9099,然后从客户端走一遍完整流程。终端里打印出来的PATH就是"路由层实际转发出去的路径",拿它和上游真实接口对比,路径问题到此为止。

3. 配置改了却没生效,比配置写错更让人抓狂

路径确认没问题之后,下一个高频原因是"你改的那份配置,不是程序真正在读的那份"。这类问题的迷惑性在于:文档照着改的、字段名没写错、值也是对的,但程序用的是另一份。我遇到过一晚上改了四次配置都不生效,最后发现是 IDE 插件缓存了旧值,重启窗口才好。

3.1 配置来源的覆盖顺序要搞清楚

Codex 这类工具通常有多个配置来源,优先级从高到低大致是这个套路(具体顺序以工具文档为准,但结构大同小异):

  1. 命令行参数(--base-url之类的显式传参)
  2. 环境变量(比如某个*_BASE_URL形式的变量)
  3. 项目目录下的配置文件
  4. 用户主目录下的全局配置文件

排在前面的会完全覆盖后面的同名字段,不是合并。这意味着你在全局配置里改得再对,只要环境变量里还留着旧值,改动就是白费。而且环境变量的存在感极低——你在编辑器里翻配置文件翻半天,根本想不到 shell 里还 export 着一个老值。

排查办法很土但有效:

# 看当前 shell 里有没有相关的环境变量 env | grep -i -E 'base_url|endpoint|api_host|router' # 看工具最终解析出的配置(多数工具有类似子命令) your-tool config show your-tool config path

第二条尤其关键——config path直接告诉你程序现在读的是哪个文件。如果这个路径和你刚才编辑的文件不是同一个,问题当场结束。

3.2 进程残留与端口占用

本地路由层是个常驻进程,改完规则一般需要 reload 或者重启。如果只改文件不重启,规则就是旧的。更隐蔽的情况是:你以为重启了,其实重启的是新进程,旧进程还占着端口没退出,新进程起不来(或者起来了但绑定失败),请求打到端口上,接的其实是那个用旧规则的老进程。

# macOS / Linux 看端口占用 lsof -i :8787 # Windows netstat -ano | findstr :8787

看到有多条记录挂在同一个端口上,基本可以确定是残留。杀掉全部、重新启动、再看一次。

还有一种情况值得单独提:规则热加载失败但不报错。有些实现会在 reload 时静默吞掉解析错误,规则文件里多了一个逗号、少了一个引号,加载失败,但进程正常运行,用的是上一版规则。表现就是"我明明改了规则,怎么行为一点没变"。所以规则文件改完之后,一定要在日志里确认看到"规则已重新加载,共 N 条"这类输出,没有这行输出就当没生效。

3.3 客户端自身的缓存

命令行工具一般没有缓存,但 IDE 插件形态的客户端经常有。插件启动时读一次配置,存在内存里,改文件不影响当前会话。判断方法:完全退出客户端(不是关窗口,是结束进程),重新打开,再测一次。

我在实际联调里的顺序是固定的:先config show确认解析结果,再lsof确认进程唯一,最后冷启动客户端。这三步做完,再来谈规则匹配,能省掉大量“改了没反应”的无谓消耗。

4. 规则匹配这一层藏着最多"静默 404"

请求到达了、配置生效了,剩下的就是规则本身。这一层的麻烦在于它不报错——不命中的时候它不告诉你"我差一点就匹配上了",只是安静地返回 404。你得靠推理把它的匹配逻辑还原出来。

4.1 匹配维度不止路径一个

很多人以为路由规则只看路径,实际上常见的匹配维度至少有这么几个:

匹配维度典型配置容易出的错
路径/v1/responses前缀层级不一致、尾部斜杠
请求方法只注册了 POST客户端先发了个 GET 探活,404
模型名按model字段分流模型名大小写不一致、带了版本后缀
请求头按某个自定义头区分客户端没带或值不同
Host按域名分流用 IP 访问时 Host 不匹配

路径没问题但还是 404,就往后面几个维度查。特别是请求方法:有些客户端在正式发请求之前,会先打一个GET或者HEAD做可达性探测。如果路由规则只注册了POST,这个探测就是 404。探测失败之后客户端可能直接放弃,你看到的报错就被归因成"配置不通",其实主请求压根没发出去。

模型名那一栏也很值一提。规则里按model值分流的情况下,gpt-4o和GPT-4o是两个不同的字符串,xxx-latest和xxx-20240101也是两个。客户端传的模型名和你规则里写的对不上,就没有任何规则命中,如果没配默认规则,就是 404。

4.2 匹配顺序与通配符的优先级陷阱

只要规则超过一条,就会涉及顺序。不同实现的策略不一样,常见的有三种:精确匹配优先于前缀匹配、按定义顺序先到先得、按前缀长度从长到短。这三种在同一份规则集上的行为可能完全不同。

举个具体的例子。规则列表是这样:

  • 规则 A:路径前缀/v1,转发到上游 X
  • 规则 B:路径精确/v1/responses,转发到上游 Y

在"精确优先"的实现里,/v1/responses走 Y;在"先定义先得"的实现里,它走 X。如果你心里的预期是 Y,实际走了 X,而 X 那边没有这个接口,你看到的就是 404,但你的规则里明明写着 Y。

所以我配规则的固定习惯是:一条路径前缀规则 + 若干条精确规则时,把精确规则放在列表最前面,并且尽量不用通配符兜底。通配符看着省事,实际上会把所有拼错的请求都"接住",然后转发到一个不存在的路径上,让问题从"本地未匹配"变成"上游 404",排查难度不降反升。

4.3 区分"本地未命中"和"上游返回 404"

这两个的处置方式完全不同,值得单独给一个判断方法。最可靠的是改上游:把上游临时指向那个打印一切的回显服务(第 2.3 节的二十行代码)。然后重复你的请求。

  • 回显服务打印出了请求 → 规则命中了,之前的 404 是上游给的,去查上游接口定义
  • 回显服务什么都没打印 → 规则没命中,404 是本地路由层自己造的,回来查规则

这一步做完,责任划分清清楚楚,不用再猜。我见过太多人在"到底是本地问题还是服务端问题"上来回摇摆,其实一个回显服务就能终结争论。

5. 那些不像 404 但会被误认成 404 的连接问题

排查到这里,路径对、配置生效、规则也命中了,还剩一类问题:请求压根没到,但表现上和 404 很像,或者错误信息被客户端加工过之后看起来像 404。这一节单独拎出来说,因为它的排查思路和前几节完全不同。

5.1 监听地址与回环地址的坑

本地路由启动时绑定的地址决定了"谁能连进来"。绑127.0.0.1只有本机能连,绑0.0.0.0所有网卡都能连,绑::1只有 IPv6 回环能连。而客户端里写localhost的时候,解析结果可能是127.0.0.1,也可能是::1,取决于系统的解析顺序。

于是就出现了这种诡异现象:http://localhost:8787连不上,换成http://127.0.0.1:8787立刻就通了。原因是路由绑在 IPv4 回环上,而localhost被解析成了 IPv6 回环。

# 看端口到底绑在哪个地址上 lsof -nP -iTCP:8787 -sTCP:LISTEN

输出里那一列TCP *:8787还是TCP 127.0.0.1:8787,直接告诉你绑定范围。如果看到的是[::1]:8787,那 IPv4 就进不去。

还有一个变种:客户端跑在容器里,或者跑在某个隔离环境里,这时候它眼里的127.0.0.1是它自己的回环,不是宿主机的。宿主机上跑着的本地路由,它当然连不上。解决办法是把客户端配置里的地址换成宿主可达的地址,同时路由层要绑到能接受外部连接的地址上。

5.2 协议、证书与重定向

客户端配https://127.0.0.1:8787而路由只提供明文 HTTP,握手阶段就会失败。这种失败在有些客户端里会被包装成"请求失败"然后附一个状态码,看起来像是 HTTP 层的错误,实际压根没进 HTTP 层。

如果确实需要本地 HTTPS,自签证书还要考虑客户端是否信任。多数客户端对自签证书是拒绝的,而且拒绝的方式往往很不友好——报一个和业务无关的底层错误。联调期我的建议是本地这一段统一走明文 HTTP,把加密放到本地路由到上游那一段去做,减少变量。

另外留意重定向。本地路由层如果对某些路径做了 301/302 跳转,客户端跟随跳转时可能把 POST 变成 GET(不同的客户端行为不同),跳到新路径上再被 404。这种情况看日志里的状态码序列就能发现:一次请求对应了两条记录,第一条是 3xx。

# 观察完整跳转链和最终状态码 curl -iL -X POST http://127.0.0.1:8787/v1/responses \ -H 'Content-Type: application/json' \ -d '{"model":"your-model-name","input":"ping"}'

6. 按顺序执行的十步自查表

前面几节是按原理解释的,实际排查时我更习惯用一张固定顺序的表,从上往下走,每一步都有明确的"通过/不通过"判据,不通过就停在那一步,不往后跳。这样做的好处是不会在多个假设之间反复横跳,每次只验证一个变量。

6.1 十步定位流程

步骤动作通过判据不通过说明什么
1lsof -i :端口只有一个进程在监听进程未启动或端口冲突
2查看监听地址与客户端目标地址族一致IPv4/IPv6 错配
3curl -i打根路径有 HTTP 响应(任意状态码)连接层就不通,别看应用层
4开 debug 日志重发请求日志里有这条请求请求没到路由层,查客户端配置
5对比日志里的原始路径与规则逐字符一致路径拼接错位
6config show看最终配置与预期一致配置被更高优先级覆盖
7换回显服务做上游回显服务收到请求规则未命中,回去查匹配维度
8检查请求方法规则支持该方法只注册了 POST,探测请求 404
9检查模型名/请求头与规则中的值一致分流规则未命中
10检查上游接口定义路径确实存在于上游是上游的问题,不是本地

这张表的顺序不是随意的。前三步排除"根本没连上",第四步确立"请求确实到了本地路由"这个分水岭,第五到第九步都在本地域内打转,第十步才把责任交出去。很多人一上来就做第十步,也就是去怀疑上游,然后被上游的正常行为反复打脸。

6.2 几个我踩过之后才记住的细节

日志级别默认是 info,看不到路径。大多数工具默认只打"收到请求、返回状态码",不打原始路径和转发目标。不改级别,你永远只能看到"404"三个字,看不到它到底在 404 什么。联调期间把级别调到 debug 是必要成本。

别在排查中途同时改两个东西。我早期的坏习惯是:怀疑是路径问题,改一下 base URL;同时怀疑是规则问题,顺手把规则也调了。结果通了,但不知道为什么通;不通,也不知道是哪个改动引起的。一次只动一个变量,这是排查的基本纪律,但着急的时候最容易破。

规则文件用 JSON 或 YAML 时,注释和尾部逗号是高发错误。有些解析器对格式错误的处理是"忽略整个文件、沿用旧规则",不报错。所以改完规则一定要看日志里有没有"已加载 N 条规则",数字对不上就是没加载上。

测试用的 key 要和真实 key 分开。排查路径问题时如果用的是真实 key,有些上游会在鉴权阶段就直接拒绝,返回的错误可能被误读成路由问题。拿一个明显无效的test-key-000反而更清晰——你能确定任何非鉴权类错误都不是 key 引起的。

通配兜底规则会让排查变难。一条"其余全部转发"的规则确实能让请求都出去,但它会掩盖所有拼写错误。联调期把它关掉,让未命中的请求老老实实 404 在本地,问题定位会快很多。

最后分享一个我自己一直在用的小技巧:把 CC Switch 的本地路由想象成一个"门卫",它只做一件事——比对来客的证件(路径、方法、头)和手里的名单(规则)。404 就是"名单上没有你"。所以排查的核心从来不是"门卫坏了吗",而是"来客报的名字,和名单上写的,是不是同一个"。把实际请求的原始路径打印出来,一个字符一个字符地和规则对,大部分 404 在这一步就结束了。剩下那些对上了还 404 的,才值得去动上游。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进