开源贡献与故障复盘

一个 `ValueError` 如何让 Agent Memory API 返回 500:从异常契约到两层回归测试

复盘 Hindsight 开源修复,统一领域异常、HTTP 语义与两层回归测试。

Hindsight异常契约开源贡献

在一个 Agent 记忆系统中,如果调用方传入不存在的记忆类型,接口应该返回什么?

答案显然不是 500 Internal Server Error

请求格式合法,只是业务参数不被支持,因此更合理的响应是 422 Unprocessable Entity。然而在 Hindsight 的 recall 接口中,一个普通的 ValueError 穿过 Engine 和 HTTP 两层后,最终被错误地包装成了 500。

这篇文章记录我如何定位这条异常传播链、选择正确的修复边界,并用两层回归测试锁定行为。

相关修复已通过 Hindsight PR #3062 合并到上游仓库。


问题是什么?

Hindsight 是一个面向 Agent 的记忆基础设施。调用 recall 接口时,可以通过 types 指定需要召回的记忆类型。

合法类型包括:

experience
observation
world

如果传入一个不存在的类型:

POST /v1/default/banks/error_test/memories/recall
Content-Type: application/json
{
  "query": "test",
  "types": ["bogus"]
}

修复前,接口返回:

500 Internal Server Error

但这不是服务器自身发生故障,而是调用方传入了无法处理的业务参数。

正确结果应该是:

422 Unprocessable Entity

并返回清晰的错误信息:

Invalid fact type(s): bogus.
Must be one of: experience, observation, world

为什么 500 是个严重的错误语义?

HTTP 状态码不只是给人看的,它还会影响调用方的控制流程。

对于 Agent 或其他自动化客户端来说:

状态码通常代表调用方可能采取的行为
422请求参数在业务上不合法修正参数,不盲目重试
500服务端发生未知故障重试、告警或触发降级
503服务暂时不可用延迟后重试

如果一个参数错误被标记成 500,Agent 可能重复发送同一个错误请求。再完善的重试机制,也无法让非法的 types=["bogus"] 突然变得合法。

因此,这个问题表面上是“状态码错误”,本质上却是异常契约失真。

系统识别出了正确的问题,却用错误的异常类型把它表达了出来。


错误是如何传播的?

问题位于 MemoryEngine.recall_async 的 fact type 校验逻辑。

原来的代码已经能够正确识别非法类型:

invalid_types = set(fact_type) - VALID_RECALL_FACT_TYPES

if invalid_types:
    raise ValueError(
        f"Invalid fact type(s): {', '.join(sorted(invalid_types))}. "
        f"Must be one of: {', '.join(sorted(VALID_RECALL_FACT_TYPES))}"
    )

真正的问题在于:

raise ValueError(...)

这是一个普通 Python 异常,只能说明“某个值不对”,却没有携带 HTTP 或业务层需要的状态码信息。

HTTP recall 处理器已经支持项目内的业务验证异常:

OperationValidationError

当它捕获这种异常时,可以读取异常中的 status_codereason,再生成正确的 HTTP 响应。

但普通 ValueError 没有匹配到这个处理分支,于是继续落入通用异常处理:

except Exception:
    # 最终返回 500

完整传播过程如下:

flowchart LR
    A["请求 types = ['bogus']"] --> B["MemoryEngine 校验 fact type"]
    B --> C["抛出普通 ValueError"]
    C --> D["HTTP 层没有匹配业务异常"]
    D --> E["进入 except Exception"]
    E --> F["返回 500"]

这里有一个重要的工程判断:

代码能否发现错误,与系统能否正确表达错误,是两件不同的事。


应该在哪一层修复?

定位原因后,有三个可能的方案。

方案一:在 HTTP 层捕获 ValueError

例如:

except ValueError as exc:
    raise HTTPException(status_code=422, detail=str(exc))

这种方式修改简单,但问题明显:

  1. ValueError 的含义太宽泛,其他编程错误也可能被误判为客户端错误。
  2. 只修复 HTTP 入口,直接调用 Engine 的调用方仍然只能收到普通异常。
  3. HTTP 层开始推测底层异常的业务含义,边界逐渐模糊。

因此没有采用。

方案二:只在请求模型中限制类型

可以使用 Pydantic 的 Literal 或枚举,在 HTTP 请求进入 Engine 前阻止非法值。

这对 HTTP 很有效,但仍不完整。

Engine 不一定只被 HTTP 调用。内部任务、测试或者其他协议入口都可能直接调用 recall_async。如果验证只存在于请求模型中,那么绕过 HTTP 后,Engine 的异常契约仍然不一致。

方案三:在 Engine 层抛出业务验证异常

最终选择是在发现非法类型的位置,直接抛出项目已有的:

OperationValidationError

修复后的代码:

invalid_types = set(fact_type) - VALID_RECALL_FACT_TYPES

if invalid_types:
    from hindsight_api.extensions.operation_validator import (
        OperationValidationError,
    )

    raise OperationValidationError(
        f"Invalid fact type(s): {', '.join(sorted(invalid_types))}. "
        f"Must be one of: {', '.join(sorted(VALID_RECALL_FACT_TYPES))}",
        status_code=422,
    )

新的传播过程变成:

flowchart LR
    A["请求 types = ['bogus']"] --> B["MemoryEngine 校验 fact type"]
    B --> C["抛出 OperationValidationError(422)"]
    C --> D["HTTP 层使用已有异常映射"]
    D --> E["保留 reason"]
    E --> F["返回 422"]

这个方案有几个优点:

  • 错误在最了解其业务含义的位置被分类。
  • HTTP 层不需要增加新的特殊判断。
  • 其他 Engine 调用方也能获得统一的业务异常。
  • 没有引入新的异常类型或处理器。
  • 合法 recall 路径完全不变。

为什么要先写两层失败测试?

只写一个 HTTP 测试,能够证明最终响应是 422,却不能准确说明 Engine 的异常契约是否正确。

只写一个 Engine 测试,又无法证明 HTTP 层确实会把它映射为 422。

因此,这次修复使用了两层回归测试。

第一层:锁定 Engine 的异常契约

直接调用 recall_async

@pytest.mark.asyncio
async def test_recall_invalid_fact_type_raises_422(
    memory,
    request_context,
):
    with pytest.raises(OperationValidationError) as exc_info:
        await memory.recall_async(
            bank_id="invalid-fact-type-test",
            query="test",
            fact_type=["bogus"],
            request_context=request_context,
        )

    assert exc_info.value.status_code == 422
    assert exc_info.value.reason == (
        "Invalid fact type(s): bogus. "
        "Must be one of: experience, observation, world"
    )

它验证了三件事:

  1. Engine 不再抛出普通 ValueError
  2. 业务异常携带 422
  3. 错误原因保持明确且稳定。

修复前,这个测试会因为实际抛出 ValueError 而失败。

第二层:锁定 HTTP 响应

通过真实 API 客户端发送请求:

response = await api_client.post(
    "/v1/default/banks/error_test/memories/recall",
    json={
        "query": "test",
        "types": ["bogus"],
    },
)

assert response.status_code == 422
assert response.json()["detail"] == (
    "Invalid fact type(s): bogus. "
    "Must be one of: experience, observation, world"
)

修复前,这个测试得到的是:

Expected: 422
Actual:   500

修复后,引擎异常通过已有映射自然变成 422,不需要修改 HTTP handler。


RED → GREEN:确认测试真的覆盖了问题

回归测试不能只在修复后通过。更重要的是确认它在修复前会因为目标问题而失败。

修复前的 RED 结果是:

Engine test:
ValueError: Invalid fact type(s): bogus...

HTTP test:
assert 500 == 422

这两个失败分别对应问题的两端:

错误分类错误 → ValueError
错误响应错误 → HTTP 500

完成最小修改后,两项测试进入 GREEN:

2 passed

随后又运行已有的完整 API workflow,确认合法 recall、retain、consolidation 和其他正常路径没有受到影响。

最终重点回归结果:

3 passed

这比“手动请求一次看到 422”更可靠,因为它同时验证了内部异常契约和外部 API 行为。


这个修复没有做什么?

小型 bug fix 很容易演变成不必要的重构。因此,明确不修改什么同样重要。

这次没有:

  • 修改 recall 请求 schema。
  • 增加新的异常类。
  • 修改 HTTP 通用异常处理器。
  • 修改其他调用入口。
  • 修改合法 fact type 的处理逻辑。
  • 重构整个错误处理体系。
  • 引入新的依赖。

最终代码改动集中在三个文件:

MemoryEngine 校验逻辑
Engine 回归测试
HTTP 集成测试

这是一种典型的低侵入修复:沿用项目既有机制,只纠正错误发生位置的异常表达。


总结

问题的根因不是 fact type 校验缺失,而是 Engine 使用普通 ValueError 表达业务验证失败,导致 HTTP 层把客户端错误误判成服务器故障。

最终修复是:

  1. 在共享 Engine 边界抛出 OperationValidationError
  2. 显式携带 status_code=422
  3. 复用已有 HTTP 异常映射。
  4. 通过 Engine 和 HTTP 两层回归测试锁定行为。
  5. 运行完整 workflow,确认合法路径不受影响。

最终,非法 recall type 从 500 正确变为 422,修复通过 Hindsight PR #3062 合并。

陈涛 · Agent Application Developer

杭州 · 2026