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("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(