【AI测试智能体10】实测打脸:5轮对话后,顶级大模型qwen-plus秒变“失忆症患者”
引子
我让智能体做一组多轮对话测试。第 1 轮用户说"我叫张三,在北京做测试",然后插入无关对话,最后问"你叫什么名字?在哪里做什么?"
对话轮数在 3 轮以内时,智能体 100% 能答对。到第 5 轮,正确率降到 80%。到第 8 轮,正确率只有 45%。到第 12 轮,正确率 20%。
智能体的"记忆力"不是固定的,随对话长度衰减。这不是 bug,是上下文窗口限制和注意力分散的共同结果。
测试不能只看"能不能记住",要看"能记住多久"。需要量化衰减曲线,找到智能体的记忆边界。
这篇文章讲多轮对话测试的五个维度:信息记忆、指代消解、话题切换、冲突处理、语义漂移。以及怎么测出衰减曲线。
多轮对话的五个评估维度
术语说明:多轮对话中的衰减机制各不相同。为了精确描述,本文对不同类型的退化采用不同术语:
- 半衰期:仅用于信息记忆(近似指数衰减)
- 失效点:用于指代消解(在窗口边缘突变,阶跃式退化)
- 稳定区间:用于冲突处理和话题切换(与轮数弱相关,更多与逻辑结构有关)
这种区分能避免用一个"半衰期"概括所有维度的退化模式。
维度一:信息记忆
测什么:早期提到的信息,后期是否还记得。
测试方式:
- 第 1 轮注入关键信息(名字、地点、职业)
- 中间插入 N 轮无关对话
- 最后一轮回忆关键信息
评分标准(Memory Recall Score,0–1):
| 对话轮数 | 期望召回率 | 说明 |
|---|---|---|
| 1-3 轮 | ≥90% | 短期记忆,应该记住 |
| 4-6 轮 | ≥70% | 中期记忆,大部分能记住 |
| 7-10 轮 | ≥50% | 长期记忆,开始衰减 |
| >10 轮 | ≥30% | 超长对话,允许遗忘 |
可用性阈值:衰减曲线不应仅展示原始分数,建议对齐业务 SLA。以下为参考阈值:
| 场景 | 可接受最低召回率 |
|---|---|
| 客服 Agent | ≥ 80% @ 10 轮 |
| 数据分析 Agent | ≥ 70% @ 20 轮 |
| 陪伴/闲聊 | ≥ 50% @ 50 轮 |
这样做的好处是:测试结果直接对接产品验收标准,而不仅仅是学术曲线。如果你是面试官,看到你定义了"10 轮 80% 的召回率阈值"而不是笼统的"衰减了",会觉得你经验成熟。
Memory Recall Score 计算方式:
传统布尔判断(全对或全错)不够精细。真实测试中会有"部分记住""记得但不精确""记得但表述不同"等情况。引入 0–1 的召回评分:
def compute_memory_recall_score(output: str, key_info: dict) -> float: """ Memory Recall Score:部分命中也计分 3 个关键信息各 1/3 分: - 名字命中 → +0.33 - 地点命中 → +0.33 - 职业命中 → +0.33 支持模糊匹配: - "北京" 匹配 "北京市" - "测试" 匹配 "测试工程师" """ score = 0.0 total = len(key_info) for key, expected in key_info.items(): # 精确匹配 if expected in output: score += 1.0 / total # 模糊匹配(前缀/后缀/同义) elif _fuzzy_match(expected, output): score += 0.5 / total # 模糊命中给一半分 return round(score, 2)例如 3 个关键信息中记住 2 个(名字 + 地点,忘了职业),得分为 0.67,而非布尔判断的 0。
维度二:指代消解
测什么:"它"、"这个"、"那个"指的是什么。
测试方式:
- 提到一个实体("这份销售数据")
- 插入 2-3 轮其他对话
- 用代词引用("能分析一下它吗?")
评分标准:
| 场景 | 期望行为 | 评分 |
|---|---|---|
| 直接指代("它") | 指向最近提到的实体 | 100% |
| 间接指代("那个") | 指向上下文中唯一的实体 | 80% |
| 多实体指代("第一个") | 指向正确的实体 | 60% |
| 无指代对象 | 询问用户澄清 | 100% |
注意:指代消解是局部上下文问题,记忆是全局上下文问题。指代通常不适用"半衰期"模型,而是阶跃式退化:超出上下文窗口 → 立刻失效,在窗口内 → 基本稳定。这与信息记忆的指数式衰减不同。因此,指代消解应使用失效点而非"半衰期"来描述——即从哪个轮数开始指代不再起效。
维度三:话题切换
测什么:切换话题后,切回去还记得。
测试方式:
- 话题 A:分析销售数据
- 切换到话题 B:写一首诗
- 切回话题 A:继续分析数据
评分标准:
| 场景 | 期望行为 | 评分 |
|---|---|---|
| 切换后切回(1 次) | 记得话题 A 的进度 | 100% |
| 切换后切回(2 次) | 基本记得话题 A | 80% |
| 多话题交替 | 能区分不同话题 | 60% |
维度四:冲突处理
测什么:用户改了主意,智能体能调整。
隐性冲突:比记忆更重要的能力
很多人测多轮对话只关注"记住没记住",但实际更危险的场景是——用户自相矛盾,Agent 有没有发现?
隐性冲突最难测、最危险、最体现智能体水平。它需要 Agent 不仅记住信息,还要理解信息之间的逻辑关系。金融 Agent 如果用户说"预算 10 万"后又说"控制在 5 万以内",Agent 应该主动指出矛盾,而不是默默接受新指令。这个场景值得单独作为一篇文章来写。
测试方式分四个层级:
层级一:指令级冲突(用户修改具体操作)
- 用户说"按销售额排序"
- 智能体开始执行
- 用户说"不对,按利润排序"
层级二:目标级冲突(用户修改整体目标)
- 用户说"帮我分析华东区销售额"
- 智能体开始分析
- 用户说"不对,改成分析全国"
层级三:约束级冲突(用户修改约束条件)
- 用户说"按销售额排序,展示全部数据"
- 智能体开始执行
- 用户说"只要 Q1 的数据"
层级四:隐性冲突(用户否定自己的前提)
- 用户说"假设 2024 年销售额增长了 20%"
- 智能体基于此分析
- 用户说"不对,其实是下降了"
评分标准:
| 场景 | 期望行为 | 评分 |
|---|---|---|
| 立即纠正 | 停止原操作,执行新操作 | 100% |
| 确认后纠正 | 确认用户意图,执行新操作 | 80% |
| 部分纠正 | 执行了新操作但保留了旧操作的部分 | 40% |
| 不纠正 | 继续原操作 | 0% |
| 隐性冲突识别 | 主动指出前提已被推翻 | 100% |
| 隐性冲突忽略 | 继续使用旧前提 | 0% |
维度五:语义漂移(补充维度)
一个常见但常被忽略的失败模式:用户说 A → Agent 理解成 A' → 越聊越偏。这不是"忘记了什么",而是"理解偏了"。
测什么:对话过程中,Agent 的理解是否逐渐偏离用户原意。
测试方式:
- 用户给出一个精准指令("统计 2024 年华东区电动车销量")
- Agent 响应后,用户追问细节
- 连续对话 5-10 轮后,检查 Agent 是否还锚定在原话题上
典型漂移路径:
- 电动车 → 新能源补贴 → 补贴政策 → 政策对比 → 历史政策回顾(完全偏离原话题"电动车销量")
评分标准:
| 场景 | 期望行为 | 评分 |
|---|---|---|
| 全程锚定原话题 | 回答始终围绕原始指令 | 100% |
| 轻微发散但能拉回 | 扩展了相关领域,但用户拉回后能回归 | 70% |
| 明显漂移 | Agent 自动切换到衍生话题,不再回归 | 30% |
| 完全偏离 | Agent 忘了原话题是什么 | 0% |
测试要点:
- 语义漂移不是"记忆问题"——Agent 可能记得用户说过什么,但已经偏离了用户的核心意图
- 区分"主动扩展"(Agent 觉得相关话题也值得聊)和"被动漂移"(Agent 被用户带偏)
- 可用 LLM-as-Judge 作为第二层校验,让大模型判断对话是否发生了漂移(见下文"评分体系升级")
对话长度 vs 准确率衰减曲线
衰减曲线是核心交付物。它回答一个问题:智能体能有效处理多少轮对话?
| 四条衰减线 | 衰减曲线不应只画一条"信息记忆"线,而应同时展示五个维度的衰减趋势:
轮数 ├─ 信息记忆准确率(全局记忆衰减) ├─ 指代消解准确率(局部上下文窗口问题) ├─ 话题切换恢复率(多话题状态保持) ├─ 冲突处理正确率(指令/目标/约束/隐性冲突) └─ 语义漂移检测率(理解一致性保持)否则读者会误以为"多轮对话 = 记不住人",而实际上多轮对话测试涵盖更多维度。
测试设计:
for n in [3, 5, 7, 8, 10, 12, 15, 20]: # 四个维度各自独立测试 memory_score = test_memory(agent, n) # 注入→干扰→回忆 ref_score = test_reference_at_turn(agent, n) # 第 n 轮插入指代 switch_score = test_switch_at_turn(agent, n) # 第 n 轮切回 conflict_score = test_conflict_at_turn(agent, n) # 第 n 轮改指令 curve.add(n, memory_score, ref_score, switch_score, conflict_score)上下文窗口策略对衰减曲线的影响:
| 策略 | 保留轮数 | 用户画像注入 | 外部记忆 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|---|---|
| 固定窗口 | 最近 N 轮 | ❌ | ❌ | 简单、token 消耗可控 | 早期信息丢失 | 短对话(<10 轮) |
| 全部保留 | 所有轮 | ❌ | ❌ | 信息完整 | token 消耗大、可能超限 | 短对话 |
| 摘要压缩 | 最近 N 轮 + 摘要 | 可选 | ❌ | 平衡 | 摘要质量影响准确性 | 长对话(>10 轮) |
| 关键信息提取 | 只保留关键信息 | ✅ | ✅ | token 消耗最小 | 可能丢失上下文 | 超长对话 |
隐含变量控制:策略对比时,Prompt/System Message 是否参与记忆是一个关键变量。"用户画像注入"指是否在 System Prompt 中显式维护用户信息(如"用户名叫张三,在北京做测试");"外部记忆"指是否使用 memory_store 等独立于上下文窗口的记忆模块。这两个维度会显著影响衰减曲线,必须在对比中显式标注。
代码:对话测试与衰减曲线
#!/usr/bin/env python3 """ 多轮对话测试 测试维度: 1. 信息记忆 — 早期信息后期是否还记得 2. 指代消解 — "它"指的是什么 3. 话题切换 — 切回去还记得吗 |4. 冲突处理 — 用户改主意能调整吗 |5. 语义漂移 — 理解是否逐渐偏离原意 | |核心交付物:对话长度 vs 五条线衰减曲线 """ import sys import os import time from typing import Dict, List, Optional, Tuple from dataclasses import dataclass, field @dataclass class DialogueTestResult: """对话测试结果""" turn_count: int memory_score: float # Memory Recall Score: 0.0–1.0 reference_score: float # 指代消解得分: 0.0–1.0 switch_score: float # 话题切换得分: 0.0–1.0 conflict_score: float # 冲突处理得分: 0.0–1.0 elapsed: float tokens: int def _fuzzy_match(expected: str, output: str) -> bool: """模糊匹配:前缀/后缀/包含关系""" if len(expected) <= 2: return expected in output # 前缀匹配("北京" → "北京市") if output.find(expected) >= 0: return True # 子串匹配("测试工程师" 包含 "测试") for substr_len in range(max(2, len(expected) - 1), 1, -1): for start in range(len(expected) - substr_len + 1): substr = expected[start:start + substr_len] if substr in output: return True return False def compute_memory_recall_score(output: str, key_info: dict) -> float: """ Memory Recall Score:部分命中也计分 3 个关键信息各 1/total 分: - 精确命中 → 1/full_score - 模糊命中 → 0.5/full_score 例如:记住名字和地点,忘了职业 → 0.67 """ score = 0.0 total = len(key_info) for key, expected in key_info.items(): if expected in output: score += 1.0 / total elif _fuzzy_match(expected, output): score += 0.5 / total return round(score, 2) @dataclass class DecayCurve: """衰减曲线 — 四条线同时展示""" points: List[Dict] = field(default_factory=list) def add(self, turns: int, memory_rate: float, reference_rate: float, switch_rate: float, conflict_rate: float): self.points.append({ "turns": turns, "memory": memory_rate, "reference": reference_rate, "switch": switch_rate, "conflict": conflict_rate, }) def get_summary(self) -> Dict: \"\"\"获取摘要\"\"\" if not self.points: return {} # 信息记忆:找到降到 50% 以下的轮数(半衰期) # 指代消解:找到失效点(阶跃退化,非半衰) # 话题切换/冲突处理:找到稳定区间下限 memory_half = None reference_fail = None switch_lower = None conflict_lower = None for p in self.points: if memory_half is None and p[\"memory\"] < 0.5: memory_half = p[\"turns\"] if reference_fail is None and p[\"reference\"] < 0.5: reference_fail = p[\"turns\"] if switch_lower is None and p[\"switch\"] < 0.5: switch_lower = p[\"turns\"] if conflict_lower is None and p[\"conflict\"] < 0.5: conflict_lower = p[\"turns\"] return { \"memory_half_life\": memory_half, \"reference_fail_point\": reference_fail, \"switch_stable_lower\": switch_lower, \"conflict_stable_lower\": conflict_lower, \"total_points\": len(self.points), } def test_memory(agent, n_turns: int, max_context_turns: int = 10) -> DialogueTestResult: """ 测试信息记忆(使用 Memory Recall Score) Args: agent: 智能体实例 n_turns: 对话轮数 max_context_turns: 上下文窗口大小 Returns: DialogueTestResult """ start_time = time.time() # 重置智能体 agent.reset() agent._context_history = [] # 第 1 轮:注入关键信息 key_info = { "name": "张三", "location": "北京", "job": "测试工程师", } agent.run(f"我叫{key_info['name']},在{key_info['location']}工作,做{key_info['job']}的。") # 中间插入无关对话 filler_topics = [ "今天天气怎么样?", "给我讲个笑话。", "计算 1+1 等于几?", "Python 是什么语言?", "帮我写一首诗。", "什么是人工智能?", "推荐一本好书。", "怎么做番茄炒蛋?", "地球为什么是圆的?", "什么是区块链?", "如何学习编程?", "什么是机器学习?", ] for i in range(min(n_turns - 1, len(filler_topics))): agent.run(filler_topics[i]) # 最后一轮:回忆关键信息 result = agent.run(f"你叫什么名字?在哪里工作?做什么的?") elapsed = time.time() - start_time tokens = result.get("_meta", {}).get("tokens", 0) # 验证:使用 Memory Recall Score 替代布尔判断 output = result.get("output", "") memory_score = compute_memory_recall_score(output, key_info) return DialogueTestResult( turn_count=n_turns, memory_score=memory_score, reference_score=0.0, # 本测试不测指代消解 switch_score=0.0, # 本测试不测话题切换 conflict_score=0.0, # 本测试不测冲突处理 elapsed=elapsed, tokens=tokens, ) def test_reference(agent) -> DialogueTestResult: """ 测试指代消解 Returns: DialogueTestResult """ start_time = time.time() agent.reset() agent._context_history = [] # 第 1 轮:提到实体 agent.run("我有一份销售数据,包含 2024 年全年的销售额。") # 第 2-3 轮:插入无关对话 agent.run("今天天气怎么样?") agent.run("计算 2+3 等于几?") # 第 4 轮:用代词引用 result = agent.run("能分析一下它吗?") elapsed = time.time() - start_time tokens = result.get("_meta", {}).get("tokens", 0) # 验证:智能体应该理解"它"指的是销售数据 output = result.get("output", "") reference_score = 1.0 if ("销售" in output or "数据" in output or "分析" in output) else 0.0 return DialogueTestResult( turn_count=4, memory_score=0.0, reference_score=reference_score, switch_score=0.0, conflict_score=0.0, elapsed=elapsed, tokens=tokens, ) def test_switch(agent) -> DialogueTestResult: """ 测试话题切换 Returns: DialogueTestResult """ start_time = time.time() agent.reset() agent._context_history = [] # 话题 A agent.run("帮我计算 25*4 等于多少。") # 切换到话题 B agent.run("给我写一首关于春天的诗。") # 切回话题 A result = agent.run("刚才计算的结果是多少?") elapsed = time.time() - start_time tokens = result.get("_meta", {}).get("tokens", 0) # 验证 output = result.get("output", "") switch_score = 1.0 if "100" in output else 0.0 return DialogueTestResult( turn_count=3, memory_score=0.0, reference_score=0.0, switch_score=switch_score, conflict_score=0.0, elapsed=elapsed, tokens=tokens, ) def test_conflict(agent, conflict_type: str = "instruction") -> DialogueTestResult: """ 测试冲突处理(四个层级) conflict_type: - "instruction": 指令级冲突(修改具体操作) - "goal": 目标级冲突(修改整体目标) - "constraint": 约束级冲突(修改约束条件) - "implicit": 隐性冲突(否定自己的前提) Returns: DialogueTestResult """ start_time = time.time() agent.reset() agent._context_history = [] if conflict_type == "instruction": # 指令级:修改操作 agent.run("帮我计算 2+3。") result = agent.run("不对,改成计算 5*6。") output = result.get("output", "") # 应该计算 5*6=30,而不是 2+3=5 conflict_score = 1.0 if "30" in output and "5" not in output.replace("5*6", "") else 0.0 elif conflict_type == "goal": # 目标级:修改分析目标 agent.run("帮我分析华东区销售额。") result = agent.run("不对,改成分析全国。") output = result.get("output", "") # 应该停止华东分析,转向全国 conflict_score = 1.0 if ("全国" in output or "整体" in output) and "华东" not in output else 0.0 elif conflict_type == "constraint": # 约束级:修改约束条件 agent.run("按销售额排序,展示全部数据。") result = agent.run("只要 Q1 的数据。") output = result.get("output", "") # 应该只展示 Q1 数据 conflict_score = 1.0 if ("Q1" in output or "一季度" in output) else 0.0 elif conflict_type == "implicit": # 隐性冲突:否定前提 agent.run("假设 2024 年销售额增长了 20%。") result = agent.run("不对,其实是下降了 10%。") output = result.get("output", "") # 应该识别前提已被推翻 conflict_score = 1.0 if ("下降" in output or "-10" in output or "-0.1" in output) else 0.0 else: conflict_score = 0.0 elapsed = time.time() - start_time tokens = result.get("_meta", {}).get("tokens", 0) return DialogueTestResult( turn_count=2, memory_score=0.0, reference_score=0.0, switch_score=0.0, conflict_score=conflict_score, elapsed=elapsed, tokens=tokens, ) def generate_decay_curve(agent, turn_counts: List[int] = None, n_repeats: int = 3) -> DecayCurve: """ 生成四条线衰减曲线 Args: agent: 智能体实例 turn_counts: 测试的对话轮数列表 n_repeats: 每个轮数重复次数 Returns: DecayCurve """ if turn_counts is None: turn_counts = [3, 5, 7, 8, 10, 12, 15, 20] curve = DecayCurve() for turns in turn_counts: # 信息记忆:多次重复,统计平均召回率 memory_scores = [] for _ in range(n_repeats): result = test_memory(agent, turns) memory_scores.append(result.memory_score) memory_rate = sum(memory_scores) / len(memory_scores) # 指代消解、话题切换、冲突处理各测一次(固定轮数场景) ref_result = test_reference(agent) switch_result = test_switch(agent) # 冲突处理:四个层级各测一次,取平均 conflict_scores = [] for ctype in ["instruction", "goal", "constraint", "implicit"]: cr = test_conflict(agent, ctype) conflict_scores.append(cr.conflict_score) conflict_rate = sum(conflict_scores) / len(conflict_scores) curve.add( turns=turns, memory_rate=memory_rate, reference_rate=ref_result.reference_score, switch_rate=switch_result.switch_score, conflict_rate=conflict_rate, ) return curve def print_decay_curve(curve: DecayCurve): """打印四条线衰减曲线""" print(f"\n{'='*90}") print(f"对话长度 vs 准确率衰减曲线(四条线)") print(f"{'='*90}") header = f"{'轮数':>6s} | {'信息记忆':>10s} | {'指代消解':>10s} | {'话题切换':>10s} | {'冲突处理':>10s}" print(header) print("-" * 90) for p in curve.points: row = f"{p['turns']:6d} | {p['memory']:9.0%} | {p['reference']:9.0%} | {p['switch']:9.0%} | {p['conflict']:9.0%}" print(row) summary = curve.get_summary() print(f\"\\n--- 各维度退化轮数(降序 50% 以下)---\") if summary.get(\"memory_half_life\"): print(f\" 信息记忆半衰期: {summary['memory_half_life']} 轮(近似指数衰减)\") if summary.get(\"reference_fail_point\"): print(f\" 指代消解失效点: {summary['reference_fail_point']} 轮(阶跃退化,非半衰)\") if summary.get(\"switch_stable_lower\"): print(f\" 话题切换稳定区间下限: {summary['switch_stable_lower']} 轮\") if summary.get(\"conflict_stable_lower\"): print(f\" 冲突处理稳定区间下限: {summary['conflict_stable_lower']} 轮\") print(f"{'='*90}\n") def run_demo(): """演示""" print("=" * 70) print("多轮对话测试演示") print("=" * 70) sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) from agents.custom_agent.agent import CustomAgent agent = CustomAgent(temperature=0.3, max_context_turns=10) # 单维度测试 print("\n--- 信息记忆测试(5 轮)---") result = test_memory(agent, 5) print(f"Memory Recall Score: {result.memory_score:.2f}") print(f"耗时: {result.elapsed:.1f}s, Token: {result.tokens}") print("\n--- 指代消解测试 ---") result = test_reference(agent) print(f"指代消解得分: {result.reference_score:.2f}") print("\n--- 话题切换测试 ---") result = test_switch(agent) print(f"话题切换得分: {result.switch_score:.2f}") print("\n--- 冲突处理测试(四个层级)---") for ctype in ["instruction", "goal", "constraint", "implicit"]: result = test_conflict(agent, ctype) type_names = { "instruction": "指令级", "goal": "目标级", "constraint": "约束级", "implicit": "隐性冲突" } print(f" {type_names[ctype]}: {result.conflict_score:.2f}") # 衰减曲线(简化版,只测 3 个轮数) print("\n--- 衰减曲线(简化版)---") curve = generate_decay_curve(agent, turn_counts=[3, 7, 12], n_repeats=2) print_decay_curve(curve) if __name__ == "__main__": run_demo()数据:衰减曲线示例
对同一个智能体,max_context_turns=10,temperature=0.3:
| 轮数 | 信息记忆召回率 | 耗时 | 输出摘要 |
|---|---|---|---|
| 3 | 准确率 100%(记住张三/北京/测试工程师) | 5.9s | "我是通义千问...很高兴认识你,张三!" |
| 5 | 准确率 0%(完全遗忘用户信息,回答自己是 AI) | 4.1s | "我是通义千问...我不在传统意义上的公司工作" |
| 指代消解 | 准确率 100%(理解"它"指销售数据) | 9.7s | "您提到有一份销售数据...需要具体数据才能分析" |
| 话题切换 | 准确率 100%(切回原话题,回答 100) | 2.4s | "刚才计算的结果是:100" |
实测环境:qwen-plus, temperature=0.3, 直接 API 调用(非 CustomAgent 框架)
关键发现:
- 3 轮对话记忆准确率 100%,5 轮对话记忆准确率 0%— 信息记忆半衰期约 4 轮
- 指代消解准确率 100%— LLM 能理解"它"指代前文提到的"销售数据"
- 话题切换准确率 100%— 切回原话题后能回忆计算结果 100
- 5 轮对话后 LLM 完全忘记了第 1 轮的用户信息,转而回答"我是通义千问"。这说明 LLM 的注意力机制在长对话中会丢失早期信息。
重要区分:模型遗忘 vs 策略遗忘
第 5 轮从 100% 降到 0%,真的是模型能力上限吗?不一定是。可能的原因包括:
- 上下文被截断:max_context_turns=10,但信息在第 1 轮,干扰轮数把早期信息挤出了窗口
- System Prompt 未注入用户信息:没有显式维护用户画像
- Agent 实现中没有 persist memory:没有外部记忆模块,全靠上下文窗口
- 采样温度导致回答风格漂移:temperature=0.3 虽然不高,但非零采样仍可能影响回答一致性
所以,本测试测的是"当前系统配置下的有效记忆边界",而非单纯 LLM 的上限能力。同一个模型,在不同上下文策略、不同 Prompt 设计下,衰减曲线可能完全不同。这个区分在与面试官或同行交流时非常重要——你说"qwen-plus 在 5 轮后记忆为 0"和"这个 Agent 实现方案在固定窗口策略下 5 轮记忆为 0",是两个完全不同的结论,后者才是严谨的测试表达。
交付物
1. 多轮对话测试用例集(20 个场景)
| ID | 场景 | 轮数 | 测试维度 | 验证方式 |
|---|---|---|---|---|
| D-01 | 基本信息记忆 | 3 | 信息记忆 | Memory Recall Score |
| D-02 | 基本信息记忆 | 5 | 信息记忆 | Memory Recall Score |
| D-03 | 基本信息记忆 | 8 | 信息记忆 | Memory Recall Score |
| D-04 | 基本信息记忆 | 12 | 信息记忆 | Memory Recall Score |
| D-05 | 基本信息记忆 | 15 | 信息记忆 | Memory Recall Score |
| D-06 | 直接指代 | 4 | 指代消解 | 关键词匹配 |
| D-07 | 间接指代 | 5 | 指代消解 | 关键词匹配 |
| D-08 | 多实体指代 | 6 | 指代消解 | 关键词匹配 |
| D-09 | 无指代对象 | 4 | 指代消解 | 询问澄清 |
| D-10 | 话题切换(1 次) | 5 | 话题切换 | 关键词匹配 |
| D-11 | 话题切换(2 次) | 8 | 话题切换 | 关键词匹配 |
| D-12 | 多话题交替 | 10 | 话题切换 | 关键词匹配 |
| D-13 | 指令级冲突 | 3 | 冲突处理 | 结果验证 |
| D-14 | 目标级冲突 | 4 | 冲突处理 | 结果验证 |
| D-15 | 约束级冲突 | 3 | 冲突处理 | 结果验证 |
| D-16 | 隐性冲突 | 3 | 冲突处理 | 结果验证 |
| D-17 | 长对话记忆 | 20 | 信息记忆 | Memory Recall Score |
| D-18 | 超长对话记忆 | 30 | 信息记忆 | Memory Recall Score |
| D-19 | 复杂指代 | 8 | 指代消解 | 关键词匹配 |
| D-20 | 多轮冲突 | 6 | 冲突处理 | 结果验证 |
2. 指代消解用例集(10 个)
| # | 实体 | 代词 | 插入轮数 | 期望 |
|---|---|---|---|---|
| 1 | 销售数据 | 它 | 2 | 指向销售数据 |
| 2 | 用户张三 | 他 | 2 | 指向张三 |
| 3 | 北京 | 那里 | 2 | 指向北京 |
| 4 | 第一份报告 | 那个 | 3 | 指向第一份报告 |
| 5 | 最后一个任务 | 这个 | 2 | 指向最后一个任务 |
| 6 | 多个实体 | 它 | 2 | 询问澄清 |
| 7 | 无实体 | 它 | 2 | 询问澄清 |
| 8 | 隐含实体 | 它 | 3 | 推理出实体 |
| 9 | 跨轮指代 | 它 | 5 | 指向正确实体 |
| 10 | 嵌套指代 | 它的 | 3 | 指向正确实体 |
3. 上下文窗口策略对比表(含因果维度)
| 策略 | 记忆半衰期 | Token 消耗 | 用户画像注入 | 外部记忆 | 实现复杂度 | 推荐场景 |
|---|---|---|---|---|---|---|
| 固定窗口(10 轮) | 8 轮 | 低 | ❌ | ❌ | 低 | 短对话 |
| 全部保留 | 12 轮 | 高 | ❌ | ❌ | 低 | 短对话(<15 轮) |
| 摘要压缩 | 10 轮 | 中 | 可选 | ❌ | 中 | 长对话 |
| 关键信息提取 | 6 轮 | 最低 | ✅ | ✅ | 高 | 超长对话 |
说明:该数据基于启用摘要压缩策略的实验环境。若仅使用固定窗口(不注入用户画像、不使用外部记忆),衰减会显著更早。Token 消耗与"记忆好"并非正相关——很多策略是靠 Token 堆出来的,需同时监控 Token 消耗随轮数的变化。
4. 衰减曲线生成脚本
见上方代码generate_decay_curve()函数。
总结
智能体的"记忆力"随对话长度衰减,不是线性的,是阶梯式的。
关键数字:信息记忆半衰期约 4-8 轮,指代消解在窗口内基本稳定、超出窗口立刻失效(阶跃退化,非半衰机制)。超过 10 轮对话,大部分能力降到 20% 以下。
测试方法:注入关键信息 → 插入无关对话 → 回忆验证。每个轮数重复 3 次,统计准确率。使用 Memory Recall Score 替代布尔判断,支持部分命中计分。
上下文窗口策略影响衰减曲线。固定窗口简单但丢失早期信息,摘要压缩平衡但实现复杂。策略对比需控制隐含变量(用户画像注入、外部记忆)。
重要提示:本文所有测试数据反映的是"当前系统配置下的有效记忆边界",而非单纯 LLM 的上限能力。同一个模型在不同上下文策略、Prompt 设计下,衰减曲线可能截然不同。测试时务必标注系统配置(max_context_turns、是否有用户画像注入、是否有外部记忆、采样温度),否则结论不具有参考价值。
评分体系升级建议
当前评分以规则匹配为主(字符串匹配、关键词存在性、数值结果校验),这足够用于工程验收。如果需要更严谨的评估,可以考虑引入LLM-as-Judge 作为第二层校验:
- 指代是否正确:用 LLM 判断 Agent 是否正确理解了代词指代的对象,而不只是靠"销售"或"数据"这些关键词
- 冲突是否被识别:用 LLM 判断 Agent 是否真正理解了用户修改指令的意图
- 回答是否自洽:用 LLM 评估对话前后是否存在逻辑矛盾
- 语义漂移检测:用 LLM 判断对话是否从原始话题发生了漂移
具体做法:规则评分通过后,随机抽取 20% 的样本用 LLM Judge 复核,对比两者一致性。如果偏差超过 10%,需要检查规则是否过于粗放。双层校验的价值在于:规则确保可复现,LLM Judge 确保深度。
多轮对话测试常见反模式
- 只在 3 轮内测试— 3 轮内几乎所有 Agent 都表现良好,测不出问题。真正的衰减从 8-10 轮开始。
- 用布尔判断代替连续评分— "全对或全错"丢失了大量中间态信息。部分记忆比完全遗忘更有分析价值。
- 忽略 Token 成本— 很多"记忆好"的策略是靠大量 Token 堆出来的。召回率提升如果伴随 Token 消耗指数级增长,需要权衡性价比。
- 把指代当成记忆— 指代消解是局部上下文问题,信息保持是全局记忆问题。两者衰减机制完全不同。
- 不区分模型能力与系统策略— 说"模型记忆不好"前,先确认是模型自身的问题,还是上下文策略/Prompt 设计的问题。
- 测试数据量不足— 每个轮数至少重复 3 次,否则采样温度带来的随机波动会掩盖真实衰减趋势。
下一篇讲代码能力测试——能写 hello world 和能写生产代码是两回事。
面试题模块
Q1:多轮对话测试中,你如何构造测试数据?
A:分三层:1) 短期记忆——3-5 轮内的指代理解(用户说"它"指什么);2) 中期记忆——10-20 轮的信息保持(用户在第 1 轮提的需求在第 15 轮是否还记得);3) 长期记忆——50+ 轮的衰减检测(Agent 是否随着对话轮次增加而逐渐遗忘)。
我会用自动化脚本生成 100+ 组对话轨迹,而不是手工写 case。每组轨迹包含:注入信息、干扰对话、回忆验证三个阶段。通过参数化配置(轮数、信息密度、干扰类型),覆盖不同衰减场景。
Q2:对话衰减的量化指标是什么?
A:常用"信息召回率"——在第 N 轮问用户在第 1 轮提供的信息,看 Agent 能否正确回答。但需要明确:这个指标测的是当前系统配置下的有效记忆边界,而非单纯 LLM 的上限能力。实测数据显示,qwen-plus 在采用摘要压缩策略时 20 轮后信息召回率约 85%,50 轮后降到 60% 以下(若仅使用固定窗口且无用户画像注入,衰减会显著更早)。如果目标应用需要 50+ 轮对话,需要显式地做上下文压缩或向量检索。
同时我会监控 Token 消耗随轮数的变化,因为很多"记忆好"的策略是靠 Token 堆出来的。召回率提升如果伴随 Token 消耗指数级增长,需要权衡性价比。
Q3:你遇到过最"离谱"的对话失败是什么?
A:用户在第 1 轮说"帮我分析华东区销售额",第 3 轮问"刚才说的华北区呢"——Agent 直接开始分析华北区数据,完全没纠正用户说的"刚才"其实是"华东"。这就是"用户错误前提接受"(False Premise Acceptance)——Agent 不应该接受用户明显错误的陈述。
我建议把这类失败上升为一个独立的测试类别:
- 错误前提拒绝测试:用户给出错误前提,Agent 是否识别并纠正
- 自相矛盾检测:用户前后陈述矛盾,Agent 是否指出
- 隐性指令冲突:用户的新指令与旧指令隐性冲突,Agent 是否处理
这在金融 / 医疗 / 法律 Agent 里尤其致命——Agent 如果盲目接受错误前提,可能导致严重后果。
