跳至主要內容

指南

教學:多輪對話

一份使用 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。
  • 代理介紹代理工具循環的同一邊界。