首页 / 文章

代理人从未下达的审计订单确认:核对数值而非文字内容

如何为负责下真实订单的客服构建需求一致性评估器:按负责人划分结果,将标识符与工具输出结果进行比对。

发布者:REACTAPP.TOP阅读约 16 分钟3706 词

来源: https://medium.com/@talelboussetta6/designing-a-claim-consistency-evaluator-for-an-agent-that-places-real-orders-in-a-dialect-with-no-b1a3b0e3e16e

一旦语言模型被允许执行操作,就会出现一种新的错误类型。当模型给出糟糕的答案时,造成的损害只是个不理想的回复,虽然令人恼火,但容易纠正,而且通常任何看到该回复的人都能察觉。而当模型描述它从未执行过的动作时,造成的损害则是关于世界上实际发生事件的虚假陈述,这些虚假信息会被流畅地传递给无法验证其真实性的人。

想象一下在线商店的客服聊天场景。顾客提供了配送地址和电话号码,这是下订单前所需的最后信息。客服回复说一切已确认,并给出了订单编号。顾客放下心来等待包裹,但实际上后台并没有生成任何相关内容。那个订单编号不过是模型像生成其他看似合理的字符序列一样随机产生的罢了。

以下是为突尼斯阿拉伯语对话设计的故障评估工具。同时,文中还介绍了该评估工具的第一个版本在整整一个月的时间里,实际测量的指标与既定目标大相径庭,而仪表板却显示着令人安心的上升趋势。

为何先讨论这一故障

智能客服的风险清单大多属于假设性内容。而这条记录是真实发生的,其原因也较为普遍。

通常的智能客服故障只会导致交易失败,但这种故障会同时让客户失去信任以及某些实物,因为客户一直在等待永远到不了的货物。对于通过Instagram销售商品的小商家而言,这类情况会切实损害其声誉,且影响程度是可以量化的。

它往往也保持隐形状态。错误的商品回复会在几分钟内引发投诉;而编造的确认信息起初不会引起任何反应,几天后才会出现看似与快递问题相关的消息。当有人最终查看时,已无人将延迟的投诉与该型号联系起来。

因此它会被首先交给评估员,在提示修复之前就进行评估,因为没有相关数据就无法判断提示修复是否有效。

第一版及未取得的进展

显而易见的解决方案是扫描智能体的回复,查找声称已下订单的表述;如果在整个对话中没有任何创建订单的工具调用记录,则触发警报:

flag  ⟺  claim_regex.matches(reply)  ∧  ¬ any(call.name == "create_order" for call in trace)

在真实流量上运行该规则的成本很低,且能立即生成标记,而这正是其中的陷阱。一旦出现数值,且并非零,人们就会产生将标记过的内容转发给对应智能体提示发送者的冲动。

更好的做法是打开这些被标记的追踪记录并逐一查看。花一周时间进行这样的查看是该项目中最值得重复的部分,同时也是在迭代计划板上最难得到认可的环节。

被标记的追踪记录中包含的内容

其中只有少数是真实的:有确认信息、订单号,且在追踪记录树中没有任何创建操作的痕迹。其余内容则可分为三类,且均不存在幻觉现象。

  • 关于已存在订单的提及。这是数量最多的类别。顾客要求取消订单,或想了解货物送达情况,或是修改昨日订单上的电话号码。每次,客服说已有确认订单是正确的,因为确实如此。该判定规则无法区分“刚刚下单”的陈述与“之前已下单”的陈述。
  • 条件性或未来时态的表述。客服解释说,一旦收到地址就会确认订单。这属于承诺而非断言,但缺乏时态意识的匹配规则却将其视为当下的事实陈述。
  • 否定句式。该系统称未找到确认的订单。突尼斯阿拉伯语使用环绕动词的附加前缀来表达否定,而非将其作为独立单词置于动词之前,因此基于拉丁字母转写的匹配器会误将隐藏在否定形式中的肯定模式识别出来。
  • 这就形成了两个独立的问题,而目标仅有一个。该系统确实偶尔会编造数据,检测器在触发时也大多出错。由于核心指标将两者混为一谈,任何关于该数值的讨论都会演变成对其真实性的争论。

    按责任主体划分判定结果

    问题并不在于正则表达式本身,真正的发现是“是否出现幻觉”这一表述将无关条件捆绑在了一起,而捆绑后的谓语无法用于做出决策。

    通过依次提出两个问题来替代它,从而使输出结果从布尔值变为分类结果。首先,回复是否表明存在某种顺序?其次,对话中的证据是否支持这一说法?最终会得到五类结果,每类都对应着具体的责任方:

    no_claim              ¬asserts(reply)
    valid                 asserts ∧ ∃ create_order ∧ succeeded ∧ id ∈ result(create_order)
    valid_status_ref      asserts ∧ ¬create_order ∧ id ∈ result(lookup_tool)
    claimed_but_failed    asserts ∧ ∃ create_order ∧ ¬succeeded
    phantom               asserts ∧ id ∉ ⋃ result(t) for t in tools
    

    在前三类情况中都没有人有过错:要么回复并未提出任何主张,要么成功的创建结果能够支持该主张,要么回复引用了查询工具已确认的先前订单。最后两类则是代理程序中的缺陷,责任在于拥有相应提示和防护机制的人。如果某个记录被归入了本不应属于它的类别,那就说明评估工具本身存在问题,负责维护该工具的人就需要处理相关工单。

    这种分类方式是该设计中最具通用性的理念。评估结果应按需要处理问题的人来划分,而非按现象类型划分。那种将模型异常与测量失误混为一谈的指标根本无法通过首次评审,因为任何因此受到不便的人都可以对所有评估结果提出质疑。

    有一个类别需要进一步仔细研究。claimed_but_failed指的是工具运行后返回错误或不确定的结果,但客服仍确认了订单的情况。第一版系统没有这种状态,会将这类记录视为成功,尽管对客户而言其结果与完全虚构的订单并无区别。此类评估系统通常会将原始判定规则中因测试通过而被归入“正常”类别的类似状态隐藏起来,而发现这类问题所带来的收益远超过调整阈值所能带来的效果。

    验证数值,忽略措辞

    效果最显著的改动是停止检查表述方式,转而关注数值。将标识符从回复中剔除,然后要求其在同一对话的工具结果中出现。

    分类器的入口会显示操作顺序。如果回复中未明确说明存在顺序,则无需再做其他处理:

    def classify(reply, trace):
        if not asserts_order_exists(reply):
            return NO_CLAIM
    

    否则,该模块会提取候选标识符,丢弃任何类似本地电话号码的格式(因为这些会与订单编号冲突),收集创建和查询调用信息,然后按顺序处理这五种结果:

        ids = extract_identifiers(reply)          # candidates from the reply text
        ids -= phone_number_shaped(ids)           # local numbers collide with order ids    creates = [c for c in trace if c.name == "create_order"]
        lookups = [c for c in trace if c.name in LOOKUP_TOOLS]    backed_by = {c: ids & identifiers_in(c.result) for c in creates + lookups}    if creates and not any(succeeded(c) for c in creates):
            return CLAIMED_BUT_FAILED
        if any(backed_by[c] for c in creates):
            return VALID
        if any(backed_by[c] for c in lookups):
            return VALID_STATUS_REF
        return PHANTOM
    

    有两个特性使得它比词汇规则更强大。

    首先看误报问题。真实的状态回复会引用从实际查询中得到的标识符,因此无需借助时态处理、礼貌表达处理或措辞列表就能满足底层规则。最大的错误类别因此作为副产品而消失。

    其次,这种检查几乎不受语言影响。从工具结果中收集标识符是对结构化数据进行的集合操作。方言仅在asserts_order_exists函数中起作用,这使得手动编写的语言处理工作仅限于一个函数。

    这种设计的缺陷恰恰体现在那些边缘情况上,因此需要更加精确的处理:

    • 意外匹配。在完整上下文中搜索标识符时,偶尔会遇到属于价格、数量或电话号码的数字。本地号码具有可识别的格式,会被明确过滤掉。一般的解决办法是仅在工具返回的结果数据中搜索,而非在已扁平化的对话内容中搜索,这样需要稍作处理即可解决该问题。
    • 提取过程会对标识符的格式做出假设。如果标识符的格式发生变化,评估器会悄悄地将所有轨迹都归类为PHANTOM类型。单元测试应当对此进行约束,因为注释是无法做到这一点的。
  • succeeded()承担了核心工作。那些返回HTTP 200状态码但响应体中包含错误的工具,除非有逻辑能读取该响应体,否则会被视为成功。正是这一细节决定了claimed_but_failed是否会触发,还是保持为空。
  • 没有简化方案的部分

    asserts_order_exists是通用设计与特定语言相接的地方,目前没有现成的库可以提供帮助。用户使用突尼斯方言、阿拉伯语和法语进行交流,常常在一条消息中混用多种语言。由于没有标准拼写方式,同一个单词在一个聊天记录中会以多种形式出现。在此类场景下,分词器和词形还原器并不可靠,而主流的阿拉伯语处理工具也是为现代标准阿拉伯语设计的,与这些语言并不相同。

    因此,断言检测器实际上是一个受保护的词汇表,由实际日志中出现的表达方式构建而成。某个声明必须与该词汇表匹配,在匹配点周围的窗口内不得出现任何否定前缀,且其前面也不得有表示将来或条件的标记:

    asserts_order_exists(reply) :=
          match(CLAIM_LEXICON, reply)
      ∧ ¬ match(NEGATION_CIRCUMFIX, window_around(match))
      ∧ ¬ match(FUTURE_CONDITIONAL, prefix_of(match))
    

    每个检测器只查看匹配周围的某个窗口内容,而非整个消息,因为一条消息可能会否定某一点而肯定另一点。若错误地限定检测范围,就会产生一个看似准确实则毫无反应的检测器,这是最危险的结果。

    这正是属于工程实现而非简单配置的工作。关于幻觉检测已有大量研究文献,但几乎没有任何研究是在类似这样的语言上进行的。这种缺乏并非出于理论原因,而是实际问题:研究人员缺乏相关对话数据,而那些拥有这些对话的人也极少发表研究成果。

    通过五个对话进行演示

    具体的对话记录有助于人们更好地记住各类情况。我们以五个简短的交流为例。

    在第一种情况下,客户询问配送时间,客服给出几天左右的范围作为答复。由于没有确认订单,因此判定结果为no_claim,后续不会执行其他操作。

    在第二种情况下,客户提供了地址和电话号码,客服调用创建工具,该工具返回一个新的订单编号,回复时也会引用这个编号。由于回复中确认了订单存在,且该编号出现在创建结果中,同时调用也成功了,因此判定结果为valid。

    在第三种情况下,客户询问包裹的位置。客服调用查询工具,获取到带有编号的现有订单,然后回复称订单已确认且正在派送,同时也会引用该编号。虽然没有进行创建操作,但编号来自查询结果,因此判定结果为valid_status_ref。第一版系统会将这种情况标记为异常,而这也是误报最多的来源。

    在第四种情况下,客服调用了创建工具,但工具返回错误信息,而系统回复却仍确认订单存在。最终判定结果为claimed_but_failed。从客户的角度来看,这显然是凭空捏造的,之前的任何版本都无法对这种情况进行描述。

    在第五种情况下,客服根本没有使用任何工具,但系统回复却给出了一个看似正常的订单编号。而该编号在所有工具的查询结果中都不存在,因此判定结果为phantom,即项目试图找出的那种纯粹的伪造信息。

    在所有五种情况下,候选编号中的电话号码格式字符都会被去除。否则,客户自己的电话号码若在回复中被重复出现,就有可能冒充订单编号,使伪造的订单看起来有真实依据。

    在信任这些数据之前值得进行的测试

    由于评估器是确定性的,因此可以像其他代码一样进行单元测试,而且只需少量测试就能提供大部分保护:

    • 每个类别对应一个测试用例,使用经过处理的真实跟踪数据,验证最终的判定结果是否正确。
    • 一种格式变化测试,会输入当前格式以及未来可能出现的格式中的提取器标识,从而确保格式变化不会悄无声息地将所有结果都转为PHANTOM状态。
    • 一种测试,让工具返回带有错误内容的HTTP 200响应,验证succeeded()函数是否会拒绝该响应,并将判定结果设为claimed_but_failed。
    • 一种否定测试,在否定范围内部包含肯定性表述;另一种则在消息的其他位置对不同名词进行否定处理,从而确保范围界定在两种情况下都能保持一致性。
  • 通过电话号码测试可确认,回复中重复出现的客户本地号码绝不能视为订单编号。
  • 这些测试都无法证明精确性。它们只能证明评估工具仍在按其设计者预期的方式运行,而这正是所有其他评估的基准。

    处理第一轮标注

    标注步骤很容易出错,因此有明确的规范能起到帮助作用。在查看评估结果之前先固定样本状态,这样标签就不会受到判断结果的干扰。给两个人相同的轨迹资料和五类定义,要求他们各自独立标注而不进行讨论。首先比较他们的标注结果,若存在差异,则一起查看轨迹资料,判断是定义不够清晰还是其中一人出现了错误。对于不清晰的定义要重新撰写,并对相关轨迹资料重新标注。只有当两位标注者在使用卡帕系数衡量后的一致性达到一定程度时,才有意义对同一样本使用评估工具,并查看各类别的精确度。

    按那种顺序操作还有另一个好处。人类之间的分歧往往集中在那些曾让第一版系统出错的边界情况上,比如在确认订单时提及尚未支付的款项,或者引用客户自己消息中的编号。每个这样的问题群要么会形成更明确的分类,要么会成为检测系统中的新过滤规则,而这些样本同时还能作为后续修改时的回归测试数据。

    判定结果的去向

    只有当每个桶都有对应的目标时,分区才有意义。claimed_but_failed和phantom这两种代理错误类别会被转发给负责该提示及确认机制的团队,理想情况下还会附上出问题的跟踪信息。而三种正常类别则不会被转发到任何地方,仅作为常规检查,确保流量符合预期。如果评估器本身出现错误,比如某个状态被错误地归类到phantom中,这类问题会转交给评估器的维护者,并被记录为新的故障案例。正是这种分离机制,使得构建代理的人和评估代理的人都能信任同一个控制面板。

    为何不直接使用大语言模型进行评估?

    这个反对意见很明确,也有合理的解答。

    • 价格与速度。用于评分的评估工具会针对每条记录进行运算。在处理大量数据时,这意味着会产生额外的计算成本,而且还有可能出现故障,而确定性版本仅需微秒级时间即可完成检测。
    • 仍然依赖于同一套标准。那些评分规则未经验证的评估工具根本无法给出有效结果。这相当于第二种意见,但其误差范围从未被计算过。必须有人手动为样本标注,才能判断该评估工具是否正确,因此这一昂贵的步骤无论如何都会被执行,而评估工具只不过是将难度转嫁到了其他地方而已。
    • 对长度和位置的偏好。评估工具会更倾向于给那些更长、更确定的答案打高分。在某些任务中,失败本身就代表一种确定的答案,这种情况下这种偏好反而会妨碍目标的实现。

    法官确实应该存在于某个特定位置:作为在样本上离线运行的第二层处理机制,用于弥补确定性层的不足。它的作用是评估召回率而非直接打分,且对于那些成本高昂、需要抽样分析的诊断任务而言,这种建模方式是正确的。

    这两种方案都不是最终目标。由确定性评估器生成的类别被标记为训练数据,而一个基于回复中的字符n-gram的小型分类器就可以以远低于“法官”系统成本的价格替代词典功能。大型边界模型负责训练,团队自用的小型模型则用于实际应用,而这只有在前置的标注问题得到解决后才能实现。

    仍存在的问题

    目前仍有三个未解决的现实限制,指出这些问题正是本文的目的所在。

    精确度是一种信念而非测量结果。至今尚无人用固定的、已标注的样本对当前版本进行评分。在这样的样本出现之前,所谓评估者表现有所提升的说法只是基于类别分布得出的有依据的判断,不应以数字形式呈现在幻灯片中。剩下的工作虽简单却并不引人注目:先固定一个样本,对其标注两次,计算两位标注者之间的一致性,然后再与评估者的结果进行比较。当两人意见不一致时,问题出在类别定义上,而调整评估工具无法改变定义本身。任何评估分数都必须以人类标注者之间的一致性作为前提,因此首先应考虑人类标注对之间的科恩卡帕值,只有在此之后,评估者针对这些标签的精确度才有意义。

    召回率受想象力限制。检测器只能识别出人们预先预料到的表达方式。以某种未列出的方式表述的确认内容会被归类到no_claim中而无人注意。要确定这一差距的大小,就需要从负样本类别中抽取数据,而当真实的虚假内容极为罕见时,大多数抽取结果都是空的。目前尚无解决这一限制的有效方法。

    检测能力较为薄弱。每个类别都是在用户已经阅读消息之后才进行计算的。评估器仅会统计谎言数量,而无法阻止任何谎言的产生。

    这种结构上的限制使得生成数值变得不可能。当确认信息是从模板中获取的,而标识符字段只能通过工具输出的对象来填充时,模型就根本没有机会自行生成数值。并非只是被禁止这样做,而是因为该数值根本无法在模型中传递:

    # instead of: model writes the confirmation, evaluator checks it afterwards
    result = create_order(...)
    if result.ok:
        reply = CONFIRM_TEMPLATE.render(order_id=result.order_id)   # model never holds the pen
    else:
        reply = model.compose(FAILURE_CONTEXT)                       # nothing to fabricate
    

    出于其最初的设计目的,大部分评估器都变得多余了,而这正是期望的结果。它的角色发生了变化:从安全机制转变为用来判断真正防护措施是否有效的工具,同时也为负责移除词汇表的分类器提供标签。

    并列展示的相同组件

    由于该设计包含多个可变动的部分,一份简洁的参考资料会很有帮助。第一版本的标记规则,也就是导致问题的那个简单谓词,内容如下:

    flag  ⟺  claim_regex.matches(reply)  ∧  ¬ any(call.name == "create_order" for call in trace)
    

    这五种结果仍按顺序列出,并对应各自的过错方:

    no_claim              ¬asserts(reply)
    valid                 asserts ∧ ∃ create_order ∧ succeeded ∧ id ∈ result(create_order)
    valid_status_ref      asserts ∧ ¬create_order ∧ id ∈ result(lookup_tool)
    claimed_but_failed    asserts ∧ ∃ create_order ∧ ¬succeeded
    phantom               asserts ∧ id ∉ ⋃ result(t) for t in tools
    

    分类器的入口守卫:

    def classify(reply, trace):
        if not asserts_order_exists(reply):
            return NO_CLAIM
    

    负责将数值与工具检测结果进行比对的机构:

        ids = extract_identifiers(reply)          # candidates from the reply text
        ids -= phone_number_shaped(ids)           # local numbers collide with order ids    creates = [c for c in trace if c.name == "create_order"]
        lookups = [c for c in trace if c.name in LOOKUP_TOOLS]    backed_by = {c: ids & identifiers_in(c.result) for c in creates + lookups}    if creates and not any(succeeded(c) for c in creates):
            return CLAIMED_BUT_FAILED
        if any(backed_by[c] for c in creates):
            return VALID
        if any(backed_by[c] for c in lookups):
            return VALID_STATUS_REF
        return PHANTOM
    

    受保护的断言检测器:

    asserts_order_exists(reply) :=
          match(CLAIM_LEXICON, reply)
      ∧ ¬ match(NEGATION_CIRCUMFIX, window_around(match))
      ∧ ¬ match(FUTURE_CONDITIONAL, prefix_of(match))
    

    预防模式则是让标识符从工具结果流入模板中:

    # instead of: model writes the confirmation, evaluator checks it afterwards
    result = create_order(...)
    if result.ok:
        reply = CONFIRM_TEMPLATE.render(order_id=result.order_id)   # model never holds the pen
    else:
        reply = model.compose(FAILURE_CONTEXT)                       # nothing to fabricate
    

    按此顺序阅读,就能看到整个流程:一条简单的规则、一次分割操作、一次值检查、一个针对特定语言的防护机制,最后是一种杜绝故障发生的设计。

    五个有效的做法

    • 直接阅读原始日志。跳过模型生成的样本摘要,手动查看那些繁杂的对话内容。这里最有价值的发现是人工构造的标识符中存在某种格式规律,这种规律既无法通过汇总数据显现,也无法通过提示预判出来。
    • 按负责修复的人员划分结果。那种将代理缺陷与测量缺陷混为一谈的指标,一旦由负责团队查看就会立刻暴露问题。
  • 检查数值而非表述。询问证据中是否存在某个声称的标识符,成本更低、效果更稳定且适用范围更广,同时还能避免因表述方式不同而产生的错误。
  • 寻找隐藏的类别。有一种工具虽然调用失败,却一直被当作成功处理。每位评估者都有自己认定的标准,通常是原始正常流程判断所依据的状态。
  • 优先考虑预防措施,并明确已部署的类型。检测机制更易于构建,演示起来也更为简单。若将两者混为一谈,就会导致监控面板上的数据持续上升,而实际失败率却毫无变化。

    构建评估工具只是较容易的部分。真正的挑战在于,在一种缺乏相关工具支持的语言中,定义出一个足够精确的正确答案,以便机器能够进行测试,同时让两名人类评估者达成一致。