遥测:上游状态码被压平、三分之二失败行没有归因列、21 条错误详情为空 #19

Closed
opened 2026-08-26 22:17:33 +08:00 by iomgaa · 1 comment
Owner

一句话

上游过载回的 529 传到调用方手里变成了 bad_response_status_code,而三分之二的失败遥测行
连是哪个模型都没记。2026-08-26 那一跑为了搞清「gemini 那条渠道到底怎么了」查了两个多小时,
最后是靠去中转后台翻日志才知道上游在回 529——这两条缺口各自贡献了一半的弯路。

口径

  • 数据来自 PolyGateway 1.3.0 写的 llm_calls 表,窗口 2026-08-26 07:04 ~ 14:11 UTC,共 4372 行
  • 调用方 CHSAnalyzer3,一次真实压测(23 位患者、7 小时),不是构造的复现
  • 涉及的四条渠道:qwen3.7-plus / gpt-5.5 / gemini-3.1-pro 走中转 newapi.iomgaa.online,
    monkey-ocr 走内网

一、上游的状态码在中转那一层被压平,PolyGateway 原样透传,于是丢了

gemini-3.1-pro 110 次调用错 46 次,其中 35 条错误正文是同一句:

503 | {"error":{"message":"openai_error","type":"bad_response_status_code","param":"","code":"bad_response_status_code"}}

上游实际回的是 529(过载)。这个数是去中转后台翻日志才拿到的,
从 PolyGateway 记的这一行里看不出来——429、500、503、529 到这边长得一模一样。

这两种的处理方式是相反的:配额打满该退避重排,上游过载该换渠道或者等。
分不开就只能一律当瞬时错误重试,而重试正是过载时最不该做的事。

压平这一步是中转做的,PolyGateway 只是原样收下。但PolyGateway 是唯一有机会
把「我实际收到的 HTTP 状态码」单独记一列的地方
——它手里有响应对象,中转的 body
再怎么糊,status line 是它自己看见的。现在这个数只以字符串形式出现在 error 的开头
(503 | {...}),要用得先 split。

建议:llm_calls 加一列存实收的 HTTP 状态码,以及一列存上游 body 的原文
(现在 body 是拼进 error 的,但只有出错时才有,且和我们自己的前缀混在一起)。

二、三分之二的失败行没有归因列

333 条失败行里,218 条的 model、provider、source_name 三列全是空串。
这批是 PolyGateway 自己的渠道级拒绝:

[179] nursing_extract_primary 网关暂时不可用: circuit_open
[ 17] nursing_extract_arbiter 网关暂时不可用: retry_exhausted
[ 11] classify 网关暂时不可用: retry_exhausted
[  4] nursing_extract_primary 网关暂时不可用: retry_exhausted
[  3] nursing_extract_arbiter 网关暂时不可用: circuit_open
[  2] cancelled
[  1] nursing_extract_reviewer 网关暂时不可用: retry_exhausted
[  1] table_extract 网关暂时不可用: retry_exhausted

渠道名明明就在 error 那个自由文本里,却没有进任何一个结构化列。
后果是「哪条渠道熔断了几次」只能拿 LIKE 去凑,而 circuit_open 那 182 条
恰恰是判断一条渠道健康与否最要紧的信号——排查时我是先按 model 分组,
得到的结论是「gemini 只错了 46 次」,直到发现还有 218 条无归因行才知道
真实的失败规模是三倍。

熔断时具体用哪个 source 可能还没选出来,但渠道是知道的。

建议:给渠道名一个自己的列。source/model/provider 选不出来时留空可以接受,
渠道名留空不行。

三、21 条错误的详情是空的

[11] 'nursing_primary 网络错误: '
[ 8] 'nursing_arbiter 超时: '
[ 1] 'nursing_arbiter 网络错误: '
[ 1] 'nursing_reviewer 网络错误: '

冒号后面什么都没有。一条超时不说自己撞的是哪个上限、撞的时候等了多久;
一条网络错误不说是连接失败还是读到一半断了。

这一条不是理论问题。这一跑里判断「12:22 前后中转不可达三分钟」这件事,
靠的是恰好有几条 网络错误 带上了 httpx 的原文:

网络错误: All connection attempts failed
网络错误: peer closed connection without sending complete message body (incomplete chunked read)

而另外 11 条同类的是空的。同一类异常,有的带原文有的不带,
说明是某条路径上 str(exc) 拿到空串就直接拼进去了。

建议:拿不到 str(exc) 时退回到异常类名。超时那一类还应该把撞上的是哪个上限
(连接超时 / 读超时 / 流活性 / 总时长)和实际等了多久记进去。

附:这次排查里被证伪的几条猜想

留个记录,免得下次重复查。这几条在 llm_calls 现有的列上都查得出来,
说明这张表本身的骨架是对的,缺的是上面那三处。

  • 熔断拒绝是瞬时的。 182 条 circuit_open 的时延中位 452 ms,
    179 条 nursing_extract_primary 里 176 条在 10 秒内。
    怀疑过「先在并发闸上等满槽位、再被熔断拒掉」,不成立。
  • 失败和并发无关。 逐次算「开工瞬间在飞的同模型调用数」,
    失败的均值 0.50、成功的 0.41,最大都是 1。
  • 失败和入参大小无关。 失败的 messages 平均 5820 字符,成功的 6447,
    失败的反而更小,区间完全重叠。
  • 不是本机网络。 走内网的 monkey-ocr 全程 1343 次只错 4 次(0.3%)。
## 一句话 上游过载回的 529 传到调用方手里变成了 `bad_response_status_code`,而三分之二的失败遥测行 连是哪个模型都没记。2026-08-26 那一跑为了搞清「gemini 那条渠道到底怎么了」查了两个多小时, 最后是靠去中转后台翻日志才知道上游在回 529——这两条缺口各自贡献了一半的弯路。 ## 口径 - 数据来自 PolyGateway 1.3.0 写的 `llm_calls` 表,窗口 2026-08-26 07:04 ~ 14:11 UTC,共 4372 行 - 调用方 CHSAnalyzer3,一次真实压测(23 位患者、7 小时),不是构造的复现 - 涉及的四条渠道:`qwen3.7-plus` / `gpt-5.5` / `gemini-3.1-pro` 走中转 `newapi.iomgaa.online`, `monkey-ocr` 走内网 ## 一、上游的状态码在中转那一层被压平,PolyGateway 原样透传,于是丢了 `gemini-3.1-pro` 110 次调用错 46 次,其中 35 条错误正文是同一句: ``` 503 | {"error":{"message":"openai_error","type":"bad_response_status_code","param":"","code":"bad_response_status_code"}} ``` 上游实际回的是 529(过载)。这个数是去中转后台翻日志才拿到的, **从 PolyGateway 记的这一行里看不出来**——429、500、503、529 到这边长得一模一样。 这两种的处理方式是相反的:配额打满该退避重排,上游过载该换渠道或者等。 分不开就只能一律当瞬时错误重试,而重试正是过载时最不该做的事。 压平这一步是中转做的,PolyGateway 只是原样收下。但**PolyGateway 是唯一有机会 把「我实际收到的 HTTP 状态码」单独记一列的地方**——它手里有响应对象,中转的 body 再怎么糊,status line 是它自己看见的。现在这个数只以字符串形式出现在 `error` 的开头 (`503 | {...}`),要用得先 split。 **建议**:`llm_calls` 加一列存实收的 HTTP 状态码,以及一列存上游 body 的原文 (现在 body 是拼进 `error` 的,但只有出错时才有,且和我们自己的前缀混在一起)。 ## 二、三分之二的失败行没有归因列 333 条失败行里,218 条的 `model`、`provider`、`source_name` 三列**全是空串**。 这批是 PolyGateway 自己的渠道级拒绝: ``` [179] nursing_extract_primary 网关暂时不可用: circuit_open [ 17] nursing_extract_arbiter 网关暂时不可用: retry_exhausted [ 11] classify 网关暂时不可用: retry_exhausted [ 4] nursing_extract_primary 网关暂时不可用: retry_exhausted [ 3] nursing_extract_arbiter 网关暂时不可用: circuit_open [ 2] cancelled [ 1] nursing_extract_reviewer 网关暂时不可用: retry_exhausted [ 1] table_extract 网关暂时不可用: retry_exhausted ``` 渠道名明明就在 `error` 那个自由文本里,却没有进任何一个结构化列。 后果是「哪条渠道熔断了几次」只能拿 LIKE 去凑,而 `circuit_open` 那 182 条 恰恰是判断一条渠道健康与否最要紧的信号——排查时我是先按 `model` 分组, 得到的结论是「gemini 只错了 46 次」,直到发现还有 218 条无归因行才知道 真实的失败规模是三倍。 熔断时具体用哪个 source 可能还没选出来,但**渠道是知道的**。 **建议**:给渠道名一个自己的列。source/model/provider 选不出来时留空可以接受, 渠道名留空不行。 ## 三、21 条错误的详情是空的 ``` [11] 'nursing_primary 网络错误: ' [ 8] 'nursing_arbiter 超时: ' [ 1] 'nursing_arbiter 网络错误: ' [ 1] 'nursing_reviewer 网络错误: ' ``` 冒号后面什么都没有。一条超时不说自己撞的是哪个上限、撞的时候等了多久; 一条网络错误不说是连接失败还是读到一半断了。 这一条不是理论问题。这一跑里判断「12:22 前后中转不可达三分钟」这件事, 靠的是恰好有几条 `网络错误` 带上了 httpx 的原文: ``` 网络错误: All connection attempts failed 网络错误: peer closed connection without sending complete message body (incomplete chunked read) ``` 而另外 11 条同类的是空的。同一类异常,有的带原文有的不带, 说明是某条路径上 `str(exc)` 拿到空串就直接拼进去了。 **建议**:拿不到 `str(exc)` 时退回到异常类名。超时那一类还应该把撞上的是哪个上限 (连接超时 / 读超时 / 流活性 / 总时长)和实际等了多久记进去。 ## 附:这次排查里被证伪的几条猜想 留个记录,免得下次重复查。这几条在 `llm_calls` 现有的列上都查得出来, 说明这张表本身的骨架是对的,缺的是上面那三处。 - **熔断拒绝是瞬时的。** 182 条 `circuit_open` 的时延中位 452 ms, 179 条 `nursing_extract_primary` 里 176 条在 10 秒内。 怀疑过「先在并发闸上等满槽位、再被熔断拒掉」,不成立。 - **失败和并发无关。** 逐次算「开工瞬间在飞的同模型调用数」, 失败的均值 0.50、成功的 0.41,最大都是 1。 - **失败和入参大小无关。** 失败的 `messages` 平均 5820 字符,成功的 6447, 失败的反而更小,区间完全重叠。 - **不是本机网络。** 走内网的 `monkey-ocr` 全程 1343 次只错 4 次(0.3%)。
Author
Owner

本 issue 由 1.3.5 关闭(main ab00aa44576bd4cd47d0680d107d5191679dc1ee,tag v1.3.5,已上传 registry)。

归因缺失的三条本体已修:上游状态码不再被压平(中转把 529 改写成 503 就记 503,不猜回 529);失败行补齐 http_status_code / error_type / cause_type / error_body 四个归因列(成功行五列全 NULL,不统一填 200);空 str() 的 Connect/Read/Write/PoolTimeout 现在把 httpx 类名落进 cause_type,error 退回类名,不再留空。另新增 scope / operation 两维,operation 由调用点给定(与 exc.operation 是两个语义),并修正了 embed() 非 200 历史误标为 chat 的问题。

下游必须做的迁移(不做会静默出错)

动作 说明
自建 TelemetryRecorder 补 10 个 keyword-only 参数(26 → 36),或改成 **fields 装配期当场 ValueError,不是运行期静默丢行。旧签名 recorder 升级后会在 TelemetryEmitter 构造时被拒
PG manual 档自行补列 telemetry_schema_sql("postgres") 自取;不补则这 10 维按现有列裁剪后不落库(库发一条点名警告)
计失败调用改 WHERE event_kind = 'terminal_failure' 一次逻辑调用恰一条终态行。沿用 error IS NOT NULL 会跨尝试行与终态行,升级后计数变大(400 密集负载下错误行翻倍,是已批准的可见变化)
AVG(latency_ms) 按 event_kind 分组 终态行的 latency_ms 是整次逻辑调用总耗时,与单次尝试不同量纲

口径澄清(避免误读)

  • 新增的失败统计列与 terminal_failure 行是常规遥测口径变更,不是"异常字段":它们对每次逻辑调用恒定产生,下游按新口径统计即可,不应当成偶发脏数据过滤掉。
  • 费用口径不变:终态行 cost IS NULL、usage_source='unavailable'、token 为 0,不参与 SUM(cost),不会双计。
  • http_status_code 不可当成败判据:MonkeyOCR 的 success != true 就是 200 下的失败;终态行该列为 NULL(不冒充某一次尝试的状态码)。
  • 新列全部可空、追加在末列之后、旧行不回填(NULL 表达"补列之前根本没记过这件事"),滚动升级期新旧进程可写同一张表。

验证范围声明

已跑:合并后 make lint(import-linter 1 kept / 0 broken)、全套件 make test(1605 passed / 23 skipped / 108 deselected / 95%)、真实网关 e2e 冒烟 4 节点、Redis 时间语义 slow 18 项、真实 Postgres 列兼容 30 项,以及从 registry 独立安装后的离线真链路复核(3 条 attempt + 恰 1 条 terminal_failure、诊断列记真实 503、终态 cost 为 NULL)。
未做全模型矩阵与全渠道验证:本版未触碰推理路径,沿用 1.3.4 的型号级结论及其 FAIL/UNKNOWN/不可达例外,不代表所有模型或所有上游渠道都已验证可用。

本 issue 由 **1.3.5** 关闭(main `ab00aa44576bd4cd47d0680d107d5191679dc1ee`,tag `v1.3.5`,已上传 registry)。 归因缺失的三条本体已修:上游状态码不再被压平(中转把 529 改写成 503 就记 503,**不猜回 529**);失败行补齐 `http_status_code` / `error_type` / `cause_type` / `error_body` 四个归因列(成功行五列全 NULL,**不统一填 200**);空 `str()` 的 Connect/Read/Write/PoolTimeout 现在把 httpx 类名落进 `cause_type`,`error` 退回类名,不再留空。另新增 `scope` / `operation` 两维,`operation` 由调用点给定(与 `exc.operation` 是两个语义),并修正了 `embed()` 非 200 历史误标为 `chat` 的问题。 ### 下游必须做的迁移(不做会静默出错) | 动作 | 说明 | | --- | --- | | 自建 `TelemetryRecorder` 补 10 个 keyword-only 参数(26 → 36),或改成 `**fields` | **装配期当场 `ValueError`**,不是运行期静默丢行。旧签名 recorder 升级后会在 `TelemetryEmitter` 构造时被拒 | | PG manual 档自行补列 | `telemetry_schema_sql("postgres")` 自取;不补则这 10 维按现有列裁剪后不落库(库发一条点名警告) | | 计失败调用改 `WHERE event_kind = 'terminal_failure'` | 一次逻辑调用恰一条终态行。沿用 `error IS NOT NULL` 会跨尝试行与终态行,升级后计数变大(400 密集负载下错误行翻倍,是已批准的可见变化) | | `AVG(latency_ms)` 按 `event_kind` 分组 | 终态行的 `latency_ms` 是整次逻辑调用总耗时,与单次尝试不同量纲 | ### 口径澄清(避免误读) - 新增的失败统计列与 `terminal_failure` 行是**常规遥测口径变更,不是"异常字段"**:它们对每次逻辑调用恒定产生,下游按新口径统计即可,不应当成偶发脏数据过滤掉。 - 费用口径不变:终态行 `cost IS NULL`、`usage_source='unavailable'`、token 为 0,不参与 `SUM(cost)`,不会双计。 - `http_status_code` **不可当成败判据**:MonkeyOCR 的 `success != true` 就是 200 下的失败;终态行该列为 NULL(不冒充某一次尝试的状态码)。 - 新列全部可空、追加在末列之后、**旧行不回填**(NULL 表达"补列之前根本没记过这件事"),滚动升级期新旧进程可写同一张表。 ### 验证范围声明 已跑:合并后 `make lint`(import-linter 1 kept / 0 broken)、全套件 `make test`(1605 passed / 23 skipped / 108 deselected / 95%)、真实网关 e2e 冒烟 4 节点、Redis 时间语义 slow 18 项、真实 Postgres 列兼容 30 项,以及从 registry 独立安装后的离线真链路复核(3 条 attempt + 恰 1 条 terminal_failure、诊断列记真实 503、终态 `cost` 为 NULL)。 **未做**全模型矩阵与全渠道验证:本版未触碰推理路径,沿用 1.3.4 的型号级结论及其 FAIL/UNKNOWN/不可达例外,**不代表所有模型或所有上游渠道都已验证可用**。
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: iomgaa/PolyGateway#19