AI测试-04-LLM Function Calling在测试工具里怎么设计输入输出和容错

在 AI 测试平台里,Function Calling 很容易被误解成一个“把自然语言转成工具调用”的轻量能力。

如果只是做 Demo,这么理解问题不大。
但只要它进入测试工具,要求就会立刻变化:

  • 输入不能只是一段模糊提示词,而要变成结构化任务上下文
  • 输出不能只看模型有没有“说对”,而要看参数是否可执行、可审计、可恢复
  • 调用失败不能简单重试,而要先判断失败类型和恢复边界
  • 工具执行结果不能只回给模型,还要沉淀进证据链和结果链

也就是说,测试工具里的 Function Calling 不是一个“模型能力展示点”,而是一条工程链路。

这篇文章只聚焦这条链路最容易写坏的几块:

  • 输入怎么建模
  • 输出怎么约束
  • 参数校验放在哪里
  • 重试与容错怎么设计
  • 哪些失败该交给模型,哪些失败必须由工具层兜底

一、先把边界说清:测试工具里的 Function Calling 不等于聊天助手里的工具调用

聊天助手场景里,模型调用工具通常只需要回答两个问题:

  • 要不要调工具
  • 调哪个工具

但测试工具里的要求更严格,因为工具调用往往会直接影响:

  • 测试环境
  • 数据状态
  • 执行顺序
  • 报告结果
  • 证据留存

例如一个 AI 测试代理要做“登录后创建订单并校验回写结果”,背后可能涉及:

  • 打开环境
  • 获取账号
  • 构造测试数据
  • 调接口
  • 查数据库
  • 截图
  • 拉日志
  • 生成报告片段

这时 Function Calling 如果只按“模型说了一个函数名,程序就照着执行”的方式落地,风险会非常高:

  • 参数可能不完整
  • 参数可能不合法
  • 调用顺序可能错误
  • 失败后可能重复写数据
  • 成功和失败边界可能不清

所以在测试工具里,更合适的理解是:

Function Calling 是一个带约束的任务编排入口,而不是一个放任模型自由发挥的工具分发器。

二、先设计分层,不要先设计函数表

Function Calling 最常见的一个误区,是先把工具清单铺出来:

  • open_page
  • click_element
  • call_api
  • query_db
  • capture_screenshot
  • assert_text

看起来很完整,但很快就会遇到一个问题:

工具很多,调用很乱,失败很难收。

更稳的做法不是先列函数,而是先拆分层。

1. 任务理解层

这一层负责把外部输入转成可执行上下文,例如:

  • 当前任务目标
  • 所属环境
  • 允许使用的工具范围
  • 当前步骤号
  • 已有中间结果
  • 风险等级

这一层不负责执行,只负责把“模糊需求”收成“结构化任务”。

2. 调用规划层

这一层负责决定:

  • 当前这一步是否需要调用工具
  • 可以调用哪个工具
  • 参数从哪里来
  • 这次调用是不是幂等动作
  • 是否需要先做前置校验

这层的重点不是让模型自由规划很多步,而是把允许调用的动作收在受控范围内。

3. 参数约束层

这一层负责:

  • 参数是否缺失
  • 类型是否正确
  • 枚举值是否合法
  • 是否越权
  • 是否命中环境限制
  • 是否会破坏已有状态

这一层必须独立于模型输出存在,不能把参数正确性寄托在模型“自己会写对”。

4. 工具执行层

这一层才真正执行:

  • HTTP 请求
  • UI 操作
  • 数据查询
  • 文件读写
  • 取证动作

它的职责是稳定执行和标准化返回,而不是推理。

5. 结果收口层

这一层负责把执行结果统一成结构化输出,例如:

  • 是否成功
  • 失败类型
  • 重试建议
  • 证据引用
  • 对后续步骤的影响

如果没有这一层,模型拿到的将是一堆杂乱日志,后续链路会越来越脆。

三、输入设计的关键,不是“提示词写得丰富”,而是“上下文是否足够约束”

测试工具里最常见的失败,不是模型不会调用,而是输入把边界给丢了。

一个可执行的输入,至少要包含 5 类信息。

1. 任务目标

不要只给一句:

帮我执行订单创建测试。

更可执行的输入应该至少带上:

  • 业务目标:创建订单并确认支付状态为待支付
  • 入口类型:API / UI / 混合
  • 成功判定:接口返回 200 且数据库生成订单记录
  • 禁止动作:不得调用删除真实订单的接口

2. 环境上下文

例如:

  • 环境标识:test-a
  • 租户:mall-regression
  • 账号池:buyer-low-risk
  • 可访问域名或 base URL
  • 可调用的数据源范围

如果这层缺失,模型很容易出现:

  • 打错环境
  • 调错租户
  • 用错账号
  • 查错库表

3. 当前状态

Function Calling 不能假设“每次调用都从零开始”。

实际链路里经常需要补充:

  • 已完成步骤
  • 上一步输出
  • 当前会话 ID
  • 当前页面或资源状态
  • 已保留的证据位置

这类上下文决定了后续是继续执行、补执行,还是需要回滚。

4. 工具白名单

不是所有工具都应该暴露给当前任务。

例如一个只做查询验证的任务,工具白名单可以限制为:

  • call_readonly_api
  • query_readonly_db
  • capture_screenshot
  • collect_log_excerpt

如果把写操作工具也一起暴露,模型很可能在恢复阶段多做一步,直接把现场改掉。

5. 输出契约

输入里就应该明确告诉模型:

  • 这次工具调用希望返回什么结构
  • 哪些字段必填
  • 哪些字段失败时也必须返回
  • 哪些结果会被后续步骤继续消费

不把这层前置,后面就会在输出解析阶段补锅。

四、输出设计的关键,不是“能解析 JSON”,而是“能不能继续执行”

很多实现把 Function Calling 的输出要求简化成:

  • 函数名正确
  • 参数能被 JSON 解析

这远远不够。

在测试工具里,真正有价值的输出需要同时满足 4 个条件:

  • 可执行
  • 可校验
  • 可追踪
  • 可恢复

一个更稳的工具返回结构,至少应该包含:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
"success": false,
"error_type": "PARAM_INVALID",
"retryable": false,
"tool_name": "query_order_api",
"request_id": "fc-20260130-00128",
"normalized_args": {
"order_id": "A20260130001",
"env": "test-a"
},
"data": null,
"evidence": [
{
"type": "log",
"ref": "logs/order-api/fc-20260130-00128.log"
}
],
"message": "order_id format mismatch",
"next_action": "abort_and_request_new_id"
}

这里面真正重要的不是字段多,而是收口清晰:

  • success 给流程判断
  • error_type 给失败分类
  • retryable 给恢复策略
  • normalized_args 给审计和复现
  • evidence 给证据链
  • next_action 给后续编排

如果只返回:

1
{"result":"failed"}

那后面无论是模型、调度器还是报告系统,都无法稳定判断下一步应该做什么。

五、参数约束不能只靠 JSON Schema,还要补业务约束和执行约束

很多工具链在接入 Function Calling 时,会先做一层 schema 校验。
这一步必须做,但只做这一步不够。

更稳的参数约束通常至少分 3 层。

1. 结构约束

这一层回答:

  • 字段是否存在
  • 类型是否正确
  • 枚举值是否在允许范围
  • 日期、金额、分页参数格式是否符合要求

这是最基础的一层,适合用 schema 完成。

2. 业务约束

这一层回答:

  • 当前任务是否允许这个动作
  • 这个账号是否有权限
  • 这个资源是否属于当前租户
  • 这个 case 是否允许写操作
  • 这个参数组合是否会触发危险行为

例如:

  • 查询接口允许 order_id
  • 但退款接口必须校验订单状态是否为已支付
  • 数据清理工具只能在沙箱环境调用

这些约束不该交给模型判断,而该交给工具层。

3. 执行约束

这一层回答:

  • 当前系统状态是否允许执行
  • 依赖资源是否可用
  • 是否与前一步冲突
  • 是否已超出重试次数
  • 是否触发熔断或静默窗口

这层很容易被忽略,但它恰恰决定链路是不是可控。

例如一个 create_user 工具,即使参数正确,也不代表现在就可以执行:

  • 当前环境账号池可能已耗尽
  • 同租户可能已有重复用户
  • 当前压测窗口可能禁止创建新数据

六、最小执行骨架:Function Calling 在测试工具里至少要有这 8 步

如果要把这条链路做成真正可复用能力,最小执行骨架可以收成下面 8 步。

1. 组装任务上下文

把任务目标、环境、账号、证据目录、步骤号和工具白名单组装好。

2. 请求模型生成调用意图

让模型只返回:

  • 是否调用工具
  • 调哪个工具
  • 参数候选值

不要在这一层放任模型直接规划很长的多步链路。

3. 做参数标准化

例如:

  • 字段名统一
  • 枚举值归一
  • 日期格式统一
  • 空字符串转空值

这一步可以把模型输出里的轻微格式噪声提前收掉。

4. 做结构校验和业务校验

结构校验不过,直接拒绝执行。
业务校验不过,返回明确失败类型,不进入工具层。

5. 执行工具

执行时必须带:

  • request id
  • timeout
  • trace id
  • evidence path
  • tool version

否则问题一多就很难定位是哪次调用出了问题。

6. 归一化结果

无论是 HTTP 结果、浏览器动作还是数据库查询,都收成统一结果结构。

7. 判断是否需要重试或降级

不要一失败就让模型重新想一遍。
先根据失败类型判断:

  • 参数错误:不重试
  • 超时:可重试
  • 环境不可用:转降级
  • 工具 bug:终止并上报

8. 写回状态和证据

把这次调用的:

  • 输入
  • 标准化参数
  • 工具返回
  • 证据引用
  • 重试次数
  • 最终状态

一起落到任务记录里,后面报告和排障才有基础。

七、失败分类要先做,不然重试链路很容易写歪

Function Calling 最容易失控的地方不是第一次失败,而是失败后的第二次动作。

如果失败没有分类,系统最容易出现三种坏结果:

  • 不该重试的错误被重试
  • 该重试的错误被直接终止
  • 模型为了“完成任务”擅自换了工具或改了参数

更实用的失败分类,通常至少分成下面 5 类。

1. 参数类失败

例如:

  • 缺字段
  • 类型错误
  • 非法枚举值
  • 不满足业务前置条件

这类失败通常不应该自动重试。
因为重试不会改变输入本身。

2. 工具可用性失败

例如:

  • 工具超时
  • 依赖服务 502
  • 浏览器实例失联
  • 数据库连接池耗尽

这类失败通常可以按策略做有限重试,但要带指数退避和上限。

3. 环境状态失败

例如:

  • 测试环境未部署完成
  • 账号池为空
  • 租户冻结
  • 配置尚未生效

这类失败更适合转成等待、切换环境或终止任务,而不是继续压重试。

4. 结果不确定失败

例如:

  • 请求超时,但服务端可能已落库
  • 页面卡死,但后端订单可能已创建
  • 回写接口失败,但主流程可能已完成

这类失败最危险,因为不能直接重复执行写操作。
更稳的做法是先查状态,再决定补执行还是终止。

5. 平台内部失败

例如:

  • 结果归一化器崩溃
  • 证据写盘失败
  • 状态机写入异常

这类失败不该继续交给模型恢复,而应该由平台层直接接管。

八、重试不是多调几次,而是“按失败类型选择恢复动作”

在测试工具里,重试策略至少应该区分四种动作。

1. 同参重试

适合:

  • 网络抖动
  • 短时超时
  • 短暂 5xx

要求:

  • 工具幂等
  • 调用次数受控
  • 每次重试都要记录

2. 补充证据后重试

适合:

  • 执行结果不确定
  • 调用前后状态不清晰

例如先加一步:

  • 查询订单状态
  • 查页面是否已跳转
  • 拉取接口 trace

确认现场后再决定是否重试。

3. 切换工具或降级执行

适合:

  • UI 操作失败但接口可验证
  • 主数据源超时但只读副本可查
  • 浏览器证据失败但日志仍可收集

这类降级要在平台层预先定义,不应该由模型临场发明。

4. 终止并上报

适合:

  • 参数错误
  • 越权风险
  • 危险写操作
  • 连续失败达到上限

终止不是失败设计不够,而是为了保护环境和结果可信度。

九、常见坑不在模型“幻觉”,而在工程链路没有收口

1. 把工具函数设计得过细

例如拆成:

  • click_login_button
  • fill_username
  • fill_password
  • fill_captcha

这样模型表面上可控,实际上编排会非常脆。
更合适的拆法是围绕业务动作和可验证边界定义工具。

2. 把工具函数设计得过粗

例如一个 complete_order_flow 同时负责:

  • 下单
  • 支付
  • 截图
  • 查库
  • 回写报告

这会让失败定位和恢复都变得困难。

3. 只校验 schema,不校验业务前置条件

参数类型都对,不代表动作就该执行。
在测试平台里,这类错误最容易直接污染环境。

4. 失败后直接把原始错误丢回模型

如果原始错误是长日志、大段 HTML 或异常堆栈,模型很容易抓不到重点。
更稳的做法是先归一化,再回传关键摘要和证据引用。

5. 没有区分“可重试失败”和“不可重试失败”

最后就会演变成所有失败都重复 3 次,看起来很稳,实际只是在放大破坏。

6. 没有把标准化参数落盘

问题复盘时只能看到模型原始输出,看不到真正执行时的参数版本,排查成本会非常高。

7. 证据链和调用链脱节

工具执行成功了,但:

  • 日志找不到
  • 截图对不上
  • 报告里没有 request id
  • 调用和证据没有统一编号

这会让 Function Calling 变成一个“执行过,但说不清”的黑箱。

十、真实案例:一次订单校验任务为什么在“自动恢复”里把数据越写越乱

场景

一个 AI 测试工具需要完成下面这条链路:

  • 调用创建订单接口
  • 查询订单详情
  • 校验数据库中的订单状态
  • 输出报告结果

工具层已经接了 Function Calling,并暴露了:

  • create_order
  • query_order_api
  • query_order_db
  • attach_evidence

执行

模型先调用 create_order,返回超时。
平台没有立刻分类失败,而是简单按“失败重试”策略再次调用 create_order
第二次调用成功后,模型继续调用查询工具,并发现数据库里出现了两条相似订单记录。

现象

最终报告里出现了几个看似矛盾的结果:

  • 接口第一次调用超时
  • 第二次调用成功
  • 数据库中有两条订单
  • 页面侧只展示了一条
  • 回归结果被判成“状态不一致”

从表面看,像是业务接口幂等有问题。
但继续排查后发现,第一次调用虽然客户端超时,服务端其实已经写入成功。

排查

后续把证据链重新串起来时,暴露出了 4 个设计问题:

  • create_order 被错误标记为“可直接重试”
  • 失败分类里没有“结果不确定”这一类
  • 平台没有在重试前先执行状态确认动作
  • 工具返回结构里没有明确的 retryablenext_action

也就是说,真正的问题不在接口本身,而在 Function Calling 这一层把恢复动作设计错了。

修复

后面做了 4 个收口动作:

  • create_order 标记成“写操作且非盲重试工具”
  • 新增 UNCERTAIN_RESULT 失败类型
  • 规定写操作超时后必须先调 query_order_apiquery_order_db 做状态确认
  • 在统一结果结构里强制要求返回 retryablenext_actionnormalized_args

改完后,同类问题的恢复策略就从“超时后重复创建”变成了:

  1. 写操作超时
  2. 进入结果不确定分支
  3. 先做状态确认
  4. 已创建则补证据并继续
  5. 未创建才允许有限重试

这样链路才真正可控。

十一、把 Function Calling 接进测试工具时,真正要优先做的不是模型调优,而是调用治理

如果要给这类能力排优先级,更值得优先补的是下面几项:

  • 明确工具分层和边界
  • 定义统一输入上下文
  • 定义统一输出结构
  • 做三层参数约束
  • 建失败分类和恢复动作表
  • 把证据链和调用链绑定

只有这些基础收稳之后,模型能力提升才会真正转化成平台能力提升。

否则最常见的结果是:

  • Demo 很聪明
  • 现场很混乱
  • 报告说不清
  • 重试越做越危险

Function Calling 在测试工具里真正的价值,不在于“模型会不会调函数”,而在于:

模型发出的每一次工具调用,能不能被平台收成一次可执行、可校验、可恢复、可追踪的受控动作。