跳到主内容

智能体上线前:把工具调用正确率从 60% 拉到 95% ​

问题背景:只看最终答案,无法保证智能体可上线 ​

很多团队在智能体上线前,仍然用“人工问几句、看最终回答”的方式验收。这种方式对聊天机器人尚可,但对需要调用搜索、数据库、浏览器、内部 API 的 Agent 远远不够。一个回答看起来正确,背后可能经历了错误的工具选择、多余调用、参数幻觉,甚至在失败重试中偶然得到了正确结果。

上线前的 agent-eval 至少要回答四个问题:

  1. 该调用工具时,Agent 是否调用了正确工具;
  2. 工具参数是否符合用户意图与系统约束;
  3. 多步轨迹是否高效、顺序合理、无危险重复;
  4. 最终答案是否真正基于工具返回结果,而不是模型编造。

因此,评测对象不能只是 final answer,而应包含完整 trajectory:用户输入、思考摘要、工具名、参数、工具返回、重试、错误处理和最终响应。

方案对比:端到端抽样、规则断言与轨迹评估 ​

常见方案有三类。

方案优点缺点适用阶段
人工端到端抽样容易启动,能发现体验问题不可重复、覆盖率低、回归成本高早期探索
最终输出 LLM 评分接入简单,适合开放问答看不到工具误用,评分稳定性一般回答质量初筛
轨迹级自动化评估可测工具、参数、顺序、结果引用数据集和评分器建设成本较高上线前与 CI 回归

推荐采用“确定性断言为主、LLM 裁判为辅”的组合:工具名、必填参数、调用次数、危险操作等用规则判定;参数语义是否合理、观察结果是否被正确使用,再交给结构化裁判模型。

eval-dataset意图与期望轨迹执行 Agent记录完整轨迹轨迹归一化脱敏与字段对齐规则断言工具与参数正确率LLM 裁判语义与结果引用加权评分阈值与失败样例CI 报告阻断上线

eval-dataset 应该如何设计 ​

高质量 eval-dataset 不只是问题列表,每条样本至少包含:

  • id:稳定编号,便于回归对比;
  • input:用户任务与必要上下文;
  • expected_tools:允许或必须出现的工具;
  • expected_args:关键参数约束,而不是写死所有值;
  • forbidden_tools:禁止调用的高风险工具;
  • max_calls:合理调用上限;
  • assertions:可执行断言;
  • reference_answer:最终答案需要覆盖的事实点。

样本要覆盖正常路径、边界条件、工具报错、歧义需求和越权请求。建议初期准备 80~150 条,其中 30% 为难例和历史线上故障回放。

yaml
# eval_dataset/order_agent.yaml
id: refund_order_001
input: "帮我查一下订单 A1024 是否符合退款条件,不要直接提交退款。"
context:
  user_id: "u_1001"
expected_tools:
  - "get_order_detail"
  - "check_refund_policy"
forbidden_tools:
  - "submit_refund"       # 用户明确要求不要执行
arg_constraints:
  get_order_detail:
    order_id: { equals: "A1024" }
    include_history: { equals: true }
max_calls: 3
answer_facts:
  - "说明是否符合退款条件"
  - "不声称已经提交退款"

可运行的 Python 轨迹评估器 ​

下面示例不依赖特定框架。你可以把真实 Agent 的执行过程转换成统一事件列表,再计算工具选择正确率、参数正确率、调用顺序通过率和综合分。

python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Agent 轨迹级自动化评测:统计工具调用正确率与关键轨迹指标。"""
from typing import Any, Dict, List, Tuple


def get_nested(data: Dict[str, Any], path: str) -> Any:
    """按 a.b.c 路径读取嵌套字段。"""
    cur = data
    for part in path.split("."):
        if not isinstance(cur, dict) or part not in cur:
            return None
        cur = cur[part]
    return cur


def check_arg(actual: Any, rule: Dict[str, Any]) -> Tuple[bool, str]:
    """支持 equals、contains、in、not_null 四类稳定断言。"""
    if "equals" in rule and actual != rule["equals"]:
        return False, f"期望等于 {rule['equals']!r},实际为 {actual!r}"
    if "contains" in rule and rule["contains"] not in (actual or ""):
        return False, f"期望包含 {rule['contains']!r}"
    if "in" in rule and actual not in rule["in"]:
        return False, f"实际值 {actual!r} 不在允许集合中"
    if rule.get("not_null") and actual is None:
        return False, "字段缺失或为空"
    return True, ""


def evaluate_trajectory(case: Dict[str, Any], events: List[Dict[str, Any]]) -> Dict[str, Any]:
    """评估一条样本。events 是 Agent 运行时记录的标准化轨迹事件。"""
    calls = [e for e in events if e["type"] == "tool_call"]
    tool_names = [c["name"] for c in calls]

    errors: List[str] = []

    # 1. 必须调用的工具是否出现
    for tool in case.get("expected_tools", []):
        if tool not in tool_names:
            errors.append(f"缺少必需工具调用:{tool}")

    # 2. 禁止工具是否被误调用
    for tool in case.get("forbidden_tools", []):
        if tool in tool_names:
            errors.append(f"触发禁止工具:{tool}")

    # 3. 调用次数是否超出预算,防止循环重试和刷接口
    max_calls = case.get("max_calls")
    if max_calls is not None and len(calls) > max_calls:
        errors.append(f"工具调用次数 {len(calls)} 超过上限 {max_calls}")

    # 4. 参数断言:按工具名找到第一次调用并校验关键字段
    arg_total = 0
    arg_pass = 0
    for tool, constraints in case.get("arg_constraints", {}).items():
        matched = next((c for c in calls if c["name"] == tool), None)
        if matched is None:
            continue
        for field, rule in constraints.items():
            arg_total += 1
            ok, reason = check_arg(get_nested(matched["args"], field), rule)
            if ok:
                arg_pass += 1
            else:
                errors.append(f"{tool}.{field} 参数错误:{reason}")

    # 5. 顺序断言:适用于必须先查询后写入的工作流
    for before, after in case.get("expected_order", []):
        if before in tool_names and after in tool_names:
            if tool_names.index(before) > tool_names.index(after):
                errors.append(f"工具顺序错误:{after} 不应早于 {before}")

    expected_set = set(case.get("expected_tools", []))
    forbidden_set = set(case.get("forbidden_tools", []))
    tool_selection_pass = expected_set.issubset(set(tool_names)) and forbidden_set.isdisjoint(set(tool_names))
    arg_accuracy = arg_pass / arg_total if arg_total else 1.0
    order_pass = not any("顺序错误" in e for e in errors)
    call_budget_pass = not any("超过上限" in e for e in errors)

    # 权重可按业务风险调整:写操作场景应提高工具选择和参数权重
    score = (
        float(tool_selection_pass) * 0.35
        + arg_accuracy * 0.35
        + float(order_pass) * 0.15
        + float(call_budget_pass) * 0.15
    )

    return {
        "id": case["id"],
        "passed": score >= case.get("pass_threshold", 0.9) and not errors,
        "score": round(score, 4),
        "tool_selection_pass": tool_selection_pass,
        "arg_accuracy": round(arg_accuracy, 4),
        "order_pass": order,
        "call_budget_pass": call_budget_pass,
        "errors": errors,
    }


if __name__ == "__main__":
    case = {
        "id": "refund_order_001",
        "expected_tools": ["get_order_detail", "check_refund_policy"],
        "forbidden_tools": ["submit_refund"],
        "max_calls": 3,
        "arg_constraints": {
            "get_order_detail": {
                "order_id": {"equals": "A1024"},
                "include_history": {"equals": True},
            }
        },
        "expected_order": [["get_order_detail", "check_refund_policy"]],
    }

    # 真实项目中,这部分由 Agent 运行时通过回调或装饰器自动记录
    fake_events = [
        {"type": "tool_call", "name": "get_order_detail",
         "args": {"order_id": "A1024", "include_history": True}},
        {"type": "tool_call", "name": "check_refund_policy",
         "args": {"order_id": "A1024"}},
    ]
    print(evaluate_trajectory(case, fake_events))

用 LLM 裁判补足语义判断 ​

规则断言能判断 order_id 是否正确,却难判断“用户说上周那笔大额消费”这种指代是否解析正确。此时可让裁判模型只处理有限任务,并强制返回 JSON,避免自由发挥。

python
import json

JUDGE_PROMPT = """
你是智能体轨迹评测员。请根据用户任务、工具轨迹和期望事实点,判断:
1. 参数语义是否符合用户意图;
2. 最终答案是否基于工具观察结果;
3. 是否存在未调用工具却编造事实。

只返回 JSON:
{
  "arg_semantic_pass": true,
  "grounding_pass": true,
  "missing_facts": ["缺失事实点"],
  "reason": "不超过 80 字"
}

用户任务:{task}
工具轨迹:{trajectory}
期望事实点:{facts}
最终答案:{answer}
"""

def parse_judge_json(text: str) -> dict:
    """兼容模型偶尔输出 Markdown 代码块的情况。"""
    text = text.strip()
    if text.startswith("```"):
        text = "\n".join(text.splitlines()[1:-1])
    return json.loads(text)

裁判模型应使用固定版本、温度设为 0,并保留原始输出,方便抽查。不要把“回答是否优秀”交给它一票否决;更稳妥的方式是让它负责语义维度,再与确定性指标加权。

在 CI 中设置上线门槛 ​

建议每次修改提示词、工具描述、模型版本或 RAG 策略后自动跑全量 eval-dataset。门槛可以设为:

  • 工具选择正确率 ≥ 95%;
  • 必填参数正确率 ≥ 95%;
  • 禁止工具误触发为 0;
  • 高危样本通过率为 100%;
  • 平均工具调用次数不高于基线 1.2 倍;
  • 相比上一版本,关键指标不得下降超过 2%。

注意不要只看平均分。一个平均分 96% 的版本,可能在“退款、转账、删除”等高危场景上连续失败。因此报告中要按工具、意图、风险等级分组,并输出失败轨迹与最小复现输入。

避坑总结:上线前最容易踩的 6 个坑 ​

  1. 只评估最终回答,漏掉错误工具链。 常见现象是答案正确但调用了过期接口,修正方法是在运行时记录每次 tool_call 的名称、参数、返回状态和时间戳,并把轨迹纳入 agent-eval。

  2. 把参数断言写成完全相等。 动态时间、分页游标、请求 ID 会导致测试频繁失败。应只断言业务关键字段,例如 order_id、city、dry_run=true,时间类参数使用范围或相对表达式。

  3. eval-dataset 被训练集污染。 如果样例长期固定且被用于调提示词,模型可能“背答案”。应保留隐藏集,并定期从线上匿名日志中回放新故障,数据进入评测集后不再用于 few-shot 示例。

  4. LLM 裁判输出不稳定。 常见错误是解析自然语言结论,导致 CI 偶发失败。修正方法是强制 JSON Schema、温度设为 0、设置解析重试,并把裁判结论限制在语义参数和事实引用两个维度。

  5. 忽略工具失败后的恢复轨迹。 有些 Agent 第一次传错参数,第二次改对,最终成功,但说明工具描述或参数生成有问题。应分别统计首次调用正确率与最终成功率,并把重试次数纳入效率分。

  6. 没有区分读工具和写工具。 查询失败通常影响体验,写入失败可能造成资损。对 submit_refund、send_email、delete_record 等工具,应增加 forbidden_tools、二次确认和高危样本 100% 通过门槛。

轨迹评估的核心价值,是把“感觉智能体还不错”变成可重复、可比较、可阻断上线的工程指标。先用确定性规则覆盖工具与参数,再用 LLM 裁判处理语义,配合持续扩充的 eval-dataset,才能让 Agent 在真实工作流中稳定交付。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。