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_idreason,但 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_paramsdict[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=falsepassed=trueorder_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 测试框架的核心:用例分三类(正常/边界/对抗)、多模型对比执行、结果按函数选择和参数填充分别评估。三个关键经验:

  1. 先建 50 个核心用例覆盖主要功能,模型上线前必须全部通过。 这 50 个用例是底线,不是上限。
  2. 生产运行后每周从真实日志抽样补充新用例。 测试集要"活"起来,用户会不断发明你想不到的输入方式。
  3. 不同模型的失败模式要分类记录,形成知识库。 Claude 容易跳过确认直接调用、Gemini 倾向选第一个匹配、mini 版有参数幻觉——这些经验比测试框架本身更值钱。

最后一点给 Go 转型者的建议:Python 版用于快速验证,Go 版用于 CI 流水线。两套都保留,不要非此即彼。Python 写测试用例快、迭代快;Go 执行快、类型安全、和工程体系统一。我们的工作流是 Python 版写用例 → 导出 JSON → Go 版加载 JSON 执行并生成报告。各取所长。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐