参数校验与结构化错误:告诉 Agent 哪里失败了
正在读取学习记录…
笔记助手收到一份查询,程序需要检查它,再决定是否读取。参数校验就是按规则检查实际收到的数据:字段是否齐全、类型是否正确、值是否有意义。检查通过,程序继续;检查失败,程序给出具体原因。
先从一次正常请求开始:
{"name": "read_note", "arguments": {"note_id": "n1"}}工具名是 read_note,参数是笔记编号 n1。应用确认当前用户是 u1,在他的笔记范围里找到 n1,返回正文“退款窗口为 7 天。”。同一条路径会依次处理文本、字段、资源和结果,像收快递时先辨认单据,再核对地址,最后检查送来的东西。
把一条请求分成可理解的步骤
| 阶段 | 做什么 | 正常输入与结果 |
|---|---|---|
| 解析 parse | 把 JSON 文本读成程序里的值 | 文本变成字典 |
| 路由 route | 根据名字找到工具 | 选择 read_note |
| 参数形状 shape | 检查字段、类型和值 | 得到字符串编号 n1 |
| 资源 resource | 在当前用户范围里找到笔记 | u1 拥有 n1 |
| 输出 output | 核对工具返回的数据 | text 是正文字符串 |
“路由”在这里指根据工具名选择处理入口。“形状”指数据由哪些字段组成、每个字段是什么类型。先认识这几个名字,后面看到错误阶段就能知道程序走到了哪里。
解析与校验是相邻的两步。json.loads 能把 {"note_id":3} 解析成字典;之后类型检查会发现笔记编号是数字。文本可以读懂,仍要继续检查它是否符合工具约定。
让程序能读懂错误
如果所有失败都返回“出错了”,上层只能猜原因。结构化错误把原因拆成有固定含义的字段:
{
"ok": false,
"call_id": "call-1",
"error": {
"code": "invalid_arguments",
"stage": "shape",
"retryable": false
}
}| 字段 | 含义 | 这条错误里的值 |
|---|---|---|
ok | 这次处理是否成功 | false,处理失败 |
call_id | 本次工具请求编号 | 用于找到对应请求 |
error.code | 错误类别 | invalid_arguments,参数不符合要求 |
error.stage | 发生错误的步骤 | shape,检查字段和类型时失败 |
error.retryable | 原样重试是否合适 | false,先修正参数 |
比如 invalid_arguments 是“参数不符合规则”,shape 是参数检查阶段。应用据此请求修正参数。错误文案可以换成更友好的中文,程序仍按稳定的 code 处理,避免每次改措辞都影响分支逻辑。
retryable: false 表示原样重试通常无法解决,需要按错误阶段处理:参数错误先改参数,资源拒绝先核对身份与归属,输出错误则修正工具实现。以后遇到短暂限流或连接失败,才会涉及等待后重试。
保存程序并运行
创建一个学习文件夹,在其中保存 validation_lab.py 和 test_validation_lab.py。程序使用固定笔记表,两个用户各有一篇笔记。
保存为 validation_lab.py:
先在 PowerShell 创建本篇的独立目录并进入;路径可换成你自己的非系统盘位置:
mkdir -Force "F:\学习\agentdepth-l2-validation"
cd "F:\学习\agentdepth-l2-validation"将本篇的 validation_lab.py、test_validation_lab.py 保存到这个目录。保存全部文件后运行 dir *.py,核对上述文件名,再执行下文命令;新开窗口时先回到该目录,并按 Python 环境篇核对 python 的解释器路径。
"""按阶段处理笔记查询,并返回结构化结果。"""
from __future__ import annotations
import json
# 固定笔记按用户身份分组。
NOTES = {"u1": {"n1": "退款窗口为 7 天。"}, "u2": {"n2": "另一份私人笔记。"}}
def handle(raw: str, identity: str, call_id: str = "call-1", result_override: dict | None = None) -> dict:
# 用同一种格式返回错误,调用方可以按 code 和 stage 分支处理。
def fail(code: str, stage: str, retryable: bool = False) -> dict:
return {"ok": False, "call_id": call_id, "error": {"code": code, "stage": stage, "retryable": retryable}}
# 第一步:把 JSON 文本解析为 Python 值。
try:
request = json.loads(raw)
except json.JSONDecodeError:
return fail("invalid_json", "parse")
# 第二步:选择工具并检查参数字段和类型。
if not isinstance(request, dict) or request.get("name") != "read_note":
return fail("unknown_tool", "route")
arguments = request.get("arguments")
if not isinstance(arguments, dict) or set(arguments) != {"note_id"}:
return fail("invalid_arguments", "shape")
note_id = arguments["note_id"]
if not isinstance(note_id, str) or not note_id.strip():
return fail("invalid_arguments", "shape")
# 第三步:在当前用户的笔记范围内查找。
if identity not in NOTES or note_id not in NOTES[identity]:
return fail("forbidden_or_missing", "resource")
# 第四步:检查结果对象、字段、编号对应关系和正文类型。
result = result_override if result_override is not None else {"note_id": note_id, "text": NOTES[identity][note_id]}
if (not isinstance(result, dict) or set(result) != {"note_id", "text"}
or not isinstance(result["note_id"], str) or result["note_id"] != note_id
or not isinstance(result["text"], str)):
return fail("invalid_tool_result", "output")
return {"ok": True, "call_id": call_id, "result": result}
if __name__ == "__main__":
# 每条固定输入观察一种处理路径。
cases = [
("valid", '{"name":"read_note","arguments":{"note_id":"n1"}}', "u1"),
("invalid_json", '{"name":', "u1"),
("extra_field", '{"name":"read_note","arguments":{"note_id":"n1","admin":true}}', "u1"),
("wrong_owner", '{"name":"read_note","arguments":{"note_id":"n1"}}', "u2"),
]
for label, raw, identity in cases:
print(json.dumps({"case": label, **handle(raw, identity)}, ensure_ascii=False))从文件夹的资源管理器地址栏输入 powershell,在打开的终端运行:
macOS:先在 Python 篇的学习文件夹中打开终端,执行 source .venv/bin/activate。再输入 cd ,把当前例子的文件夹拖入终端并回车,运行下面的命令。
python -X utf8 validation_lab.py终端会逐行显示四个结果:
{"case": "valid", "ok": true, "call_id": "call-1", "result": {"note_id": "n1", "text": "退款窗口为 7 天。"}}
{"case": "invalid_json", "ok": false, "call_id": "call-1", "error": {"code": "invalid_json", "stage": "parse", "retryable": false}}
{"case": "extra_field", "ok": false, "call_id": "call-1", "error": {"code": "invalid_arguments", "stage": "shape", "retryable": false}}
{"case": "wrong_owner", "ok": false, "call_id": "call-1", "error": {"code": "forbidden_or_missing", "stage": "resource", "retryable": false}}先读第一行:ok: true,result 包含实际读取的笔记内容。随后三行分别停在解析、参数和资源阶段,case 帮你对应演示输入。四个固定案例共用 call-1;正式应用为不同调用分配不同编号。
沿着 handle 看控制流
handle 的第一个输入 raw 是 JSON 文本,第二个输入 identity 是应用确认的用户身份。
json.loads(raw) 负责读文本;坏 JSON 会触发 JSONDecodeError,程序立刻返回解析错误。之后的工具名和参数检查都有前置 return:某一步返回了错误,函数就结束,后面的读取分支不会运行。Python json 文档介绍了解析返回值和解析异常。
set(arguments) 取得所有参数名,再与 {"note_id"} 比较。集合要求恰好相同,所以漏字段和多字段都会在这里被发现。类型与内容检查继续判断编号是否为含实际字符的字符串。
资源阶段用 NOTES[identity] 查当前用户的笔记。例子将“找不到”和“当前用户无权读取”合并为 forbidden_or_missing,对外提供相同错误类别;内部日志可以分别记录原因,方便维护人员定位。
最后的输出检查确认结果是字典、字段恰好包含 note_id 和 text、两者都是字符串,且返回编号对应请求的笔记。工具返回值也会进入后续程序,检查它与检查输入一样重要。
每次改一个值,观察检查阶段
打开程序底部 cases,修改 valid 那条输入,运行后核对结果:
| note_id 改成 | 预期阶段 | 为什么 |
|---|---|---|
"" | shape | 编号为空 |
" " | shape | strip 去掉空格后为空 |
3 | shape | 类型为数字 |
"n9" | resource | 当前用户没有这个编号 |
"n1" | 正常结果 | u1 的笔记存在 |
当 JSON 字符串里面使用数字时,写 "note_id":3;字符串编号写 "note_id":"n1"。每次改一个值,比较输出的 error.stage,最后恢复原输入。
保存下面的测试为 test_validation_lab.py:
下面的测试检查正常结果,以及解析、参数、资源和输出各阶段的错误类别。
test_validation_lab.py:检查每个处理阶段
import unittest
from validation_lab import handle
# 每项断言对应一个处理阶段。
class ValidationLabTests(unittest.TestCase):
def test_valid(self):
self.assertTrue(handle('{"name":"read_note","arguments":{"note_id":"n1"}}', "u1")["ok"])
def test_parse_failure(self):
self.assertEqual(handle("{bad", "u1")["error"]["stage"], "parse")
def test_extra_field(self):
self.assertEqual(handle('{"name":"read_note","arguments":{"note_id":"n1","admin":true}}', "u1")["error"]["stage"], "shape")
def test_resource_scope(self):
self.assertEqual(handle('{"name":"read_note","arguments":{"note_id":"n1"}}', "u2")["error"]["stage"], "resource")
def test_call_id_preserved(self):
self.assertEqual(handle("{bad", "u1", "abc")["call_id"], "abc")
def test_output_failure(self):
# 注入数字正文,确认错误发生在返回结果阶段。
result = handle('{"name":"read_note","arguments":{"note_id":"n1"}}', "u1", result_override={"note_id": "n1", "text": 3})
self.assertEqual(result["error"]["stage"], "output")
def test_output_must_be_object(self):
# 工具返回列表时,应用仍应给出结构化输出错误。
result = handle('{"name":"read_note","arguments":{"note_id":"n1"}}', "u1", result_override=[])
self.assertEqual(result["error"]["stage"], "output")
def test_output_requires_matching_note_id(self):
# 缺失编号、数字编号、返回另一篇笔记,都要在输出检查中发现。
for value in ({"text": "正文"}, {"note_id": 3, "text": "正文"}, {"note_id": "n2", "text": "正文"}):
with self.subTest(value=value):
result = handle('{"name":"read_note","arguments":{"note_id":"n1"}}', "u1", result_override=value)
self.assertEqual(result["error"]["stage"], "output")
if __name__ == "__main__":
unittest.main()运行:
python -X utf8 -m unittest -v test_validation_lab.py预期是 8 项测试和 OK。测试里的 assertEqual 比较实际与预期,assertTrue 检查条件为真。test_output_failure 给工具结果注入数字正文,确认应用返回 output 阶段错误。新增输出测试还检查列表结果、缺失编号、数字编号及返回另一篇笔记。test_call_id_preserved 检查错误仍携带原调用编号,方便找到出问题的请求。
按原因选择下一步
错误类别像路口标识,上层可以依它选择修正、等待或停止:
| 类别 | 常见情况 | 合适动作 |
|---|---|---|
| 输入格式错误 | JSON 未闭合、编号类型错误 | 给出字段提示,有限次数请求修正 |
| 工具选择错误 | 工具名不在清单 | 重新提供清单或结束这条动作 |
| 资源访问错误 | 当前用户没有目标笔记 | 核对身份与编号,停止读取 |
| 工具输出错误 | 正文被返回为数字 | 修正工具实现并重新测试 |
| 临时服务错误 | 限流或短暂连接故障 | 按服务约定等待后有限重试 |
| 写入结果未知 | 订单已创建,但响应丢失 | 查询业务状态,再决定下一步 |
最后一种情况要结合幂等与副作用控制:重发创建请求可能产生第二个订单。先确定之前那次动作的状态,才能安全继续。
MCP 还区分协议请求错误和工具执行结果中的错误。协议层的未知工具、消息格式问题,与工具内部的查询失败属于不同位置,集成时按 MCP 工具规范转换结果。
参数校验保证请求符合规则,结构化错误把“失败在哪、为何失败”交给上层。先跑通正常请求,再用一个改动观察一个阶段,才能把错误码读成具体处理过程。下一篇是工具权限,继续检查谁能操作哪份资源。
课后练习展开习题收起习题
正在读取本地记录…
不调用模型,不执行代码。简答、代码和场景题仅保存答案并展示自查标准,不生成评分、徽章或认证。输入内容不会自动保存,请点击提交;不要填写个人信息或密钥。
选择题 · 先判断边界
关于“参数校验与结构化错误”,哪种做法更稳妥?
对应知识点:L2-validation
简答题 · 解释失败
写出一个失败输入并说明记录字段。
对应知识点:L2-validation
代码题 · 边界测试
补充正常、边界、失败三组输入和预期结果。
对应知识点:L2-validation
场景题 · 结果未知
证据不足时如何设计状态和人工接管?
对应知识点:L2-validation
描述会发送给本站维护者,用于修订文章,保存90天。请只填写文章问题,省略个人信息、密钥和内部资料。