Skip to main content

lc_evaluation/
pairwise.rs

1//! 成对比较评测器:让 LLM 裁判在两个回答中二选一(竞技场模式)。
2//!
3//! 带位置偏差缓解:交换 A/B 顺序跑两次,两次都选同一个才算真赢,否则判平局。
4
5use async_trait::async_trait;
6use futures_util::future;
7use serde::Deserialize;
8
9use lc_core::judge::{structured_call, truncate, StructuredJudgeError};
10use lc_core::tools::ToolDefinition;
11use lc_core::BaseChatModel;
12use lc_schema::Message;
13
14use super::{EvalError, PairwiseEvaluator, Score};
15
16/// 成对比较结果
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum Verdict {
19    /// A 回答更优
20    AWins,
21    /// B 回答更优
22    BWins,
23    /// 平局
24    Tie,
25}
26
27/// 裁判选了哪个位置
28#[derive(Debug, Clone, PartialEq, Eq)]
29enum Pick {
30    First,
31    Second,
32    Tie,
33}
34
35/// 成对比较评测器(用 LLM 当裁判,二选一)。
36///
37/// P1-1: 实现 `PairwiseEvaluator` trait,可与单点 `Evaluator` 一起进 `EvalRunner`
38/// 统一报告;直接调用仍可走 `compare` 拿细粒度 `Verdict`(A 赢 / B 赢 / 平局)。
39pub struct PairwiseJudge<M: BaseChatModel> {
40    judge: M,
41    rubric: String,
42}
43
44const DEFAULT_PAIRWISE_RUBRIC: &str = "\
45正确性:回答是否事实准确、是否切题。
46完整性:是否完整回答了问题。
47清晰性:表达是否清晰、简洁。";
48
49impl<M: BaseChatModel> PairwiseJudge<M> {
50    /// 创建使用默认评分标准的成对比较评测器。
51    pub fn new(judge: M) -> Self {
52        Self {
53            judge,
54            rubric: DEFAULT_PAIRWISE_RUBRIC.to_string(),
55        }
56    }
57
58    /// 设置自定义评分标准(builder 风格)。
59    pub fn with_rubric(mut self, rubric: impl Into<String>) -> Self {
60        self.rubric = rubric.into();
61        self
62    }
63
64    /// 比较 A、B 两个回答,返回谁更好。
65    ///
66    /// 交换 A/B 顺序跑两次,消除位置偏差:两次都选同一个才算真赢,否则判平局。
67    /// P2-4: 两次 ask 相互独立,用 `future::join` 并发发起(消除 N+1 串行往返)。
68    pub async fn compare(&self, input: &str, a: &str, b: &str) -> Result<Verdict, EvalError> {
69        let (v1, v2) = future::join(self.ask(input, a, b), self.ask(input, b, a)).await;
70        let v1 = v1?; // A 在前
71        let v2 = v2?; // 交换,B 在前
72
73        Ok(match (v1, v2) {
74            (Pick::Tie, _) | (_, Pick::Tie) => Verdict::Tie,
75            (Pick::First, Pick::Second) => Verdict::AWins, // v1 选 A(前),v2 选 A(后)
76            (Pick::Second, Pick::First) => Verdict::BWins, // v1 选 B(后),v2 选 B(前)
77            _ => Verdict::Tie, // 位置偏差:两次选的位置一致但映射回不同答案
78        })
79    }
80
81    async fn ask(&self, input: &str, first: &str, second: &str) -> Result<Pick, EvalError> {
82        let system = format!(
83            "你是裁判。根据评分标准,判断两个回答哪个更好。调用 pick_better 工具提交判定。\n\n\
84             评分标准:\n{rubric}\n\n\
85             判定的 verdict 取三者之一:\"a\"(第一个更好) / \"b\"(第二个更好) / \"tie\"(平局)",
86            rubric = self.rubric
87        );
88        let user =
89            format!("题目:\n{input}\n\n第一个回答:\n{first}\n\n第二个回答:\n{second}\n\n哪个更好?");
90        let messages = vec![Message::system(system), Message::human(user)];
91
92        // P0-1: 优先结构化输出(verdict: a/b/tie);不支持工具绑定的模型走文本解析回落。
93        let args: PickArgs = structured_call(&self.judge, pick_tool(), messages, |raw| {
94            let pick = parse_pick(raw).ok_or_else(|| {
95                StructuredJudgeError::Parse(format!(
96                    "failed to parse winner from judge reply: {}",
97                    truncate(raw, 200)
98                ))
99            })?;
100            Ok(PickArgs {
101                verdict: pick_to_str(pick).to_string(),
102                reason: String::new(),
103            })
104        })
105        .await?;
106        str_to_pick(&args.verdict)
107    }
108}
109
110/// P1-1: 作为 `PairwiseEvaluator` 进 `EvalRunner`,judge 以
111/// (a=prediction, b=reference) 为两个候选。得分映射:1.0 = A 优、
112/// 0.5 = 平局、0.0 = B 优,label 保留裁决含义(a_wins / tie / b_wins)。
113#[async_trait]
114impl<M: BaseChatModel> PairwiseEvaluator for PairwiseJudge<M> {
115    async fn eval_pair(&self, input: &str, a: &str, b: &str) -> Result<Score, EvalError> {
116        let (value, label) = match self.compare(input, a, b).await? {
117            Verdict::AWins => (1.0, "a_wins"),
118            Verdict::Tie => (0.5, "tie"),
119            Verdict::BWins => (0.0, "b_wins"),
120        };
121        Ok(Score::new(value).with_label(label))
122    }
123
124    fn name(&self) -> &str {
125        "pairwise"
126    }
127}
128
129/// 结构化判定参数(经 tool_calls 返回)。
130#[derive(Debug, Deserialize)]
131struct PickArgs {
132    verdict: String, // "a" | "b" | "tie"
133    /// 让 LLM 附上简短理由(改善判定质量),当前不消费。
134    #[serde(default)]
135    #[allow(dead_code)]
136    reason: String,
137}
138
139/// 构建二选一工具:让 LLM 以 `{"verdict": "a"|"b"|"tie", "reason": "..."}` 提交判定。
140fn pick_tool() -> ToolDefinition {
141    ToolDefinition::new(
142        "pick_better",
143        "判断两个回答哪个更好。verdict 取 \"a\"(第一个更好)、\"b\"(第二个更好)、\"tie\"(平局)。",
144    )
145    .with_parameters(serde_json::json!({
146        "type": "object",
147        "properties": {
148            "verdict": {
149                "type": "string",
150                "enum": ["a", "b", "tie"],
151                "description": "a=第一个更好, b=第二个更好, tie=平局"
152            },
153            "reason": { "type": "string", "description": "简短理由" }
154        },
155        "required": ["verdict", "reason"]
156    }))
157}
158
159fn pick_to_str(pick: Pick) -> &'static str {
160    match pick {
161        Pick::First => "a",
162        Pick::Second => "b",
163        Pick::Tie => "tie",
164    }
165}
166
167/// 把结构化 verdict 字符串映射回 `Pick`;非法值报解析错误。
168fn str_to_pick(verdict: &str) -> Result<Pick, EvalError> {
169    match verdict {
170        "a" => Ok(Pick::First),
171        "b" => Ok(Pick::Second),
172        "tie" => Ok(Pick::Tie),
173        other => Err(EvalError::ParseError(format!(
174            "judge returned invalid verdict: {}",
175            other
176        ))),
177    }
178}
179
180/// 解析裁判回复为 Pick。无任何有效标记时返回 `None`(解析失败,由调用方报错),
181/// 而非静默默认为平局——避免 LLM 跑题回复被当成"无偏好"。
182fn parse_pick(raw: &str) -> Option<Pick> {
183    let lower = raw.to_lowercase();
184    if lower.contains("平局") || lower.contains("tie") || lower.contains("一样") {
185        return Some(Pick::Tie);
186    }
187    // 第一个 / 前者 / former:任一措辞,取最早出现位置
188    let first_pos = ["第一个", "first", "前者", "former"]
189        .into_iter()
190        .filter_map(|kw| lower.find(kw))
191        .min();
192    // 第二个 / 后者 / latter
193    let second_pos = ["第二个", "second", "后者", "latter"]
194        .into_iter()
195        .filter_map(|kw| lower.find(kw))
196        .min();
197    match (first_pos, second_pos) {
198        (Some(f), Some(s)) if f < s => Some(Pick::First),
199        (Some(_), Some(_)) => Some(Pick::Second),
200        (Some(_), None) => Some(Pick::First),
201        (None, Some(_)) => Some(Pick::Second),
202        (None, None) => None,
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209    use async_trait::async_trait;
210    use futures_util::Stream;
211    use lc_core::language_models::LLMResult;
212    use lc_core::{BaseLanguageModel, Runnable, RunnableConfig};
213    use std::pin::Pin;
214    use std::sync::atomic::{AtomicUsize, Ordering};
215    use std::sync::Arc;
216
217    #[derive(Debug)]
218    struct JudgeError(String);
219    impl std::fmt::Display for JudgeError {
220        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
221            write!(f, "{}", self.0)
222        }
223    }
224    impl std::error::Error for JudgeError {}
225
226    /// 依次返回预设回复的 mock 裁判
227    struct SeqMockJudge {
228        replies: Vec<String>,
229        call: Arc<AtomicUsize>,
230    }
231    impl SeqMockJudge {
232        fn new(replies: Vec<String>) -> Self {
233            Self {
234                replies,
235                call: Arc::new(AtomicUsize::new(0)),
236            }
237        }
238    }
239
240    #[async_trait]
241    impl Runnable<Vec<Message>, LLMResult> for SeqMockJudge {
242        type Error = JudgeError;
243        async fn invoke(
244            &self,
245            _input: Vec<Message>,
246            _config: Option<RunnableConfig>,
247        ) -> Result<LLMResult, Self::Error> {
248            Err(JudgeError("use chat".into()))
249        }
250    }
251
252    #[async_trait]
253    impl BaseLanguageModel<Vec<Message>, LLMResult> for SeqMockJudge {
254        fn model_name(&self) -> &str {
255            "seq-mock"
256        }
257        fn get_num_tokens(&self, t: &str) -> usize {
258            t.len()
259        }
260        fn with_temperature(self, _: f32) -> Self {
261            self
262        }
263        fn with_max_tokens(self, _: usize) -> Self {
264            self
265        }
266    }
267
268    #[async_trait]
269    impl BaseChatModel for SeqMockJudge {
270        async fn chat(
271            &self,
272            _messages: Vec<Message>,
273            _config: Option<RunnableConfig>,
274        ) -> Result<LLMResult, Self::Error> {
275            let idx = self.call.fetch_add(1, Ordering::SeqCst);
276            let reply = self.replies.get(idx).cloned().unwrap_or_default();
277            Ok(LLMResult {
278                content: reply,
279                model: "seq-mock".to_string(),
280                token_usage: None,
281                tool_calls: None,
282                thinking_content: None,
283            })
284        }
285        async fn stream_chat(
286            &self,
287            _messages: Vec<Message>,
288            _config: Option<RunnableConfig>,
289        ) -> Result<Pin<Box<dyn Stream<Item = Result<String, Self::Error>> + Send>>, Self::Error>
290        {
291            Err(JudgeError("not supported".into()))
292        }
293    }
294
295    #[tokio::test]
296    async fn test_pairwise_a_wins() {
297        // 第一次(A在前)选第一个=A;第二次(B在前)选第二个=A => A赢
298        let judge = PairwiseJudge::new(SeqMockJudge::new(vec![
299            "第一个更好".into(),
300            "第二个更好".into(),
301        ]));
302        assert_eq!(judge.compare("q", "A", "B").await.unwrap(), Verdict::AWins);
303    }
304
305    #[tokio::test]
306    async fn test_pairwise_b_wins() {
307        // 第一次(A在前)选第二个=B;第二次(B在前)选第一个=B => B赢
308        let judge = PairwiseJudge::new(SeqMockJudge::new(vec![
309            "第二个更好".into(),
310            "第一个更好".into(),
311        ]));
312        assert_eq!(judge.compare("q", "A", "B").await.unwrap(), Verdict::BWins);
313    }
314
315    #[tokio::test]
316    async fn test_pairwise_position_bias_tie() {
317        // 裁判总选第一个(位置偏差):两次都选 first => 映射回不同答案 => 平局
318        let judge = PairwiseJudge::new(SeqMockJudge::new(vec![
319            "第一个更好".into(),
320            "第一个更好".into(),
321        ]));
322        assert_eq!(judge.compare("q", "A", "B").await.unwrap(), Verdict::Tie);
323    }
324
325    #[tokio::test]
326    async fn test_pairwise_explicit_tie() {
327        let judge = PairwiseJudge::new(SeqMockJudge::new(vec!["平局".into(), "平局".into()]));
328        assert_eq!(judge.compare("q", "A", "B").await.unwrap(), Verdict::Tie);
329    }
330
331    #[test]
332    fn test_parse_pick() {
333        assert_eq!(parse_pick("第一个更好"), Some(Pick::First));
334        assert_eq!(parse_pick("第二个更好"), Some(Pick::Second));
335        assert_eq!(parse_pick("平局"), Some(Pick::Tie));
336        assert_eq!(parse_pick("两个一样好"), Some(Pick::Tie));
337        assert_eq!(parse_pick("第二个比第一个好"), Some(Pick::Second));
338        // 前者/后者、former/latter:LLM 不一定按"第一个"格式回
339        assert_eq!(parse_pick("前者更好"), Some(Pick::First));
340        assert_eq!(parse_pick("后者更准确"), Some(Pick::Second));
341        assert_eq!(parse_pick("the former is better"), Some(Pick::First));
342        assert_eq!(parse_pick("the latter wins"), Some(Pick::Second));
343        // 无任何有效标记 = 解析失败,不应静默默认为平局
344        assert_eq!(parse_pick("我无法判断"), None);
345    }
346
347    /// P0-1: 支持 bind_tools 的模型走结构化输出(verdict: a/b/tie)。
348    #[tokio::test]
349    async fn test_pairwise_structured_verdict() {
350        use crate::test_support::ToolJudge;
351        // A 赢:第一轮(A 在前)选 "a"(第一个=A),第二轮(B 在前)选 "b"(第二个=A)
352        let judge = PairwiseJudge::new(ToolJudge::sequence(vec![
353            r#"{"verdict": "a", "reason": "第一个更完整"}"#.into(),
354            r#"{"verdict": "b", "reason": "第二个更完整"}"#.into(),
355        ]));
356        assert_eq!(judge.compare("q", "A", "B").await.unwrap(), Verdict::AWins);
357    }
358
359    #[tokio::test]
360    async fn test_pairwise_structured_verdict_b() {
361        use crate::test_support::ToolJudge;
362        // B 赢:第一轮(A 在前)选 "b"(第二个=B),第二轮(B 在前)选 "a"(第一个=B)
363        let judge = PairwiseJudge::new(ToolJudge::sequence(vec![
364            r#"{"verdict": "b", "reason": "第二个更准确"}"#.into(),
365            r#"{"verdict": "a", "reason": "第一个更准确"}"#.into(),
366        ]));
367        assert_eq!(judge.compare("q", "A", "B").await.unwrap(), Verdict::BWins);
368    }
369
370    #[tokio::test]
371    async fn test_pairwise_structured_verdict_tie() {
372        use crate::test_support::ToolJudge;
373        let judge = PairwiseJudge::new(ToolJudge::new(
374            r#"{"verdict": "tie", "reason": "难分高下"}"#,
375        ));
376        assert_eq!(judge.compare("q", "A", "B").await.unwrap(), Verdict::Tie);
377    }
378}