Skip to content

Commit 2af7221

Browse files
authored
diag(executor): 扩展语义拒绝诊断日志覆盖范围,进一步定位 zhipu [1210] 真因 (#247)
* diag(executor): 为语义拒绝路径增加请求体可疑参数诊断日志; 在 execute_message 和 execute_stream 的 semantic rejection 日志中 附加请求体参数快照(thinking/extended_thinking/reasoning_effort 顶层参数、 会话历史中 thinking blocks 数量、cache_control 存在情况、模型名、消息数), 用于定位 zhipu glm-4.7 [1210] 参数校验拒绝的具体祸根参数。 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang<threefish.ai@gmail.com> * diag(executor): 扩展语义拒绝诊断日志覆盖范围至 system/tools/sampling/metadata/content_types 等维度 PR #244 部署后的诊断日志反转了原推断:失败请求均不含 thinking/cache_control, 说明祸根在更细粒度的参数。扩展 _build_semantic_rejection_diagnostic 函数: 新增维度(仅存在时输出): - system 形态(string/blocks + cache_control 计数) - tools 数量 + tool_choice 形态 - 采样参数(max_tokens/temperature/top_p/top_k/stop_sequences) - stream / metadata_keys - messages.content 类型分布(含 string content) - 请求体字节数估算(json.dumps) 新增 14 个单元测试(TestBuildSemanticRejectionDiagnostic)覆盖各字段组合 与真实失败请求形态。所有测试通过(1478 passed)。 * docs(agents): 记录 zhipu [1210] 诊断阶段进展(证据反转 + Step 1 v2) * build(version): 版本号升级至 0.4.1a6;
1 parent b0d688e commit 2af7221

3 files changed

Lines changed: 376 additions & 0 deletions

File tree

docs/agents/issue.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,3 +230,47 @@ SUM(input_tokens + output_tokens
230230

231231
- 历次 PR 中 cache token 字段的引入是渐进式的(schema 已有四列、`log()` 入参齐全、Overview 已全口径消费),但部分聚合视图的口径升级被遗漏;任何向 `usage_log` 增列后,**必须**审计所有 `SUM(input_tokens` / `SUM(output_tokens` 出现处的聚合表达式是否需要同步更新。
232232
- 跨标签页同一指标(如"总 Tokens")的口径一致性,建议在添加新视图时主动与 Overview 现有口径做交叉核对,必要时在 SQL 注释中标注口径来源,便于后续 review。
233+
234+
---
235+
236+
## Zhipu vendor 间歇性 `[1210][API 调用参数有误]` 拒绝(诊断阶段)
237+
238+
**问题描述**
239+
240+
Zhipu vendor 作为首选 tier 时,处理 `claude-haiku-* → glm-5-turbo` 的部分请求被上游直接拒绝:
241+
242+
```
243+
WARNING Tier zhipu semantic rejection
244+
(type=invalid_request_error,
245+
msg=[1210][API 调用参数有误,请检查文档。][...])
246+
[model=claude-haiku-4-5-20251001, messages=1], trying next tier without recording failure
247+
INFO Tier anthropic message succeeded (took over from failed tier: zhipu)
248+
```
249+
250+
失败请求统一表现为 `duration<1s + tokens=[0 0 0 0]`,被 zhipu 在入口校验阶段直接拒绝、未消耗任何 token。两次观察窗口失败率分别为 4%(2026-05-23 22:24,glm-4.7 旧映射)与 27%(2026-05-25 17:26+,glm-5-turbo 当前映射),均触发降级至 anthropic / copilot。
251+
252+
**表因**
253+
254+
`is_semantic_rejection` 检测到 zhipu 返回 `invalid_request_error + 1210` 含「API 调用参数有误」中文标记,判定为语义拒绝,跳过下一层 tier。1210 是智谱官方错误码,[官方文档](https://docs.bigmodel.cn/cn/api/api-code) 定义为「参数格式/类型不符规范」(区别于 1213「必需字段缺失」、1214「字段参数非法」)。
255+
256+
**根因(仍在收集证据)**
257+
258+
PR #244 的初版诊断字段仅覆盖 `thinking / thinking_blocks / cache_control / model / messages`,但 2026-05-25 17:26 后的诊断日志显示失败请求**均不含**上述任何字段。说明真正祸根在更细粒度的参数(system / tools / max_tokens / sampling / metadata / content_types / body_size 等)。
259+
260+
**处理方式(分阶段)**
261+
262+
- **Step 1(PR #244,已合并)**:在 `executor.py::_build_semantic_rejection_diagnostic` 中输出 thinking / cache_control 相关字段 — 但证据反转,覆盖不足以定位真因。
263+
- **Step 1 v2(本次)**:扩展诊断函数覆盖 `system_kind|blocks(+cc)` / `tools` / `tool_choice` / 采样参数 / `stream` / `metadata_keys` / `content_types` / `body_bytes` 等维度。所有项「仅存在时输出」以控制日志噪声。配套 14 个单元测试(`TestBuildSemanticRejectionDiagnostic`)覆盖各字段组合。
264+
- **Step 2(待定)**:依据扩展诊断日志的新证据,定位具体祸根参数后再施修复(候选路径:`ZhipuVendor._prepare_request` 参数剥离 / 调用现有 `normalize_for_zhipu` / pre-validation 警告)。
265+
266+
**后续防范**
267+
268+
- **「无证据,不下结论」**:当初版诊断字段无法覆盖根因时,禁止反复猜测,应优先扩展诊断维度抓取更多线索。本次先扩展再修复的迭代节奏可作为同类「黑盒 API 报错」问题的范式。
269+
- **诊断字段设计原则**:所有诊断项应「仅存在时输出」,避免常态化噪声;输出格式紧凑(`key=val`)便于日志检索;参数值用 `!r:.N` 截断防止巨型对象灌入日志。
270+
- **错误码差异化**:智谱 12xx 系列错误码语义并不等价(1210 ≠ 1213 ≠ 1214),未来面对类似 `[code][message]` 形式的供应商错误时,应优先查阅其官方错误码字典,避免基于错误消息字面意思的误判。
271+
272+
**同类问题影响与处理注意事项**
273+
274+
- 其他薄透传 vendor(minimax / kimi / doubao / alibaba / xiaomi)共用 `NativeAnthropicVendor._prepare_request`,若它们也开始报「参数错误」类语义拒绝,可复用本次扩展的诊断函数定位差异。
275+
- 若证据指向 `tools` 字段(如工具 schema 不兼容)、`metadata` 字段(如自定义键被 zhipu 拒收)等具体路径,修复时应优先复用 `convert/vendor_channels.py` 中已有的 `normalize_for_zhipu` / `strip_thinking_blocks` 工具,避免在 vendor 内部重复实现剥离逻辑。
276+
- 部署 Step 1 v2 后,建议观察至少 48 小时收集足够样本(>20 次失败),通过失败/成功请求形态对比统计找出**唯一差异维度**,再进入 Step 2。

src/coding/proxy/routing/executor.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from __future__ import annotations
88

9+
import json
910
import logging
1011
import re
1112
import time
@@ -175,6 +176,124 @@ def _build_semantic_rejection_diagnostic(body: dict[str, Any]) -> str:
175176
return f" [{', '.join(parts)}]" if parts else ""
176177

177178

179+
def _build_semantic_rejection_diagnostic(body: dict[str, Any]) -> str:
180+
"""构建语义拒绝的请求体诊断上下文.
181+
182+
在 semantic rejection 日志中附加请求体的可疑参数快照,
183+
用于定位供应商参数校验失败的具体祸根参数。
184+
185+
覆盖范围:
186+
* 模型 / messages 数(baseline)
187+
* thinking 系列顶层参数 + history thinking_blocks 数
188+
* system 形态(string / blocks,含 cache_control 计数)
189+
* tools 数量 + tool_choice 形态
190+
* 采样参数(max_tokens / temperature / top_p / top_k / stop_sequences)
191+
* stream / metadata 形态
192+
* cache_control 存在性
193+
* messages.content 类型分布
194+
* 请求体大小估算(json.dumps 字节数)
195+
"""
196+
parts: list[str] = []
197+
198+
# ── 模型 + 消息数(baseline,始终输出)──
199+
parts.append(f"model={body.get('model', 'N/A')}")
200+
parts.append(f"messages={len(body.get('messages', []))}")
201+
202+
# ── 顶层 thinking 系列参数 ──
203+
for key in ("thinking", "extended_thinking", "reasoning_effort"):
204+
if key in body:
205+
val = body[key]
206+
parts.append(f"{key}={val!r:.80}")
207+
208+
# ── system 形态 ──
209+
system = body.get("system")
210+
if isinstance(system, str):
211+
parts.append(f"system_kind=string(len={len(system)})")
212+
elif isinstance(system, list):
213+
cc_count = sum(
214+
1 for item in system if isinstance(item, dict) and "cache_control" in item
215+
)
216+
if cc_count:
217+
parts.append(f"system_blocks={len(system)},cc={cc_count}")
218+
else:
219+
parts.append(f"system_blocks={len(system)}")
220+
221+
# ── tools 与 tool_choice ──
222+
tools = body.get("tools")
223+
if isinstance(tools, list):
224+
parts.append(f"tools={len(tools)}")
225+
tool_choice = body.get("tool_choice")
226+
if tool_choice is not None:
227+
parts.append(f"tool_choice={tool_choice!r:.60}")
228+
229+
# ── 采样参数(仅存在时输出)──
230+
for key in ("max_tokens", "temperature", "top_p", "top_k"):
231+
if key in body:
232+
parts.append(f"{key}={body[key]!r:.40}")
233+
stop_sequences = body.get("stop_sequences")
234+
if isinstance(stop_sequences, list) and stop_sequences:
235+
parts.append(f"stop_sequences={len(stop_sequences)}")
236+
237+
# ── stream / metadata ──
238+
if "stream" in body:
239+
parts.append(f"stream={body['stream']}")
240+
metadata = body.get("metadata")
241+
if isinstance(metadata, dict) and metadata:
242+
parts.append(f"metadata_keys={len(metadata)}")
243+
244+
# ── 会话历史中的 thinking blocks 与 content_types 分布 ──
245+
thinking_count = 0
246+
content_type_counts: dict[str, int] = {}
247+
for msg in body.get("messages", []):
248+
content = msg.get("content")
249+
if isinstance(content, str):
250+
content_type_counts["string"] = content_type_counts.get("string", 0) + 1
251+
continue
252+
if not isinstance(content, list):
253+
continue
254+
for block in content:
255+
if not isinstance(block, dict):
256+
continue
257+
btype = block.get("type")
258+
if isinstance(btype, str):
259+
content_type_counts[btype] = content_type_counts.get(btype, 0) + 1
260+
if btype in ("thinking", "redacted_thinking"):
261+
thinking_count += 1
262+
if thinking_count:
263+
parts.append(f"thinking_blocks_in_history={thinking_count}")
264+
if content_type_counts:
265+
type_repr = ",".join(f"{k}:{v}" for k, v in sorted(content_type_counts.items()))
266+
parts.append(f"content_types={{{type_repr}}}")
267+
268+
# ── cache_control 存在检测(messages / tools,不含 system 因已单独统计)──
269+
has_cc = False
270+
sections: list[Any] = []
271+
for m in body.get("messages", []):
272+
if isinstance(m.get("content"), list):
273+
sections.append(m["content"])
274+
if isinstance(body.get("tools"), list):
275+
sections.append(body["tools"])
276+
for section in sections:
277+
for item in section:
278+
if isinstance(item, dict) and "cache_control" in item:
279+
has_cc = True
280+
break
281+
if has_cc:
282+
break
283+
if has_cc:
284+
parts.append("cache_control_fields=present")
285+
286+
# ── 请求体大小估算 ──
287+
try:
288+
body_bytes = len(json.dumps(body, ensure_ascii=False).encode("utf-8"))
289+
parts.append(f"body_bytes={body_bytes}")
290+
except (TypeError, ValueError):
291+
# 极少数情况下 body 含非可序列化对象,跳过
292+
pass
293+
294+
return f" [{', '.join(parts)}]" if parts else ""
295+
296+
178297
def _log_http_error_detail(
179298
tier_name: str,
180299
exc: Exception,

0 commit comments

Comments
 (0)