前面的销售助手只能查询库存。真实业务还需要处理两种常见操作:新货到达时增加库存,商品卖出时减少库存。
这一篇会加入 add_inventory 和 remove_inventory。真正值得观察的问题不是“怎样写两个加减法函数”,而是:工具从一个增加到三个以后,ask() 是否需要跟着增加新的判断代码?
这也是程序第一次拥有修改数据的工具。get_inventory 只读取数据,add_inventory 和 remove_inventory 会改变数据。以后做权限控制时,读操作和写操作需要区别对待;本篇暂时只把它们接入现有流程。
文章对应代码仓库 agent-from-zero 的 v0.2.1-multi-tool-registry。可以切换到这个 tag,对照 src/tools/inventory.py、src/agent/agent.py、tests/test_inventory.py 与 tests/test_agent.py 阅读。
先加入两个最简单的库存操作
src/tools/inventory.py 新增两个函数:
def add_inventory(product_id: str, qty: int) -> dict | None:
product = products.get(product_id)
if product is None:
return None
product["stock"] += qty
return product
def remove_inventory(product_id: str, qty: int) -> dict | None:
product = products.get(product_id)
if product is None:
return None
product["stock"] -= qty
return product这一版只实现最基本的加减法,故意没有检查数量是否合理:
- —
qty可以是负数。 - —出库数量可以大于现有库存。
- —库存可能被减成负数。
这不是正确的生产实现,而是本篇有意保留的缺口。当前目标只是先把写操作接入工具调用;参数和业务规则校验会在后面的章节单独处理。文中必须把这个限制说清楚,避免读者误以为这段代码已经可以安全地操作真实库存。
两个新工具也各有一份 Tool Schema。它们在 product_id 之外增加了必填参数 qty,并用 integer 表示它必须是整数:
"parameters": {
"type": "object",
"properties": {
"product_id": {"type": "string", ...},
"qty": {"type": "integer", ...},
},
"required": ["product_id", "qty"],
}这里的 Schema 只能要求 qty 是整数,还没有限制它必须大于零,也没有检查出库后是否会出现负库存。Schema 的基本格式正确,不等于参数符合业务规则。
工具变多以后,`ask()` 不需要增加分支
src/agent/agent.py 原来就有两份对应的数据:
- —
TOOLS保存给模型看的工具说明。 - —
TOOL_FUNCTIONS保存程序真正可以执行的 Python 函数。
现在只需把两个新工具加入这两处:
TOOLS = [GET_INVENTORY_SCHEMA, ADD_INVENTORY_SCHEMA, REMOVE_INVENTORY_SCHEMA]
TOOL_FUNCTIONS = {
"get_inventory": get_inventory,
"add_inventory": add_inventory,
"remove_inventory": remove_inventory,
}ask() 本身没有改动。模型返回工具名称后,程序仍然使用同一行代码查找并执行函数:
tool_result = TOOL_FUNCTIONS[tool_call.function.name](**args)这种“工具名称对应工具说明和实际函数”的组织方式,就是一份简单的工具注册表。它让通用的调用流程不必写成下面这样不断增长的判断:
if name == "get_inventory":
...
elif name == "add_inventory":
...
elif name == "remove_inventory":
...注册表能正常工作有一个前提:Schema 中的 function.name 必须和 TOOL_FUNCTIONS 的键完全一致。否则模型提出的名称在程序里找不到对应函数,调用就会失败。
工具虽然增加到三个,但 ask() 仍然只读取 message.tool_calls[0]。因此,这次改动解决的是“有多个工具可供选择”,还没有解决“一次处理多个工具调用”。
写操作会让测试互相影响
查询工具不会修改 products,但入库和出库会直接改变这个模块级字典。如果一个测试把 A100 的库存从 20 改成 25,下一个测试仍然会看到 25。测试结果就会取决于运行顺序,而不是只取决于当前测试的代码。
解决办法是在每个测试结束后恢复原始数据:
@pytest.fixture(autouse=True)
def _restore_products():
snapshot = copy.deepcopy(products)
yield
products.clear()
products.update(snapshot)pytest 的 fixture 是为测试准备环境并在测试后清理环境的函数。autouse=True 表示测试不需要显式写出 _restore_products 参数,pytest 会自动为当前范围内的每个测试运行它。
这段代码的执行顺序是:
- 1.测试开始前,用
copy.deepcopy()保存完整的库存数据。 - 2.
yield把执行权交给测试。 - 3.测试结束后,清空已经被修改的字典,再把快照放回去。
这里使用 clear() 和 update(),而不是直接写 products = snapshot,是为了保留原来那个字典对象。其他已经引用 products 的代码仍然会看到恢复后的内容。
用测试记录当前还没有校验数量
这一版允许库存变成负数。测试把这个已知限制明确记录下来:
def test_does_not_validate_quantity_yet(self):
result = remove_inventory("B200", 999)
assert result["stock"] == inventory.products["B200"]["stock"]
assert result["stock"] < 0这不是在说“负库存是正确行为”,而是在说明当前版本确实还没有校验数量。以后加入校验后,这条测试应该失败,提醒我们旧行为已经改变。确认失败确实来自新校验后,再把测试改成“拒绝超出库存的出库请求”。测试失败只是提醒,不应在没有检查原因时直接当作新功能正确的证明。
另一个测试检查注册表两边的名称是否一致:
def test_tool_functions_keys_match_tool_schema_names():
schema_names = {schema["function"]["name"] for schema in agent.TOOLS}
assert schema_names == set(agent.TOOL_FUNCTIONS.keys())这个测试不关心一共有几个工具,只比较两组名称。因此,以后继续增加工具时,不需要为每个工具再写一条同类断言。
真实调用三个工具
自动化测试可以检查函数和注册表,却不能证明模型面对自然语言时会选择哪个工具。这里依次发送三个真实问题:
print(ask("A100 还有多少库存?").text)
print(ask("刚到货 5 个 A100,帮我入库。").text)
print(ask("卖出去 3 个 A100,帮我出库。").text)真实调用结果(deepseek-chat,2026-09-20,库存从 20 → 25 → 22):
--- get ---
A100(工业传感器 A100)目前还有 **20 件**库存。
--- add ---
入库完成 ✅
- 产品:Industrial Sensor A100
- 本次入库:5 个
- 当前库存:25 个
--- remove ---
出库完成 ✅
- 商品:Industrial Sensor A100
- 出库数量:3 个
- 剩余库存:22 个在这三次调用中,模型分别选择了查询、入库和出库工具,qty 也与问题中的 5 和 3 一致。库存的变化说明程序确实执行了对应函数,而不只是生成了三段文字。
这只是三次实验的结果。它不能保证模型面对含糊说法、多个商品或其他表达方式时仍然会选对工具。
自动化测试检查确定的程序行为
pytest -v当时的实际输出:
tests/test_agent.py::test_tool_functions_keys_match_tool_schema_names PASSED
tests/test_inventory.py::TestAddInventory::test_increases_stock_by_qty PASSED
tests/test_inventory.py::TestAddInventory::test_returns_none_for_unknown_product PASSED
tests/test_inventory.py::TestAddInventory::test_schema_requires_product_id_and_qty PASSED
tests/test_inventory.py::TestRemoveInventory::test_decreases_stock_by_qty PASSED
tests/test_inventory.py::TestRemoveInventory::test_returns_none_for_unknown_product PASSED
tests/test_inventory.py::TestRemoveInventory::test_schema_requires_product_id_and_qty PASSED
tests/test_inventory.py::TestRemoveInventory::test_does_not_validate_quantity_yet PASSED
...(加上此前的 24 个)
32 passed in 0.55s这些测试检查库存计算、未知商品的返回值、Schema 的必填字段、测试后的数据恢复,以及注册表名称是否一致。模型能否根据自然语言选对工具,仍然要通过真实调用观察,不能用测试替身中预先写好的工具选择来证明。
留给你的三个实验
- 1.把
TOOL_FUNCTIONS中的"add_inventory"临时改成"addInventory",运行注册表一致性测试,观察它如何报告两边名称不一致。 - 2.输入“处理一下 A100”,不要说明是入库还是出库。观察模型会要求补充信息,还是自行猜测操作类型。对会修改数据的工具来说,猜测执行尤其危险。
- 3.输入“A100 和 B200 分别还有多少库存?”,观察只读取
message.tool_calls[0]的实现会遗漏什么。这正是下一步 Agent Loop 要解决的问题。
小结
这一篇把库存工具从一个扩展到三个,并确认了原有调用代码可以继续使用:
- —
TOOLS告诉模型有哪些工具,TOOL_FUNCTIONS告诉程序怎样执行它们。 - —两边通过完全相同的工具名称对应起来,
ask()不需要为每个工具增加新的判断分支。 - —写操作会修改共享数据,因此测试结束后必须恢复库存,避免前一个测试影响后一个测试。
- —当前版本还没有数量和库存下限校验,不能直接用于真实库存系统。
现在,模型可以从多个工具中选择一个,但程序仍然只能执行一次调用。下一篇会让它在得到工具结果后继续判断是否还要调用工具,从而形成 Agent Loop。