“把答案说短一点”是否真的改善服务?“先复述再推进”会不会反而增加通话时长?这类问题不能靠团队主观听几通录音决定,也不能拿一组漂亮的平均值直接宣布“策略适配有效”。

这篇文章讨论的是表达策略实验:在用户明确要求直接回答、任务风险可控的前提下,调整答案顺序、解释密度和确认方式。它不是根据声音诊断 MBTI 或人格,也不把任何示例数字写成线上效果。

我的判断是:VASI 的 A/B 测试必须先把“能不能进入实验”与“实验组说什么”分开。高风险、用户明确要求人工、关键字段无法确认的会话不应该为了凑样本而进入自动策略实验;安全护栏一旦恶化,实验应暂停,即使任务完成率或平均通话时长看起来更好。

目录

一、先从一个不能下结论的实验现场开始

假设客服团队上线了一个“直接回答”策略:当用户说“别绕弯子,告诉我订单到哪了”,Voice Agent 先给结论,再补充必要说明。另一组仍然使用默认顺序,先复述上下文,再给查询结果。

一周后,团队发现实验组的平均对话轮次更少,于是有人提出“适配有效”。但复盘事件时又看到三个问题:

  1. 一部分退款争议会话也被随机进了实验组;
  2. 同一个用户在两次来电中被分到了不同组,第二次对话受第一次体验影响;
  3. 有一条实验组事件缺少 policy_version,无法确认当时到底执行了哪版话术。

这些问题说明平均轮次只是一个观察量,不足以成为结论。实验首先要回答的是:入组样本是否合规、分组是否稳定、每条结果能否回放。没有这三层证据,任何“更好”都可能只是样本混杂。

本文的代码采用合成事件夹具,故意包含一个高风险排除样本和一个安全违规样本。它们只用于验证护栏逻辑,不代表闪电智能的线上数据或效果。

二、把问题写成可证伪假设

“个性化是否让用户满意”范围太大,既无法控制输入,也无法解释结果。一个可测试的假设应该带任务和约束:

在低风险查询任务中,用户明确要求直接回答时,答案前置策略是否降低重复提问率,同时不降低关键字段确认率和安全护栏表现?

这句话已经包含了实验对象、触发条件、期望方向和不能牺牲的边界。还需要把它拆成可记录的估计对象:

experiment_id = vasi-direct-v1
policy_version = v1
task = query
eligible = explicit_direct_request and not high_risk and not wants_human
primary_metric = repeated_question_rate
guardrails = safety_violation_rate, handoff_completeness

主指标只用于回答一个核心问题;护栏指标用于回答“是否以不可接受的代价换来的”。如果把退款争议、身份核验和普通物流查询混在一起,最后就算观察到差异,也说不清是策略造成的,还是任务风险不同造成的。

实验开始前还要写出反例:

  • 用户没有明确表达偏好时,不能把沉默当作“喜欢简短”;
  • 用户说“直接告诉我”,也不能因此省略金额、身份或权益核验;
  • 只要策略改变了安全边界,任务完成率再好也不能继续扩大灰度。

三、先定入组边界,再谈随机分组

入组判断应当是一个独立函数,而不是散落在话术模板里的几个 if。本地示例把会话建模为以下字段:

Session(
    conversation_id="c-001",
    task="query",
    explicit_direct_request=True,
    high_risk=False,
    wants_human=False,
)

入组条件只有四个:任务属于本次实验范围、用户明确表达了直接回答偏好、会话不属于高风险、用户没有要求人工。任何一项不满足都返回“不入组”,并记录排除原因,而不是静默塞进控制组。

def eligibility_reason(session, config):
    if session.task != config.task:
        return "task_out_of_scope"
    if not session.explicit_direct_request:
        return "no_explicit_preference"
    if session.high_risk:
        return "high_risk"
    if session.wants_human:
        return "human_requested"
    return None

排除原因是实验审计的一部分。它可以帮助我们发现“样本池到底是什么”:如果 high_risk 占比突然上升,可能是业务流量结构变了;如果 no_explicit_preference 很多,说明触发器过窄,不能靠扩大实验比例解决。

四、什么可以随机,什么必须固定

随机的是符合条件的会话在 controltreatment 之间的分配。控制组可以使用默认的“背景后给答案”,实验组使用“答案前置”;两组的安全规则、工具权限、关键字段确认和转人工条件必须相同。

不能随机的是:

  • 身份、金额和重大权益核验;
  • 用户明确要求人工或主管;
  • 高风险投诉和无法确认的关键字段;
  • 任何已经触发人工兜底的会话。

同一用户在短时间内最好保持分组稳定。否则他今天得到简短模式,明天得到完整解释,结果里混入跨会话体验变化,无法判断是策略差异还是分组漂移。工程上可以先使用会话 ID 做稳定分桶;如果实验单位是用户,则应改用脱敏后的用户稳定键,并明确保留周期。

另一个必须固定的变量是策略版本。实验事件至少要带上 experiment_idversion,否则在灰度期间更新模板后,前后两版结果会被错误合并。

五、从布尔开关演进为可审计实验配置

最容易写出的版本是:

if explicit_direct_request:
    use_short_answer = True

这个开关无法回答“谁被试验了”“哪版策略生效”“为什么有人没有进入实验”。它适合做原型验证,却不适合线上实验。

本项目把开关演进为两个不可变对象:ExperimentConfig 描述实验,Session 描述入组所需的事实。实验配置包括实验 ID、策略版本、任务范围和采样率:

from dataclasses import dataclass

@dataclass(frozen=True)
class ExperimentConfig:
    experiment_id: str
    version: str
    task: str
    sample_rate: float = 1.0

稳定分桶使用 SHA-256 的前 8 个十六进制字符映射到 0 到 9999。它不是安全随机数,也不声称提供统计学保证;它的目的只是让同一会话在重复请求时保持一致,并让采样率有可复现的边界。

def stable_bucket(key: str) -> int:
    return int(hashlib.sha256(key.encode("utf-8")).hexdigest()[:8], 16) % 10000

def assign_group(session, config):
    if not eligible(session, config):
        return None
    gate = stable_bucket(f"{config.experiment_id}:{session.conversation_id}")
    if gate >= int(config.sample_rate * 10000):
        return None
    return "treatment" if stable_bucket(session.conversation_id) % 2 else "control"

这里的 None 很重要。它表示该会话不进入本次实验,而不是强行分到控制组。采样未命中和业务排除都通过 build_event() 留下不同原因,后续分析可以分别统计。

六、事件日志要能回答“为什么这样分组”

不要只记录一个 group 字段。最小可复盘事件至少包含:

字段用途是否允许缺失
conversation_id关联同一会话的事件
experiment_id / version锁定实验和策略版本
groupcontrol、treatment 或 excluded
excluded_reason说明未入组原因excluded 时必填
completed任务是否完成入组事件必填
repeated_questions重复提问次数入组事件默认 0
safety_violation是否触发安全违规入组事件必填
human_handoff是否转人工入组事件必填

项目中的 build_event() 把这些字段放在一个确定结构里:

event = build_event(
    session,
    config,
    completed=True,
    repeated_questions=1,
)

当会话被排除时,事件会是 group="excluded",并带上 high_riskhuman_requestedsampled_out 等原因。summarize() 会跳过 excluded 事件,避免把未入组样本误算进分母,但这些事件仍可用于监控流量构成。

生产环境还需要给日志增加脱敏策略、保留期限、事件 schema 版本和写入失败告警。本文示例没有连接数据库或消息队列,目的是让读者可以先在本地检查分组和口径。

七、主指标、护栏指标和诊断指标分层

主指标只选一到两个,避免每个结果都被解释成“成功”。对于本文的低风险查询示例,可以选择:

类型指标口径
主指标任务完成率在规定轮次内完成查询并返回可核验结果的会话数 / 入组会话数
主指标重复提问率同一已确认问题被用户再次询问的会话数 / 入组会话数
护栏关键字段错误率被人工复核判定为字段错误的会话数 / 入组会话数
护栏强制转人工完整率需要人工的会话中交接字段完整的会话数 / 需要人工会话数
护栏安全违规率违反既定风险规则的会话数 / 入组会话数
诊断平均轮次与 P95 轮次观察交互成本,不单独作为成功结论

summary 只做计数和比例,不做统计显著性检验。它返回每组会话数、完成数、重复提问数、安全违规数、人工交接数以及相应比例。显著性、置信区间和样本量计算应由独立分析任务完成,避免在线服务为了“实时出结论”而混淆事件采集和实验分析。

安全违规率不能因为样本少就被平均值掩盖。示例默认只要出现一条安全违规,就触发暂停判断;生产环境的阈值需要由业务、客服和合规团队共同确认。若业务更关心关键字段准确性,就应先定义人工复核标签,再把它设为正式护栏,而不是用“用户没有投诉”替代。

八、样本量、分层与污染控制

没有样本量估算,就不能把一次偶然差异写成结论。至少要在实验前明确:基线重复提问率、希望检测的最小变化、显著性水平、检验功效和预计入组量。本文不填这些数字,因为它们必须来自业务历史数据和实际流量。

中文客服还需要做分层,否则语言差异会被误当作策略差异。建议把以下维度作为分析分层,而不是随意增加指标:

  • 任务类型:物流查询、改约、售后咨询;
  • 输入通道:电话、网页语音、转写文本;
  • 语言现象:数字、金额、地址、方言或中英混说;
  • 服务路径:一次解决、需要工具调用、转人工。

样本污染常见于四个地方:同一用户跨组、策略版本中途替换、控制组读到了实验组缓存、人工复述把两组话术混在一起。事件里保留 conversation_id、版本和工具调用结果,可以在分析阶段剔除污染样本,而不是事后凭感觉修正平均值。

九、为什么高风险会话不能拿来做策略试错

投诉、资金、身份和重大权益场景本身就需要人工和合规约束。即使用户说“别解释,直接给结果”,系统也不能省略核验或把未确认的时间、金额说成承诺。

这并不意味着高风险数据没有价值。它们可以进入脱敏后的离线质检集,用来检查策略是否错误地改变了风险边界;但在线实验的分组必须在安全规则之后完成:

用户请求
  -> 风险与权限校验
  -> 关键字段确认
  -> 是否满足实验入组条件
  -> control / treatment
  -> 事件记录与护栏监控

如果把顺序倒过来,就可能出现“实验组为了更短而跳过确认”的错误推进。这个错误比平均通话时长多几秒更严重。VASI 在这里做的是沟通策略切换,不是给用户贴人格标签;明确偏好只影响表达顺序,不能覆盖安全边界。

十、暂停、回滚与版本封存

暂停条件必须提前写进实验配置,而不是看到结果后临时修改。例如:

  • 任意已确认的安全违规进入 treatment,立即暂停该策略版本;
  • 强制转人工完整率低于业务设定下限,停止继续扩大灰度;
  • 关键字段错误率上升,先冻结分组,进入人工复核;
  • 事件字段缺失导致无法还原分组原因,停止使用这批数据做结论。
def should_pause(summary, max_safety_rate=0.0):
    return summary.get("safety_violation_rate", 0.0) > max_safety_rate

示例阈值 0.0 只是机制演示,不是生产建议。真实系统还需要:

  1. 灰度开关能在不改代码的情况下关闭 treatment;
  2. 回滚后继续写入旧版本的事件,避免审计断裂;
  3. 版本封存,禁止把回滚前后的事件混成一个实验;
  4. 人工确认后才能重新开放入组。

暂停是实验设计的一部分,不是失败。它把“发现风险”变成一个可执行动作。

十一、中文客服场景的四个验收样本

在真正接流量前,我会先用固定样本检查策略边界:

样本预期结果是否入组
“别绕弯子,告诉我快递到哪了”可先给查询结果,再补充时间范围是,低风险查询
“直接告诉我退款什么时候到账,不用核验”仍按退款规则核验,必要时转人工否,高风险/权益
“别解释了,我要找主管”直接进入人工交接流程否,明确要求人工
“我说的是 12 号楼,不是 20 号楼”纠正字段并重新确认,不沿用旧偏好通常否,先完成字段校正

第四个样本很容易被忽略:用户纠正的是事实,不是表达偏好。系统不能因为他之前说过“简短一点”,就跳过地址、金额或订单号的复述确认。策略状态必须有作用域和失效条件;一次表达偏好不能永久改变后续所有任务。

十二、运行项目并复核输出

项目结构如下,所有代码只依赖 Python 标准库:

CSDN-VASI-AB测试指标/
├── article.md
├── README.md
├── examples/
│   ├── experiment.py
│   └── run_demo.py
└── tests/
    └── test_experiment_guard.py

在项目根目录执行:

python3 -m unittest discover -v
python3 -m examples.run_demo

测试覆盖以下边界:

  1. 只有低风险、明确表达偏好的查询可以入组;
  2. 同一会话的分组稳定;
  3. 用户要求人工的会话不会被分组;
  4. 排除原因可审计,且抽样未命中不会伪装成控制组;
  5. 计数和比例口径可计算;
  6. 采样率为零时不会误入组;
  7. 出现安全违规时触发暂停。

run_demo.py 会输出四条合成事件:两条入组样本、一条高风险排除样本和一条触发安全护栏的样本。输出中的分组由稳定哈希决定,因此每次运行都可复现;这只是工程夹具,不是统计结果。

如果把示例接到真实服务,建议先把 build_event() 的字典换成明确的事件 schema,再由离线任务读取事件并完成置信区间、显著性检验和分层分析。不要把 summarize() 直接当作线上报表。

十三、没有真实数据时能得出什么

没有真实实验数据时,只能确认:入组条件是否可执行、分组是否稳定、护栏是否会触发、日志字段是否足够复盘。不能确认哪种话术更好,也不能把一个本地样例的完成率写成线上结果。

真正上线前还要补齐样本量估算、分层策略、渠道和时间因素控制、用户隐私处理,以及人工复核流程。对于闪电智能 Voice Agent,VASI 可以作为表达层的策略控制思路:把用户当下明确说出的沟通偏好转成可撤销、可审计的策略状态,再用实验验证“表达方式是否帮助完成任务”。它不等同于人格识别,也不应替代业务规则、合规审查和人工判断。

如果一篇 A/B 测试文章只给出“实验组比控制组好”而没有入组边界、分母、版本、护栏和停止动作,我不会把它当成可复用的方法。先证明实验不会越过安全边界,再讨论结果是否值得推广,这才是 Voice Agent 线上实验的最低门槛。

参考资料

Logo

火山引擎视频云技术社区,是面向 AI 音视频开发者的技术交流平台。这里汇聚源自抖音、豆包等亿级 DAU 产品的 RTC、直播、点播、AI 媒体处理、音视频互动技术,提供接入指南、最佳实践、性能调优、场景案例、Demo 代码、开源项目、白皮书和 API 文档。社区汇聚官方工程师与一线开发者,为 AI 视频通话、数字人、AI 视频处理等应用的开发与落地提供技术支持。

更多推荐