fix(memory): 记忆抽取容忍"单元素数组"形状,空结果不再被判为无效 JSON

## 现象(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` 属主干线代码。此处是从**实际运行日志**里发现的
> 健壮性缺陷,改动限定在输出形状归一化,不改变抽取契约与校验规则。
This commit is contained in:
2026-09-12 14:11:42 +08:00
parent be970aa46a
commit 58c28ef4a0
2 changed files with 47 additions and 2 deletions
+14 -2
View File
@@ -163,8 +163,20 @@ class MemoryExtractionService:
candidate = candidate.removeprefix("```") candidate = candidate.removeprefix("```")
candidate = candidate.removeprefix("json").removesuffix("```").strip() candidate = candidate.removeprefix("json").removesuffix("```").strip()
try: try:
return _ExtractionPayload.model_validate(json.loads(candidate)) data = json.loads(candidate)
except (json.JSONDecodeError, ValidationError, TypeError) as exc: 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 raise RecoverableAgentError("模型记忆抽取输出不是有效 JSON") from exc
def _validate( def _validate(
@@ -161,6 +161,39 @@ async def test_declared_empty_extraction_returns_none() -> None:
await extract_with(json.dumps({**payload, "confidence": 0.5})) 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 @pytest.mark.asyncio
async def test_missing_endpoint_fails_closed_without_calling_model() -> None: async def test_missing_endpoint_fails_closed_without_calling_model() -> None:
service, model = build_service(json.dumps(VALID_PAYLOAD), resolver=StubResolver([])) service, model = build_service(json.dumps(VALID_PAYLOAD), resolver=StubResolver([]))