fix: stop CAD retries on truncated model output

This commit is contained in:
chenpei 2026-09-08 13:34:24 +08:00
parent aa412fb893
commit c05c3c525c
5 changed files with 153 additions and 1 deletions

View File

@ -308,3 +308,12 @@
- v116 将 401/403 单独归类为不可重试的 `model_credentials_invalid`。模型请求只执行一次,源资格层立即以 `external_dependency_failed` 终止,不再重复请求模型,也不再向用户虚构缺图、缺尺寸或 CAD 问题。服务器隔离现场耗时 3.753 秒,返回中文阻塞信息“读图模型已拒绝当前鉴权凭据”。
- 候选完整回归 229 项通过(246.290 秒),1 项真实 CAD 内核用例按设计因本机未启用服务器依赖而跳过;新增专项测试验证 401 只请求一次、鉴权失败不进入读图/CAD循环。v115 因外部鉴权阻塞,尚未实际验证上一轮分阶段 CAD 修复门能否在这组原图上完成收敛,不能宣称原图重跑成功。
- 当前仍需新的有效阿里云模型密钥才能继续多视图识别、CAD/BOM冻结盲测和公司成本误差不超过 5% 的目标。本轮候选未上线、未重启生产服务,接口状态必须保持测试失败/继续优化。
## 2026-09-08 v117 本地多模态输出上限审计与截断终止门(候选,未上线)
- 在生产 Chat API 活动、等待、得物任务与持久队列连续为空后,服务器仅在 `/tmp/dalongxia-cad-v85-eI8HeF` 使用原始四图启动全新隔离试验;没有读取公司 BOM、旧图谱或旧模型响应,`company_bom_used_in_inference=false`,没有修改生产目录或重启服务。
- 本地 Gemma4 多模态服务正确复读出整体宽 1200mm、深 400mm、高 800mm,三轴均有两次独立证据且置信度为 1.0;但结构专项响应在第 72 行被截断为未闭合字符串,五轮图谱请求也全部返回空或不可解析 JSON,故没有生成可验收 CAD/BOM。
- 现场核对服务端实现后确认根因:调用方请求 3072–8192 tokens,服务却用 `GEMMA_LOCAL_MAX_NEW_TOKENS=512` 静默钳制输出,并且无论是否触顶都返回 `finish_reason=stop`。因此旧逻辑把确定的容量不足误判为偶发 JSON 格式问题,重复提交同一个过大的结构任务;这条“原样重试五轮”路线正式判定失败。
- 通用请求层现在同时识别显式 `finish_reason=length` 与“未闭合 JSON + 完成 token 正好落在常见隐藏上限 + 请求量至少为其两倍”的静默截断证据。命中后立即抛出 `model_output_truncated`,责任层标记为 `model_gateway_capacity` 且不可原样重试;普通短小畸形 JSON 仍保留最多三次格式重试,不会把所有格式错误都误判成容量问题。
- 新增测试覆盖 401 终止、512-token 假 `stop` 只请求一次、短畸形 JSON 仍执行有界重试,以及探针把截断归类为外部模型网关容量问题。最终完整回归 231 项通过(234.508 秒),1 项真实 CAD 内核用例按设计因本机未启用服务器依赖而跳过。
- 该修复只根治截断后的无效循环和错误归因,不代表 Gemma4 已具备长结构图谱生成能力,更不代表原图结构、CAD、BOM或成本通过。下一步必须把高层视觉识别拆成可验证的小型结构事实,再由确定性参数/装配编译器生成 CAD;或者接入可返回完整长 JSON 的有效多模态网关。不同公司产品冻结后盲测与总成本误差不超过 5% 仍未完成。

View File

@ -58,6 +58,14 @@ def terminal_dependency_feedback(error: Exception | str) -> dict | None:
"next_owner": "runtime_configuration",
"retryable": False,
}
if "读图模型输出被截断" in message:
return {
"code": "model_output_truncated",
"severity": "blocking",
"detail": "读图服务的实际输出上限不足以返回完整结构化结果",
"next_owner": "model_gateway_capacity",
"retryable": False,
}
return None

View File

@ -36,6 +36,10 @@ class SolidWoodCostError(RuntimeError):
pass
class ModelOutputTruncatedError(SolidWoodCostError):
"""The provider ended a structured response at its hidden output cap."""
class SolidWoodCostEngine:
"""Stateful multi-view furniture CAD/BOM/cost estimator."""
@ -2609,11 +2613,37 @@ class SolidWoodCostEngine:
"returned_model": payload.get("model"),
"finish_reason": (payload.get("choices") or [{}])[0].get("finish_reason"),
"usage": payload.get("usage") or {},
"requested_max_tokens": attempt_body.get("max_completion_tokens")
or attempt_body.get("max_tokens"),
}
raw = payload["choices"][0]["message"]["content"]
if isinstance(raw, list):
raw = "".join(str(item.get("text") or "") for item in raw if isinstance(item, dict))
return (decoder or self._decode_model_json)(str(raw or ""))
raw = str(raw or "")
self._last_model_response_metadata["raw_characters"] = len(raw)
try:
return (decoder or self._decode_model_json)(raw)
except (ValueError, TypeError, json.JSONDecodeError, SolidWoodCostError) as exc:
if self._model_json_was_truncated(
payload,
raw,
requested_max_tokens=attempt_body.get("max_completion_tokens")
or attempt_body.get("max_tokens"),
):
completion_tokens = int((payload.get("usage") or {}).get("completion_tokens") or 0)
self._last_model_response_metadata.update({
"truncated": True,
"truncation_detection": "finish_reason_or_silent_provider_cap",
})
raise ModelOutputTruncatedError(
f"{failure_label}:读图模型输出被截断(返回{completion_tokens} tokens,"
"JSON未闭合;服务端输出上限低于本次结构任务要求)"
) from exc
raise
except ModelOutputTruncatedError:
# Repeating the same oversized schema against the same hidden
# provider cap cannot repair it and only creates a retry loop.
raise
except urllib.error.HTTPError as exc:
# Authentication failures cannot be repaired by asking the
# model for JSON again. Preserve a stable, non-secret error
@ -2640,6 +2670,40 @@ class SolidWoodCostEngine:
f"{failure_label}:模型连续{bounded_attempts}次未返回可解析JSON({last_error})"
) from last_error
@classmethod
def _model_json_was_truncated(
cls,
payload: dict,
raw: str,
*,
requested_max_tokens: int | None,
) -> bool:
"""Recognize explicit and silently capped incomplete JSON responses.
Some OpenAI-compatible relays clamp ``max_tokens`` (for example to
512) yet incorrectly return ``finish_reason=stop``. We only infer
that hidden cap when a JSON container is visibly unfinished, the
provider reports a sizeable power-of-two completion boundary, and the
caller requested at least twice that amount. Ordinary malformed JSON
remains eligible for the bounded formatting retry.
"""
choice = (payload.get("choices") or [{}])[0]
finish_reason = str(choice.get("finish_reason") or "").strip().lower()
if finish_reason in {"length", "max_tokens", "max_output_tokens"}:
return True
text = str(raw or "").strip()
start = min((index for index in (text.find("{"), text.find("[")) if index >= 0), default=-1)
if start < 0 or cls._json_fragment(text, start):
return False
usage = payload.get("usage") or {}
try:
completion_tokens = int(usage.get("completion_tokens") or 0)
requested = int(requested_max_tokens or 0)
except (TypeError, ValueError):
return False
is_common_cap = completion_tokens >= 256 and completion_tokens & (completion_tokens - 1) == 0
return bool(is_common_cap and requested >= completion_tokens * 2)
def _adjudicate_annotated_third_view(self, candidates: list[dict]) -> dict:
"""Use pixels, not filenames, to split two annotated perspective views.

View File

@ -82,6 +82,12 @@ class ManufacturingOracleTest(unittest.TestCase):
invalid = terminal_dependency_feedback(RuntimeError("读图模型鉴权失败(HTTP 401)"))
self.assertEqual("model_credentials_invalid", invalid["code"])
self.assertFalse(invalid["retryable"])
truncated = terminal_dependency_feedback(RuntimeError(
"家具图谱:读图模型输出被截断(返回512 tokens,JSON未闭合)"
))
self.assertEqual("model_output_truncated", truncated["code"])
self.assertEqual("model_gateway_capacity", truncated["next_owner"])
self.assertFalse(truncated["retryable"])
self.assertIsNone(terminal_dependency_feedback(RuntimeError("ordinary compile error")))
def test_visual_review_drops_self_declared_non_issues_and_unproven_proportion_guess(self):

View File

@ -54,6 +54,71 @@ class SolidWoodCostEngineTest(unittest.TestCase):
self.engine._request_model_json(body, "测试鉴权", max_attempts=3)
self.assertEqual(1, opened.call_count)
def test_silent_provider_output_cap_is_terminal_and_not_retried(self):
raw = '{"parts":[{"name":"侧板","quantity":2,"evidence":"原图"}' + (" " * 900)
payload = {
"model": "capped-model",
"choices": [{
"finish_reason": "stop",
"message": {"role": "assistant", "content": raw},
}],
"usage": {"prompt_tokens": 1200, "completion_tokens": 512, "total_tokens": 1712},
}
class FakeResponse:
def __enter__(self):
return self
def __exit__(self, *_args):
return False
def read(self):
return json.dumps(payload, ensure_ascii=False).encode("utf-8")
os.environ["DASHSCOPE_API_KEY"] = "test-key"
body = {
"model": "capped-model",
"messages": [{"role": "user", "content": "return json"}],
"response_format": {"type": "json_object"},
"max_tokens": 3072,
}
with mock.patch.object(self.module.urllib.request, "urlopen", return_value=FakeResponse()) as opened:
with self.assertRaisesRegex(self.module.ModelOutputTruncatedError, "输出被截断"):
self.engine._request_model_json(body, "结构识别", max_attempts=3)
self.assertEqual(1, opened.call_count)
self.assertTrue(self.engine._last_model_response_metadata["truncated"])
def test_malformed_short_json_still_uses_bounded_format_retry(self):
payload = {
"model": "test-model",
"choices": [{
"finish_reason": "stop",
"message": {"role": "assistant", "content": '{"parts":['},
}],
"usage": {"prompt_tokens": 100, "completion_tokens": 7, "total_tokens": 107},
}
class FakeResponse:
def __enter__(self):
return self
def __exit__(self, *_args):
return False
def read(self):
return json.dumps(payload).encode("utf-8")
os.environ["DASHSCOPE_API_KEY"] = "test-key"
body = {
"model": "test-model",
"messages": [{"role": "user", "content": "return json"}],
"max_tokens": 3072,
}
with mock.patch.object(self.module.urllib.request, "urlopen", return_value=FakeResponse()) as opened:
with self.assertRaisesRegex(self.module.SolidWoodCostError, "连续3次"):
self.engine._request_model_json(body, "结构识别", max_attempts=3)
self.assertEqual(3, opened.call_count)
def setUp(self):
self.temp = tempfile.TemporaryDirectory(prefix="solid-wood-cost-test-")
os.environ["DALONGXIA_SOLID_WOOD_COST_STATE_DIR"] = self.temp.name