TT Lab
开始
学习 学习路径 课程

AI 智能体 — 是图不是模型

工具就是契约——调用前先量,拿到结果再量一次

在 TT Lab 中继续学习

一句话总结

智能体事故的一半,出在调用工具的一方,而不是模型。因为不检查参数就调用,不检查返回的内容就相信。

为什么需要它

接上了工具的智能体,第一次出故障通常是这样。

TypeError: stock_lookup() got an unexpected keyword argument 'sku_id'

模型把参数名自己改了个名字编造出来,原样传给函数,异常就蹦出了图之外。这个异常结束的不是节点,而是整个运行。状态、走过的路径、为什么会这样,什么都没留下。

接下来出故障的方式因为不是异常而更糟。工具返回了空列表,却在它上面编出了答案。用户收到“3 个地点有库存”这样一句话,日志里没有任何错误。

两起事故的原因相同。工具调用没有契约。接收哪些参数、什么类型和范围,返回什么、是什么形态,如果代码不在一处写明,检查的代码就会分散在各个节点里,或者干脆没有。

契约写在哪里

契约不应是给人读的文档,而应是程序读的值。把一个名称对应的参数 schema、返回的形态、实际函数绑在一起,放进一张表。

TOOLS = {
    "stock_lookup": {
        "fn": stock_lookup,
        "args": {"sku": {"type": "str", "required": True},
                 "region": {"type": "str", "required": True, "allowed": ["busan", "jeju", "seoul"]},
                 "limit": {"type": "int", "required": True, "min": 1, "max": 2}},
        "returns": {"sku": {"type": "str"},
                    "rows": {"type": "list", "max_len": 2}},
    },
}

这样做,三件事会一并带来。第一,不需要为每个工具各写一份检查参数的代码——一个读表的函数就够了。第二,可以从这张表里提取要给模型的工具说明。第三,调用工具的门被收窄到一处。Workflows and agents 所展示的工具调用结构,说到底也是把这扇窄门放在哪里的问题。

调用之前先检查

参数校验中最重要的是调用之前这个顺序。这与调用之后再捕获异常不同。用错误的参数调用一次的那一刻,钱就出去了,邮件就发出去了,订单就下了。查询类工具还可以撤回,写入类工具就不行。

要检查的有四样。缺失的参数、类型、范围、白名单。再加上传入契约中没有的参数(拼写错误、幻觉),就是五样。

用 Python 检查类型时,一定会遇到一个陷阱。bool 是 int 的子类型,所以 isinstance(True, int) 为真。意思是,数量的位置上传来 True 也会通过“整数”检查。所以检查整数时,必须先用 isinstance(value, bool) 把它筛掉。

还要事先确定校验返回什么。只有一个布尔值是不够的。必须以原因字符串的形式留下哪里为什么错了,下次才能用。像 allowed:region 这样,把“哪项检查”“在哪个参数上”被拦下放在一行里,光凭这一个字符串就能在后面选择应对方式。

选择参数的位置本来是模型

本文和下一个实验不调用模型。选择参数的节点只是用规则实现的,真正的智能体会在那个位置去问模型——调用哪个工具、参数填什么,由模型决定。其余结构一行都不会变。

反而,选择的一方越是模型,就越需要这个结构。规则即使出错,每次也以同样的方式出错,而模型每次都出错得不同。把参数名换成相近的,把数字当作字符串传,编造契约中没有的地区名。所以,从契约表里提取要给模型的工具说明,再把模型返回的参数用同一张契约表重新检查,让这两个方向在一处咬合——这就是这种设计的价值。

收到之后还要再检查

工具返回的是别人做出来的值。不能像对待我们自己函数的返回值那样对待它。实际经常遇到的有四种。

检查这四样的函数,也是读契约表来生成的。把结果校验手写在节点里,每多一个工具就会漏掉一处。

失败是值,不是异常

调用工具的门,要做成不向外抛异常的函数。

{"ok": False, "value": None, "error": "args:allowed:region"}

返回的形态始终一致,节点就有了可以分支的依据。而且,写在 error 里的原因累积到状态里,下一次尝试要改什么就由代码决定。原因是 args:allowed:region 就把地区换成默认值,原因是 result:too_big 就把工具从旧版本换成新版本。反过来,如果作为异常抛出,剩下的只有“出了点问题”,就无法区分能修的失败和不能修的失败。

也确实有不能修的原因。空结果就是这样。同一个问题再问一次,还是空结果。此时重试只是白白烧掉预算,而编造答案更糟。把能修的原因列表写进代码里,其余的立刻放弃。尝试上限是在此之上再加的一层安全装置。

同一件事不问两次

在循环中运行的智能体会一直用同样的参数调用同一个工具。因为状态里没有留下结果,或者留下了但下一个节点找不到。调用的门只有一处的话,在那扇门前面放一个记忆就行了。

缓存键是 이름 + 정규화한 인자(韩文,意为“名称 + 规范化后的参数”)。即使参数写的顺序不同,也必须得到同样的键,所以要像 json.dumps(args, sort_keys=True) 那样排序后生成。而且只记住成功的。连失败也记住的话,修好后重新调用的路就被堵死了。

在现场相遇的样子

第一,模型编造参数名。出现契约中没有的参数不是事故,而是日常。拦下来,留下原因,再问一遍。

第二,查询成功了,但没有实质内容。只看 ok 就放过的话,就会在它上面生成句子。把空结果归为失败的那一行,就能挡住这种情况。

第三,旧版本 API 无视参数。不遵守上限的响应原样进入模型的上下文,消耗令牌(token)。结果校验也是成本管理。

第四,同一个查询在一次运行里转了五六遍。账单上会最先看出来。是因为状态里没有留下结果,或者留下了但下一个节点找不到它。

第五,一个异常就结束了整个运行。工具函数内部出现的 KeyError 如果跑出了节点、跑出了图,那一条就整个消失了。已经积累到一半的状态也一起消失,重新运行时必须从头开始。

实际工作中真正重要的事

下一项实验要做什么

一步步扩展 /root/work/agtool/tools.py。做出工具契约表,写出调用之前检查参数的函数和收到结果之后检查的函数,建起不向外抛异常的调用门。接着加上记忆,使同样的参数不会调用两次,搭起把失败当作路径来处理的图,最后做出看着原因修改参数和工具、再次尝试的版本。选择参数的位置用规则代替,但真正的智能体会在那个位置去问模型——其余结构完全一样。评分器会真正导入你的模块,每次用不同的值检验校验函数,连工具主体运行了几次都会数。