From 58c28ef4a045cd64fe02797efe6e0796be8d17eb Mon Sep 17 00:00:00 2001 From: Windows <19353512109@163.com> Date: Sat, 12 Sep 2026 14:11:42 +0800 Subject: [PATCH] =?UTF-8?q?fix(memory):=20=E8=AE=B0=E5=BF=86=E6=8A=BD?= =?UTF-8?q?=E5=8F=96=E5=AE=B9=E5=BF=8D"=E5=8D=95=E5=85=83=E7=B4=A0?= =?UTF-8?q?=E6=95=B0=E7=BB=84"=E5=BD=A2=E7=8A=B6=EF=BC=8C=E7=A9=BA?= =?UTF-8?q?=E7=BB=93=E6=9E=9C=E4=B8=8D=E5=86=8D=E8=A2=AB=E5=88=A4=E4=B8=BA?= =?UTF-8?q?=E6=97=A0=E6=95=88=20JSON?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 现象(2026-09-12 跑真实 Worker 时发现) `episode_worker` 反复报 `模型记忆抽取输出不是有效 JSON` 并重试到失败: ``` pydantic_core.ValidationError: Input should be a valid dictionary or instance of _ExtractionPayload input_value=[{'memory_key': None, 'value': None, 'memory_type': None, 'confidence': 0}] input_type=list ``` ## 根因 契约是**对象** `{...}`,而模型在 episode 抽取路径会返回**单元素数组** `[{...}]`。 `_parse` 直接 `json.loads` 后交给 pydantic,数组自然过不了 `model_validate`, 于是被归入"输出不是有效 JSON"这一条 —— 但它其实是**合法的空结果** (`_validate` 已能把"三字段为 null 且 confidence=0"正确识别为"无持久事实",返回 None)。 后果:这类 episode 白跑一遍模型调用、重试到 `retry_count` 上限后判失败, **该片段的记忆永远抽不出来**。 ## 修法 `_parse` 里只对"**恰好一个对象**的数组"做归一化: - `[{...}]` → 取 `{...}`(空结果照常返回 None;有事实照常解析) - 多元素数组、元素非对象、空数组 → **不猜**,仍交给校验失败关闭 (多元素时无法判断哪个是答案,猜错会把错误记忆写进库,比失败更糟) ## 测试 `tests/unit/service/test_memory_extraction_service.py` 追加 2 个用例: - `test_single_element_array_is_normalized`:数组包空结果 → None;数组包有事实 → 正常解析 - `test_multi_element_or_non_dict_array_still_fails_closed`:多元素 / 非对象元素 / 空数组 → 仍失败 ## 验证 - `pytest tests/unit/service/test_memory_extraction_service.py tests/unit/worker/test_memory_extraction_worker.py` → 28 passed - `mypy app` → 0 错 / 245 文件 > 说明:`memory_extraction_service.py` 属主干线代码。此处是从**实际运行日志**里发现的 > 健壮性缺陷,改动限定在输出形状归一化,不改变抽取契约与校验规则。 --- app/service/memory_extraction_service.py | 16 +++++++-- .../service/test_memory_extraction_service.py | 33 +++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/app/service/memory_extraction_service.py b/app/service/memory_extraction_service.py index 0286f3d..31a9fef 100644 --- a/app/service/memory_extraction_service.py +++ b/app/service/memory_extraction_service.py @@ -163,8 +163,20 @@ class MemoryExtractionService: candidate = candidate.removeprefix("```") candidate = candidate.removeprefix("json").removesuffix("```").strip() try: - return _ExtractionPayload.model_validate(json.loads(candidate)) - except (json.JSONDecodeError, ValidationError, TypeError) as exc: + data = json.loads(candidate) + except (json.JSONDecodeError, TypeError) as exc: + raise RecoverableAgentError("模型记忆抽取输出不是有效 JSON") from exc + # 模型有时把结果包在**单元素数组**里,而契约是**对象**。实测出现于 episode 抽取路径: + # [{"memory_key": null, "value": null, "memory_type": null, "confidence": 0}] + # 该形状此前直接进 pydantic 校验、报 `Input should be a valid dictionary`, + # 被记成"输出不是有效 JSON"并反复重试直到 episode 判失败 —— 而它其实是**合法的空结果** + # (`_validate` 已能把"三字段为 null 且 confidence=0"识别为"无持久事实")。 + # 只归一化"恰好一个对象的数组";多元素或元素非对象时**不猜**,仍交给校验失败关闭。 + if isinstance(data, list) and len(data) == 1 and isinstance(data[0], dict): + data = data[0] + try: + return _ExtractionPayload.model_validate(data) + except (ValidationError, TypeError) as exc: raise RecoverableAgentError("模型记忆抽取输出不是有效 JSON") from exc def _validate( diff --git a/tests/unit/service/test_memory_extraction_service.py b/tests/unit/service/test_memory_extraction_service.py index c06a57a..ac93368 100644 --- a/tests/unit/service/test_memory_extraction_service.py +++ b/tests/unit/service/test_memory_extraction_service.py @@ -161,6 +161,39 @@ async def test_declared_empty_extraction_returns_none() -> None: await extract_with(json.dumps({**payload, "confidence": 0.5})) +@pytest.mark.asyncio +async def test_single_element_array_is_normalized() -> None: + """模型把结果包在**单元素数组**里时归一化为对象,而不是当成"无效 JSON"。 + + 背景(2026-09-12 实测):契约是对象,但模型在 episode 抽取路径会返回 + `[{"memory_key": null, "value": null, "memory_type": null, "confidence": 0}]`。 + 该形状此前直接进 pydantic 校验,报 + `Input should be a valid dictionary ... input_type=list`,被记成"输出不是有效 JSON", + 于是反复重试到 episode 判失败 —— 而它其实是**合法的空结果**。 + """ + empty = {"memory_key": None, "value": None, "memory_type": None, "confidence": 0} + # 数组包着的空结果 → 归一化后按"无持久事实"处理,返回 None(不是失败)。 + assert await extract_with(json.dumps([empty])) is None + # 数组包着的**有事实**结果 → 同样归一化,照常解析出来。 + extracted = await extract_with(json.dumps([VALID_PAYLOAD])) + assert extracted is not None + assert extracted.memory_key == VALID_PAYLOAD["memory_key"] + + +@pytest.mark.asyncio +async def test_multi_element_or_non_dict_array_still_fails_closed() -> None: + """只归一化"恰好一个对象的数组";其余形状**不猜**,仍失败关闭。 + + 多元素时无法判断哪一个是答案,猜错会把错误记忆写进库 —— 比失败更糟。 + """ + with pytest.raises(RecoverableAgentError): + await extract_with(json.dumps([VALID_PAYLOAD, VALID_PAYLOAD])) + with pytest.raises(RecoverableAgentError): + await extract_with(json.dumps(["not-a-dict"])) + with pytest.raises(RecoverableAgentError): + await extract_with(json.dumps([])) + + @pytest.mark.asyncio async def test_missing_endpoint_fails_closed_without_calling_model() -> None: service, model = build_service(json.dumps(VALID_PAYLOAD), resolver=StubResolver([]))