前三篇里,程序已经能得到模型回答、区分基础失败,并记录 token、延迟和估算成本。
但销售助手仍然解决不了最开始的业务问题:
A100 还有多少库存?
模型能理解问题,却没有接触公司库存数据的路径。要让销售助手回答真实库存,先解决两个问题:
- 1.程序里真正执行查询的函数是什么?
- 2.模型怎样知道这个函数存在、什么时候该用、调用时需要提供什么?
答案分别是一个真实查询函数和一份模型可读的说明。本篇只准备好这两块,还不执行模型发起的工具调用。
这里的 Tool(工具),是应用程序提供的一项外部能力,例如查询库存或创建订单。模型提出要用哪个工具、传什么参数;程序校验请求并执行。因此,一个工具有两面:可运行的代码和模型可读的说明。
文章对应代码仓库 agent-from-zero 的 v0.1.1-inventory-tool-schema。可以切换到这个 tag,对照 src/tools/inventory.py 与 tests/test_inventory.py。
先准备一份真的能查到的数据
为了让实验足够小,我们暂时不连接数据库,只在程序内存中放两条商品记录。程序退出后,这些示例数据不会长期保留。
products = {
"A100": {
"name": "Industrial Sensor A100",
"price": 100,
"stock": 20,
},
"B200": {
"name": "Industrial Sensor B200",
"price": 180,
"stock": 8,
},
}查询函数只有一行:
def get_inventory(product_id: str) -> dict | None:
return products.get(product_id)dict | None 表示找到商品时返回字典,找不到时返回 None;后者表示没有结果,不等于执行失败。
现在业务程序已经能得到一个确定结果:
>>> 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(工具结构说明),告诉模型工具的名称、用途和参数要求:
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 不能代替函数执行查询;它只规定模型可以怎样提出调用请求:
tools 是随请求发送的工具列表。当前接口支持 function 类型,但这不表示模型能直接运行 Python:它只能生成一份调用请求,真正执行仍由程序负责。
Tool Schema 和 JSON Schema 是两层约定
这份结构由两层约定组成,不要把字段的来源混在一起:
| 层次 | 字段示例 | 来源与用途 |
|---|---|---|
| Tool Schema 外层 | type: "function"、function.name、function.description、function.parameters | OpenAI 兼容的 Tool Calling 接口约定,描述工具及参数入口 |
parameters 内部 | type: "object"、properties、required、additionalProperties | 通用 JSON Schema 关键字,描述参数对象的形状 |
这也是为什么同一个 Schema 中出现了两个 type:外层的 function 表示工具种类,内层的 object 表示参数的数据类型。
`parameters` 为什么长得像另一门语言
看 parameters 这一层:
{
"type": "object",
"properties": {
"product_id": {
"type": "string"
}
},
"required": ["product_id"]
}这里使用的是 JSON Schema,一种描述 JSON 数据结构的通用规范。JSON 是 API 常用的数据格式;Python 字典可以转换成 JSON,但并不是 JSON 本身。
把这份 Schema 翻译成普通话,就是:
调用参数必须是一个 JSON 对象,也就是一组“字段名—字段值”;其中必须包含
product_id,而且它的值必须是字符串。
几个关键字分别承担不同职责:
`type: object`:所有参数组成一个对象
即使函数只有一个参数,模型生成的调用参数也不是裸字符串:
"A100"而是一个带字段名的对象:
{"product_id": "A100"}字段名让参数可以在未来扩展,而不依赖位置顺序。
`properties`:每个字段允许什么类型
properties 声明对象有哪些字段、各是什么类型。这里的 product_id 是字符串:
"product_id": {
"type": "string"
}以后需要 quantity: integer(整数)或 include_price: boolean(布尔值)时,也在这里增加字段。
`required`:声明字段并不等于要求它必须出现
JSON Schema 中,出现在 properties 里的字段默认并不是必填项。只有加入 required,才表示调用参数不能缺少它:
"required": ["product_id"]因此,properties 负责描述字段,required 负责指定必填字段。
`additionalProperties`:要不要允许未声明字段
当前 Schema 没有写 additionalProperties。JSON Schema 默认允许未声明的额外字段,因此下面的参数虽然多了 warehouse_id,仍符合当前 Schema:
{
"product_id": "A100",
"warehouse_id": "SG-01"
}如果希望拒绝所有未声明字段,需要明确加入:
"additionalProperties": falseadditionalProperties 是 JSON Schema 的标准关键字,后面 DeepSeek strict mode 也会要求显式设置它。当前 commit 保持最小 Schema;后续校验参数时,不能把“声明了字段”误当成“禁止了其他字段”。
两层 `description` 都是提示词
Schema 里有两处描述:
function.description
└── 解释整个工具做什么、何时使用
properties.product_id.description
└── 解释这个参数代表什么、应该填什么这两处描述都会随请求发送给模型:前者影响工具选择,后者帮助模型填写参数。
如果整个工具只写一句模糊的:
Get inventory.模型可能不知道它是否也能查询价格,“有没有现货”时该不该调用,或者参数该填商品 ID 还是商品名称。
当前描述明确说明工具查询库存与单价、接受商品 ID,以及什么时候该考虑调用。函数实现可以完全正确,但描述含糊仍可能让模型选错工具或填错参数。
真的把 Schema 发给 API
本地测试能检查字典结构,却不能证明服务端接受这份 Schema。原始实验因此把它放进一次真实请求的 tools 参数:
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):
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.API 没有因为结构错误拒绝请求;
- 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 存在、当前用户有权查询,或这次调用符合业务规则。格式校验不能代替业务校验。
用测试守住函数和契约
运行当前阶段的测试:
pytest -v tests/test_llm.py tests/test_inventory.py实际结果:
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.把工具描述缩短成
Get inventory.,重新询问模型“有哪些工具”,比较它复述出的能力边界是否也变得模糊。 - 2.增加一个可选的
include_price: boolean,观察它为什么应该出现在properties,却不一定出现在required。 - 3.尝试为
product_id增加enum: ["A100", "B200"]。enum用来把字段限制在一组预先列出的值中;再思考:产品目录持续变化时,把所有 ID 固定进 Schema 是否仍是好设计。
回到问题:工具是两份彼此对应的东西
一个模型工具同时包含可执行实现和模型可读契约;函数负责做事,Schema 负责让模型描述它想做什么。
本篇已写好 get_inventory(),也验证了 API 能接收 GET_INVENTORY_SCHEMA。但模型提出请求后,程序还不会执行这个函数。
下一篇会读取 tool_calls、校验 arguments、执行 get_inventory(),再把查询结果交回模型,让销售助手第一次真正从“生成答案”跨到“调用外部能力”。