AI 测试开发面试高频题:"测 Agent 和测普通接口有什么本质区别?"
普通接口测的是"一次调用对不对",Agent 测的是"一条决策链会不会失控"。这篇用一次半夜烧钱的真实事故,讲轨迹测试和熔断怎么设计。
一、真实事故:两个工具的结论打架,Agent 在中间荡了一夜秋千
某电商公司的售后 Agent 负责自动处理物流异常:先调 query_order 查订单状态,再调 check_logistics 核实物流,确认异常就创建工单。上线第一周风平浪静,第八天早上,财务同学看着 token 账单找上门:一晚上一个任务烧掉了平时一个月的量,粗算上万元。
拉出那个任务的完整调用日志,画面非常荒诞:
00:12 query_order → {"status": "物流异常"}
00:12 check_logistics → {"status": "正常"}
00:13 query_order → {"status": "物流异常"}
00:13 check_logistics → {"status": "正常"}
……(重复 1,700 多次,直到 07:40 被值班同学手动停掉)
两个工具的结论互相矛盾,模型每次都"认真"地想 reconcile 这个矛盾:信 A 就去问 B,信 B 就去问 A,永远对不上,永远不放弃。没有步数上限、没有预算上限、没有循环检测——三个保险一个都没装,于是它兢兢业业地荡了一夜秋千。
复盘结论很明确:Agent 的失控不是模型"变笨了",而是系统没给它的决策链设边界。 模型可以犹豫,系统必须兜底。
二、思路:给决策链做三件事——记录、检测、熔断
轨迹(trajectory)就是 Agent 的"作案录像":每一步打算调什么工具、传什么参数、观察到什么。有了录像才能做三件事:
- 记录:每步落盘,事后可回放;
- 检测:识别"重复调用"和"A-B-A-B 振荡"两类典型死循环;
- 熔断:步数、token 预算、墙钟时间,任何一条越线立即终止。
三、核心代码:轨迹记录 + 循环检测 + 三重熔断
第 1 步:轨迹记录与循环检测
import hashlib, time
from dataclasses import dataclass, field
def _h(x): # 参数/观察结果归一化取哈希,忽略 dict 顺序差异
return hashlib.md5(repr(sorted(x.items()) if isinstance(x, dict) else x)
.encode()).hexdigest()
@dataclass
class Trajectory:
steps: list = field(default_factory=list)
def record(self, tool, args, observation):
self.steps.append({
"tool": tool, "args": _h(args),
"obs": _h(observation), "ts": time.time()})
def detect_loop(self):
sig = [(s["tool"], s["args"], s["obs"]) for s in self.steps]
# 类型一:同一步原地踏步,连续 3 次
if len(sig) >= 3 and len(set(sig[-3:])) == 1:
return "stuck"
# 类型二:A→B→A→B 振荡,连续 4 步两两相同
if len(sig) >= 4 and sig[-4] == sig[-2] and sig[-3] == sig[-1] \
and sig[-4] != sig[-3]:
return "oscillate"
return None
注意 detect_loop 把观察结果的哈希也算进签名:事故里那种"参数相同、返回相同、还在重复问"的情况,obs 哈希一致才能精准命中;如果模型每次换着花样问但观察结果不变,同样会被 stuck 规则抓住。
第 2 步:带熔断的执行器
def run_agent_with_guard(task, max_steps=8, token_budget=20_000, timeout_s=120):
traj, total_tokens, start = Trajectory(), 0, time.time()
for step in range(max_steps): # 熔断一:步数上限
if time.time() - start > timeout_s: # 熔断二:墙钟超时
return finish(traj, total_tokens, "timeout")
if total_tokens > token_budget: # 熔断三:token 预算
return finish(traj, total_tokens, "token_budget")
action, tokens = agent.plan(task, traj.steps) # 模型决策
total_tokens += tokens
observation = execute(action) # 执行工具
traj.record(action["tool"], action["args"], observation)
if loop := traj.detect_loop(): # 循环检测提前终止
return finish(traj, total_tokens, f"loop:{loop}")
if agent.is_done(observation):
return finish(traj, total_tokens, "success")
return finish(traj, total_tokens, "max_steps")
事故任务如果跑在这个执行器里,第 4 步就会命中 oscillate 熔断,成本从一夜上万元变成几分钱。熔断之后不是简单报错,而是降级:把轨迹和矛盾点打包升级给人工("query_order 与 check_logistics 结论冲突,请人工介入")——Agent 允许放弃,不允许空转。
第 3 步:把"不失控"写成测试用例
def test_conflicting_tools_never_loop():
"""两个工具结论矛盾时,Agent 必须在预算内终止并升级人工"""
with mock_tools({
"query_order": {
"status": "物流异常"},
"check_logistics": {
"status": "正常"},
}):
r = run_agent_with_guard("核查订单 A1002 的物流异常并处理")
assert r.reason != "max_steps", "跑满步数上限,说明循环检测没生效"
assert r.steps <= 6, f"决策步数 {r.steps} 超出预期"
assert r.total_tokens < 20_000, "单任务成本失控"
assert r.escalated_to_human, "矛盾场景必须升级人工,不允许猜"
def test_normal_task_still_succeeds():
"""熔断不能误伤正常任务:黄金任务集成功率不降"""
with mock_tools(happy_path_tools):
r = run_agent_with_guard("查询订单 A1001 状态并回复用户")
assert r.reason == "success" and r.steps <= 4
第二条用例同样重要:熔断是刹车,刹车太灵车就没法开。 每次收紧阈值,必须用黄金任务集验证成功率没有掉。
四、沉淀成方法:Agent 测试三层 + 线上监控
| 层级 | 测什么 | 核心指标 |
|---|---|---|
| 单步契约层 | 每次工具调用参数合法吗(见第 4 篇) | 参数非法率、重试收敛率 |
| 轨迹断言层 | 决策链合理吗 | 任务成功率、步数 P95、循环率、单任务 token 成本 |
| 熔断与监控层 | 线上失控能秒级发现吗 | 熔断触发率、token 消耗曲线告警、轨迹全量可回放 |
三条工程纪律:一是轨迹全量落日志,工具名 + 参数 + 观察结果 + token 数,出事能像这次一样完整回放;二是熔断阈值从数据来:用黄金任务集跑出步数分布,取 P95 × 1.5 作为 max_steps,token 预算同理,拍脑袋的阈值要么漏报要么误伤;三是熔断触发率本身是发布指标,新版本熔断率明显升高,说明模型决策质量退化,和成功率下降同等严重。
五、面试追问,你答得上来吗
- 测 Agent 和测普通接口的本质区别是什么?——答:接口是"一次调用一个契约",断言输入输出即可;Agent 是"一条不确定的决策链",要断言轨迹行为——目标是否达成、路径是否合理(步数、有无循环)、成本是否受控,并且必须给系统装熔断,因为 Agent 的失败模式不是"返回错误",而是"停不下来"。
- 怎么定义"Agent 完成了任务"?——答:把目标翻译成可验证的终态谓词:比如"工单已创建且字段正确"直接查工单系统,不靠模型自评;主观目标用 LLM 裁判 + 人工抽检验证。终态能查系统的查系统,查不了的才用裁判。
- 循环检测会不会误判"合理的重复"(比如分页翻查同一工具)?——答:会,所以签名里带参数和观察结果哈希:分页调用参数不同,不会命中 stuck;真正该抓的是"参数相同、观察相同、还在重复"。再配合黄金任务集回归误伤率,检测规则和熔断阈值一样,都是要被测试的组件。
下一篇预告:《流式输出只显示半句话——SSE 流式接口的测试点》