AIGC标识 Agent 工具返回了 0,为什么被当成失败?用反例审查一次 Python 重构

审查 Agent 工具调用代码时,有一种修改很容易被接受:把判断字段是否存在,简化成判断字段的值是否为真。代码少了几个字符,普通样例也能通过,但 0、False、空列表和 None 可能因此被丢掉。

下面用一个单响应适配器复现这个问题。完整实现与测试都在正文里,不需要模型 API 或第三方依赖。

先明确:工具结果不一定是一段非空文字

假设工具返回的是已解析的 JSON-RPC 风格字典:

reply = {"jsonrpc": "2.0", "id": "call-7", "result": 0}

这里的 0 可以表示查询计数为零。换成 False 可以表示某项检查不成立;空列表可以表示没有匹配记录。这些值是否有效,取决于工具的接口,不能仅凭 Python 的真假值判断。

本文采用一个有意缩小的契约:调用方的 expected_id 是非空字符串;只接收一个与该 ID 对应的响应字典;成功响应包含 result,错误响应包含结构合法的 error;两者必须恰好存在一个。result 可以是任何已解析的 JSON 值,包括 None。

这不是完整的 JSON-RPC 客户端,不处理批量调用、通知、网络传输或解析失败后 id=null 的协议响应。

一个普通样例查不出的重构错误

原本正确的成功分支是:

if "result" in reply:
    return {"status": "ok", "value": reply["result"]}

如果改成 if reply.get("result"):,就把“键存在”换成了“值为真”。对 result=3 两者一致;对 result=0,后者却走不到成功分支。

Python 中,None、False、数值零和空容器都属于假值;dict.get 又会在键不存在时默认返回 None。因此下面几个条件含义各不相同:

判断 检查的事实 本文成功分支是否合适
"result" in reply 键是否存在 合适
reply.get("result") 值是否为真 会漏掉合法假值
reply.get("result") is not None 取到的值是否不是 None 仍会漏掉显式 null

把条件改成 is not None 能修复零和空列表,但没有修复本题允许的显式 null。回归测试必须保留这个反例。

完整适配器

def interpret_tool_reply(reply, expected_id):
    if not isinstance(reply, dict):
        return {"status": "invalid"}
    if reply.get("jsonrpc") != "2.0" or reply.get("id") != expected_id:
        return {"status": "invalid"}

    has_result = "result" in reply
    has_error = "error" in reply
    if has_result == has_error:
        return {"status": "invalid"}

    if has_result:
        return {"status": "ok", "value": reply["result"]}

    error = reply["error"]
    if (not isinstance(error, dict)
            or type(error.get("code")) is not int
            or not isinstance(error.get("message"), str)):
        return {"status": "invalid"}
    return {"status": "error", "error": error}

has_result == has_error 拒绝“两者都存在”和“两者都缺失”。错误码使用 type(...) is int,是因为 Python 的 bool 是 int 的子类,isinstance(True, int) 会返回真,而本契约不接受布尔错误码。这里也有意只接受 Python int,不接受 1.0 这样的浮点表示;这是本文适配器的输入约定。

函数只读输入,返回新的外层字典。它没有深拷贝内部的 result 或 error,因此调用方后续修改嵌套对象仍可能影响原数据。若需要隔离可变对象,应该另行约定复制策略,不能把“函数执行时未修改输入”理解成“结果完全独立”。

用有区分力的测试验证

把下面的代码接在实现之后运行:

import copy
import json


def envelope(**fields):
    return {"jsonrpc": "2.0", "id": "call-7", **fields}


# JSON 字符串比较避免 Python 将 False 与 0 视为相等。
def assert_json_equal(actual, expected):
    assert json.dumps(actual, sort_keys=True) == json.dumps(expected, sort_keys=True)


values = [3, 0, False, "", [], {}, None, {"items": [0, False, None]}]
for value in values:
    source = envelope(result=value)
    before = copy.deepcopy(source)
    assert_json_equal(
        interpret_tool_reply(source, "call-7"),
        {"status": "ok", "value": value},
    )
    assert_json_equal(source, before)

error = {"code": -32001, "message": "Retry later", "data": {"after": 0}}
assert_json_equal(
    interpret_tool_reply(envelope(error=error), "call-7"),
    {"status": "error", "error": error},
)

invalid = [
    [],
    envelope(),
    envelope(result=0, error=error),
    {"jsonrpc": "1.0", "id": "call-7", "result": 3},
    {"jsonrpc": "2.0", "id": "call-8", "result": 3},
    {"jsonrpc": "2.0", "result": 3},
    envelope(error=None),
    envelope(error={"code": True, "message": "Oops"}),
    envelope(error={"code": 1}),
    envelope(error={"code": 1.0, "message": "Oops"}),
    envelope(error={"code": 1, "message": None}),
]
for source in invalid:
    assert interpret_tool_reply(source, "call-7") == {"status": "invalid"}

# 两种局部修补都应被反例区分出来。
assert bool(envelope(result=0).get("result")) is False
assert (envelope(result=None).get("result") is not None) is False
assert "result" in envelope(result=None)

这组测试先覆盖成功值的不同语义,再检查信封结构和错误对象。只用一个“工具成功返回文本”的样例,无法证明简化是安全的。

如何把这个检查迁移到其他 Agent 代码

看到 AI 提议用 or 提供默认值时,也应该问同一个问题:接口是在缺失时使用默认值,还是在所有假值时使用默认值?例如 timeout = config.get("timeout") or 30 会覆盖显式的零;是否有错,要看零在接口里是否表示立即超时。

代码审查时可以先写出允许值,再选一个让新旧实现产生不同结果的输入,最后保留为回归测试。这样练习的重点就是读懂契约、审查修改与验证行为,而不只是从空白函数开始写算法。

参考与延伸

本文使用 AI 辅助整理文字,代码和断言经过实际执行检查。

posted @ 2026-10-15 10:48  dijefff  阅读(1)  评论(0)    收藏  举报