第 2 课
把工具调用做稳:参数约束、系统事实、置信度路由与结构化抽取
用 cactus.Field 把金额范围和收款人格式写进解码语法,并亲眼看到越界的 50000 被改写成 500;用系统事实让模型把「明天」「下周二」换算成日期;按置信度把调用分成执行、确认、拒绝三档,拦住越界值和否定句两条错误调用;用 extract() 把一句话抽成 Pydantic 对象;最后运行官方智能家居 32 条测试集给模型打分。
课程目录第 2 / 3 课
学习位置仅保存在当前浏览器,有效期 180 天。
图文教材
本课对照的官方页面:Needle Python Docs 、How to Design Tools for Needle 3 、Leveraging Needle's Confidence 、Structured JSON Extraction with Needle 。测试环境同课时 1(
cactus-compute4.1.0,Python 3.13.5)。下面所有「对照结果」都是测试机的真实输出,置信度等数值每次运行可能有零点零几的出入。
上一课看到,Needle 3 是官方标称 2900 万到 1.21 亿参数的小模型,会多调、偶尔填错。官方给的办法不是把描述写得更长,而是三件事:把约束写进类型定义,把环境信息作为「事实」传进去,按置信度决定执行还是确认。本课逐个动手试,最后用 extract() 做结构化抽取,并用官方自带的 32 条测试集给模型打分。
第 1 步:用 Field 把参数约束写进类型
cactus.Field 可以给参数加范围、正则、长度等约束。这些约束会被编译进解码语法,模型根本写不出不合法的值。新建 money.py(工具定义来自官方 Python 文档):
from typing import Annotated
import cactus
@cactus.tool
def send_money(
amount: Annotated[float, cactus.Field(
gt=0, le=10000, description="USD, up to 10,000")],
to: Annotated[str, cactus.Field(
pattern=r"^@[a-z0-9_]+$", description="recipient handle")],
memo: Annotated[str, cactus.Field(max_length=80)] = "",
):
"Send money to a handle."
return {"sent": amount, "to": to, "memo": memo}
agent = cactus.Cactus(tools=[send_money])
for q in ["send 25 dollars to @maya_k for pizza",
"send money to Leo",
"send 50000 dollars to @maya_k"]:
agent.reset()
r = agent.complete(q)
print(q)
for c in r["function_calls"]:
print(" call:", c["arguments"])
if not r["function_calls"]:
print(" (空调用)held =", r["suppressed_calls"])
运行 python money.py,对照结果:
send 25 dollars to @maya_k for pizza
call: {'amount': 25.0, 'to': '@maya_k'}
send money to Leo
(空调用)held = []
send 50000 dollars to @maya_k
call: {'amount': 500.0, 'to': '@maya_k'}
逐条看:
- 第一句金额和收款人都对。
memo是可选参数,这一次被省略了(另一次运行里填成了pizza),所以读的时候要用.get("memo", "")。 - 第二句没有金额,
Leo也不符合@开头的格式。必填参数在原句里找不到依据时,模型不会去猜,直接不调用。 - 第三句要特别注意:用户说的是 50000,超过了
le=10000的上限,模型没有拒绝,而是输出了 500.0。语法约束保证了「值一定在范围内」,但不保证「值和用户说的一致」。涉及钱、删除、发消息这类操作,一定要在执行前拿参数和原句再核一遍,或者一律先让用户确认。
第 2 步:用系统事实处理日期这类相对说法
「明天」「下周二」要换算成具体日期,模型得知道今天是哪天。官方的做法是在 system 里传一串「事实」,比如日期、语言区域、设备。新建 facts.py:
import cactus
@cactus.tool
def create_event(title: str, date: str):
"""Create a calendar event.
Args:
title: event title
date: event date as YYYY-MM-DD
"""
return {"title": title, "date": date}
agent = cactus.Cactus(
tools=[create_event],
system="date: 2026-11-12 Thu 09:00; locale: en-US; device: phone")
for q in ["lunch with Priya tomorrow",
"dentist appointment next Tuesday"]:
agent.reset()
r = agent.complete(q)
print(q)
print(" ->", r["function_calls"][0]["arguments"])
运行 python facts.py,对照结果:
lunch with Priya tomorrow
-> {'title': 'Lunch with Priya', 'date': '2026-11-13'}
dentist appointment next Tuesday
-> {'title': 'Dentist appointment', 'date': '2026-11-17'}
2026-11-12 是周四,「明天」是 11-13,「下周二」是 11-17,都算对了。官方文档列出的可用键是 date、locale、device、battery、network、location、user、assistant。这里只能放事实,不能放指令:写「请总是用中文回答」之类的话不会改变模型行为。不传 system 时,Python 包会自动补上本机当前日期。
第 3 步:按置信度分三档:执行、确认、拒绝
官方的置信度指南给出一个三档写法:置信度高于阈值就执行,有调用但不够高就把调用和 reasoning 拿给用户确认,没有调用就拒绝。新建 router.py:
from typing import Annotated, Literal
import cactus
@cactus.tool
def set_lights(
room: Literal["kitchen", "bedroom", "living room"],
on: bool,
brightness: Annotated[int, cactus.Field(ge=0, le=100)] = 100,
):
"Turn a room's lights on or off, or dim them."
return {"room": room, "on": on, "brightness": brightness}
@cactus.tool
def lock_door(door: Literal["front door", "back door", "garage door"]):
"Lock a door."
return {"locked": door}
agent = cactus.Cactus(tools=[set_lights, lock_door])
def route(text, threshold=0.7):
agent.reset()
r = agent.complete(text)
calls, held = r["function_calls"], r["suppressed_calls"]
conf = r["confidence"]
if calls and conf is not None and conf >= threshold:
verdict = "执行"
elif calls or held:
verdict = "先确认"
else:
verdict = "拒绝"
print(f"{text}\n -> {verdict} conf={conf}")
for c in calls or held:
print(" ", c["name"], c["arguments"])
for q in ["dim the living room to 30 and lock the back door",
"turn off the kitchen lights",
"lock the front door",
"set the bedroom lights to 150 percent",
"don't lock the back door",
"order a pizza"]:
route(q)
运行 python router.py,对照结果:
dim the living room to 30 and lock the back door
-> 先确认 conf=0.5962
set_lights {'room': 'living room', 'on': True, 'brightness': 30}
lock_door {'door': 'back door'}
turn off the kitchen lights
-> 执行 conf=0.7447
set_lights {'room': 'kitchen', 'on': False}
lock the front door
-> 执行 conf=0.8118
lock_door {'door': 'front door'}
set the bedroom lights to 150 percent
-> 先确认 conf=0.5567
set_lights {'room': 'bedroom', 'on': False}
don't lock the back door
-> 先确认 conf=0.5196
lock_door {'door': 'back door'}
order a pizza
-> 拒绝 conf=0.9203
这组结果正好说明为什么要有中间那一档:
- 两条简单指令置信度在 0.74 以上,直接执行,结果正确。
- 一句话里两个动作,两个调用都对,但置信度只有 0.6,进了确认档。
- 「150 percent」超出范围,模型给出的是
on: False(关灯),这是错的;「don't lock」是否定句,模型仍然给出了锁门调用,也是错的。两条都落在 0.5 多一点,被确认档拦住了。如果阈值设成 0.5,这两条就会被直接执行。 - 和工具无关的「order a pizza」返回空调用,进拒绝档。
阈值没有标准答案。官方指南的建议是:开关灯这类无所谓的操作可以放到 0.5,转账这类操作要 0.9 以上并且仍然确认;最好用一批自己的测试句来定。
第 4 步:用 extract() 把一句话变成结构化数据
cactus.extract() 不需要定义工具,直接给一个 Pydantic 模型或 JSON Schema,就能从文本里抽字段。先安装 Pydantic:
pip install pydantic
新建 extract_demo.py:
from typing import Optional
from pydantic import BaseModel
import cactus
class Meeting(BaseModel):
title: str
date: str
attendees: list[str]
room: Optional[str] = None
text = ("Quarterly review with Dana and Luis on March 4th, 2027 "
"at 3pm in room B12.")
m = cactus.extract(text, Meeting)
for k, v in m.model_dump().items():
print(f" {k}: {v}")
text2 = "Lunch with Priya next Friday."
m2 = cactus.extract(text2, Meeting,
system="date: 2026-11-12 Thu 09:00")
print(" ---")
for k, v in m2.model_dump().items():
print(f" {k}: {v}")
print(" ---")
print(" ", cactus.extract("The weather is nice today.", Meeting))
运行 python extract_demo.py,对照结果:
title: Quarterly review with Dana and Luis
date: 2027-03-04
attendees: ['Dana', 'Luis']
room: B12
---
title: Lunch with Priya
date: 2026-11-20
attendees: ['Priya']
room: None
---
None
返回的是一个 Meeting 实例,可以直接 .model_dump() 存库。第二句没提会议室,room 留空而不是瞎编。「next Friday」被换算成 11-20,也就是下一周的周五,而不是 11-13 这周的周五,英语里这两种理解都有人用,日程类产品要在界面上给用户确认。第三句里没有任何会议信息,返回 None。默认 strict=True,如果抽出的日期年份和原文对不上,会直接抛出 cactus.ExtractionValidationError。
第 5 步:用官方测试集给模型打分
cactus.environments 自带 6 套现成场景(智能家居、媒体播放、办公、可穿戴、厨房电器、数据采集),每套 32 条固定测试,覆盖正确调用、缺参数、无关请求、否定句、越界值和一句多动作。直接运行智能家居这一套:
python -m cactus.environments.smart_home
对照结果(节选):
FAIL [irrelevant] lock the back door
FAIL [irrelevant] play some jazz in the living room
FAIL [invalid] dim the bedroom lights to 150 percent
FAIL [parallel] start the vacuum in the kitchen and open the living room blinds
28/32 passed, 1 critical failures (confidence gate 0.0)
有失败项时进程退出码是 1,可以直接放进 CI。测试机上连跑几次,结果在 27 到 28 条之间。加上置信度门槛再跑:
python -c "from cactus.environments import smart_home; \
smart_home.run_tests(min_confidence=0.4)"
对照结果(最后一行):
29/32 passed, 1 critical failures (confidence gate 0.4)
测试机上两次运行分别是 29 条和 28 条。门槛从 0 提到 0.4 后,一部分低置信度的错误调用会被当成拒绝处理,通过数略有上升。官方建议给自己的工具也照这个格式写一套测试:同样覆盖这六类情况,改完工具描述或阈值后先跑测试再上线。
常见坑
- 越界值被改写而不是拒绝:第 1 步的 50000 变成了 500。范围约束只能保证格式,关键操作要自己再核对原文。
- 工具太多:官方指南写明 5 个以内的工具会全部放进提示词;超过 5 个会按相似度只挑最接近的 5 个,其余工具这一轮根本调不到。
- 一个万能工具:
control_home(device, action, value)这种写法会逼模型编造参数。官方建议一个动作一个工具,枚举值用用户真会说的词。 - 在 system 里写指令:只放日期、设备等事实,写指令不起作用。
- 微调后的模型看 confidence:加载自己用
cactus build导出的.cact时,confidence为None,需要自己验证输出。
小结
这一课用 cactus.Field 把约束写进了语法,也看到了越界值被改写的风险;用系统事实让模型换算相对日期;按置信度把调用分成执行、确认、拒绝三档,拦住了「150 percent」和否定句两条错误调用;用 extract() 从一句话里抽出了会议记录;最后用官方 32 条测试集给模型打了分。下一课换到语音和图片:用 Whistle 转写音频,用 Mirage 识别图片里的文字,并把模型准备成断网可用。
