Lazy loaded image
网关08丨怎么确认请求真的打到了真实上游
字数 4523阅读时长 12 分钟
2026-4-12
type
Post
status
Published
date
Apr 12, 2026
slug
gateway008
summary
tags
gateway
category
icon
password
摘要:这篇从头到尾都在回答“怎么确认请求真的打到了真实上游”
前面几篇把本地链路、治理、回退、可观测性和基线都铺出来了。再往下写,我发现还有一个前提一直没单独拿出来说:我以为自己已经在测真实上游,结果流量也许根本没到我以为的那条上游线路;就算到了,那条线上接口也未必真按这个仓库预期的方式返回结果。这个前提不先弄清楚,后面的基准测试、故障演练和可观测性都可能建在错地方。
本地假上游当然有用。它能证明这个仓库自己的处理链路是通的,但它回答不了另一件更现实的事:我手上这个线上中转站,真跑起来以后,回来的东西到底是不是我以为的那一套。离开本地假上游之后,我先要确认的是“请求到底打到了谁,回来的东西是不是对的”。离开本地假上游以后,我要先确认线上这条路到底靠不靠谱。
我一开始也以为,配好 APP_PROVIDER_PRIMARY_*,再打一个 curl,差不多就够了。再往下看我才发现,这个网关不是这么工作的。它会先把配置里的上游线路建出来,再把 MySQL 里的 route_rules 叠上去;探活还会继续影响上游线路的健康状态。配了环境变量,不代表流量真的去了那条线路;就算我直接打线上接口拿到了 200,也还没回答“经过网关之后,这条请求到底是不是走到了我要看的那条线路”。这篇要看的,是程序真正跑起来以后怎么选路,以及单条请求怎么对上
所以我最后只留了一条线:先排掉“我测错上游线路”,再排掉“这条线上中转站看起来能用,其实回来的结果跟仓库默认预期对不上”,最后再用同一个 X-Request-Id 把响应和日志里的 backend=openai-primary 连起来。关键就在误判有没有被一层层拿掉。所以我想说的是,先确认流量到底打到了谁,再谈后面的数字。

一、先看线上上游自己通不通

因为后面最容易把线上中转站自己的问题,错记到网关头上。这一节先把网关外面的干扰排掉。
这次我用的是RightCode中转站的这组配置。
💻
export APP_PROVIDER_PRIMARY_TYPE='openai' export APP_PROVIDER_PRIMARY_NAME='openai-primary' export APP_PROVIDER_PRIMARY_BASE_URL='https://right.codes/codex/v1' export APP_PROVIDER_PRIMARY_API_KEY='sk-...' export APP_PROVIDER_PRIMARY_MODEL='gpt-5.4-mini'
我先在网关外补了两次最小直连,只为了排掉“中转站自己就不兼容”这个误判。一条是 /v1/chat/completions。
curl -sS -i 'https://right.codes/codex/v1/chat/completions' \ -H 'Authorization: Bearer sk-...' \ -H 'Content-Type: application/json' \ -d '{"model":"gpt-5.4-mini","messages":[{"role":"user","content":"Say hello in one sentence."}]}'
回的是这段最小结果。我想说的是,这里先看 /chat/completions 直连能不能成立。
HTTP/2 200 {"id":"resp_00a20375f7f2c5c00169e76a17b8c4819ba74b1f261f642520","object":"chat.completion","created":1776773656,"model":"gpt-5.4-mini-2026-03-17","choices":[{"index":0,"message":{"role":"assistant","content":"Hello!"},"finish_reason":"stop"}],"usage":{"prompt_tokens":2501,"completion_tokens":6,"total_tokens":2507}}
另一条是 /v1/models。我想说的是,还得顺手把 /models 这条线也核对一遍。
curl -sS -i 'https://right.codes/codex/v1/models' \ -H 'Authorization: Bearer sk-...'
回的是这段最小结果。我想说的是,这里看的是 /models 这条返回结果是不是也一起对上了。
HTTP/2 200 {"object":"list","source":"pricing_fallback","upstream":"Codex","data":[...,{"id":"gpt-5.4-mini","object":"model","owned_by":"Codex"},...]}
这两条先回答了一件很基础的事:RightCode 至少没有卡在上一个中转站那种“/models 和 chat/completions 先对不上”的地方。到这里我才愿意继续往下看网关本身。线上这头先核对清楚,后面能少绕很多路。我想说的是,这一步先把“问题其实出在线上中转站自己”这个误判拿掉了。

二、配好环境变量还不够,还得看程序跑起来以后怎么选路

为什么?因为这篇最容易测错的,不是 key,而是请求最后到底走到了哪条上游线路。我想说的是,这一节要先把能走到的上游收干净。
如果只是做一个演示,到前面那几步大概就会停。问题在这个仓库里,请求走哪条上游线路,不是只靠环境变量决定的。cmd/gateway/main.go 里的 mustLoadProviderBackends() 会先 buildProviderBackends(cfg),再从 MySQL 读 route_rules,最后再 applyRouteRules(backends, routeRules)。环境变量只是先把上游线路建出来,数据库里的 route_rules 和运行时健康状态,才会继续决定这次请求最后走到谁。我想说的是,真正决定请求走到谁的,是程序跑起来以后怎么选路,不是那几行环境变量。
所以我这次没有直接起服务就打请求,而是先把规则临时收成只剩一条。我想说的是,先把能走到的上游收成一条,后面的结果才有解释力。
go run ./cmd/routerulectl replace -rule 'openai-primary,,10'
这里故意用了空 model 的通用规则。原因很简单:我要先把“只让 RightCode 这一条上游线路参与选择”收干净,不让 secondary mock 在验证过程中把失败或成功兜成另一个故事。这一步不是在美化结果,而是在先把路收窄。路不先收窄,后面的 200 其实没什么讨论价值。我想说的是,这一步先把“也许还是别的上游接住了”拿掉了。

三、我最后只保留了 6 个动作

为什么?因为这篇不是要把所有接口都打一遍,而是要用最少动作把误判一层层排掉。我想说的是,这一节就是把验证动作压到最少。
我后来把注意力收到了 6 个动作上。它们不是一条漂亮流程,就是 6 个专门用来排误判的检查点。我想说的是,这 6 步合在一起,才够支撑这篇的主线。
动作
我看的原始东西
先排掉的误判
1. 看 route_rules
routerulectl list 输出
我以为自己在测 openai-primary,其实能走到的上游不是它
2. 起隔离副本
127.0.0.1:18080
我一边测,一边被现有 8080 进程和旧状态干扰
3. 看 /debug/providers
只剩 openai-primary,而且 ready=true
网关当前也许还认着别的上游线路
4. 打 /v1/models
200 + 模型列表里有 gpt-5.4-mini
探活也许已经先把它判成不健康
5. 打非流式聊天请求
200 + X-Request-Id + model
也许只有 /models 通了,真正的聊天请求没走出去
6. 打流式聊天请求,再去核对日志
text/event-stream + [DONE] + 日志里的 backend=openai-primary
看起来像成功,其实没法证明它命中了我要看的那条上游线路
这 6 步合在一起,才够我说“这条请求真的打到了真实上游”。如果只停在环境变量、直连 curl、或者某个单独的 200,都还差半步。我想说的是,这篇的验证骨架就收在这里。

四、先站住的是 /debug/providers 和 /v1/models

为什么?因为这两步更轻,也更适合先把网关眼里的上游列表和探活结果看明白。我想说的是,这一节先把网关现在认的是谁、这条探活请求能不能通说清楚。
我起的是一个不影响现有 8080 进程的隔离副本。我想说的是,先把验证环境隔离开,结果才不会被旧进程干扰。
APP_SERVER_ADDRESS='127.0.0.1:18080' go run ./cmd/gateway
副本起来以后,我先看 /debug/providers。我想说的是,这里先看网关当前认的是谁。
curl -sS http://127.0.0.1:18080/debug/providers
返回的是这段最小结果。我想说的是,这里先看上游列表是不是已经收干净了。
{ "providers": [ { "name": "openai-primary", "priority": 10, "route_rules": [{"priority":10}], "healthy": true, "consecutive_failures": 0, "unhealthy_until": "0001-01-01T00:00:00Z", "last_probe_at": "2026-04-21T20:18:58.68543+08:00" } ], "ready": true }
这一步先把网关视角收干净了:当前这台副本只认一个上游线路,就是 openai-primary。这还不是单请求证据,但它先排掉了“也许请求还是会落到 secondary”这层误判。我想说的是,网关当前认的是谁,这一步已经先说明白了。
接着我打 /v1/models。我想说的是,这里再看探活这条请求是不是也跟着成立。
curl -sS -i http://127.0.0.1:18080/v1/models \ -H 'Authorization: Bearer lag-local-dev-key'
回的是这段最小结果。我想说的是,这里看的是网关里的 /models 路径能不能成立。
HTTP/1.1 200 OK Content-Type: application/json X-Request-Id: 1776773949408059000-1 X-Trace-Id: 1776773949408059000-1 {"object":"list","data":[...,{"id":"gpt-5.4-mini","object":"model","created":0,"owned_by":"Codex"},...]}
这一步我关注的不是整个列表,而是两件事:第一,它通过网关返回了 200;第二,列表里确实有 gpt-5.4-mini。这说明 RightCode 这次不只是“直连能回”,它也通过了这个仓库默认的 /models 探活请求。它很重要,但它先回答的是“这条探活请求通了”,还不是最后那条单请求证据。我想说的是,这一步把 /models 这条线先收进来了,但还没到最硬那层。

五、非流式聊天请求,才开始回答请求是不是真的走出去了

为什么?因为 /v1/models 只能说明探活通了,真正的聊天请求还得看真正的聊天接口。我想说的是,这一节开始看真实聊天请求。
真正让我开始敢把结论往下写的,是非流式聊天请求。我想说的是,真正的聊天路径从这里才开始站住。
curl -sS -i http://127.0.0.1:18080/v1/chat/completions \ -H 'Authorization: Bearer lag-local-dev-key' \ -H 'Content-Type: application/json' \ -d '{"model":"gpt-5.4-mini","messages":[{"role":"user","content":"请用一句话说明什么是 API 网关。"}]}'
回的是这段最小结果。我想说的是,这里先看非流式聊天请求的返回形态是不是完整成立。
HTTP/1.1 200 OK Content-Type: application/json X-Request-Id: 1776817310913566000-3 X-Trace-Id: 1776817310913566000-3 {"id":"resp_00509a020cc1dc150169e8149fd828819abdd914f7a0c475a7","object":"chat.completion","created":1776817312,"model":"gpt-5.4-mini-2026-03-17","choices":[{"index":0,"message":{"role":"assistant","content":"API 网关是系统对外提供统一入口的组件,负责接收客户端请求并将其路由、聚合、鉴权和限流后转发到后端服务。"},"finish_reason":"stop"}],"usage":{"prompt_tokens":2506,"completion_tokens":44,"total_tokens":2550}}
到这里我关注的是这几个标志是不是一起出现了:200、chat.completion、model=gpt-5.4-mini-2026-03-17,还有 X-Request-Id / X-Trace-Id。这次返回体里还有一个更具体的东西:RightCode 自己生成的 resp_00509...。它们一起排掉的误判是:也许只有 /models 探活走通了,真正的聊天请求其实没出去。我想说的是,非流式聊天请求开始回答“聊天请求是不是真的走出去了”。
这一步还不只是返回体对了。我继续拿同一个 X-Request-Id 去核对网关日志,看到的是这组结果。我想说的是,这里开始把响应和上游日志字段连起来。
2026/04/22 08:21:52 INFO trace span finished backend=openai-primary attempt=1 trace_id=1776817310913566000-3 span_id=0000000000000007 parent_span_id=0000000000000006 request_id=1776817310913566000-3 span_name=provider.backend.create status=ok duration=1.792581666s 2026/04/22 08:21:52 INFO provider event type=provider_request_succeeded operation=create backend=openai-primary attempt=1 consecutive_failures=0 duration=1.792581791s 2026/04/22 08:21:52 INFO http request completed request_id=1776817310913566000-3 trace_id=1776817310913566000-3 span_id=0000000000000003 method=POST path=/v1/chat/completions status=200 bytes=463 duration=1.816064375s
到这里,我才敢把话写满:同一个 X-Request-Id 已经能继续核对到日志字段 backend=openai-primary,而且是成功路径,不是错误路径。这条非流式聊天请求确实已经通过网关打到了 RightCode。真正让我把结论写实的,也不是那个 200,而是这条“从 request_id 一路对到日志字段 backend=openai-primary”的成功链。我想说的是,这一步才是全文最硬的单请求证据。
我这次又往前补了一步,想把这条链继续压到 RightCode 自己那边。先拿返回体里的 resp_00509... 去试 GET /responses/{id},结果是 404;再直连 /chat/completions 去看响应头,也没有额外的远端 x-request-id 一类键。也就是说,RightCode 这次会给我一个上游自己生成的响应编号,但它没有给我一个入口,让我继续按这个编号把那条远端请求查出来。我想说的是,到这里,单条请求这组证据已经能一路对到“request_id -> 日志字段 backend=openai-primary -> 上游响应编号”,再往前就不是网关这边能继续补的了。
HTTP/2 404 {"timestamp":"2026-04-22T00:23:37.464+00:00","path":"/job/codex/v1/responses/resp_00509a020cc1dc150169e8149fd828819abdd914f7a0c475a7","status":404,"error":"Not Found","requestId":"3faedd2e-8600020","message":"No static resource job/codex/v1/responses/resp_00509a020cc1dc150169e8149fd828819abdd914f7a0c475a7."}
这段返回对我最重要的,不是 404 本身,而是它把边界说清楚了:我已经拿到了 RightCode 生成的 resp_...,但当前这个中转站没有给我继续往下查的入口。这样一来,这篇里最稳的写法就不是“我已经拿到了上游侧日志”,而是“我已经把网关这边能拿到的单请求证据查到头了”。我想说的是,这里要收住的不是成功有多漂亮,而是证据现在已经到哪一层。

六、流式聊天请求这次也一起成立了

为什么?因为这条仓库最容易被假信号带偏的,一直就是流式返回。我想说的是,这一节要把 stream=true 这条线也收实。
这次我继续打流式聊天请求。我想说的是,前面非流式聊天请求站住以后,流式返回也得一起过。
curl -sS -i -N http://127.0.0.1:18080/v1/chat/completions \ -H 'Authorization: Bearer lag-local-dev-key' \ -H 'Content-Type: application/json' \ -d '{"model":"gpt-5.4-mini","messages":[{"role":"user","content":"请用一句话说明什么是 API 网关。"}],"stream":true}'
这一步我关注的不是“有几行输出就算完”,而是这三件事是不是一起出现了。我想说的是,流式返回这一步不能只看一个 200。
  • Content-Type: text/event-stream
  • 多段真实 data:
  • 最后的 data: [DONE]
这次返回的是这段最小结果。我想说的是,这里看的是流式返回的形态是不是完整成立。
HTTP/1.1 200 OK Content-Type: text/event-stream X-Request-Id: 1776773971526267000-4 X-Trace-Id: 1776773971526267000-4 data: {"id":"resp_03bfb80a115964f50169e76b558888819a97a1ed38d734506c","object":"chat.completion.chunk","created":1776773973,"model":"gpt-5.4-mini-2026-03-17","choices":[{"index":0,"delta":{"content":"API"},"finish_reason":""}]} ... data: {"id":"resp_03bfb80a115964f50169e76b558888819a97a1ed38d734506c","object":"chat.completion.chunk","created":1776773974,"model":"gpt-5.4-mini-2026-03-17","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]} data: [DONE]
到这里,这条流式返回已经不只是“看起来像流式”,而是把这个仓库自己最在意的几项信号都对上了。为了避免又停在“看起来对”,我同样去核对了日志。我想说的是,流式返回这一步也得把日志一起接上。
2026/04/21 20:19:33 INFO provider event type=provider_request_succeeded operation=stream backend=openai-primary attempt=1 consecutive_failures=0 duration=1.495776667s 2026/04/21 20:19:34 INFO trace span finished backend=openai-primary attempt=1 chunk_count=33 trace_id=1776773971526267000-4 span_id=000000000000000d parent_span_id=000000000000000c request_id=1776773971526267000-4 span_name=provider.backend.stream status=ok duration=3.22036475s 2026/04/21 20:19:34 INFO http request completed request_id=1776773971526267000-4 trace_id=1776773971526267000-4 span_id=0000000000000008 method=POST path=/v1/chat/completions status=200 bytes=7594 duration=3.245485292s real_ip=127.0.0.1:50134 user_agent=curl/8.7.1 content_type=text/event-stream
这一步把最后一层误判也收掉了:不是只有非流式聊天请求成功,也不是网关自己在本地拼了一个看起来像流式的响应。stream=true 这条请求也已经通过 openai-primary 成功打到了真实上游。我想说的是,RightCode 这次连流式返回这条线也一起成立了。

七、从这里开始,后面的时延和错误就不能再全算在网关头上了

为什么?因为请求一旦真的出了网关,后面的时间就不再只属于网关自己。我想说的是,这一节要把后面的归因口径一起改掉。
到这里我已经愿意继续谈时延和错误了,但口径也得跟着变。因为从这一步开始,请求路径里已经不只有网关自己这一段了,还真的包含了远端 RightCode 中转站、自身的实现、模型服务和网络往返。像这次非流式聊天请求大约 1.82s、流式聊天请求大约 3.24s,已经不能再全算在网关头上。我想说的是,请求一旦真的走到线上上游,后面的数字就得重新算是谁的。
这也是我为什么一直不想把这篇写成“配置好一个 key,然后拿到 200 就结束”。对我来说,这篇真正站住的地方,是我终于拿到了一条完整的成功路径证据。route_rules 把能走到的上游收干净,/debug/providers 告诉我网关当前认的是谁,/v1/models 说明探活这条请求也成立了,非流式聊天请求和流式聊天请求再把“聊天请求真的走出去了”这件事收实,最后同一个 X-Request-Id 还能继续核对到日志字段 backend=openai-primary。到这里,后面的数字才值得讨论。所以我想说的是,先把请求到底打到了谁说清楚,后面的时延和错误才有资格继续谈。

结尾

为什么?因为这篇最后能不能留下来,就看读者记不记得住那句主线。我想说的是,结尾就只留主线本身。
这篇最重要的,不是我把真实模型接上了,而是我知道该怎么确认:这条请求真的打到了真实上游。对这次 RightCode 这条线来说,/v1/models 先让探活这条请求站住,非流式聊天请求让真实聊天请求走出去,“request_id 一路对到日志字段 backend=openai-primary”把单请求证据钉实,流式聊天请求再把流式返回这条线补齐。先确认流量到底打到了谁,再谈后面的数字。这就是这篇最后该留下来的那句话。
回到首页