BFCL v3 深读:多轮函数调用的评测口径,从「对答案」换成「diff 状态」

Berkeley 那篇 BFCL v3 博客表面在讲 1000 条新多轮题目,底下真正换掉的是评测口径:从 AST 匹配一条正确轨迹,变成让代码跑进沙盒后比对对象属性。附源码定位、条数核对、四类多轮任务的判据分工,以及一处被注释掉的更严判据留下的证据。

Berkeley 那篇 BFCL v3 博客 表面说的是「新增 1000 条多轮题目」,读完源码后我认为真正的动作在别处:它把评测口径从「AST 比对一条正确轨迹」换成了「让模型的函数调用真跑进一个沙盒,拿沙盒对象的属性做 diff」。这是软件测试从 mock 换成 integration test 的那次古老转弯,搬到 Agent 评测里再走了一遍。

我读 v1/v2 时觉得 BFCL 是一个”格式对不对”的榜——生成的 API 调用能不能 parse、参数键值对能不能和标注对上。看到 v3 引入多轮的第一反应也是”哦,加轨迹匹配了吧”,直到把 multi_turn_checker.py 拉下来对着看:一半判据在 diff 对象属性,一半在做 response 的无序子集匹配,还有一段更严格的顺序判据被作者亲手注释掉了。那段注释掉的代码是这次读源码最大的收获——它把这次口径切换的理由,直接写在了未提交的分支上。

名词速查

深度文默认在导语后放这张表;这些词后文会反复出现。

术语一句话解释
function calling让模型输出结构化的函数调用(名字 + JSON 参数),由外部 runtime 执行,再把结果喂回模型
AST 匹配把模型输出的调用 parse 成语法树,和一个”标准答案”树在结构上比对。BFCL v1/v2 的默认判据
状态评测 (state-based eval)让模型的调用真跑进后端沙盒,再比对沙盒对象的属性——不看走了什么路,看走完之后世界长什么样
响应评测 (response-based eval)状态之外的兜底:对读操作(不改状态)看返回值集合是否覆盖标注答案
subset / 子集匹配模型的调用集合⊇标注答案集合就算过——允许模型额外调、允许换顺序,只要该做的都做了
多轮 (multi-turn)用户和模型多次往返;每一轮内部还可能”多步”(模型自己连续调用多个函数)
initial_config每条测试题在评测开始前灌到后端的初始状态(文件系统树、tweet 表、订单表……),定义了这局的世界
involved_classes这条题用到的沙盒后端类。BFCL v3 有 8 个:文件系统、交易机器人、订机票、车控、发帖、消息、工单、数学
τ-benchAnthropic 22 年那套多轮工具评测,也走状态比对,是这条路线的早期同行者。见站内τ-bench 姊妹地图

一句话主线

BFCL v3 之所以要换口径,是因为多轮任务在同一个目标下天然存在多条合法路径这条约束——任何把标注答案写死成一条轨迹的判据,都会把探索、纠错、冗余但正确的操作全部误判成错误。BFCL v1/v2 的 AST 匹配之所以能撑住,是因为单轮 function calling 里”正确轨迹”就只有一条;进入多轮,这个前提消失。

反事实测试:如果这个约束不存在——比如所有多轮题目都能被压缩成”唯一正确轨迹”——BFCL v3 大可以继续用 AST 匹配,题库扩容就行了。但 200 条基础多轮里我扫过的第一条 multi_turn_base_0(GorillaFileSystem 上的四轮操作:创目录 → 移文件 → grep → sort → diff)已经不可能只有一条走法。判据必须换轴,不然分数只是在惩罚”看起来不像标准答案的正确做法”。

门槛铺垫:从 BFCL v1 讲起,你才能看懂 v3 换了什么

如果你熟悉 v1/v2 可以跳过这节。铺垫不是注水,是为下节的对比留地基。

BFCL v1(2024 年初) 想解决的问题很干净:给模型一堆 API 文档,看它能不能生成能跑的调用。评测方式两档——

  1. AST 评测:把模型输出的调用 parse 成 AST,和标注答案 AST 逐字段比。参数键值对集合相等、简单表达式(2+3)按语义等价处理、多函数调用集合无序匹配。
  2. 可执行评测:少数题跑一遍真实 API,看返回码。

BFCL v2(24 年中) 加了 “live” 分区——用户贡献的真实场景题,补上 v1 单纯合成数据的偏差。评测方式没变,还是 AST + 可执行。

到 v2 为止,BFCL 测的都是单次调用的形状对不对。每道题的答案空间几乎唯一——比如”查上海邮编”,标答就是 get_zipcode_by_city(city="Shanghai"),你写成 get_zip_code(city="Shanghai") 或者 get_zipcode_by_city(城市="Shanghai") 就是错。这个前提下 AST 匹配非常合理。

v3 想加多轮,前提就崩了。多轮题目的目标是”用 GorillaFileSystem 把 final_report.pdf 挪到 temp/,然后 grep ‘budget analysis’”:

  • 我可以先 mkdir temp/mv,也可以先 mv(自动创目录)——两条路径达到同一状态。
  • 我可以先 pwd 探一下当前目录再动手,也可以直接开干——多一次调用不代表错。
  • 我可以先 grep 定位再 sort,也可以先 cat 全文再肉眼扫——同一结果多种手段。
  • 中途 rm 错了文件再 cp 恢复,如果最终状态对,也应该算过。

如果继续用 AST 匹配一条标准轨迹,以上四种情况全部会被打成”错”。你会系统性地把善于探索、善于纠错的模型排在后面——这正是 agent 评测最不该干的事。

根本约束和一次换轴

一句话:当同一目标存在多条合法路径,评测就不能盯路径,只能盯终点。这是从软件测试搬来的老经验——mock-based 测试(interaction testing)看”函数按什么顺序被调用”,integration testing 看”跑完之后数据库/文件系统长什么样”。BFCL v3 是把这个二分复刻进 agent 评测里。

反事实里最朴素的做法先崩在哪儿?我在读源码时构造了这么一个用例:标注答案是 [mkdir("temp"), mv("final_report.pdf", "temp/")],模型输出的是 [mv("final_report.pdf", "temp/")](它假设 mv 自动创目录,这是合理假设)。AST 匹配立刻报错——集合不等,少一个 mkdir。但如果我在沙盒里跑一遍,mv 真的把目录建出来了,最终状态和标答完全一样。这就是 AST 匹配在多轮场景下的第一个数量级失效:假阴率会随轨迹长度指数上升,因为每一步都可能有等价替代。

共同祖先值得挂一下——软件工程里 Kent Beck、Martin Fowler 那批人在 2000 年前后就在讨论 “mockist vs classicist”(Mocks Aren’t Stubs, 2007):Mockist 派用 mock 检查交互轨迹,Classicist 派用真依赖检查最终状态。行业最后收敛到”单元测试用 mock、集成测试用真状态”,因为真实系统的正确路径不唯一。BFCL v3 到 v2 的评测差异,和 mockist → classicist 那一次是同构的。

源码窥探:三个判据、被删掉的第四个

打开 multi_turn_checker.py(全文 314 行),核心是三个函数,按调用顺序:

# 伪代码,对应 multi_turn_checker.py 的 multi_turn_checker() 主干
# (原文 100+ 行,含错误处理、初始化,这里只留决策骨架)

for turn_index, gt_calls in enumerate(ground_truth_list):
    # 1) 把模型这一轮的所有函数调用真跑进沙盒
    model_instances = execute_multi_turn_func_call(
        func_call_list=model_response_list[turn_index],
        initial_config=initial_config,       # 这一局的初始世界
        involved_classes=involved_classes,   # 用到的后端类
    )
    # 2) 同样跑一遍标注答案,拿到 ground_truth_instances
    ground_truth_instances = execute_multi_turn_func_call(gt_calls, ...)

    # 3) 判据一:状态比对——遍历每个后端类的实例,diff 属性
    if not state_checker(model_instances, ground_truth_instances):
        return "fail: instance_state_mismatch"

    # 4) 判据二:响应比对——把模型这一轮和之前所有轮的返回值合起来,
    #    检查标注答案的返回值集合是不是其无序子集
    if not response_checker(all_turn_model_execution_results,
                             gt_execution_results, turn_index):
        return "fail: execution_response_mismatch"

    # # 5) 判据三:方法调用顺序——已被作者注释掉,见下文
    # if not method_invoke_order_checker(model_instances, ground_truth_instances):
    #     return "fail: method_invoke_order_mismatch"

三个判据的分工非常清晰:

判据一:state_checker(定义在 143 行)。真跑之后,对每个 involved_classes 里的实例做 vars(),逐属性比对。文件系统类 GorillaFileSystem 就 diff 目录树结构;订机票类 TravelBooking 就 diff 预订表。哪个属性不等,报 instance_state_mismatch。这是主力判据,治所有”改状态”的操作。

判据二:response_checker(163 行)。有些函数是纯读——get_current_directorylist_filesget_zipcode_by_city——不改状态,state_checker 抓不到。这时判据二上:把模型截止到这一轮的所有返回值汇成一个列表,标答本轮返回值必须是它的无序子集。注意”截止到这一轮的所有返回值”这个设计——意味着你可以在前几轮就把这个信息读到,后面不用再读一遍,这是有意留给探索型模型的空间。

判据三(死掉的那个):method_invoke_order_checker(定义在 224 行)。这段代码定义完整、逻辑清晰:检查模型实例调用方法的顺序,是标注答案调用顺序的有序子序列——模型可以多调,但不能漏、不能颠倒。你猜怎么着——它的调用点在主循环里被注释掉了(122-127 行)。作者留了一段注释说这个判据”目前只检查方法名不检查参数”。

这段被注释掉的代码是这次读源码的最强证据。它告诉你:

  1. 作者一开始想要更严的判据——不只是终点对,过程顺序也要对。
  2. 落地试了之后放弃了。原因大概率是误杀太高:多轮里”该按什么顺序调用”经常有多解。
  3. 但代码没删,留在源码里当”设计过、拒绝了”的化石。

这是本次的第一条一手结论:BFCL v3 的评测口径不是一次到位的,是一次从 AST 匹配 → 更严的 order 检查 → 最终收敛到 state + response subset 的收缩。作者的路径你直接就能看见。

200 × 4 条题目的实盘核对

Berkeley 博客说 v3 加了 5 类多轮题目,共 1000 条:base(200)+ miss_func(200)+ miss_param(200)+ long_context(200)+ composite(200)。我把数据目录拉下来数了一遍:

# 我实际跑的命令(替换成 curl 版,方便你也跑一次):
for f in multi_turn_base multi_turn_miss_func multi_turn_miss_param multi_turn_long_context; do
  n=$(curl -sL "https://raw.githubusercontent.com/ShishirPatil/gorilla/main/berkeley-function-call-leaderboard/bfcl_eval/data/BFCL_v4_${f}.json" | wc -l)
  echo "$f: $n 行(每行一条题)"
done
# 输出:
# multi_turn_base: 200
# multi_turn_miss_func: 200
# multi_turn_miss_param: 200
# multi_turn_long_context: 200

四类各 200 条,和博客一致。但 BFCL_v4_multi_turn_composite.json 不存在。可 multi_turn_checker.py:46 里还写着 long_context=("long_context" in test_category or "composite" in test_category)——代码留着钩子,数据没跟上。

这算什么?第二条一手结论:Berkeley 博客里说的 “composite(200 条,把三种增强揉在一起)” 这一类事实上未在公开数据集里发布。可能是当时的开发计划,可能因为标注太贵砍掉,也可能后来演化成了 v4 的其他类(memory / web_search)。读别人的博客要拿源码/数据对一遍,博客描述的能力和实际能跑的能力经常差一节。

顺便看一下四类题目在判据上的分工——这才是真的干货:

类别场景关键判据想测什么
base完整信息、正常多轮state + response基础的多轮 tool use 能不能跑通
miss_param用户消息缺关键参数irrelevance:该轮 ground truth 为空,模型也必须空调用模型该不该主动追问,还是硬编造参数
miss_func需要的函数不在工具列表irrelevance + 后续轮补齐工具后的 state模型该不该识别”没工具”,而不是硬凑
long_context沙盒里塞几百个无关文件/几千条无关记录state + response(阈值不变)长上下文里能不能保持正确

miss_parammiss_func 用的是 multi_turn_irrelevance_checker(133 行):这一轮 ground truth 是空列表,那模型的输出也必须为空——不能瞎调。“该问问题时不能瞎调” 被形式化成了 “irrelevance 判据”,非常干净。

那几个真实的翻车样式

Berkeley 博客举了三种典型错误。我把它们和上面的判据分工挂一挂,你就能看出每种错误落在哪个判据上翻车:

  1. 隐式动作不做:用户说”加最省钱的油”,模型直接 fillFuelTank(50)——没先 getFuelLevel() 看当前存量。翻在判据一(state):最终油箱状态和标答不一致(标答会先查后加,只加差额)。这是”探索前置”能力缺失的直接体现。

  2. 状态误判:用户说”在 alex 目录下建 alex 目录”,模型没先 pwdmkdir alex,结果建到了错误层级。翻在判据一:目录树结构不匹配。不可逆操作的可怕之处在这里露头——如果这是 rm 而不是 mkdir,评测挂了事小,生产环境挂了事大。

  3. 过度规划:系统已认证,模型还硬调 authenticate()。翻在判据一 + 二:多出的 auth 调用改了 session 状态(判据一),或者返回了不该返回的东西(判据二)。这条最有意思——过度谨慎和过度粗心在 state-based 评测里都会翻车,判据没有偏向哪一头。

三条错误合起来看:BFCL v3 的判据实际上在训练模型学会一件事——先看世界的状态,再决定动作。这个诉求和 ReAct 论文里那个”Reason + Act 交替”的模式是自洽的,只是 BFCL 用评测的方式把它变成了硬约束。

我做 Veral 时能带走的三件事

我在做 Veral 评测平台 时挑评测方案挑过一阵。读完 BFCL v3 源码,我要把这三条写进内部选型文档:

第一,判据分层。 单一判据吃不下所有函数——写操作用 state diff、读操作用 response subset、行为顺序(如果真的重要)单独加一个 optional order check。BFCL v3 把三层拆得干净,直接可以抄它的接口分工。

第二,判据本身要留改造空间。 BFCL v3 的源码里那段注释掉的 method_invoke_order_checker 就是最好的证据——判据不是一次性写死的,而是随着测评经验不断收缩边界。上一篇《先测判据,再测 Agent》里我用作弊脚本给判据体检,BFCL 用”注释掉但不删”给判据留出历史。都是承认判据是活的这个事实。

第三,initial_config 的价值被低估。 BFCL 每条题目都自带一份 initial_config——一个能被沙盒直接加载的世界初值。这个字段的价值不只是”给评测用”,更是让每一条测试题变成一个可复现的最小场景。业务评测经常败在环境不一致(每个人跑一次结果不同),BFCL 用 JSON 里嵌套的初值定义把这个问题从根上解掉了。

「诚实的提醒」段

  • 我没跑过完整的 BFCL v3 评测流水线——环境部署、模型接入、跑完 1000 条题目要花一台机器跑一天,本次没做。本文所有数字要么来自我 wc -l 数出来的样本数,要么来自 Berkeley 博客/源码,分类清楚。
  • Composite 那类未发布的判断是我全仓检索的结论(仓库根搜过 composite,只在 checker 代码里出现,数据目录里没有对应 JSON)。有可能仓库有历史 tag 里存过,我没翻 git log 追溯。
  • 关于 method_invoke_order_checker 被注释的具体历史,我看的是当前 main 分支的静态代码。如果你想追溯它被注释的具体 commit,可以 git log -L :method_invoke_order_checker:multi_turn_checker.py 一句话拿到时间线——一条命令,大约 30 秒。这条追溯我留给了对判据演化最感兴趣的读者做。

小结

三行压缩:

  • 根本约束:多轮 agent 任务同一目标有多路径 → 评测口径不能盯轨迹,只能盯终点。
  • 崩点:AST 匹配在多轮里假阴率随长度指数上升——每一步都可能有等价替代,轨迹匹配就是在惩罚”看起来不像标答的正确做法”。
  • 可带走:判据要分层(state / response / order),要给自己留可撤回的空间(注释而不是删除),要把 initial_config 当一等公民而不是附庸。

5 分钟第一步

你如果只想验证”BFCL v3 的判据真的在做状态 diff”,拿这条命令:

curl -sL https://raw.githubusercontent.com/ShishirPatil/gorilla/main/berkeley-function-call-leaderboard/bfcl_eval/eval_checker/multi_turn_eval/multi_turn_checker.py \
  | sed -n '100,145p'

看第 104-107 行的 state_checker 调用、118-121 行的 response_checker 调用、122-127 行被注释掉的 order checker。三段代码 40 行看完,这次口径切换的完整决策链就在你眼前。用时不超过 5 分钟,只需要有 curl

参考来源

工程实践:

arXiv 论文:

站内回链: