技术·Agent From Zero:从一次模型调用到可靠 Agent · 第 5 篇·2026-09-16·15 分钟

第四篇:先给模型一份查库存的说明书

前三篇里,程序已经能得到模型回答、区分基础失败,并记录 token、延迟和估算成本。

但销售助手仍然解决不了最开始的业务问题:

A100 还有多少库存?

模型能理解问题,却没有接触公司库存数据的路径。要让销售助手回答真实库存,先解决两个问题:

  1. 1.程序里真正执行查询的函数是什么?
  2. 2.模型怎样知道这个函数存在、什么时候该用、调用时需要提供什么?

答案分别是一个真实查询函数和一份模型可读的说明。本篇只准备好这两块,还不执行模型发起的工具调用。

这里的 Tool(工具),是应用程序提供的一项外部能力,例如查询库存或创建订单。模型提出要用哪个工具、传什么参数;程序校验请求并执行。因此,一个工具有两面:可运行的代码和模型可读的说明。

文章对应代码仓库 agent-from-zero 的 v0.1.1-inventory-tool-schema。可以切换到这个 tag,对照 src/tools/inventory.py 与 tests/test_inventory.py。

先准备一份真的能查到的数据

为了让实验足够小,我们暂时不连接数据库,只在程序内存中放两条商品记录。程序退出后,这些示例数据不会长期保留。

python
products = {
    "A100": {
        "name": "Industrial Sensor A100",
        "price": 100,
        "stock": 20,
    },
    "B200": {
        "name": "Industrial Sensor B200",
        "price": 180,
        "stock": 8,
    },
}

查询函数只有一行:

python
def get_inventory(product_id: str) -> dict | None:
    return products.get(product_id)

dict | None 表示找到商品时返回字典,找不到时返回 None;后者表示没有结果,不等于执行失败。

现在业务程序已经能得到一个确定结果:

python
>>> get_inventory("A100")
{"name": "Industrial Sensor A100", "price": 100, "stock": 20}

>>> get_inventory("Z999")
None

真正查询库存的是普通 Python 函数,不是模型。 模型以后只负责判断是否需要查询、应该查询哪个产品。

一个工具同时面对程序和模型两种接口
一个工具同时面对程序和模型两种接口

为什么查不到商品时返回 `None`

products.get(product_id) 在找不到商品时返回 None,而不是抛出异常。这样可以区分:

  • —“库存服务发生故障”是执行失败;
  • —“库存服务正常工作,但没有 Z999”是一次成功查询得到的空结果。

下一阶段会把 None 交回模型,观察它能否如实告诉用户没有找到商品,而不是编造库存。

内存字典不是生产用数据库,返回值也没有更精确的商品类型。但当前实验只需要一份确定、可测试的数据源。

模型看不见 Python 函数

写出 get_inventory() 并不会让模型自动知道它存在。

模型看不到 Python 源码,也不能扫描程序里有哪些函数。程序必须把可用能力写成接口规定的结构,随 messages 一起发送。

我们为 get_inventory() 写下这份 Tool Schema(工具结构说明),告诉模型工具的名称、用途和参数要求:

python
GET_INVENTORY_SCHEMA = {
    "type": "function",
    "function": {
        "name": "get_inventory",
        "description": (
            "Look up the current stock level and unit price for a product, "
            "given its product ID. Use this whenever the user asks about "
            "stock, availability, or price for a specific product."
        ),
        "parameters": {
            "type": "object",
            "properties": {
                "product_id": {
                    "type": "string",
                    "description": "The product ID, e.g. 'A100' or 'B200'.",
                },
            },
            "required": ["product_id"],
        },
    },
}

Schema 不能代替函数执行查询;它只规定模型可以怎样提出调用请求:

get_inventory Tool Schema 的结构
get_inventory Tool Schema 的结构

tools 是随请求发送的工具列表。当前接口支持 function 类型,但这不表示模型能直接运行 Python:它只能生成一份调用请求,真正执行仍由程序负责。

Tool Schema 和 JSON Schema 是两层约定

这份结构由两层约定组成,不要把字段的来源混在一起:

层次字段示例来源与用途
Tool Schema 外层type: "function"、function.name、function.description、function.parametersOpenAI 兼容的 Tool Calling 接口约定,描述工具及参数入口
parameters 内部type: "object"、properties、required、additionalProperties通用 JSON Schema 关键字,描述参数对象的形状

这也是为什么同一个 Schema 中出现了两个 type:外层的 function 表示工具种类,内层的 object 表示参数的数据类型。

`parameters` 为什么长得像另一门语言

看 parameters 这一层:

json
{
  "type": "object",
  "properties": {
    "product_id": {
      "type": "string"
    }
  },
  "required": ["product_id"]
}

这里使用的是 JSON Schema,一种描述 JSON 数据结构的通用规范。JSON 是 API 常用的数据格式;Python 字典可以转换成 JSON,但并不是 JSON 本身。

把这份 Schema 翻译成普通话,就是:

调用参数必须是一个 JSON 对象,也就是一组“字段名—字段值”;其中必须包含 product_id,而且它的值必须是字符串。

几个关键字分别承担不同职责:

`type: object`:所有参数组成一个对象

即使函数只有一个参数,模型生成的调用参数也不是裸字符串:

json
"A100"

而是一个带字段名的对象:

json
{"product_id": "A100"}

字段名让参数可以在未来扩展,而不依赖位置顺序。

`properties`:每个字段允许什么类型

properties 声明对象有哪些字段、各是什么类型。这里的 product_id 是字符串:

json
"product_id": {
  "type": "string"
}

以后需要 quantity: integer(整数)或 include_price: boolean(布尔值)时,也在这里增加字段。

`required`:声明字段并不等于要求它必须出现

JSON Schema 中,出现在 properties 里的字段默认并不是必填项。只有加入 required,才表示调用参数不能缺少它:

json
"required": ["product_id"]

因此,properties 负责描述字段,required 负责指定必填字段。

`additionalProperties`:要不要允许未声明字段

当前 Schema 没有写 additionalProperties。JSON Schema 默认允许未声明的额外字段,因此下面的参数虽然多了 warehouse_id,仍符合当前 Schema:

json
{
  "product_id": "A100",
  "warehouse_id": "SG-01"
}

如果希望拒绝所有未声明字段,需要明确加入:

json
"additionalProperties": false

additionalProperties 是 JSON Schema 的标准关键字,后面 DeepSeek strict mode 也会要求显式设置它。当前 commit 保持最小 Schema;后续校验参数时,不能把“声明了字段”误当成“禁止了其他字段”。

两层 `description` 都是提示词

Schema 里有两处描述:

text
function.description
└── 解释整个工具做什么、何时使用

properties.product_id.description
└── 解释这个参数代表什么、应该填什么

这两处描述都会随请求发送给模型:前者影响工具选择,后者帮助模型填写参数。

如果整个工具只写一句模糊的:

text
Get inventory.

模型可能不知道它是否也能查询价格,“有没有现货”时该不该调用,或者参数该填商品 ID 还是商品名称。

当前描述明确说明工具查询库存与单价、接受商品 ID,以及什么时候该考虑调用。函数实现可以完全正确,但描述含糊仍可能让模型选错工具或填错参数。

真的把 Schema 发给 API

本地测试能检查字典结构,却不能证明服务端接受这份 Schema。原始实验因此把它放进一次真实请求的 tools 参数:

python
response = client.chat.completions.create(
    model="deepseek-chat",
    messages=[
        {
            "role": "user",
            "content": (
                "What tools do you have available? "
                "Just list their names and describe what each one does "
                "in one sentence."
            ),
        }
    ],
    tools=[GET_INVENTORY_SCHEMA],
)

真实调用结果(2026-09-13):

text
schema accepted, no error

I have one tool available:

- get_inventory — Looks up the current stock level and unit price
  for a product given its product ID.

其中 schema accepted, no error 是实验脚本打印的状态,不是 API 返回的一句话。这次调用提供了两条证据:

  1. 1.API 没有因为结构错误拒绝请求;
  2. 2.模型的复述与 description 基本一致,说明描述进入了请求并反映在这次回答中。

它还不能证明模型面对库存问题时一定会正确选择工具。

但 Schema 通过,不代表工具链已经可靠

接口接收了工具说明,不等于应用程序已经能完成工具调用。

当前验证覆盖了什么,又还没有覆盖什么
当前验证覆盖了什么,又还没有覆盖什么

我们还没有验证模型能否选对 get_inventory、提取 A100,或生成合法的参数;程序也尚未执行函数、把 None 送回模型并检查它的最终回答。

tool_calls 是模型返回的工具调用请求列表;其中的 arguments 按约定是一段表示 JSON 参数的字符串。程序要先解析并校验它,才能取出 product_id 执行查询。

下一篇会把这个请求接到 Python 函数,再把查询结果送回模型。

当前 Schema 没有启用 strict mode(严格模式)。普通模式下,即使提供了 JSON Schema,arguments 仍可能不是合法 JSON,或包含未声明的字段。因此程序必须在执行函数前做运行时校验。

DeepSeek 提供 Beta 版 strict mode,让生成的工具参数遵守其支持范围内的 JSON Schema。启用它需要使用 Beta API 地址、给 tools 中每个 function 设置 strict: true,并让每个对象的 required 列出全部 properties,同时设置 additionalProperties: false。

“全部字段都列入 required”是 DeepSeek strict mode 的额外限制,不是通用 JSON Schema 的规则;普通 JSON Schema 允许只要求部分字段。不能把 strict mode 的要求套用到这篇当前的普通模式 Schema。

即使参数格式完全符合 Schema,也不能证明 A100 存在、当前用户有权查询,或这次调用符合业务规则。格式校验不能代替业务校验。

用测试守住函数和契约

运行当前阶段的测试:

bash
pytest -v tests/test_llm.py tests/test_inventory.py

实际结果:

text
tests/test_llm.py::test_chat_returns_the_model_text PASSED
tests/test_llm.py::test_chat_sends_system_and_user_messages PASSED
tests/test_llm.py::test_chat_accepts_custom_system_and_temperature PASSED
tests/test_llm.py::test_get_client_reads_api_key_from_env PASSED
tests/test_llm.py::TestChatResult::test_token_counts_come_from_response_usage PASSED
tests/test_llm.py::TestChatResult::test_cache_hit_and_miss_tokens_are_reported_separately PASSED
tests/test_llm.py::TestChatResult::test_latency_is_measured_and_non_negative PASSED
tests/test_llm.py::TestChatResult::test_estimated_cost_matches_the_pricing_table PASSED
tests/test_llm.py::TestFailureClassification::test_authentication_error_is_reclassified PASSED
tests/test_llm.py::TestFailureClassification::test_connection_error_is_reclassified_as_network_failure PASSED
tests/test_llm.py::TestFailureClassification::test_timeout_error_is_also_reclassified_as_network_failure PASSED
tests/test_llm.py::TestFailureClassification::test_bad_request_error_is_reclassified PASSED
tests/test_llm.py::TestFailureClassification::test_all_three_failures_share_a_common_base_class PASSED
tests/test_inventory.py::test_get_inventory_returns_the_full_record_for_a_known_product PASSED
tests/test_inventory.py::test_get_inventory_returns_none_for_an_unknown_product PASSED
tests/test_inventory.py::test_products_data_matches_the_documented_scenario PASSED
tests/test_inventory.py::test_schema_has_the_shape_the_openai_compatible_api_expects PASSED

17 passed in 0.53s

新增的四个测试守住已知和未知商品的返回结果、示例数据,以及 Tool Schema 的关键结构。

真实 API 验证没有放进日常测试。外部模型是否在线、当时如何复述描述,不是项目能够控制的确定性逻辑;日常测试只重复验证本地函数与 Schema 结构。

留给你的三个实验

  1. 1.把工具描述缩短成 Get inventory.,重新询问模型“有哪些工具”,比较它复述出的能力边界是否也变得模糊。
  2. 2.增加一个可选的 include_price: boolean,观察它为什么应该出现在 properties,却不一定出现在 required。
  3. 3.尝试为 product_id 增加 enum: ["A100", "B200"]。enum 用来把字段限制在一组预先列出的值中;再思考:产品目录持续变化时,把所有 ID 固定进 Schema 是否仍是好设计。

回到问题:工具是两份彼此对应的东西

一个模型工具同时包含可执行实现和模型可读契约;函数负责做事,Schema 负责让模型描述它想做什么。

本篇已写好 get_inventory(),也验证了 API 能接收 GET_INVENTORY_SCHEMA。但模型提出请求后,程序还不会执行这个函数。

下一篇会读取 tool_calls、校验 arguments、执行 get_inventory(),再把查询结果交回模型,让销售助手第一次真正从“生成答案”跨到“调用外部能力”。

参考资料

《Agent From Zero:从一次模型调用到可靠 Agent》合集 · 第 5 / 8 篇