工具调用 3|工具拿到参数以后,程序还必须检查什么?

假设你问个人资料助手:

帮我查一下保存的读书会通知,报名截止日期是哪天?

要回答这个问题,助手先要找到相应的通知。我们给它提供了一个名叫 search_notices 的工具,意思是"搜索通知",对应程序里实际搜索已保存通知的函数。

程序把用户问题交给模型时,也会提供工具说明:这个工具能搜索已保存通知,调用时需要搜索关键词,还可以限定通知的发布日期。模型根据用户问题和这些说明,判断是否请求使用它。这里要核对的是通知中的报名日期,因此搜索通知是合适的操作。

模型请求使用这个工具时,需要把搜索条件交给程序。例如,下面是一份参数示例:

json 复制代码
{
  "query": "读书会 报名",
  "published_from": null
}

这里,query 是搜索关键词;published_from 用来限定通知的发布日期,null 表示这次不加日期限制。

接下来,程序要把这份请求变成真正的搜索。执行前需要确认:工具名是否在允许范围内,参数能否被搜索函数正确处理,以及本次搜索应读取哪些资料。下面就沿这次查询,逐项看这些检查解决什么问题。

1. 参数能解析,还要检查它是否符合这项操作的约定

先明确这个搜索工具接受什么:query 是用空格分隔的关键词;published_from 是通知发布日期的下限,包含当天,使用 YYYY-MM-DD;不筛选发布时间时填 null。

收到调用后,程序至少要分清下面几种情况:

收到的内容 为什么不能直接搜索 程序可以怎样检查
工具名为 delete_notices 本次执行入口只提供通知搜索 只接受程序预先登记的工具名
参数文字只有 {"query":"读书会" JSON 尚未完整,无法确定参数对象 完整解析,失败就拒绝,不补出缺失部分
query 为 123,或缺少 published_from 字段类型或字段集合不符合约定 检查必填字段、额外字段及类型
query 全是空格,或长达几千个字符 类型虽然是字符串,却不是本例允许的查询 去除首尾空白后检查内容与长度
published_from 为 "2026-02-30" 写法像日期,但日历上没有这一天 按日期解析,并检查规定的格式

前面几项主要检查"交来的是什么",后面几项检查"这个值是否可用"。只要求日期是字符串,拦不住不存在的日期;只确认搜索词是字符串,也拦不住空查询。

长度限制同样需要依据具体实现。在这个小例子里,我们把参数文字限制为 4096 个字符,把去除首尾空白后的关键词限制为 100 个字符,避免把过大的输入交给解析器或搜索函数。这是教学程序的取值,不是所有搜索工具都应采用的标准。实际服务还要根据请求大小、搜索成本等条件确定限制。

模型接口提供的结构约束可以减少格式错误,程序侧仍应执行自己依赖的检查。尤其是当前权限、资料归属等条件,不能由生成参数的结构约束决定。

检查也不应顺带扩大工具能力。程序只登记了搜索,就只调用对应的搜索函数;不能把模型给出的名称当作代码执行,也不能因为名称看起来像某个函数,就在整个程序里寻找它并调用。

2. 访问谁的资料,要由程序在读取时决定

现在给教学资料增加一位用户。这个练习不设置共享授权,每位用户只能读取自己保存的通知。三份通知都含有"读书会"和"报名",但归属不同:

通知 保存者 发布日期 主要内容
D1 当前用户 Alice 2026-09-10 报名原定于 9 月 20 日截止
D2 当前用户 Alice 2026-09-16 报名截止日期提前到 9 月 18 日
D3 另一位用户 Bob 2026-09-16 读书会志愿者报名联系名单

Alice 搜索"读书会 报名",关键词能够匹配三份资料。但属于她的结果只能是 D1、D2。匹配关键词和允许读取,是两个不同的条件。

程序需要知道两个值:当前登录用户是谁,以及这个用户当前是否允许搜索。这些值应来自程序可信的登录会话和权限判断,不是模型在参数里填写的身份。模型输出一个 user_id,只能说明它生成了这个字符串,不能证明这次请求属于该用户。

因此,模型可填写的仍然只有 query 和 published_from。执行时,程序另外带入当前用户,并在数据读取处限定范围:

这个顺序很重要。如果先把所有人的通知交给模型,再要求它"只回答 Alice 的资料",其他人的内容已经进入了模型输入。即使最终答案没有显示 D3,也不能说访问范围得到了控制。限定范围必须发生在资料交给模型之前;查询数据库时,应把用户或授权范围作为数据查询条件落实。

公开工具实现中也能看到这种身份来源的划分。LangChain 官方工具文档的账户查询示例中,应用传入 UserContext(user_id=...),账户查询函数从 runtime.context.user_id 取得用户编号,再读取对应账户。文档还说明,runtime 不属于模型填写的工具参数。

这里值得借鉴的是:身份通过程序提供的上下文进入工具,函数按这个身份取数据。这个公开示例没有展示完整的登录验证或权限系统;把身份字段从模型参数中移走以后,应用仍要保证传入的身份可信,并落实实际访问规则。

只读工具也需要这项控制。搜索不会修改通知,但读到另一个人的资料,本身就已经越过了允许访问的范围。

3. 用一个能运行的小程序,观察检查怎样阻止执行

下面的练习只搜索三份虚构通知,不联网、不调用模型。输入是已经从完整工具调用中提取出的工具名和参数文字,便于单独观察执行检查。真实接入时,接收层还应先确认响应和调用内容完整,不能把未完成的参数送进执行入口。

代码中的 Context 保存程序提供的用户与权限。为了能在本地直接运行,示例手工固定这两个值;接入真实应用时,必须由服务端根据登录会话和执行时的权限产生,不能照搬浏览器请求或模型输出中的同名字段。

使用 Python 3.10 或更高版本,无需安装额外依赖。将下面的完整程序命名为 tool_guard.py,运行:

powershell 复制代码
python -X utf8 -B tool_guard.py

完整只读练习代码

python 复制代码
import json
from dataclasses import dataclass
from datetime import date


@dataclass(frozen=True)
class Context:
    user_id: str
    can_search: bool


class RejectedCall(ValueError):
    pass


NOTICES = (
    {
        "id": "D1", "owner_id": "u-alice", "published_on": "2026-09-10",
        "content": "本次读书会报名将于2026年9月20日截止。",
    },
    {
        "id": "D2", "owner_id": "u-alice", "published_on": "2026-09-16",
        "content": "本次读书会报名截止日期提前到2026年9月18日,原通知中的截止日期作废。",
    },
    {
        "id": "D3", "owner_id": "u-bob", "published_on": "2026-09-16",
        "content": "九月读书会志愿者报名联系名单,由另一位用户保存。",
    },
)


def unique_fields(pairs):
    result = {}
    for key, value in pairs:
        if key in result:
            raise RejectedCall("invalid_arguments")
        result[key] = value
    return result


def reject_constant(value):
    raise RejectedCall("invalid_arguments")


def validate_arguments(text):
    if not isinstance(text, str) or len(text) > 4096:
        raise RejectedCall("invalid_arguments")
    try:
        args = json.loads(
            text, object_pairs_hook=unique_fields, parse_constant=reject_constant,
        )
    except (ValueError, RecursionError) as error:
        raise RejectedCall("invalid_arguments") from error
    if not isinstance(args, dict) or set(args) != {"query", "published_from"}:
        raise RejectedCall("invalid_arguments")
    query = args["query"]
    if not isinstance(query, str) or not 1 <= len(query.strip()) <= 100:
        raise RejectedCall("invalid_arguments")
    since = args["published_from"]
    if since is not None:
        if not isinstance(since, str):
            raise RejectedCall("invalid_arguments")
        try:
            parsed = date.fromisoformat(since)
        except ValueError as error:
            raise RejectedCall("invalid_arguments") from error
        if parsed.isoformat() != since:
            raise RejectedCall("invalid_arguments")
        since = parsed
    return query.strip(), since


def user_notices(user_id):
    return [row for row in NOTICES if row["owner_id"] == user_id]


def search_notices(query, since, context):
    if not context.can_search:
        raise RejectedCall("forbidden")
    matches = []
    for row in user_notices(context.user_id):
        if since is not None and date.fromisoformat(row["published_on"]) < since:
            continue
        if all(word in row["content"] for word in query.split()):
            matches.append({key: row[key] for key in ("id", "published_on", "content")})
    return {"status": "ok", "matches": matches}


def execute_call(name, arguments_text, context):
    if name != "search_notices":
        raise RejectedCall("unknown_tool")
    query, since = validate_arguments(arguments_text)
    return search_notices(query, since, context)


if __name__ == "__main__":
    context = Context(user_id="u-alice", can_search=True)
    arguments = '{"query":"读书会 报名","published_from":null}'
    try:
        result = execute_call("search_notices", arguments, context)
    except RejectedCall as error:
        result = {"status": "rejected", "reason": str(error)}
    print(json.dumps(result, ensure_ascii=False, indent=2))

这里最值得跟着看的是三处:execute_call 只接受登记的工具名,validate_arguments 检查并解释参数,search_notices 在取得资料前检查权限,再通过 user_notices 取得当前用户范围内的通知。

两个 JSON 解析辅助函数用来拒绝重复字段以及 NaN 等非标准数值。Python 的 JSON 文档说明,默认解码器会接受这些情况;如果参数约定不允许,就需要明确拒绝。日期使用标准库的 日期解析方法,再确认格式与约定一致。

直接运行默认输入,实际返回 status="ok",通知编号为 D1、D2,没有 D3。再用人工准备的请求分别触发几种条件,得到以下结果。这些输入用于验证程序的检查行为,不代表模型在实测中生成过这些错误。

请求变化 实际处理结果 是否进入资料读取
当前用户 Alice,关键词为"读书会 报名",不筛选发布时间 成功,返回 D1、D2 是,范围为 Alice 的通知
将发布日期下限改为 "2026-09-15" 成功,只返回 D2 是,范围仍为 Alice 的通知
将 query 改为数字 123 拒绝,invalid_arguments 否
将日期改为 "2026-02-30" 拒绝,invalid_arguments 否
将工具名改为 delete_notices 拒绝,unknown_tool 否
在参数里额外加入 "user_id":"u-bob" 拒绝,invalid_arguments 否
保留正常参数,但程序提供的 can_search 为 False 拒绝,forbidden 否

这份结果要核对的不只是返回文字。检查程序同时观察了资料读取函数有没有被调用:上表中被拒绝的请求都没有进入该函数。把执行时的用户改为 Bob,正常请求只返回 D3,也说明范围随程序提供的身份变化,没有固定成某一位用户。

示例在内存中按保存者筛选资料,便于直接看见规则。接入真实系统时,需要在服务端的资料服务或数据库访问处落实同样的边界;本地手工指定的身份与权限不能代替登录验证。

4. 拒绝以后,不能为了"让调用成功"悄悄改掉条件

检查发现问题时,处理方式应保留原任务的含义。去除关键词首尾空白,是本例允许的整理;把不存在的 2 月 30 日改成 2 月 28 日,却已经改变了查询条件。

同样,缺少 published_from 不应被自动解释成"从今天开始",空字符串也不应悄悄变成 null。前者可能漏掉需要核对的旧通知,后者掩盖了调用方没有遵守约定的问题。程序没有足够依据时,应拒绝这份参数,让调用方纠正,必要时向用户确认。

权限拒绝则需要另一种处理。当前用户不允许搜索,不能靠换一组关键词恢复资格,也不能改成搜索其他用户。只能在程序确认权限已恢复之后,重新判断新的调用能否执行。

练习代码用 RejectedCall 表示这些拒绝,运行入口把它显示为 status="rejected"。这与"查询成功,但没有匹配通知"不同:前者没有执行查询,后者已经在允许范围内查过。记录和展示时保留这个区别,才不会把权限失败误解成资料不存在。

5. 检查通过,为什么仍然可能查错问题

最后看一个程序不能靠上述规则识别的错误。用户问:

报名截止日期是不是 2026 年 9 月 18 日?

假如模型把这个日期填成通知发布日期下限:

json 复制代码
{
  "query": "读书会 报名",
  "published_from": "2026-09-18"
}

字段齐全,类型正确,日期合法,当前用户也允许搜索。这个请求会通过检查,但在教学资料里实际返回空列表:D1、D2 都是在 9 月 18 日之前发布的。

错在两个日期的关系。用户给的是待核对的报名截止日期,工具参数却被解释成"只查这天及之后发布的通知"。仅检查参数对象,程序不知道这个日期是怎样从问题中取得的,因此无法证明它符合用户原意。

能够确定的业务条件,应继续放进程序检查。例如用户在页面上明确选择了"从 9 月 15 日发布的通知开始查",程序可以直接带入这个值,或者核对模型参数是否与选择一致。只有自然语言问题、没有独立确定条件时,还需要通过清楚的工具说明、针对性任务和结果核对,检查模型是否理解了日期的用途。不能看到参数校验通过,就把"这次请求允许执行"扩大为"这次搜索一定能回答问题"。

再试着判断两个变化:

  1. 搜索工具定义发给模型时,用户有权限;模型返回参数之前,权限已经被撤销。仍然执行吗?
  2. 模型请求 read_notice("D3"),编号真实存在、格式正确,但 D3 属于 Bob。Alice 可以读取吗?

第一种情况要以实际执行时的权限为准。第二种情况,即使程序提供了按编号读取的工具,也要在读取那份通知时检查归属或授权;编号存在不等于当前用户可以访问。搜索入口有范围控制,不能代替另一个读取入口的检查。

回到开头,程序拿到参数后,需要确认操作名称、参数结构、可确定的取值条件、当前权限与实际访问范围。通过这些检查,才允许在限定范围内执行;执行是否成功、结果是否足以支持回答,仍要依据真实返回继续判断。

相关推荐
AI语宙漫游指南1 小时前
RAG 向量检索(上):从 FLAT 到 IVF,查得快为何可能漏?
后端·算法
java资料站1 小时前
七、Spring AI Alibaba · Skills
java·后端·spring
殷紫川2 小时前
Arthas 4 深度实战:从字节码原理到生产故障秒解
后端
JuiceFS2 小时前
地平线 EB 级存储工程实践
人工智能·后端·自动驾驶
霸道流氓气质3 小时前
LLM API密钥管理与轮换策略:Spring Vault动态密钥、熔断限流协同与多Provider路由的 Java 生产级实现
java·后端·spring
凤山老林3 小时前
Spring Boot 集成 Redis Stream 做可靠消息队列:持久化、消费组重组与流式处理实战
spring boot·redis·后端
codigger3 小时前
哈希算法:一根看不见的 "指纹",如何撑起整个互联网
后端·区块链·哈希算法·sha256
the局外人3 小时前
FastAPI中jwt的基本使用与实践
后端·python·fastapi
FullStackGeek3 小时前
微信缴费APIV3接入
后端