工具描述与输入输出 Schema:让模型建议变成可校验的请求
正在读取学习记录…
给笔记助手一个 read_note 工具,它就能读取指定笔记。怎样告诉模型该填哪个字段,又怎样让程序检查收到的数据?需要一份Schema(数据结构规则):列出字段、类型和取值要求。
像一张快递单,收件人和地址有固定的位置;工具的输入也有固定字段。工具描述讲“这项服务做什么”,Schema 讲“这张表怎样填写”。接着用笔记查询,把这两部分连起来。
把工具说明写清楚
read_note 的工具描述是“读取当前用户指定编号的笔记,返回编号和正文”。这里有三个信息:动作是读取,对象是笔记,结果是正文。模型据此判断它是否适合回答“我的 n1 笔记写了什么”。
含糊的“处理用户数据”会让读者和模型都猜用途。更好的描述可以写清操作对象、输入含义、返回内容和必要前提。对搜索工具,还可以说明返回几条结果以及是否按相关度排序。
一份工具定义如下。里面的 inputSchema 使用 JSON Schema:用JSON描述数据应有哪些字段、每个字段是什么类型,以及哪些字段必须填写。先把它当作这份工具申请表的填写规则:
{
"name": "read_note",
"description": "读取当前用户的一篇笔记,返回笔记编号和正文。",
"inputSchema": {
"type": "object",
"properties": {
"note_id": {"type": "string", "minLength": 1}
},
"required": ["note_id"],
"additionalProperties": false
}
}JSON 中没有注释语法,逐项说明放在下面:
| 字段 | 含义 | 对应输入 |
|---|---|---|
type: "object" | 输入是一个带字段的对象 | {"note_id":"n1"} |
properties | 说明各字段的规则 | 声明 note_id |
type: "string" | 字段值是字符串 | "n1" |
minLength: 1 | 字符串至少有一个字符 | 空字符串长度为 0 |
required | 列出必须出现的字段 | 要填写 note_id |
additionalProperties: false | 字段集合限定为已声明字段 | 额外的 admin 会被拒绝 |
properties 负责描述字段,required 负责要求字段出现。两者分工不同。对象默认允许额外属性,封闭字段集合需要明确配置。规则可对照 JSON Schema 对象文档。
" " 包含一个空格,长度也是 1,因此满足 minLength: 1。如果业务要求编号包含实际文字,程序还要检查去掉首尾空格后的值。这是业务规则:结合任务意义判断数据是否可用。
输入与输出各有一份约定
输入符合规则后,工具查询笔记并返回:
{"note_id": "n1", "text": "仅供 u1 读取的示例笔记"}输出也有固定形状:note_id 是字符串,text 是正文字符串。对应的输出 Schema 如下:
{
"type": "object",
"properties": {
"note_id": {"type": "string", "minLength": 1},
"text": {"type": "string"}
},
"required": ["note_id", "text"],
"additionalProperties": false
}两个字段都必须出现,而且都使用字符串。应用检查返回值,才能把正确的数据交给后续回答步骤。如果工具返回 {"text":3},既缺少编号,正文类型也有问题。下面程序的字段集合与类型检查,对应这份输出约定。
输入 Schema 放在模型选择动作之前,帮助它填写参数;运行时校验发生在应用收到参数之后,检查实际数据。输出校验发生在工具执行之后。它们沿同一条数据流工作:
用两份笔记运行完整检查
创建学习文件夹,在其中保存 tool_gate.py 和 test_tool_gate.py。程序中的 u1、u2 是两位固定用户,n1、n2 是各自笔记的编号。
NoteStore 是保存笔记的对象。reads 统计成功读取次数;effects 统计实际创建标签的次数。先沿 read_note 路径看输入和输出;创建标签的分支留给后半节。
第一次读完整程序时,先找到 call_tool,沿 request["name"] == "read_note" 这条分支读五步:检查工具名→检查 arguments 中的笔记编号→比较 note["owner"] 与当前身份→调用 store.read 读取正文→检查 result 的编号与文本字段。创建标签会改变数据,后面再沿它的业务键和计数读一次。
保存为 tool_gate.py:
先在 PowerShell 创建本篇的独立目录并进入;路径可换成你自己的非系统盘位置:
mkdir -Force "F:\学习\agentdepth-l2-tool-schema"
cd "F:\学习\agentdepth-l2-tool-schema"将本篇的 tool_gate.py、test_tool_gate.py 保存到这个目录。保存全部文件后运行 dir *.py,核对上述文件名,再执行下文命令;新开窗口时先回到该目录,并按 Python 环境篇核对 python 的解释器路径。
"""用内存笔记观察参数校验、资源归属和重复写入。"""
from copy import deepcopy
class ToolError(Exception):
def __init__(self, code, message):
super().__init__(message)
self.code = code
class NoteStore:
def __init__(self):
# 两位用户各有一篇笔记,owner 保存归属。
self.notes = {
"n1": {"owner": "u1", "text": "仅供 u1 读取的示例笔记"},
"n2": {"owner": "u2", "text": "u2 的示例笔记"},
}
# reads 记录成功读取次数,effects 记录实际创建次数。
self.reads = 0
self.effects = 0
self.seen_keys = {}
def read(self, identity, note_id):
note = self.notes.get(note_id)
if note is None or note["owner"] != identity:
raise ToolError("forbidden", "resource_not_owned")
self.reads += 1
return {"note_id": note_id, "text": note["text"]}
def create_tag(self, identity, note_id, tag, idem_key):
if identity != "u1" or note_id != "n1":
raise ToolError("forbidden", "resource_not_owned")
# 同一个业务键对应同一次创建动作,保存输入和结果用于去重。
payload = {"identity": identity, "note_id": note_id, "tag": tag}
if idem_key in self.seen_keys:
if self.seen_keys[idem_key]["payload"] != payload:
raise ToolError("idempotency_conflict", "same_key_different_payload")
return deepcopy(self.seen_keys[idem_key]["result"])
self.effects += 1
result = {"tag_id": "tag-%d" % self.effects, "status": "created"}
self.seen_keys[idem_key] = {"payload": deepcopy(payload), "result": result}
return deepcopy(result)
def call_tool(request, identity, store):
"""依次检查工具名、输入字段、资源归属及返回值。"""
if not isinstance(request, dict):
raise ToolError("invalid_request", "object_required")
if request.get("name") not in ("read_note", "create_tag"):
raise ToolError("unknown_tool", "tool_not_allowlisted")
args = request.get("arguments")
if not isinstance(args, dict):
raise ToolError("invalid_arguments", "object_required")
if request["name"] == "read_note":
if set(args) != {"note_id"} or not isinstance(args["note_id"], str) or not args["note_id"].strip():
raise ToolError("invalid_arguments", "read_note_schema")
# 先核对笔记归属,再执行读取;读取成功后计数增加。
note = store.notes.get(args["note_id"])
if note is None or note["owner"] != identity:
raise ToolError("forbidden", "resource_not_owned")
result = store.read(identity, args["note_id"])
if (not isinstance(result, dict) or set(result) != {"note_id", "text"}
or not isinstance(result["note_id"], str) or not result["note_id"]
or not isinstance(result["text"], str)):
raise ToolError("invalid_tool_result", "read_note_output_schema")
return result
if set(args) != {"note_id", "tag", "idempotency_key"}:
raise ToolError("invalid_arguments", "create_tag_schema")
if not all(isinstance(args[k], str) and args[k].strip() for k in args):
raise ToolError("invalid_arguments", "non_empty_strings_required")
return store.create_tag(identity, args["note_id"], args["tag"], args["idempotency_key"])
def demo():
store = NoteStore()
cases = [
("own_read", {"name": "read_note", "arguments": {"note_id": "n1"}}, "u1"),
("cross_user_read", {"name": "read_note", "arguments": {"note_id": "n1"}}, "u2"),
("unknown_tool", {"name": "delete_all", "arguments": {}}, "u1"),
]
# 收集每个请求的结果,最后查看实际读取和创建的次数。
result = []
for label, request, identity in cases:
try:
value = call_tool(request, identity, store)
result.append({"case": label, "status": "ok", "value": value})
except ToolError as error:
result.append({"case": label, "status": "error", "code": error.code})
return {"cases": result, "body_reads": store.reads, "effects": store.effects}
if __name__ == "__main__":
print(demo())在该文件夹打开 PowerShell,运行:
python -X utf8 tool_gate.py结果中的 cases 有三项,展开理解如下:
| 案例 | 结果 | 原因 |
|---|---|---|
own_read | ok,返回 n1 和正文 | u1 读取自己的 n1 |
cross_user_read | forbidden | u2 请求 u1 的笔记 |
unknown_tool | unknown_tool | 服务清单里没有 delete_all |
最后的 body_reads 为 1,effects 为 0。三次请求里,只有第一条实际读取了正文,全部没有创建标签。
沿 call_tool 看执行顺序:
- 检查请求是字典,工具名位于注册清单。
- 检查
arguments是字典,字段恰好包含note_id。 - 检查编号是非空字符串。
- 用
identity核对笔记归属,再调用store.read。 - 检查返回对象的字段和类型。
set(args) 会得到参数字段的集合。把它与 {"note_id"} 比较,可以一次发现缺少字段和额外字段。Python 类型注解 str 可以帮助阅读代码,实际类型检查由 isinstance 执行。
这里手写了这一个工具所需的规则。工具数量增加后,可以使用 JSON Schema 校验库统一执行声明的规则,减少重复条件。
参数正确,为什么还要检查归属
输入 {"note_id":"n1"} 无论由哪位用户发来,都有正确的字段和类型。要判断谁能读这篇笔记,还需要授权,也就是检查这个用户是否具有操作这份资源的权限。
在程序里,identity 和 arguments 分开传入:参数由调用方填写,身份由应用确认。正式服务从已验证的登录会话取得身份,再检查笔记归属。练习使用固定字符串,让你直接观察两种身份的差别。
逐项验证,再改一个字段
保存测试为 test_tool_gate.py:
下面的测试检查缺字段、字段类型、资源归属和工具返回值;同时检查重复操作的处理结果。
test_tool_gate.py:检查行为的测试
import unittest
from tool_gate import NoteStore, ToolError, call_tool
class ToolGateTests(unittest.TestCase):
# 每个测试都使用一份新笔记库,让读取次数从零开始。
def setUp(self):
self.store = NoteStore()
def test_allowlisted_owned_read(self):
self.assertEqual(call_tool({"name": "read_note", "arguments": {"note_id": "n1"}}, "u1", self.store)["note_id"], "n1")
self.assertEqual(self.store.reads, 1)
def test_cross_user_read_is_rejected_before_read(self):
with self.assertRaises(ToolError) as error:
call_tool({"name": "read_note", "arguments": {"note_id": "n1"}}, "u2", self.store)
self.assertEqual(error.exception.code, "forbidden")
self.assertEqual(self.store.reads, 0)
def test_unknown_tool_is_rejected(self):
with self.assertRaises(ToolError) as error:
call_tool({"name": "delete_all", "arguments": {}}, "u1", self.store)
self.assertEqual(error.exception.code, "unknown_tool")
def test_schema_rejects_extra_field(self):
with self.assertRaises(ToolError) as error:
call_tool({"name": "read_note", "arguments": {"note_id": "n1", "admin": True}}, "u1", self.store)
self.assertEqual(error.exception.code, "invalid_arguments")
self.assertEqual(self.store.reads, 0)
def test_schema_rejects_missing_field(self):
with self.assertRaises(ToolError) as error:
call_tool({"name": "read_note", "arguments": {}}, "u1", self.store)
self.assertEqual(error.exception.code, "invalid_arguments")
self.assertEqual(self.store.reads, 0)
def test_schema_rejects_wrong_type(self):
with self.assertRaises(ToolError) as error:
call_tool({"name": "read_note", "arguments": {"note_id": 3}}, "u1", self.store)
self.assertEqual(error.exception.code, "invalid_arguments")
self.assertEqual(self.store.reads, 0)
def test_blank_note_id_is_rejected_as_arguments(self):
# 空格字符串在业务含义上仍然缺少编号。
with self.assertRaises(ToolError) as error:
call_tool({"name": "read_note", "arguments": {"note_id": " "}}, "u1", self.store)
self.assertEqual(error.exception.code, "invalid_arguments")
self.assertEqual(self.store.reads, 0)
def test_output_contract_rejects_bad_tool_result(self):
# 故意让工具返回数字正文,观察输出校验。
class BrokenStore(NoteStore):
def read(self, identity, note_id):
return {"note_id": note_id, "text": 3}
with self.assertRaises(ToolError) as error:
call_tool({"name": "read_note", "arguments": {"note_id": "n1"}}, "u1", BrokenStore())
self.assertEqual(error.exception.code, "invalid_tool_result")
def test_idempotency_prevents_duplicate_effect(self):
request = {"name": "create_tag", "arguments": {"note_id": "n1", "tag": "todo", "idempotency_key": "op-1"}}
# 连续提交相同创建请求,应该得到同一个结果。
first = call_tool(request, "u1", self.store)
second = call_tool(request, "u1", self.store)
self.assertEqual(first, second)
self.assertEqual(self.store.effects, 1)
def test_output_note_id_is_nonempty(self):
# 输出编号也要满足声明的最短长度。
class EmptyIdStore(NoteStore):
def read(self, identity, note_id):
return {"note_id": "", "text": "正文"}
with self.assertRaises(ToolError) as error:
call_tool({"name": "read_note", "arguments": {"note_id": "n1"}}, "u1", EmptyIdStore())
self.assertEqual(error.exception.code, "invalid_tool_result")
def test_idempotency_conflict_is_rejected(self):
request = {"name": "create_tag", "arguments": {"note_id": "n1", "tag": "todo", "idempotency_key": "op-1"}}
call_tool(request, "u1", self.store)
request["arguments"]["tag"] = "urgent"
with self.assertRaises(ToolError) as error:
call_tool(request, "u1", self.store)
self.assertEqual(error.exception.code, "idempotency_conflict")
if __name__ == "__main__":
unittest.main()在两个文件所在目录运行:
python -X utf8 -m unittest -v test_tool_gate.py你会看到 11 项测试和 OK。测试使用新建笔记库分别观察正常读取、跨用户读取、未知工具、缺字段、多余字段、类型错误、空白编号、坏输出、输出编号为空以及重复写入。assertEqual 检查实际值与预期值相等;assertRaises 检查是否抛出指定异常;OK 表示这些检查符合预期。
打开测试里的 test_schema_rejects_extra_field,把 "admin": True 改成 "debug": True,运行同一条命令。结果仍应通过:两者都是未声明字段,错误码为 invalid_arguments,读取计数为 0。之后恢复原值。
create_tag 是给笔记添加标签的写操作。idempotency_key 称为幂等键,表示同一次业务动作的编号。程序保存该编号对应的输入和结果,相同请求再到达时返回已有结果,所以两次请求只增加一次 effects。如果同一个编号换了标签内容,程序返回冲突。后续幂等篇会继续展开写操作。
实际接入工具协议
MCP 工具定义使用 inputSchema 描述参数,并可提供 outputSchema 描述结构化结果。structuredContent 是结构化结果所在的字段;声明了输出规则时,服务端应返回符合约定的结构化内容,客户端负责核对。这些字段可在 MCP 工具规范中查阅。
接入时,把模型接口返回的参数转换为工具输入,执行规则检查,再将结果转换为模型接口所需消息。数据的规则可以复用,消息包装按接口适配。
| 运行现象 | 检查位置 | 修复动作 |
|---|---|---|
| 找不到 tool_gate 模块 | 当前文件夹 | 把程序与测试放在同一目录并从那里运行 |
| 收到 invalid_arguments | 字段集合与类型 | 根据输入约定修正参数 |
| 收到 forbidden | identity 与 owner | 核对当前身份和笔记归属 |
| 收到 invalid_tool_result | 工具返回值 | 修正输出字段与类型 |
工具描述让模型理解用途;输入规则和运行时检查让参数符合约定;授权限定资源;输出检查让后续步骤拿到可用结果。继续读参数校验与结构化错误,把不同阶段的失败变成程序能处理的结果。
课后练习展开习题收起习题
正在读取本地记录…
不调用模型,不执行代码。简答、代码和场景题仅保存答案并展示自查标准,不生成评分、徽章或认证。输入内容不会自动保存,请点击提交;不要填写个人信息或密钥。
选择题 · 先判断边界
关于“工具描述与输入输出 Schema”,哪种做法更稳妥?
对应知识点:L2-tool-schema
简答题 · 解释失败
写出一个失败输入并说明记录字段。
对应知识点:L2-tool-schema
代码题 · 边界测试
补充正常、边界、失败三组输入和预期结果。
对应知识点:L2-tool-schema
场景题 · 结果未知
证据不足时如何设计状态和人工接管?
对应知识点:L2-tool-schema
描述会发送给本站维护者,用于修订文章,保存90天。请只填写文章问题,省略个人信息、密钥和内部资料。