fix(llm): P2-3 双防御——robust_json_parse 入口 isinstance(text,str) 统一 JSONParseError(非 str 曾 TypeError 裸逃逸穿透 502 契约变原生 500)+client 空回复 content:null 降级走 strict 重试一次(两轮仍空才 LLMError);null content 双调用+非 str 五形态测试 [vps] [no-doc]
CI/CD / test (push) Successful in 52s
CI/CD / nas-deploy (push) Has been cancelled
CI/CD / nas-verify (push) Has been cancelled

This commit is contained in:
2026-10-05 09:13:14 +08:00
parent d55c9cd9aa
commit 2731e26737
4 changed files with 30 additions and 3 deletions
+7 -3
View File
@@ -64,13 +64,12 @@ class LLMClient:
await self._sleep(_BACKOFF_SECONDS[min(attempts - 1, 1)])
continue
if status == 200:
content = self._content(text)
usage = json.loads(text).get("usage") or {}
logger.info("llm tokens prompt=%s completion=%s",
usage.get("prompt_tokens"),
usage.get("completion_tokens"))
try:
return robust_json_parse(content), usage
return robust_json_parse(self._content(text)), usage
except JSONParseError:
return await self._strict_retry(messages, payload)
if status in (401, 403, 404):
@@ -121,6 +120,11 @@ class LLMClient:
@staticmethod
def _content(text: str) -> str:
try:
return json.loads(text)["choices"][0]["message"]["content"]
content = json.loads(text)["choices"][0]["message"]["content"]
except (KeyError, IndexError, TypeError, ValueError) as e:
raise LLMError(f"LLM 响应缺 choices/message: {e}") from e
if content is None:
# P2-3: 兼容端点空回复 content:null 不罕见——按空回复降级走
# strict 重试(JSONParseError 通道),两轮仍空才 LLMError=502 契约
raise JSONParseError("content 为 null(空回复)")
return content
+3
View File
@@ -53,6 +53,9 @@ def _balanced_braces(text: str) -> str | None:
def robust_json_parse(text: str) -> dict:
"""五级降级:直读→围栏→配平截取→反斜杠修复→宽松(尾逗号/尾垃圾).全败 raise."""
if not isinstance(text, str): # P2-3: 非 str(含 None)统一 JSONParseError,
raise JSONParseError( # 曾 TypeError 裸逃逸穿透 502 契约
f"输入须为 str: {type(text).__name__}")
candidates: list[str] = [text]
m = _FENCE.search(text)
if m: