课程LubanAgent 实战课:8 课从零到部署你的业务 Agent / 第二章 · 核心能力:真模型、工具与知识库 / 第 4 课 写自己的 Tool:低风险 / 高风险与人工审批
— 13 min read

第 4 课 写自己的 Tool:低风险 / 高风险与人工审批

用 @tool 装饰器写出查借阅(低风险直通)和挂失(高风险走审批)两个工具,理解 risk/approval/idempotent/timeout 治理参数、签名冻结机制与「模型说、代码做」的分工。

第 4 课:写自己的 Tool——让小图真的会做事

学完本课你能:用 @tool 装饰器写出一个低风险工具和一个带审批的高风险工具,挂到 Agent 上跑通完整链路 | 预计耗时 45-60 分钟 | 前置:第 3 课(真模型已绑 smart 槽位) | 难度 ★★☆

第 3 课结尾留了一个缺口:小图的服务范围写着"借阅记录查询",可它没有查询工具——用户问"我借了哪些书",它只能编(真模型编起来更像真的,这正是第 0 课说的幻觉问题)。这一课把"说"变成"做":写两个工具,一个查借阅记录(只读,低风险),一个挂失校园卡(收 20 元工本费,高风险走审批)。

两个工具总共二十几行 Python。你会发现 Tool 开发的心智负担极低——真正值得花脑子的地方只有一个:这个工具该定什么风险等级。这也是本课练习的主角。

1. 学习目标

  • @tool 装饰器写工具并理解参数 JSON Schema 是怎么从函数签名自动生成的
  • 区分 risk / approval_required / idempotent / timeout_seconds 四个治理参数各自为谁服务
  • 走通高风险工具的完整审批链路(事件 → 人工决定 → 执行)
  • 对"什么操作该要审批"形成自己的判断标准

2. 概念讲解:模型说,代码做

第 0 课讲过 Tool 的分工,这里讲实现视角。一个 Tool 在 LubanAgent 里就是一个普通 Python 函数 + 一份装饰器声明的元数据

@tool(key="my_lib_lookup", description="按学号查询借阅记录。",
      risk=RiskLevel.LOW, timeout_seconds=10.0, idempotent=True)
def my_lib_lookup(student_id: str) -> dict:
    """查借阅记录。"""
    ...

关键在框架怎么让模型知道这个工具的存在:运行期把工具清单(名字、描述、参数结构)发给模型——参数的 JSON Schema 是从函数签名自动生成的:student_id: str 变成 {"type": "string"},docstring 变成参数说明。模型读了清单,决定调用时输出一段结构化数据(工具名 + 参数值),框架解析后真的去调你的函数,把返回值喂回模型组织答案。

所以你写工具时唯一的"接口设计"工作是:函数签名。参数名要语义清晰(模型靠它猜该传什么),类型注解要给(模型靠它生成正确类型的值)。

装饰器的治理参数各自为谁服务:

  • risk:风险等级。LOW(只读,直接执行)/ MEDIUM / HIGH(默认要审批)。
  • approval_required:显式声明要人工审批。HIGH 或 approval_required 任一为真就进审批闸门——审批与否由代码强制,不靠模型自觉。
  • idempotent:幂等性。只有幂等工具在失败时会被自动重试(查询可重试,扣费/挂失不可——重试一次就是双倍扣钱)。
  • timeout_seconds:单次执行超时。防工具把整个对话挂死。

还有一个隐蔽但重要的机制:签名冻结。工具的参数结构会算一个哈希记进 Agent 版本快照;你以后改了工具签名(比如加参数),旧版本 Agent 会报"签名漂移"拒绝运行——防止"定义说两参、代码要三参"的静默错乱。碰到它时重新 init 一次产生新版本即可。

3. 动手:主线步骤

步骤 1:建工具包

mkdir -p app/tools/my_library

新建 app/tools/my_library/__init__.py

from . import lookup, report_loss  # 导入即注册

新建 app/tools/my_library/lookup.py

"""查借阅记录(低风险只读工具)。"""

from lubanagent.core import RiskLevel
from lubanagent.extension import tool

# 教程演示用内置数据;真实场景换成 HTTP 调用你的业务系统,
# 模式照抄 app/tools/customer_service/order.py。
_BORROWS: dict[str, list[dict]] = {
    "2023011234": [
        {"title": "三体", "due": "2026-09-20", "renewals_left": 1},
        {"title": "机器学习系统设计", "due": "2026-09-12", "renewals_left": 0},
    ],
}


@tool(
    key="my_lib_lookup",
    description="按学号查询借阅记录(书名/应还日期/剩余续借次数)。",
    risk=RiskLevel.LOW,
    timeout_seconds=10.0,
    idempotent=True,
)
def my_lib_lookup(student_id: str) -> dict:
    """查借阅记录。

    参数: student_id: 学号(如 2023011234)
    """
    records = _BORROWS.get(student_id, [])
    return {"student_id": student_id, "count": len(records), "records": records}

数据先写死在模块里——这一课的注意力留给工具机制,调业务 API 的模式第 3 课你在 order.py 里见过(就是 httpx 一个 GET)。

✅ 检查点:grep -c "@tool" app/tools/my_library/lookup.py 输出 1。

步骤 2:写高风险工具

新建 app/tools/my_library/report_loss.py

"""校园卡挂失(高风险工具:收工本费、触发人工审批)。"""

from lubanagent.core import RiskLevel
from lubanagent.extension import tool

CARD_LOSS_FEE = 20.0  # 挂失补办工本费(元)


@tool(
    key="my_lib_report_loss",
    description=f"提交校园卡挂失申请(高风险:收工本费 {CARD_LOSS_FEE:g} 元,需人工审批)。",
    risk=RiskLevel.HIGH,
    timeout_seconds=15.0,
    idempotent=False,
    approval_required=True,
)
def my_lib_report_loss(student_id: str) -> dict:
    """挂失校园卡并生成补办申请。

    参数: student_id: 学号(如 2023011234)
    """
    return {
        "student_id": student_id,
        "status": "loss_reported",
        "fee": CARD_LOSS_FEE,
        "pickup": "三日后凭学生证到图书馆前台领新卡",
    }

对照两个工具的声明差异:lookup 是 LOW + idempotent(查询可重试),report_loss 是 HIGH + approval_required + 非幂等(收钱的事,拦下来等人点头,失败不重试)。

✅ 检查点:两个文件都保存,__init__.py 的 import 与文件名一致(lookupreport_loss)。

步骤 3:挂到小图身上

app/agents/my_lib_assistant.yaml 的 instruction 服务范围那段后面加一行,并加 tools 段:

instruction: |
  你是「小图」,学校图书馆的借阅服务助手,语气亲切像个耐心的学姐。
  服务范围:借阅记录查询、校园卡挂失、馆藏规章咨询。
  查借阅用 my_lib_lookup(要学号);挂失用 my_lib_report_loss(收 20 元工本费,走人工审批)。
  边界:不处理图书采购建议、不提供文献代查,超出范围如实说明并建议到馆咨询前台。
brain: smart
tools: [my_lib_lookup, my_lib_report_loss]
memory: session
...

(注意人设里顺带告诉了模型每个工具是干嘛的、什么时候用——工具的 description 模型也看得到,但人设里的使用规则让调度更稳。)

导入并重启(工具注册发生在服务启动扫描时,新文件必须重启生效):

.venv/bin/luban init
bash scripts/dev.sh restart

✅ 你应该看到:init 输出 my-lib-assistant@3(或你当前的版本 +1);工作台工具页出现 my_lib_lookupmy_lib_report_loss,点开能看到自动生成的参数 Schema。

步骤 4:不起对话,先单测工具

写完工具不用急着对话——工作台工具页每个工具都有在线试跑(不起 Runtime、不走模型,直接执行工具函数):

curl -X POST http://localhost:8000/v1/tools/my_lib_lookup/test \
  -H "Content-Type: application/json" \
  -d '{"student_id": "2023011234"}'

✅ 你应该看到:{"success": true, "value": {"student_id": "2023011234", "count": 2, "records": [{"title": "三体", ...}, ...]}, ...}

注意请求体就是参数本身(不是包一层的 {"args": ...})。这一步验证的是工具函数本身没写错——把"工具坏了"和"模型没调对"两类问题分开查,是排障的基本功。

步骤 5:对话里用低风险工具

调试台选小图,输入:我是学号 2023011234 的学生,我借了哪些书?

✅ 你应该看到:回复里出现《三体》和《机器学习系统设计》、各自的应还日期——来自你刚写的工具返回的真数据。curl 看事件流(或看调试台的工具状态行):tool.started(模型决定调 my_lib_lookup)→ tool.finished(真数据回来)。从这一刻起,小图在借阅查询上不可能再编——它说的每一本书都来自工具返回。

步骤 6:走挂失审批

/chat 页选小图,输入:我的校园卡丢了,学号 2023011234,帮我挂失

✅ 你应该看到:气泡内出现**「批准 / 拒绝」按钮**(不是直接执行)——my_lib_report_loss 是高风险,闸门拦下等人点头。点「批准」,挂失真实执行,模型接着告诉你工本费 20 元、三日后凭学生证领新卡。事件流里是 tool.approval.requested →(你点按钮)→ tool.approval.resolved

用 curl 走这条链也行,但别加 --max-time 短超时:审批等人点击期间连接要保持——浏览器天然如此,curl 加了超时会在你批准前把连接掐断。这是真实踩过的坑。

第 3 课你用的是内置客服的退款审批;这次是你自己写的工具、自己定的风险等级——同一套闸门机制,你已经会造受它保护的东西了。

4. 完成自检清单

  • 工具页看到两个 my_lib 工具,Schema 自动生成
  • 在线试跑直接返回借阅数据(步骤 4)
  • 对话中 tool.started / tool.finished 出现,回复含真数据(步骤 5)
  • 挂失走完「审批按钮 → 批准 → 执行 → 告知工本费」(步骤 6)
  • 能说出 lookup 和 report_loss 治理参数的每一处差异为什么那样定

5. 常见坑

改了工具/加了新工具,工具页没有。 工具注册在服务启动扫描时发生(EXTENSION_PACKAGES=app.tools 驱动)。bash scripts/dev.sh restart。另外检查 __init__.py 有没有 import 新模块——没 import 就不会被扫描到。

对话中说"我帮你查了"但事件流里没有 tool.started。 模型在编造工具调用(幻觉的变体)。常见原因:人设里没写清什么时候用哪个工具,或工具 description 太含糊。把使用规则写进人设(见步骤 3 的做法)。

报"工具签名漂移"。 你改了已挂载工具的函数签名,而 Agent 版本快照里记的是旧签名。重跑 luban init 产生包含新签名的新版本即可。这是保护,不是故障。

在线试跑 400/422。 九成是请求体格式——参数直接放 body({"student_id": "..."}),不要包 {"args": {...}}

6. 练习

练习 A(模仿):给 lookup 加一个可选参数 title_filter: str = ""(按书名过滤),重启后在线试跑验证。观察 Agent 版本是否报签名漂移——想想为什么(提示:签名哈希变了,旧版本钉的是旧签名)。

练习 B(变式):把 report_loss 的 approval_required 改成 Falserisk 降到 MEDIUM,重启重导,再走一次挂失。观察:这次没有审批按钮,直接执行了。改回去。然后想一个问题:风险等级定低了,谁来兜底?(答案在第 0 课:没人——这就是为什么定级是严肃决策。)

练习 C(综合,本课主菜):给图书馆再加一个"续借"工具 my_lib_renew(student_id, title):把指定书的 renewals_left 减一、应还日期延 30 天。先别写代码——先回答三个问题并把答案写下来:①它该是什么风险等级?②要不要审批?③幂等吗?然后再写代码、按你自己的答案定参数。写完后对照:内置客服的退款工具定 HIGH+审批,你的续借和它比,危险程度差在哪?(参考思路:续借改的是借阅状态不直接动钱,但消耗了不可逆的续借次数——现实中这类"中等风险"的定级争议最大,值得多想一分钟。)

7. 小结与下一课预告

小图现在会做两件事了:查借阅(低风险直通)和挂失(高风险审批)。你掌握了 Tool 的全部机制:装饰器声明、签名即接口、治理参数、审批闸门。更重要的是练习 C 那个问题——风险定级是工程判断,不是模板填空。

还差最后一块能力:馆藏规章咨询。规章有几十条,不可能写成人设(塞不下、改不动),也不能靠工具(每条规章不是"操作"是"知识")。下一课用 RAG:上传规章文档、切块、校准阈值——顺便见识这个框架最硬核的一个特性:宁可说不知道,绝不错着答,而且这条底线是代码守的,不是求模型守的。


延伸阅读:用户手册 §4(Tool 与 MCP——含 MCP 外部工具接入) | 参考产出:reference/04-library_tools.py | 对照范本:app/tools/customer_service/order.py(HTTP 调业务系统的真实模式)、refund.py(内置客服的高风险工具)

目录