From c05c3c525cee649a288491a1ea923fb97ca9d313 Mon Sep 17 00:00:00 2001 From: chenpei Date: Tue, 8 Sep 2026 13:34:24 +0800 Subject: [PATCH] fix: stop CAD retries on truncated model output --- .../ai-solid-wood-cost/VALIDATION_LEDGER.md | 9 +++ .../probe_structure_graph.py | 8 +++ .../solid_wood_cost_engine.py | 66 ++++++++++++++++++- .../test_manufacturing_oracle.py | 6 ++ .../test_solid_wood_cost_engine.py | 65 ++++++++++++++++++ 5 files changed, 153 insertions(+), 1 deletion(-) diff --git a/server_patch/ai_3d_model/ai-solid-wood-cost/VALIDATION_LEDGER.md b/server_patch/ai_3d_model/ai-solid-wood-cost/VALIDATION_LEDGER.md index 2e982c0..2646119 100644 --- a/server_patch/ai_3d_model/ai-solid-wood-cost/VALIDATION_LEDGER.md +++ b/server_patch/ai_3d_model/ai-solid-wood-cost/VALIDATION_LEDGER.md @@ -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% 仍未完成。 diff --git a/server_patch/ai_3d_model/ai-solid-wood-cost/probe_structure_graph.py b/server_patch/ai_3d_model/ai-solid-wood-cost/probe_structure_graph.py index 5835ffc..9550637 100644 --- a/server_patch/ai_3d_model/ai-solid-wood-cost/probe_structure_graph.py +++ b/server_patch/ai_3d_model/ai-solid-wood-cost/probe_structure_graph.py @@ -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 diff --git a/server_patch/ai_3d_model/ai-solid-wood-cost/solid_wood_cost_engine.py b/server_patch/ai_3d_model/ai-solid-wood-cost/solid_wood_cost_engine.py index decdaca..c0fc335 100644 --- a/server_patch/ai_3d_model/ai-solid-wood-cost/solid_wood_cost_engine.py +++ b/server_patch/ai_3d_model/ai-solid-wood-cost/solid_wood_cost_engine.py @@ -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. diff --git a/server_patch/ai_3d_model/ai-solid-wood-cost/test_manufacturing_oracle.py b/server_patch/ai_3d_model/ai-solid-wood-cost/test_manufacturing_oracle.py index b88dd35..49b932c 100644 --- a/server_patch/ai_3d_model/ai-solid-wood-cost/test_manufacturing_oracle.py +++ b/server_patch/ai_3d_model/ai-solid-wood-cost/test_manufacturing_oracle.py @@ -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): diff --git a/server_patch/ai_3d_model/ai-solid-wood-cost/test_solid_wood_cost_engine.py b/server_patch/ai_3d_model/ai-solid-wood-cost/test_solid_wood_cost_engine.py index b591ae8..b2b8f07 100644 --- a/server_patch/ai_3d_model/ai-solid-wood-cost/test_solid_wood_cost_engine.py +++ b/server_patch/ai_3d_model/ai-solid-wood-cost/test_solid_wood_cost_engine.py @@ -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