让 Agent 少写防御性代码:每条校验都要有理由
一个策略选择工具,本来只需要让 Agent 查看模板、选择版本、取得写作规则。写着写着,代码却变成了另一副样子:遍历字段名、判断字典形状、检查数组元素、猜测字段类型,再把已经检查过的数据交给下一层继续检查。 每一条判断单独看都有解释,放在一起却让人看不清工具到底做什么。 这发生在一次写作策略工具教程的审查中。删除一个通用转换助手后,代码甚至更长了:检查没有减少,只是搬进了另一个函数。问题并不是少用了一个框架,也不是变量命名不够规范,而是还没决定哪些错误需要处理,就先把能想到的检查写了出来。 本文适合已经了解 Python dataclass、正在让 Agent 编写小型工具或分层应用的读者。以下例子从那次审查中缩小而来,用来说明设计取舍,不是完整策略工具的实现。 前两篇分别讨论模型与仓储的职责和入口检查与业务约束的分工。这篇往前追问一步:在决定校验放在哪里之前,我们为什么需要这条校验? 一个数组转换,怎么长成了通用校验器 假设模板包含 structure 字段。JSON 中它是数组,领域对象中希望用 tuple 表示: structure = tuple(data["structure"]) 这行代码完成转换,但不保证输入正确: assert tuple("post") == ("p", "o", "s", "t") 如果 Agent 把数组写成字符串,转换不会报错,错误会以另一种形式进入模型。因此,在接收 Agent 请求的地方,检查它是不是字符串数组是有具体理由的。 但这不意味着每个处理 structure 的方法,都应该重新执行下面三项检查: 它是不是数组? 数组里的元素是不是字符串? 数组是不是非空? 前两项属于输入协议;第三项取决于业务:这个模板是否允许没有正文结构?如果模型已经保证结构不能为空,入口无需重复保证一次。 同样的字段,也可能来自程序自己写出的文件。是否检查,要先确定文件的使用方式。 数据来源 能依赖的前提 处理方式 Agent 提交的编辑 JSON 尚未确认类型和字段 入口解析,拒绝不符合约定的结构 已解析的内部请求对象 调用者遵守既定契约 直接使用,不重复判断类型 同一程序写出、没有独立编辑入口的文件 写入与读取遵循同一格式 可以直接映射,加载失败时暴露错误 允许人工编辑或导入的文件 内容可能不符合格式 把文件读取当作输入边界,定义需要的检查 “放在仓库里”不等于“可信”。如果维护者经常手改 JSON,它就是一个真实的编辑入口。反过来,受控文件也不是永远不会损坏;选择直接映射,意味着不承诺为每一种损坏提供定制诊断,更不保证发现所有能被转换的错误类型。 一个能直接运行的例子 缩小需求:模板名称不能为空,结构至少有一项,每一项不能是空白文字。外部请求只接受 name 和 structure,不自动转换数字或字符串数组。 保存下面代码为 template_boundary.py,使用 Python 3.10 或更新版本运行: from dataclasses import dataclass @dataclass(frozen=True) class Template: name: str structure: tuple[str, ...] def __post_init__(self): if not self.name.strip(): raise ValueError("模板名称不能为空") if not self.structure or any(not step.strip() for step in self.structure): raise ValueError("模板必须包含非空的写作步骤") def decode_template(data: object) -> Template: if not isinstance(data, dict) or set(data) != {"name", "structure"}: raise ValueError("请求必须包含且只能包含 name 和 structure") if not isinstance(data["name"], str): raise ValueError("name 必须是字符串") steps = data["structure"] if not isinstance(steps, list) or not all(isinstance(step, str) for step in steps): raise ValueError("structure 必须是字符串数组") return Template(name=data["name"], structure=tuple(steps)) def writing_outline(template: Template) -> str: return "\n".join(f"{i}. {step}" for i, step in enumerate(template.structure, 1)) if __name__ == "__main__": template = decode_template({ "name": "问题与解释", "structure": ["提出具体问题", "解释原因与适用条件"], }) print(writing_outline(template)) cases = [ ({"name": "问题与解释", "structure": "post"}, "structure 必须是字符串数组"), ({"name": "问题与解释", "structure": [42]}, "structure 必须是字符串数组"), ({"name": "问题与解释", "structure": []}, "模板必须包含非空的写作步骤"), ({"name": "问题与解释", "structure": [" "]}, "模板必须包含非空的写作步骤"), ({"name": " ", "structure": ["解释"]}, "模板名称不能为空"), ] for payload, expected in cases: try: decode_template(payload) except ValueError as error: assert str(error) == expected else: raise AssertionError(f"错误输入被接受:{payload!r}") print("正常输入和 5 个失败场景均符合约定") 运行 python3 template_boundary.py,输出: ...