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

第七篇:让销售助手学会入库和出库

前面的销售助手只能查询库存。真实业务还需要处理两种常见操作:新货到达时增加库存,商品卖出时减少库存。

这一篇会加入 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 新增两个函数:

python
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 表示它必须是整数:

python
"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 函数。

现在只需把两个新工具加入这两处:

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() 本身没有改动。模型返回工具名称后,程序仍然使用同一行代码查找并执行函数:

python
tool_result = TOOL_FUNCTIONS[tool_call.function.name](**args)

这种“工具名称对应工具说明和实际函数”的组织方式,就是一份简单的工具注册表。它让通用的调用流程不必写成下面这样不断增长的判断:

python
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。测试结果就会取决于运行顺序,而不是只取决于当前测试的代码。

解决办法是在每个测试结束后恢复原始数据:

python
@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. 1.测试开始前,用 copy.deepcopy() 保存完整的库存数据。
  2. 2.yield 把执行权交给测试。
  3. 3.测试结束后,清空已经被修改的字典,再把快照放回去。
每个测试结束后恢复库存数据
每个测试结束后恢复库存数据

这里使用 clear() 和 update(),而不是直接写 products = snapshot,是为了保留原来那个字典对象。其他已经引用 products 的代码仍然会看到恢复后的内容。

用测试记录当前还没有校验数量

这一版允许库存变成负数。测试把这个已知限制明确记录下来:

python
def test_does_not_validate_quantity_yet(self):
    result = remove_inventory("B200", 999)

    assert result["stock"] == inventory.products["B200"]["stock"]
    assert result["stock"] < 0

这不是在说“负库存是正确行为”,而是在说明当前版本确实还没有校验数量。以后加入校验后,这条测试应该失败,提醒我们旧行为已经改变。确认失败确实来自新校验后,再把测试改成“拒绝超出库存的出库请求”。测试失败只是提醒,不应在没有检查原因时直接当作新功能正确的证明。

另一个测试检查注册表两边的名称是否一致:

python
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())

这个测试不关心一共有几个工具,只比较两组名称。因此,以后继续增加工具时,不需要为每个工具再写一条同类断言。

真实调用三个工具

自动化测试可以检查函数和注册表,却不能证明模型面对自然语言时会选择哪个工具。这里依次发送三个真实问题:

python
print(ask("A100 还有多少库存?").text)
print(ask("刚到货 5 个 A100,帮我入库。").text)
print(ask("卖出去 3 个 A100,帮我出库。").text)

真实调用结果(deepseek-chat,2026-09-20,库存从 20 → 25 → 22):

text
--- get ---
A100(工业传感器 A100)目前还有 **20 件**库存。

--- add ---
入库完成 ✅
- 产品:Industrial Sensor A100
- 本次入库:5 个
- 当前库存:25 个

--- remove ---
出库完成 ✅
- 商品:Industrial Sensor A100
- 出库数量:3 个
- 剩余库存:22 个

在这三次调用中,模型分别选择了查询、入库和出库工具,qty 也与问题中的 5 和 3 一致。库存的变化说明程序确实执行了对应函数,而不只是生成了三段文字。

这只是三次实验的结果。它不能保证模型面对含糊说法、多个商品或其他表达方式时仍然会选对工具。

自动化测试检查确定的程序行为

bash
pytest -v

当时的实际输出:

text
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. 1.把 TOOL_FUNCTIONS 中的 "add_inventory" 临时改成 "addInventory",运行注册表一致性测试,观察它如何报告两边名称不一致。
  2. 2.输入“处理一下 A100”,不要说明是入库还是出库。观察模型会要求补充信息,还是自行猜测操作类型。对会修改数据的工具来说,猜测执行尤其危险。
  3. 3.输入“A100 和 B200 分别还有多少库存?”,观察只读取 message.tool_calls[0] 的实现会遗漏什么。这正是下一步 Agent Loop 要解决的问题。

小结

这一篇把库存工具从一个扩展到三个,并确认了原有调用代码可以继续使用:

  • —TOOLS 告诉模型有哪些工具,TOOL_FUNCTIONS 告诉程序怎样执行它们。
  • —两边通过完全相同的工具名称对应起来,ask() 不需要为每个工具增加新的判断分支。
  • —写操作会修改共享数据,因此测试结束后必须恢复库存,避免前一个测试影响后一个测试。
  • —当前版本还没有数量和库存下限校验,不能直接用于真实库存系统。

现在,模型可以从多个工具中选择一个,但程序仍然只能执行一次调用。下一篇会让它在得到工具结果后继续判断是否还要调用工具,从而形成 Agent Loop。

参考资料

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