04 | 模型编造 API 参数,工具调用连环 500——Function Calling 契约测试
AI 测试开发面试高频题:"Function Calling / Tool Use 怎么测?"
工具调用是 Agent 的手脚,也是事故重灾区:模型会编造不存在的参数值,而下游 API 不会惯着它。这篇讲怎么在中间加一道契约防线。
一、真实场景:一个不存在的枚举值,烧掉一晚上 token
某物流公司的客服 Agent 上线了工具调用能力:用户问订单,Agent 自动调 query_order 查状态,符合条件再调 create_refund 发起退款。真实 API 的退款接口要求 status 字段必须是枚举值:pending | shipped | delivered。
某天晚上,用户问"我的订单三天没动了,帮我退款"。Agent 判断订单卡在 shipped 状态,发起退款调用——但它传的参数是 "status": "shipped_today"。一个它自己发明的枚举值。
API 返回 400。接下来发生的事才是真正的损失:错误信息被原样塞回给模型("Bad Request"),模型看不懂哪里错了,换了个姿势再试:"status": "已发货"、"status": "in_transit"……每一次都是编的,每一次都是 400。重试逻辑没有上限,这个死循环跑了四十多分钟,直到值班同学发现 token 消耗曲线异常才手动掐掉。一次本该 3 秒完成的退款请求,烧掉了平时一整天的调用预算。
复盘发现两个叠加的坑:一是工具描述是写在 prompt 里的一段自然语言("status 是订单状态"),没有严格 schema,模型只能猜;二是重试逻辑只判断"调用失败就重试",不看失败原因,也不限制次数。
二、思路:工具调用就是接口调用,先立契约再放行
传统接口测试里,参数合法性靠 schema 校验。Function Calling 也一样——模型生成的每次工具调用,都必须先过一道契约校验,合法才放行到真实 API,不合法就把结构化的错误喂回给模型让它自我纠正。 这道关卡叫工具网关。
三、核心代码:工具网关校验 + Mock 契约测试
第 1 步:用 Pydantic 定义工具契约,网关统一拦截
from enum import Enum
from pydantic import BaseModel, ValidationError
class OrderStatus(str, Enum):
pending = "pending"
shipped = "shipped"
delivered = "delivered"
class RefundParams(BaseModel):
order_id: str
status: OrderStatus # 枚举写死,模型编的值在这里就被拦住
reason: str
TOOL_MODELS = {
"create_refund": RefundParams}
def tool_gateway(name: str, raw_args: dict):
"""所有工具调用的唯一入口:先校验,再转发;非法参数不进真实 API"""
try:
params = TOOL_MODELS[name](**raw_args)
except ValidationError as e:
# 关键:把"哪里错了"结构化地告诉模型,给它自我纠正的机会
return {
"error": "invalid_params",
"detail": [{
"field": x["loc"][0], "msg": x["msg"]} for x in e.errors()]}
return call_real_api(name, params.model_dump())
注意 detail 的设计:不是丢一句 "Bad Request",而是明确告诉模型"status 字段取值必须是 pending/shipped/delivered 之一"。同样一次 400,喂回错误信息的质量决定了模型是"改对"还是"继续编"。
第 2 步:契约测试——Mock 掉真实 API,断言模型"开出的调用单"
测 Function Calling,核心不是测 API 返回什么,而是测模型决定调用什么、传了什么。用 Mock 工具层拦截并记录:
def test_refund_call_contract():
"""用户要求退款,Agent 发起的工具调用必须参数合法、一次到位"""
calls = []
with mock_tool_server(record=calls): # Mock 层拦截,不打真实 API
agent.run("订单 A1002 三天没物流更新了,帮我退款")
refund_calls = [c for c in calls if c["name"] == "create_refund"]
assert len(refund_calls) == 1, f"期望调用 1 次,实际 {len(refund_calls)} 次"
name, args = refund_calls[0]["name"], refund_calls[0]["args"]
RefundParams(**args) # 参数非法直接抛 ValidationError,用例失败
assert args["order_id"] == "A1002"
第 3 步:异常分支覆盖——工具返回错误时,模型该怎么办
故障的第二个坑就在这里。把工具的错误返回也纳入测试:
def test_tool_error_recovery():
"""工具返回 invalid_params 后,模型应修正参数重试,且总次数不超过 3"""
calls = []
with mock_tool_server(record=calls,
fail_first=1, # 第 1 次调用故意返回参数错误
fail_response={
"error": "invalid_params",
"detail": [{
"field": "status",
"msg": "取值须为 pending/shipped/delivered"}]}):
agent.run("订单 A1002 帮我退款")
assert len(calls) <= 3, "重试未收敛,存在死循环风险"
assert calls[-1]["args"]["status"] in {
"pending", "shipped", "delivered"}
这条用例盯的就是"连环 400":收到结构化错误后,模型第二次调用必须把枚举改对,而不是换个词继续编。
四、沉淀成方法:Function Calling 测试三层
| 层级 | 测什么 | 核心手段 |
|---|---|---|
| 参数契约层 | 模型会不会编参数 | Pydantic/JSON Schema 校验 + Mock 断言参数合法性 |
| 异常分支层 | 工具报错时会不会失控 | Mock 返回 4xx/5xx/超时,断言自我纠正、重试有上限 |
| 任务完成层 | 端到端目标达成吗 | 任务成功率 + 调用次数/成本统计 |
配套三条工程纪律:一是工具描述必须用严格 JSON Schema,枚举值、必填项、取值范围写死,自然语言描述"大概是个状态"就是埋雷;二是真实 API 永远藏在网关后面,模型的一切调用先过校验,测试环境全走 Mock;三是每次工具调用全量落日志(工具名 + 参数 + 返回),出问题时能像这次一样把完整的"作案过程"回放出来。
五、面试追问,你答得上来吗
- 模型为什么会编造参数?怎么降低概率?——答:根因是工具描述模糊 + 模型把参数生成当"续写"。手段按性价比排序:严格 JSON Schema 带枚举和描述、给每个枚举值加注释、开启各平台的 strict 工具调用模式、把校验错误结构化喂回给模型。注意这些都只是"降低概率",所以网关拦截这道硬防线不能省——校验是兜底,提示词优化是减少触发。
- 工具调用的异常分支和普通接口的异常测试有什么不同?——答:普通接口异常测试断的是"系统返回正确错误码";工具调用异常测试断的是模型的下一步行为——它是修正参数重试、换个工具、还是向用户求助?所以用例里要有行为序列断言和次数上限,防止"重试"变成"死循环烧钱"。
- 工具的接口本身升级了(加了必填字段),你怎么保证 Agent 不崩?——答:工具 schema 进版本管理,schema 变更必须跑两遍回归:契约测试套件验证校验逻辑跟上了,黄金任务集验证模型在新 schema 下还能正确调用——这和 prompt 变更是一个道理:工具 schema、prompt、模型是绑定的三元组,动任何一个都要回归。
下一篇预告:《Agent 半夜陷入死循环,一夜烧掉上万 token——轨迹测试与熔断》