指南
教程:多轮对话
一份使用 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.jsonl | 40 段脚本化的对话 |
| 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.pybaseline 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_00output: {
"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。
- 智能体介绍智能体工具循环的同一边界。