跳到主要内容

指南

教程:多轮对话

一份使用 Python SDK 评估对话助手的可运行演练:你的应用在一个全新的会话中重放一段脚本化的对话,把它的回答记录为一个 conversation/v1 产物,再由两个评估器对每段完整的对话进行评判。它离线运行,用一个脚本化的替身代替评判模型,然后比较一次候选修复,最后介绍另一种做法:把各轮作为聚类用例来评估。

区间、决策状态和发布动作等术语在核心概念中有定义;记录系统做了什么介绍了产物。

Oloproof 在这里做什么、不做什么

Oloproof 做的事你的应用做的事
把脚本化的对话存储为数据集,对每个系统都相同驱动对话:按顺序提出每个脚本化的轮次
检查每个脚本化的轮次都得到了回答(ConversationCompleted)负责会话状态,为每个用例启动一个全新的会话,并重置它
用模型评判整段对话记录(ConversationJudge)决定无法继续时会发生什么,并记录它停止了
计算区间、比较两个系统,并依据策略作出决策记录 conversation/v1 产物

Oloproof 没有用户模拟器:它从不编写用户轮次,所以用户一方就是数据集脚本里写的内容。在一段记录下来的对话内部,它没有轮次级指标,也无法针对新系统重放一段记录下来的对话。对话评估器只存在于 Python SDK 中:ConversationCompleted 和 ConversationJudge 不是 oloproof.yaml 中的评估器类型,所以本教程使用脚本,而不是 oloproof run。

前提条件

  • Python 3.11 或更高版本,以及 pip install oloproof,与快速入门中一样。
  • 示例文件,它们随软件包一起提供。把它们复制到一个新目录,让运行的存储落在那里:
oloproof init --example conversation ~/oloproof-conversation
cd ~/oloproof-conversation
文件它是什么
assistant.py被测应用:一个带会话状态的套餐助手
systems.py适配器:重放脚本,记录 conversation/v1
judge_offline.py评判模型的脚本化替身
evaluate.py运行评估、比较以及按轮次评估的替代做法
release.yaml单次运行的策略
comparison.yaml候选对比基线的策略
turns_release.yaml按轮次评估这一替代做法的策略
data/conversations.jsonl40 段脚本化的对话
data/turns.jsonl同样的对话,每轮一个用例

在最后的可选实时步骤之前,没有密钥、没有网络,也没有提供方费用。

应用

assistant.py 回答关于三个价格套餐的问题。它保存一项状态,即对话所涉及的套餐,这样像“Does that include SSO?”这样的追问就能解析出“that”指什么。基线有一个故意设置的缺陷:它不记得套餐,所以追问会按默认套餐来回答。用户要求转人工时,对话以 HandoffRequested 结束。

class PlanAssistant:
    def __init__(self, *, remembers_plan):
        self.remembers_plan = remembers_plan
        self.reset()

    def reset(self):
        """Forget everything, so one conversation never leaks into the next."""
        self.current_plan = None

    def ask(self, question): ...

这是你要换成自己应用的部分:一个聊天机器人客户端、一个智能体会话、一个连到你服务的 HTTP 会话。无论它是什么,它都自己负责其状态和重置;Oloproof 只能看到适配器记录下来的内容。

数据集:脚本就是输入

data/conversations.jsonl 的一行就是一段对话:

{"expected": {"plan": "enterprise"}, "id": "conv_00", "input": {"turns": ["What does the enterprise plan cost?", "Does that include SSO?"]}, "metadata": {"pattern": "pronoun_followup"}}

用户的轮次是数据集内容,被测试套件的摘要所覆盖,并且对于针对它们度量的每个系统都完全相同;正是这一点让两个系统可以比较。expected 是展示给评判模型的参考。这 40 段对话中,24 段有一个没有指明套餐的追问,12 段每一轮都指明了套餐,4 段在三轮中的第二轮要求转人工。

适配器

systems.py 为每个用例启动一个全新的会话,按顺序提出每个脚本化的轮次,并记录返回的内容:

from oloproof import CONVERSATION, current_case, system


def replay(case, *, remembers_plan):
    script = [str(turn) for turn in case["turns"]]
    session = PlanAssistant(remembers_plan=remembers_plan)  # a new session per case
    turns = []
    truncated = False
    for index, question in enumerate(script, start=1):
        try:
            reply = session.ask(question)
        except HandoffRequested:
            truncated = True  # the recording stops here and says so
            break
        turns.append({"index": index, "asked": question, "answer": reply["answer"]})
    current_case().artifact(
        CONVERSATION,
        {"turns": turns, "declared_turns": len(script), "truncated": truncated},
    )
    last = turns[-1]["answer"] if turns else None
    return {"answer": last, "turns_answered": len(turns)}


@system(name="plan-assistant", version="baseline", records=(CONVERSATION,))
def baseline(case):
    return replay(case, remembers_plan=False)


@system(name="plan-assistant", version="candidate-remembers-plan", records=(CONVERSATION,))
def candidate(case):
    return replay(case, remembers_plan=True)

每个用例使用全新的会话很重要:Oloproof 并发地、不按固定顺序运行用例,用例之间共享的会话会让一段对话的状态泄漏到另一段中。records= 声明该系统会记录这个产物;没有它时,对话评估器会在任何东西运行之前被拒绝,而不是把每个用例都计为缺失。

conversation/v1 产物

基线为 conv_00 记录的内容,来自 oloproof export RUN_ID(包中的 cases.jsonl):

{"declared_turns": 2, "truncated": false, "turns": [{"answer": "The enterprise plan costs a price agreed per contract.", "asked": "What does the enterprise plan cost?", "index": 1, ...}, {"answer": "The starter plan does not include SSO.", "asked": "Does that include SSO?", "index": 2, ...}]}

以及一段要求转人工的对话:

{"declared_turns": 3, "truncated": true, "turns": [{"answer": "The team plan costs $20 a month.", "asked": "What does the team plan cost?", "index": 1, ...}]}
字段含义
turns[].index这是对哪个脚本化轮次的回答;从 1 开始连续编号
turns[].answer助手返回的内容,任意 JSON
turns[].asked可选,仅供阅读;引擎按 index 匹配
turns[].retrieval可选,该轮检索到的内容,采用 retrieval/v1 结构
declared_turns脚本声明了多少轮
truncated无论是什么原因截断,记录在脚本结束之前就停止了

产物在被记录时就会被检查:轮次少于声明数量的记录必须写明 truncated: true,索引必须连续,而且记录回答的轮次不能多于被提问的轮次。格式错误的产物会让运行以 SystemContractError 停止。

两个评估器,以及为什么两个都要

  • ConversationCompleted 是确定性的:助手是否回答了每个脚本化的轮次?它最先运行,因为对一段在三轮中第一轮就停止的对话所作的任何其他断言,都是关于另一段对话的断言。被截断的对话不能通过它;这是一个结果,而不是一个缺失的用例。
  • ConversationJudge 是作用于整段对话记录(每个用户轮次和助手轮次)的模型评判,因为对话产品被指责的失败是关系性的:一个与上一轮回答相矛盾的回答,只有放在上一轮旁边才是错的。被截断的对话按已记录的内容来评判,而对话记录会告诉评判模型它在哪里停止。
evaluators = [
    ConversationCompleted(),
    ConversationJudge(criterion="plan_coherent", provider=..., model=..., rubric_text=RUBRIC),
]

离线评判

评判需要一个模型。为了在没有网络的情况下运行,judge_offline.py 传入一个脚本化的提供方,也就是 Oloproof 自己的测试所用的同一个辅助工具(来自引擎内部的 FakeProvider,不是公开 API)。它按一条固定规则回答每个评判提示:当每个助手轮次都指明了参考所指明的套餐时就通过。这让评判结果是确定性的,教程是可复现的。它完全不能度量真实评判模型的表现。

策略

release.yaml:

version: 1
confidence_level: 0.95
block_on: [FAIL, INSUFFICIENT_EVIDENCE, MANUAL_REVIEW]
rules:
  - {id: completion-floor, metric: conversation_completed, min: 0.75}
  - {id: coherence-floor, metric: plan_coherent, min: 0.80}

运行

python evaluate.py
baseline run run_...
  conversation_completed: 0.900 [0.763, 0.972] over 40 conversations
  plan_coherent: 0.400 [0.249, 0.567] over 40 conversations
  gate BLOCK (exit 3)
  completion-floor: PASS (lower_bound_meets_minimum)
  coherence-floor: INSUFFICIENT_EVIDENCE (evaluator_not_validated)
  first failing conversation:
    conversation_completed: passed=True {'turns_recorded': 2, 'turns_declared': 2, 'truncated': False}
    plan_coherent: passed=False {'provider_model': 'offline-rule'}

如何阅读:

  • 完成率是 40 中的 36,即那四次转人工。它的下界越过了 0.75,所以该规则是 PASS。
  • 连贯性是 40%,它的规则显示 INSUFFICIENT_EVIDENCE,原因为 evaluator_not_validated,而不是 FAIL。在评判模型对照人工标签度量之前,它的规则不作决策(require_validated_evaluators 默认开启;评判器对此有解释)。估计值仍然会显示,它仍然是证据:只是它不能单独放行或阻止发布。
  • gate BLOCK (exit 3):策略会因 INSUFFICIENT_EVIDENCE 而阻止。退出码 3 就是这个状态,而不是失败。

验证离线替身毫无意义,因为它是为这个示例编写的一条规则。对于真实的评判模型,先用 oloproof review RUN_ID --criterion plan_coherent --by YOU --sample 20 标注该运行的一个样本,再运行 oloproof evaluators validate EVALUATOR_ID --by YOU。

查看一段失败的对话

SDK 写入的是 CLI 读取的同一个存储,即你运行时所在目录中的 .oloproof/:

oloproof inspect RUN_ID --case conv_00
output: {
  "answer": "The starter plan does not include SSO.",
  "turns_answered": 2
}
judgments:
  conversation_completed: passed
  plan_coherent: failed
    judge text, not verified:
      every answer is about enterprise: False

用户问的是企业版套餐,而追问却按入门版套餐来回答。oloproof inspect RUN_ID --failures 列出每段失败的对话;全部 24 个没有套餐名称的追问都以同样的方式失败。下一步在应用里:把套餐保存在会话状态中。

一次候选变更,以及比较

systems.py 中的 candidate 设置了 remembers_plan=True。evaluate.py 在相同的脚本上运行两个系统,并依据 comparison.yaml 逐个用例比较它们:

rules:
  - {id: coherence-better, kind: superiority, metric: plan_coherent}
  - {id: completion-no-worse, kind: non_inferiority, metric: conversation_completed, margin: 0.05}
comparison, candidate minus baseline
  conversation_completed: +0.000 [-0.127, +0.127] over 40 pairs
  plan_coherent: +0.600 [+0.337, +0.817] over 40 pairs
  gate BLOCK (exit 3)
  coherence-better: INSUFFICIENT_EVIDENCE (evaluator_not_validated)
  completion-no-worse: INSUFFICIENT_EVIDENCE (interval_overlaps_margin)

连贯性的差异很大,其区间不包含零,但评判模型未经验证,所以它的规则仍然不作决策。完成率没有变化,而 40 对无法证明它在五个百分点以内:区间在两个方向上都延伸到 12.7 个百分点。两者都指向同一个下一步:验证评判模型并增加对话。

替代做法:把轮次作为用例,按聚类分析

对话级的结论说明的是对话有多经常进展顺利,而不是哪一轮出了错。由于 Oloproof 在一段记录下来的对话内部没有轮次级指标,另一条路线是把每一轮作为单独的用例,并用 group_id 把一段对话的各轮联系在一起:

{"expected": {"plan": "enterprise"}, "group_id": "conv_00", "id": "conv_00_t2", "input": {"history": ["What does the enterprise plan cost?"], "question": "Does that include SSO?"}}

系统把脚本化的历史重放到一个全新的会话中,然后回答该轮:

@system(name="plan-assistant-turns", version="baseline")
def turn_baseline(case):
    session = PlanAssistant(remembers_plan=False)
    for earlier in case["history"]:
        session.ask(str(earlier))
    return session.ask(str(case["question"]))

同一段对话的各轮并不独立,所以只要有任何用例带有 group_id,测试套件就会按聚类分析,使用一种策略必须接受的近似方法(turns_release.yaml 设置了 allow_approximate_methods: true;聚类用例对此有解释):

turns as cases: turn_plan 0.667 [0.588, 0.749] over 72 turns
  gate BLOCK (exit 1)
  turn-plan-floor: FAIL (upper_bound_below_minimum)

取舍:

每段对话一个用例每轮一个用例,按聚类
比率的单位进展顺利的对话回答正确的轮次
有效样本量对话的数量仍然是对话的数量,而不是轮次的数量
哪一轮失败了阅读对话记录每一轮都有自己的结论
每一轮看到的历史助手自己之前的回答脚本中之前的用户轮次,经过重放
能否发现由它自己之前的回答引起的偏移能不能,每一轮都从脚本化的历史开始
评估器ConversationCompleted、ConversationJudge(仅限 SDK)任何评估器,在 YAML 或 SDK 中

按轮次的替代做法排除了那四段转人工的对话,所以它的 72 个轮次来自 36 段对话。在这里,它能在评判模型无法决策的地方作出决策,因为 ExactMatch 是确定性的,不需要验证。

可选:实时评判模型

这一步需要一个在你的机器上提供服务的模型。离线教程及其测试不会运行它。在 Ollama 运行并已拉取 llama3.1 的情况下:

python evaluate.py --live

然后评判模型以 provider="openai_compatible" 调用 http://localhost:11434/v1。回环地址上的服务器不需要密钥,也不会把任何东西发出本机。云端提供方需要在环境中提供它的密钥,会把每段对话记录发送给该提供方,并且每次评判都要花钱。真实模型的结论与替身的不同,所以上面的数字会改变,而在你验证它之前,它的规则仍然显示 evaluator_not_validated。

故障排除

症状原因与修复
this evaluator needs exactly one conversation/v1 artifact; the case recorded 0适配器没有调用 current_case().artifact(CONVERSATION, ...),或在此之前抛出了异常。即使对话提前停止,也要记录。
malformed conversation/v1 artifact: ... 0 of 2 turns recorded and truncated is false运行以 SystemContractError 停止。轮次少于 declared_turns 的记录必须设置 truncated: true。
conversation turn indexes must be contiguous starting at 1按各轮所回答的脚本化轮次编号为 1、2、3。
evaluator 'conversation_completed' needs conversation/v1 artifacts, but system ... does not declare that it records them在 @system 装饰器中加入 records=(CONVERSATION,)。
oloproof run 报出 Input tag 'conversation_completed' found using 'type' does not match any of the expected tags对话评估器仅限 SDK。像这里一样使用脚本。
回答在对话之间泄漏用例之间共享了一个会话。为每个用例创建一个。
连贯性规则从不作出决策评判模型未经验证。验证它,或在知情的情况下设置 require_validated_evaluators: false。

局限

  • 没有用户模拟器:每个用户轮次都来自数据集脚本,所以对话无法根据助手说了什么而产生分支。
  • 在一段记录下来的对话内部没有轮次级指标;改为把轮次作为聚类用例,并接受上面的取舍。
  • 没有对话重放:记录下来的对话无法针对另一个系统重新运行。比较两个系统意味着每个系统都重放同一个脚本。
  • ConversationCompleted 和 ConversationJudge 仅限 Python SDK。
  • 仅限文本:展示给评判模型的是 JSON 文本,从不是图像或音频。
  • 离线评判模型是一条脚本化的规则。它的结论展示的是机制,而不是真实评判模型的准确性。

下一步

  • 评判器介绍提供方、验证和重新校准。
  • 聚类用例介绍 group_id 以及近似方法的选择性启用。
  • Python API介绍 evaluate 和 evaluate_comparison。
  • 智能体介绍智能体工具循环的同一边界。