引子

我让智能体做一组多轮对话测试。第 1 轮用户说"我叫张三,在北京做测试",然后插入无关对话,最后问"你叫什么名字?在哪里做什么?"

对话轮数在 3 轮以内时,智能体 100% 能答对。到第 5 轮,正确率降到 80%。到第 8 轮,正确率只有 45%。到第 12 轮,正确率 20%。

智能体的"记忆力"不是固定的,随对话长度衰减。这不是 bug,是上下文窗口限制和注意力分散的共同结果。

测试不能只看"能不能记住",要看"能记住多久"。需要量化衰减曲线,找到智能体的记忆边界。

这篇文章讲多轮对话测试的五个维度:信息记忆、指代消解、话题切换、冲突处理、语义漂移。以及怎么测出衰减曲线。

多轮对话的五个评估维度

术语说明:多轮对话中的衰减机制各不相同。为了精确描述,本文对不同类型的退化采用不同术语:

  • 半衰期:仅用于信息记忆(近似指数衰减)
  • 失效点:用于指代消解(在窗口边缘突变,阶跃式退化)
  • 稳定区间:用于冲突处理和话题切换(与轮数弱相关,更多与逻辑结构有关)

这种区分能避免用一个"半衰期"概括所有维度的退化模式。

维度一:信息记忆

测什么:早期提到的信息,后期是否还记得。

测试方式:

  1. 第 1 轮注入关键信息(名字、地点、职业)
  2. 中间插入 N 轮无关对话
  3. 最后一轮回忆关键信息

评分标准(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。

维度二:指代消解

测什么:"它"、"这个"、"那个"指的是什么。

测试方式:

  1. 提到一个实体("这份销售数据")
  2. 插入 2-3 轮其他对话
  3. 用代词引用("能分析一下它吗?")

评分标准:

场景 期望行为 评分
直接指代("它") 指向最近提到的实体 100%
间接指代("那个") 指向上下文中唯一的实体 80%
多实体指代("第一个") 指向正确的实体 60%
无指代对象 询问用户澄清 100%

注意:指代消解是局部上下文问题,记忆是全局上下文问题。指代通常不适用"半衰期"模型,而是阶跃式退化:超出上下文窗口 → 立刻失效,在窗口内 → 基本稳定。这与信息记忆的指数式衰减不同。因此,指代消解应使用失效点而非"半衰期"来描述——即从哪个轮数开始指代不再起效。

维度三:话题切换

测什么:切换话题后,切回去还记得。

测试方式:

  1. 话题 A:分析销售数据
  2. 切换到话题 B:写一首诗
  3. 切回话题 A:继续分析数据

评分标准:

场景 期望行为 评分
切换后切回(1 次) 记得话题 A 的进度 100%
切换后切回(2 次) 基本记得话题 A 80%
多话题交替 能区分不同话题 60%

维度四:冲突处理

测什么:用户改了主意,智能体能调整。

隐性冲突:比记忆更重要的能力

很多人测多轮对话只关注"记住没记住",但实际更危险的场景是——用户自相矛盾,Agent 有没有发现?

隐性冲突最难测、最危险、最体现智能体水平。它需要 Agent 不仅记住信息,还要理解信息之间的逻辑关系。金融 Agent 如果用户说"预算 10 万"后又说"控制在 5 万以内",Agent 应该主动指出矛盾,而不是默默接受新指令。这个场景值得单独作为一篇文章来写。

测试方式分四个层级:

层级一:指令级冲突(用户修改具体操作)

  1. 用户说"按销售额排序"
  2. 智能体开始执行
  3. 用户说"不对,按利润排序"

层级二:目标级冲突(用户修改整体目标)

  1. 用户说"帮我分析华东区销售额"
  2. 智能体开始分析
  3. 用户说"不对,改成分析全国"

层级三:约束级冲突(用户修改约束条件)

  1. 用户说"按销售额排序,展示全部数据"
  2. 智能体开始执行
  3. 用户说"只要 Q1 的数据"

层级四:隐性冲突(用户否定自己的前提)

  1. 用户说"假设 2024 年销售额增长了 20%"
  2. 智能体基于此分析
  3. 用户说"不对,其实是下降了"

评分标准:

场景 期望行为 评分
立即纠正 停止原操作,执行新操作 100%
确认后纠正 确认用户意图,执行新操作 80%
部分纠正 执行了新操作但保留了旧操作的部分 40%
不纠正 继续原操作 0%
隐性冲突识别 主动指出前提已被推翻 100%
隐性冲突忽略 继续使用旧前提 0%

维度五:语义漂移(补充维度)

一个常见但常被忽略的失败模式:用户说 A → Agent 理解成 A' → 越聊越偏。这不是"忘记了什么",而是"理解偏了"。

测什么:对话过程中,Agent 的理解是否逐渐偏离用户原意。

测试方式:

  1. 用户给出一个精准指令("统计 2024 年华东区电动车销量")
  2. Agent 响应后,用户追问细节
  3. 连续对话 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 框架)

关键发现:

  1. 3 轮对话记忆准确率 100%,5 轮对话记忆准确率 0% — 信息记忆半衰期约 4 轮
  2. 指代消解准确率 100% — LLM 能理解"它"指代前文提到的"销售数据"
  3. 话题切换准确率 100% — 切回原话题后能回忆计算结果 100
  4. 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 确保深度

多轮对话测试常见反模式

  1. 只在 3 轮内测试 — 3 轮内几乎所有 Agent 都表现良好,测不出问题。真正的衰减从 8-10 轮开始。
  2. 用布尔判断代替连续评分 — "全对或全错"丢失了大量中间态信息。部分记忆比完全遗忘更有分析价值。
  3. 忽略 Token 成本 — 很多"记忆好"的策略是靠大量 Token 堆出来的。召回率提升如果伴随 Token 消耗指数级增长,需要权衡性价比。
  4. 把指代当成记忆 — 指代消解是局部上下文问题,信息保持是全局记忆问题。两者衰减机制完全不同。
  5. 不区分模型能力与系统策略 — 说"模型记忆不好"前,先确认是模型自身的问题,还是上下文策略/Prompt 设计的问题。
  6. 测试数据量不足 — 每个轮数至少重复 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 如果盲目接受错误前提,可能导致严重后果。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐