返回学习路线/阶段 03 · 给 Agent 一双手
LESSON 09 / 18

项目:会使用工具的助手

做一个库存与报价助手,验证工具选择、错误处理与停止条件。

60 分钟 · 含动手练习进阶项目实战工具助手

学完这一课,你将能够

  • 组合查询与计算两类工具
  • 用固定决策替身验证整个流程
  • 交付一组成功和失败用例

项目目标

完成一个库存与报价助手,回答“买 2 本 Python 入门需要多少钱,有货吗?” 系统必须先查询商品,再根据查询结果计算报价。 本项目只读与计算,不创建订单,也不扣款。

建议先完成第三模块前两课。 离线基础版约需 1 小时;接入真实模型后,需要额外测试工具选择质量。

交付标准

  • 提供查询和计算两个白名单工具。
  • 商品不存在、数量非法和库存不足有明确分支。
  • 一次任务最多执行 5 次决策。
  • 保存行动与观察,能够解释最终金额来自哪里。
  • 至少覆盖成功、未知商品、非法数量和预算耗尽用例。

先把业务规则写清楚

金额使用整数分,避免浮点数计算货币时引入精度问题。 数量必须是正整数;Python 中布尔值是整数的子类,校验时需要特别处理。 商品单价必须来自查询结果,不能由模型凭空提供。 库存不足可以返回报价参考,但本基础版选择直接说明无法满足数量。

完整离线基础版

保存为 tool_assistant.py 并运行。decide 是明确的固定决策替身。

import json

CATALOG = {"python-book": {"stock": 3, "unit_cents": 6800}}

def lookup(args):
    if not isinstance(args, dict) or set(args) != {"product_id"}:
        return {"ok": False, "error": "invalid_fields"}
    key = args["product_id"]
    item = CATALOG.get(key) if isinstance(key, str) else None
    return {"ok": True, **item} if item else {"ok": False, "error": "not_found"}

def quote(args):
    if not isinstance(args, dict) or set(args) != {"product_id", "quantity"}:
        return {"ok": False, "error": "invalid_fields"}
    quantity = args["quantity"]
    if type(quantity) is not int or not 1 <= quantity <= 100:
        return {"ok": False, "error": "invalid_quantity"}
    item = lookup({"product_id": args["product_id"]})
    if not item["ok"]:
        return item
    if quantity > item["stock"]:
        return {"ok": False, "error": "insufficient_stock"}
    return {"ok": True, "total_cents": item["unit_cents"] * quantity}

TOOLS = {"lookup": lookup, "quote": quote}

def decide(request, trace):
    if not trace:
        return {"tool": "lookup", "args": {"product_id": request["product_id"]}}
    last = trace[-1]["result"]
    if not last["ok"]:
        return {"final": f'未完成报价:{last["error"]}'}
    if len(trace) == 1:
        return {"tool": "quote", "args": request}
    cents = last["total_cents"]
    return {"final": f"有货,总价 {cents // 100}.{cents % 100:02d} 元。"}

def run(request, max_steps=5):
    trace = []
    for _ in range(max_steps):
        action = decide(request, trace)
        if "final" in action:
            return {"status": "completed", "answer": action["final"], "trace": trace}
        name = action.get("tool")
        if not isinstance(name, str) or name not in TOOLS:
            return {"status": "failed", "error": "unknown_tool", "trace": trace}
        result = TOOLS[name](action.get("args"))
        trace.append({"action": action, "result": result})
    return {"status": "budget_exhausted", "trace": trace}

for request in [
    {"product_id": "python-book", "quantity": 2},
    {"product_id": "missing", "quantity": 1},
    {"product_id": "python-book", "quantity": -1},
]:
    print(json.dumps(run(request), ensure_ascii=False, indent=2))

第一条应返回总价 136.00 元;另外两条应说明无法完成报价。 这里的 completed 表示流程正常结束,可能以业务失败说明结束。 产品中可以再增加独立的 task_success 字段,用于区分业务结果。

为什么计算工具再次查询商品

查询之后库存或价格可能变化,工具执行层不能完全信任决策层传回的单价。 本例通过商品 ID 在可信目录里重新读取,避免模型篡改价格。 真实订单服务还需要事务、价格有效期和幂等机制;本项目仅做报价。

连接真实模型的扩展

把 decide 换成模型适配器,工具和执行循环保持独立。 向模型提供工具说明、用户需求和已有观察,要求返回经过校验的行动结构。 先对固定样本重复运行,观察它是否跳过查询、误填数量或在失败后继续计算。 不要把离线基础版标成“真实模型实测”。

验收清单

新增库存不足、数量为 true、未知工具和最大步数为 1 的用例。 将每次运行的状态、答案、工具序列和预期写入一份报告。

合格交付包含代码、运行说明、用例结果和已知局限。 如果只有离线替身,应明确标注;如果接入模型,应记录提供商、模型配置和测试日期。

延伸阅读

接下来让助手不仅能操作工具,还能从你的文档中找到证据。

让这一课,真正成为你的收获

完成练习后标记完成,也可以随时回来复习。

笔记与进度保存在当前浏览器,无需登录

AgentStudy · Learn by building.以理解为起点,以作品为答案