AgentDepthAgent 工程学习指南
输入概念或问题,检索正文和待编写目录。
第一次学习?从 L0 总览开始 →

工具描述与输入输出 Schema:让模型建议变成可校验的请求

我的学习 →

正在读取学习记录…

给笔记助手一个 read_note 工具,它就能读取指定笔记。怎样告诉模型该填哪个字段,又怎样让程序检查收到的数据?需要一份Schema(数据结构规则):列出字段、类型和取值要求。

像一张快递单,收件人和地址有固定的位置;工具的输入也有固定字段。工具描述讲“这项服务做什么”,Schema 讲“这张表怎样填写”。接着用笔记查询,把这两部分连起来。

把工具说明写清楚

read_note 的工具描述是“读取当前用户指定编号的笔记,返回编号和正文”。这里有三个信息:动作是读取,对象是笔记,结果是正文。模型据此判断它是否适合回答“我的 n1 笔记写了什么”。

含糊的“处理用户数据”会让读者和模型都猜用途。更好的描述可以写清操作对象、输入含义、返回内容和必要前提。对搜索工具,还可以说明返回几条结果以及是否按相关度排序。

一份工具定义如下。里面的 inputSchema 使用 JSON Schema:用JSON描述数据应有哪些字段、每个字段是什么类型,以及哪些字段必须填写。先把它当作这份工具申请表的填写规则:

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。如果业务要求编号包含实际文字,程序还要检查去掉首尾空格后的值。这是业务规则:结合任务意义判断数据是否可用。

输入与输出各有一份约定

输入符合规则后,工具查询笔记并返回:

JSON
{"note_id": "n1", "text": "仅供 u1 读取的示例笔记"}

输出也有固定形状:note_id 是字符串,text 是正文字符串。对应的输出 Schema 如下:

JSON
{
  "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 创建本篇的独立目录并进入;路径可换成你自己的非系统盘位置:

Bash
mkdir -Force "F:\学习\agentdepth-l2-tool-schema"
cd "F:\学习\agentdepth-l2-tool-schema"

将本篇的 tool_gate.py、test_tool_gate.py 保存到这个目录。保存全部文件后运行 dir *.py,核对上述文件名,再执行下文命令;新开窗口时先回到该目录,并按 Python 环境篇核对 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,运行:

Bash
python -X utf8 tool_gate.py

结果中的 cases 有三项,展开理解如下:

案例结果原因
own_readok,返回 n1 和正文u1 读取自己的 n1
cross_user_readforbiddenu2 请求 u1 的笔记
unknown_toolunknown_tool服务清单里没有 delete_all

最后的 body_reads 为 1,effects 为 0。三次请求里,只有第一条实际读取了正文,全部没有创建标签。

沿 call_tool 看执行顺序:

  1. 检查请求是字典,工具名位于注册清单。
  2. 检查 arguments 是字典,字段恰好包含 note_id。
  3. 检查编号是非空字符串。
  4. 用 identity 核对笔记归属,再调用 store.read。
  5. 检查返回对象的字段和类型。

set(args) 会得到参数字段的集合。把它与 {"note_id"} 比较,可以一次发现缺少字段和额外字段。Python 类型注解 str 可以帮助阅读代码,实际类型检查由 isinstance 执行。

这里手写了这一个工具所需的规则。工具数量增加后,可以使用 JSON Schema 校验库统一执行声明的规则,减少重复条件。

参数正确,为什么还要检查归属

输入 {"note_id":"n1"} 无论由哪位用户发来,都有正确的字段和类型。要判断谁能读这篇笔记,还需要授权,也就是检查这个用户是否具有操作这份资源的权限。

在程序里,identity 和 arguments 分开传入:参数由调用方填写,身份由应用确认。正式服务从已验证的登录会话取得身份,再检查笔记归属。练习使用固定字符串,让你直接观察两种身份的差别。

输入字段校验和资源归属检查分开进行,通过后读取笔记
同一个 n1 输入可以通过字段检查;是否继续读取,取决于当前用户与笔记归属是否匹配。

逐项验证,再改一个字段

保存测试为 test_tool_gate.py:

下面的测试检查缺字段、字段类型、资源归属和工具返回值;同时检查重复操作的处理结果。

test_tool_gate.py:检查行为的测试
Python
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()

在两个文件所在目录运行:

Bash
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字段集合与类型根据输入约定修正参数
收到 forbiddenidentity 与 owner核对当前身份和笔记归属
收到 invalid_tool_result工具返回值修正输出字段与类型

工具描述让模型理解用途;输入规则和运行时检查让参数符合约定;授权限定资源;输出检查让后续步骤拿到可用结果。继续读参数校验与结构化错误,把不同阶段的失败变成程序能处理的结果。

课后练习展开习题收起习题

4 道练习 · 客观题规则评分 · 其余题目待批改

正在读取本地记录…

不调用模型,不执行代码。简答、代码和场景题仅保存答案并展示自查标准,不生成评分、徽章或认证。输入内容不会自动保存,请点击提交;不要填写个人信息或密钥。

练习 1 / 4 · v1.0.1

选择题 · 先判断边界

关于“工具描述与输入输出 Schema”,哪种做法更稳妥?

对应知识点:L2-tool-schema

选择一个答案
练习 2 / 4 · v1.0.0

简答题 · 解释失败

写出一个失败输入并说明记录字段。

对应知识点:L2-tool-schema

练习 3 / 4 · v1.0.0

代码题 · 边界测试

补充正常、边界、失败三组输入和预期结果。

对应知识点:L2-tool-schema

练习 4 / 4 · v1.0.0

场景题 · 结果未知

证据不足时如何设计状态和人工接管?

对应知识点:L2-tool-schema

图片查看器
100%

文章配图 · 按 Esc 关闭