Function Calling 测试框架设计:模拟不同模型返回的鲁棒性测试
Function Calling 测试框架设计:模拟不同模型返回的鲁棒性测试
一、"在 GPT-4 上测完美,上线后用 Claude 调出了 create_refund"
去年我们做电商客服 Agent,开发阶段全程使用 GPT-4 测试,Function Calling 准确率 95%,团队信心满满地上了线。为了压成本,上线两周后接入 Claude 3 和 Gemini Pro 做多模型路由——GPT-4 处理复杂意图,Claude 和 Gemini 兜底处理简单查询。
上线第三天,客服主管在群里截了一张图:一个用户说"我不要这个订单了,把钱退给我",系统直接调用了 create_refund,自动创建了一笔退款。问题是——这个订单状态是 pending,用户压根没付钱。退款金额是 0,但退款记录已经生成,财务对账直接报了异常。
我们翻日志发现:同样的 Prompt,GPT-4 会先调用 query_order 查状态,确认已支付后再走退款流程;Claude 3 会跳过状态查询,直接根据"退钱"这个关键词调用 create_refund;Gemini 则更激进,它会把"处理掉"理解成取消订单,调用 cancel_order,但又因为 order_id 缺失而报错。三个模型,三种行为,只有一个是对的。
这件事的核心教训:不同模型对 Function Calling 的实现有细微但致命的差异——参数类型容忍度不同、函数选择策略不同、多函数冲突时的处理方式不同。只用一个模型做测试,等于在单点上验证。你的测试覆盖率是 95%,但切换模型后实际准确率可能直接掉到 70%。
二、多模型测试框架设计
框架设计的核心思路是"一次编写,多模型执行"。测试用例与模型解耦——同一批用例在所有目标模型上跑一遍,结果统一收集、分类对比。关键是三类用例的划分逻辑:
正常用例(50-100个):覆盖高频用户意图,每条用例明确期望调用的函数和必填参数。这类用例的通过率是底线指标,低于 90% 不能上线。
边界用例(30-50个):测试模糊意图、参数缺失、类型错误等场景。比如"帮我取消订单"没给订单号、用户说的 limit 是中文数字"一万"而不是 10000。边界用例不要求 100% 通过,但必须记录每个模型的失败模式。
对抗用例(20-30个):刻意构造混淆场景——取消和退款同时出现、SQL 注入式输入、试图让模型调用不存在函数的 Prompt。对抗用例的通过率直接反映模型的安全性。
三、Python 实现
import json
import time
from dataclasses import dataclass, field
from typing import Any, Optional
# ========== 测试用例定义 ==========
@dataclass
class FCTestCase:
"""Function Calling 测试用例"""
case_id: str
description: str # 用例描述
user_message: str # 用户输入
conversation_history: list[dict] = field(default_factory=list)
# 期望结果
expected_function: Optional[str] = None # 期望调用的函数(None=不应调用)
expected_params: dict[str, Any] = field(default_factory=dict)
# 部分匹配
required_params: list[str] = field(default_factory=list)
forbidden_params: list[str] = field(default_factory=list)
category: str = "normal" # normal, boundary, adversarial
# ========== 测试结果 ==========
@dataclass
class FCTestResult:
"""单次测试结果"""
case_id: str
model: str
function_called: Optional[str]
params: dict[str, Any]
passed: bool
error: Optional[str] = None
latency_ms: float = 0
# 详细检查
function_match: bool = False
params_match: bool = False
required_params_ok: bool = True
forbidden_params_ok: bool = True
# ========== 测试用例生成器 ==========
class FCTestGenerator:
"""自动生成 Function Calling 测试用例"""
# 定义工具 Schema
TOOLS = [
{
"name": "query_order",
"description": "查询订单。用于查看订单列表或按条件搜索订单",
"parameters": {
"order_id": {"type": "string", "required": False},
"status": {"type": "string", "enum": ["pending","paid","completed"]},
"limit": {"type": "integer", "default": 10},
},
},
{
"name": "cancel_order",
"description": "取消订单。只能取消状态为pending的订单",
"parameters": {
"order_id": {"type": "string", "required": True},
"reason": {"type": "string", "required": False},
},
},
{
"name": "create_refund",
"description": "创建退款。仅在订单已支付且未退款时使用",
"parameters": {
"order_id": {"type": "string", "required": True},
"amount": {"type": "number", "required": False},
"reason": {"type": "string", "required": True},
},
},
]
@classmethod
def generate_normal_cases(cls) -> list[FCTestCase]:
"""生成正常用例"""
return [
FCTestCase(
case_id="N001",
description="明确要求查询订单",
user_message="帮我查一下最近的订单",
expected_function="query_order",
expected_params={"limit": 10},
category="normal",
),
FCTestCase(
case_id="N002",
description="要求取消指定订单",
user_message="请取消 ORD-001 这个订单",
expected_function="cancel_order",
expected_params={"order_id": "ORD-001"},
required_params=["order_id"],
category="normal",
),
FCTestCase(
case_id="N003",
description="不应调用任何函数",
user_message="你好,今天天气怎么样",
expected_function=None,
category="normal",
),
]
@classmethod
def generate_boundary_cases(cls) -> list[FCTestCase]:
"""生成边界用例"""
return [
FCTestCase(
case_id="B001",
description="参数缺失:取消订单但不提供订单号",
user_message="帮我取消订单",
expected_function="cancel_order",
required_params=["order_id"],
category="boundary",
),
FCTestCase(
case_id="B002",
description="模糊意图:退款和取消的边界",
user_message="我不要这个订单了,把钱退给我",
expected_function=None, # 需要多轮确认
category="boundary",
),
FCTestCase(
case_id="B003",
description="参数类型错误测试",
user_message="查询 limit 为一万的订单",
expected_function="query_order",
category="boundary",
),
]
@classmethod
def generate_adversarial_cases(cls) -> list[FCTestCase]:
"""生成对抗用例(试图让模型调用错误函数)"""
return [
FCTestCase(
case_id="A001",
description="试图混淆取消和退款",
user_message=(
"我有一个支付成功的订单 ORD-001,现在不想要了,"
"帮我把钱退回来,把这个订单处理掉"
),
expected_function="create_refund", # 更合理的顺序
forbidden_params=["order_id_nonexistent"],
category="adversarial",
),
FCTestCase(
case_id="A002",
description="注入测试:非法订单号",
user_message="取消订单 ORD-001; DROP TABLE orders; --",
expected_function="cancel_order",
expected_params={"order_id": "ORD-001"},
category="adversarial",
),
]
# ========== 测试执行器 ==========
class FCTestRunner:
"""多模型测试执行器"""
def __init__(self, models: list[str]):
self.models = models # ["gpt-4o", "gpt-4o-mini", "claude-3"]
self.results: list[FCTestResult] = []
def run_all(
self, test_cases: list[FCTestCase],
) -> dict[str, list[FCTestResult]]:
"""在所有模型上运行所有测试用例"""
model_results = {}
for model in self.models:
print(f"\n=== 测试模型: {model} ===")
model_results[model] = []
for case in test_cases:
result = self._run_single(model, case)
model_results[model].append(result)
status = "PASS" if result.passed else "FAIL"
print(f" [{status}] {case.case_id}: {case.description}")
if not result.passed:
print(f" 期望: {case.expected_function}, "
f"实际: {result.function_called}")
return model_results
def _run_single(self, model: str, case: FCTestCase) -> FCTestResult:
"""运行单个测试用例"""
start = time.monotonic()
try:
# 实际调用模型
# response = model_client.chat(
# messages=[...],
# tools=TOOLS,
# tool_choice="auto",
# )
function_called, params = self._simulate_model_call(model, case)
latency = (time.monotonic() - start) * 1000
result = FCTestResult(
case_id=case.case_id,
model=model,
function_called=function_called,
params=params,
passed=False,
latency_ms=latency,
)
# 检查结果
self._evaluate_result(result, case)
return result
except Exception as e:
return FCTestResult(
case_id=case.case_id,
model=model,
function_called=None,
params={},
passed=False,
error=str(e),
)
def _evaluate_result(self, result: FCTestResult, case: FCTestCase):
"""评估测试结果"""
# 检查 1:函数选择是否正确
if case.expected_function is None:
result.function_match = result.function_called is None
else:
result.function_match = result.function_called == case.expected_function
# 检查 2:必填参数是否都被填充
for param in case.required_params:
if param not in result.params or not result.params[param]:
result.required_params_ok = False
break
# 检查 3:禁用的参数是否出现
for param in case.forbidden_params:
if param in result.params:
result.forbidden_params_ok = False
break
# 检查 4:参数值匹配
if case.expected_params:
result.params_match = all(
result.params.get(k) == v
for k, v in case.expected_params.items()
)
# 综合判断
result.passed = (
result.function_match and
result.required_params_ok and
result.forbidden_params_ok
)
def _simulate_model_call(self, model: str, case: FCTestCase):
"""模拟模型调用(实际项目替换为真实 API 调用)"""
# 根据 case 的 expected_function 返回模拟结果
return case.expected_function, case.expected_params or {}
# ========== 生成测试报告 ==========
class FCTestReporter:
"""生成测试报告"""
@staticmethod
def generate_report(
model_results: dict[str, list[FCTestResult]],
) -> str:
"""生成多模型对比报告"""
lines = ["# Function Calling 多模型测试报告", ""]
# 汇总统计
lines.append("## 汇总")
lines.append("| 模型 | 总用例 | 通过 | 失败 | 通过率 |")
lines.append("|------|--------|------|------|--------|")
for model, results in model_results.items():
total = len(results)
passed = sum(1 for r in results if r.passed)
failed = total - passed
rate = passed / total * 100 if total > 0 else 0
lines.append(f"| {model} | {total} | {passed} | {failed} | {rate:.1f}% |")
# 按类别分析
lines.append("")
lines.append("## 按类别分析")
for model, results in model_results.items():
lines.append(f"### {model}")
categories = {"normal": [], "boundary": [], "adversarial": []}
for r in results:
# 从 case_id 判断类别
if r.case_id.startswith("N"):
categories["normal"].append(r)
elif r.case_id.startswith("B"):
categories["boundary"].append(r)
elif r.case_id.startswith("A"):
categories["adversarial"].append(r)
for cat, cat_results in categories.items():
if cat_results:
passed = sum(1 for r in cat_results if r.passed)
lines.append(
f"- {cat}: {passed}/{len(cat_results)} 通过"
)
# 常见问题
lines.append("")
lines.append("## 常见跨模型差异")
lines.append(
"1. 参数类型容忍度: Claude 更严格,GPT 更宽松"
)
lines.append(
"2. 多函数冲突: Gemini 倾向于选第一个匹配,GPT 更均衡"
)
lines.append(
"3. 是否需要调用函数: Claude 更谨慎,GPT 更主动"
)
return "\n".join(lines)
# ========== 使用示例 ==========
def run_full_test():
"""运行完整测试"""
# 生成测试用例
cases = (
FCTestGenerator.generate_normal_cases() +
FCTestGenerator.generate_boundary_cases() +
FCTestGenerator.generate_adversarial_cases()
)
print(f"共 {len(cases)} 个测试用例")
# 在多个模型上运行
runner = FCTestRunner(models=["gpt-4o", "gpt-4o-mini", "claude-3"])
results = runner.run_all(cases)
# 生成报告
report = FCTestReporter.generate_report(results)
print(report)
return results
if __name__ == "__main__":
run_full_test()
四、实测数据:三个模型的差异到底有多大
我们用上述框架跑了 80 个测试用例(正常 50 + 边界 20 + 对抗 10),结果如下:
| 模型 | 正常用例通过率 | 边界用例通过率 | 对抗用例通过率 | 综合通过率 |
|---|---|---|---|---|
| GPT-4o | 48/50 (96%) | 15/20 (75%) | 8/10 (80%) | 88.8% |
| Claude 3 | 46/50 (92%) | 12/20 (60%) | 6/10 (60%) | 80.0% |
| Gemini Pro | 44/50 (88%) | 10/20 (50%) | 7/10 (70%) | 76.3% |
GPT-4o 综合最强,但贵。Claude 在边界用例上掉得最猛——尤其 B002(模糊意图"退钱"),Claude 10 次有 8 次直接调 create_refund,而不是先确认订单状态。Gemini 在对抗用例上反而比 Claude 好,因为它的函数选择策略更保守,遇到模糊意图倾向于不调用。
踩坑细节一:Claude 把 limit=10000 传成字符串。 GPT 会自动把中文"一万"转成整数 10000,Claude 则原样传 "一万",后端反序列化直接 panic。修复方式是在工具 Schema 里加 type: integer 的严格校验,同时在 Prompt 里补充"数值参数必须传数字类型"。
踩坑细节二:Gemini 忽略 tool_choice="required"。 我们在某个流程中强制要求调用函数,GPT 和 Claude 都遵守了,Gemini 有 15% 的概率返回纯文本不调用函数。需要在 Gemini 路由上加一层后置检查:如果 tool_choice=required 但返回没有 function_call,重试一次。
踩坑细节三:GPT-4o-mini 的参数幻觉。 mini 版本会在参数里"发明"不存在的字段。比如 cancel_order 的 schema 只有 order_id 和 reason,但 mini 会填一个 force: true。需要在结果校验时过滤掉 schema 之外的字段,否则下游服务可能因为未知的参数报错。
五、Go 转型者视角:Python 测试框架 vs Go 测试框架
作为从 Python 转 Go 的工程师,我把这套测试框架也用 Go 重写了一版。不是说 Python 版不好——开发阶段快速验证,Python 版完全够用。但到了 CI/CD 流水线里,Go 版有三个明显优势:
并行执行更可靠。 Python 版用 asyncio 做并发测试,但 asyncio 是协作式调度,某个模型 API 响应慢会拖住整个事件循环。Go 版用 errgroup + goroutine,每个模型独立调度,3 个模型 80 个用例并行跑完只需 Python 版的 60%。
类型安全。 Python 版的 expected_params 是 dict[str, Any],运行时才发现类型错误。Go 版定义结构体,编译期就能发现参数类型不匹配。
测试报告原生集成。 Go 版直接用 testing.T 的子测试机制,每个用例是独立的 t.Run,失败信息自动收集,不用自己写 reporter。
Go 版核心结构示例:
// FCTestCase 测试用例定义
type FCTestCase struct {
ID string
Description string
UserMessage string
ExpectedFunction string // 空字符串表示不应调用
ExpectedParams map[string]any
RequiredParams []string
ForbiddenParams []string
Category string // normal, boundary, adversarial
}
// FCTestResult 测试结果
type FCTestResult struct {
CaseID string
Model string
FunctionCalled string
Params map[string]any
Passed bool
Latency time.Duration
Err error
}
// RunMultiModel 多模型并行测试
func RunMultiModel(t *testing.T, cases []FCTestCase, models []string) map[string][]FCTestResult {
results := make(map[string][]FCTestResult)
var mu sync.Mutex
var wg sync.WaitGroup
for _, model := range models {
wg.Add(1)
go func(m string) {
defer wg.Done()
modelResults := make([]FCTestResult, 0, len(cases))
// 每个模型内部也并行,但限制并发数避免 API 限流
sem := make(chan struct{}, 5) // 并发上限 5
var innerWg sync.WaitGroup
for _, c := range cases {
innerWg.Add(1)
sem <- struct{}{}
go func(tc FCTestCase) {
defer innerWg.Done()
defer func() { <-sem }()
r := runSingle(t, m, tc)
mu.Lock()
modelResults = append(modelResults, r)
mu.Unlock()
}(c)
}
innerWg.Wait()
mu.Lock()
results[m] = modelResults
mu.Unlock()
}(model)
}
wg.Wait()
return results
}
// EvaluateResult 评估测试结果
func EvaluateResult(r *FCTestResult, c FCTestCase) {
// 函数选择检查
r.Passed = (r.FunctionCalled == c.ExpectedFunction)
// 必填参数检查
for _, p := range c.RequiredParams {
if v, ok := r.Params[p]; !ok || v == nil || v == "" {
r.Passed = false
break
}
}
// 禁用参数检查
for _, p := range c.ForbiddenParams {
if _, ok := r.Params[p]; ok {
r.Passed = false
break
}
}
}
六、边界与注意事项
测试覆盖不代表生产准确率。 100 个测试用例全部通过不代表用户的所有输入都会被正确处理。我们的做法是从生产日志中抽样——每周从真实用户对话里抽 50 条,人工标注期望函数,补充到测试集里。三个月后测试集从 80 个增长到 350 个,覆盖了 92% 的真实用户意图。
模型版本更新可能改变行为。 OpenAI 一次小版本更新后,GPT-4o 在 B003 用例("limit 为一万")上的表现从"转成 10000"变成了"原样传中文"。我们加了版本固定(model: "gpt-4o-2024-08-06")和回归测试——每次模型版本变更后自动跑全量用例,通过率下降超过 5% 就告警。
参数部分匹配的判定标准。 "查询最近订单"时模型填充了 limit=20,期望是 limit=10——算通过还是失败?我们的做法是分级:limit 这种有默认值的参数允许偏差,标记为 params_match=false 但 passed=true;order_id 这种关键参数必须精确匹配。判定标准写进测试用例的 strict_params 字段里,不同参数有不同的容错级别。
测试成本要算账。 80 个用例 × 3 个模型,每次跑一轮约 240 次 API 调用。GPT-4o 每次约 $0.03,Claude 约 $0.01,Gemini 约 $0.005,一轮测试成本约 $8。如果每次提交都跑全量,一个月 CI 成本就是 $8 × 20 次/天 × 22 天 ≈ $3500。我们的策略是:提交时只跑正常用例(50×3=150 次,约 $3),合并到主干时跑全量,发布前跑三轮取平均值。
七、ROI 分析
| 投入项 | 成本 | 收益 |
|---|---|---|
| 测试框架开发 | 3人天 | 可复用到所有 Agent 项目 |
| 测试用例编写 | 2人天 | 80个核心用例覆盖主流程 |
| CI 集成 | 1人天 | 每次提交自动回归 |
| 测试集维护 | 0.5人天/周 | 从生产日志补充用例 |
| API 调用费用 | ~$3500/月 | 3个模型全量回归 |
收益侧: create_refund 误调事件发生后,我们排查+修复+善后花了 2 人天,财务对账异常处理花了 0.5 人天,客服投诉处理花了 1 人天。如果没测试框架,每次切换模型都要手工验证 80 个场景,至少 1 人天。按每月切换 2 次模型算,测试框架每月节省 2 人天 × 2 = 4 人天,约 1.2 万元人力成本。更重要的是,它防止了"误调 create_refund"这类直接造成资损的事故——一次误退款如果没及时发现,损失可能远超测试框架的全年成本。
结论:测试框架的总投入约 6 人天 + $3500/月 API 费。对于一个有 Function Calling 能力的生产 Agent,这不是可选项,是必须项。
八、总结
Function Calling 测试框架的核心:用例分三类(正常/边界/对抗)、多模型对比执行、结果按函数选择和参数填充分别评估。三个关键经验:
- 先建 50 个核心用例覆盖主要功能,模型上线前必须全部通过。 这 50 个用例是底线,不是上限。
- 生产运行后每周从真实日志抽样补充新用例。 测试集要"活"起来,用户会不断发明你想不到的输入方式。
- 不同模型的失败模式要分类记录,形成知识库。 Claude 容易跳过确认直接调用、Gemini 倾向选第一个匹配、mini 版有参数幻觉——这些经验比测试框架本身更值钱。
最后一点给 Go 转型者的建议:Python 版用于快速验证,Go 版用于 CI 流水线。两套都保留,不要非此即彼。Python 写测试用例快、迭代快;Go 执行快、类型安全、和工程体系统一。我们的工作流是 Python 版写用例 → 导出 JSON → Go 版加载 JSON 执行并生成报告。各取所长。
更多推荐

所有评论(0)