Skip to main content

lc_evaluation/
pairwise.rs

1//! 成对比较评测器:让 LLM 裁判在两个回答中二选一(竞技场模式)。
2//!
3//! 带位置偏差缓解:交换 A/B 顺序跑两次,两次都选同一个才算真赢,否则判平局。
4
5use lc_core::BaseChatModel;
6use lc_schema::Message;
7
8use super::EvalError;
9
10/// 成对比较结果
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum Verdict {
13    AWins,
14    BWins,
15    Tie,
16}
17
18/// 裁判选了哪个位置
19#[derive(Debug, Clone, PartialEq, Eq)]
20enum Pick {
21    First,
22    Second,
23    Tie,
24}
25
26/// 成对比较评测器(用 LLM 当裁判,二选一)。
27///
28/// 注意:成对比较接口与单点 `Evaluator` 不同(需要两个预测),故不实现 `Evaluator` trait,
29/// 通过 `compare` 方法调用。
30pub struct PairwiseJudge<M: BaseChatModel> {
31    judge: M,
32    rubric: String,
33}
34
35const DEFAULT_PAIRWISE_RUBRIC: &str = "\
36正确性:回答是否事实准确、是否切题。
37完整性:是否完整回答了问题。
38清晰性:表达是否清晰、简洁。";
39
40impl<M: BaseChatModel> PairwiseJudge<M> {
41    pub fn new(judge: M) -> Self {
42        Self {
43            judge,
44            rubric: DEFAULT_PAIRWISE_RUBRIC.to_string(),
45        }
46    }
47
48    pub fn with_rubric(mut self, rubric: impl Into<String>) -> Self {
49        self.rubric = rubric.into();
50        self
51    }
52
53    /// 比较 A、B 两个回答,返回谁更好。
54    ///
55    /// 交换 A/B 顺序跑两次,消除位置偏差:两次都选同一个才算真赢,否则判平局。
56    pub async fn compare(&self, input: &str, a: &str, b: &str) -> Result<Verdict, EvalError> {
57        let v1 = self.ask(input, a, b).await?; // A 在前
58        let v2 = self.ask(input, b, a).await?; // 交换,B 在前
59
60        Ok(match (v1, v2) {
61            (Pick::Tie, _) | (_, Pick::Tie) => Verdict::Tie,
62            (Pick::First, Pick::Second) => Verdict::AWins, // v1 选 A(前),v2 选 A(后)
63            (Pick::Second, Pick::First) => Verdict::BWins, // v1 选 B(后),v2 选 B(前)
64            _ => Verdict::Tie, // 位置偏差:两次选的位置一致但映射回不同答案
65        })
66    }
67
68    async fn ask(&self, input: &str, first: &str, second: &str) -> Result<Pick, EvalError> {
69        let system = format!(
70            "你是裁判。根据评分标准,判断两个回答哪个更好。\n\n\
71             评分标准:\n{rubric}\n\n\
72             只输出三者之一:\"第一个更好\" / \"第二个更好\" / \"平局\"",
73            rubric = self.rubric
74        );
75        let user =
76            format!("题目:\n{input}\n\n第一个回答:\n{first}\n\n第二个回答:\n{second}\n\n哪个更好?");
77        let result = self
78            .judge
79            .chat_with_system(system, vec![Message::human(user)])
80            .await
81            .map_err(|e| EvalError::PredictorError(e.to_string()))?;
82        Ok(parse_pick(&result.content))
83    }
84}
85
86/// 解析裁判回复为 Pick。
87fn parse_pick(raw: &str) -> Pick {
88    let lower = raw.to_lowercase();
89    if lower.contains("平局") || lower.contains("tie") || lower.contains("一样") {
90        return Pick::Tie;
91    }
92    // 第一个 / 前者 / former:任一措辞,取最早出现位置
93    let first_pos = ["第一个", "first", "前者", "former"]
94        .into_iter()
95        .filter_map(|kw| lower.find(kw))
96        .min();
97    // 第二个 / 后者 / latter
98    let second_pos = ["第二个", "second", "后者", "latter"]
99        .into_iter()
100        .filter_map(|kw| lower.find(kw))
101        .min();
102    match (first_pos, second_pos) {
103        (Some(f), Some(s)) if f < s => Pick::First,
104        (Some(_), Some(_)) => Pick::Second,
105        (Some(_), None) => Pick::First,
106        (None, Some(_)) => Pick::Second,
107        (None, None) => Pick::Tie,
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114    use async_trait::async_trait;
115    use futures_util::Stream;
116    use lc_core::language_models::LLMResult;
117    use lc_core::{BaseLanguageModel, Runnable, RunnableConfig};
118    use std::pin::Pin;
119    use std::sync::atomic::{AtomicUsize, Ordering};
120    use std::sync::Arc;
121
122    #[derive(Debug)]
123    struct JudgeError(String);
124    impl std::fmt::Display for JudgeError {
125        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
126            write!(f, "{}", self.0)
127        }
128    }
129    impl std::error::Error for JudgeError {}
130
131    /// 依次返回预设回复的 mock 裁判
132    struct SeqMockJudge {
133        replies: Vec<String>,
134        call: Arc<AtomicUsize>,
135    }
136    impl SeqMockJudge {
137        fn new(replies: Vec<String>) -> Self {
138            Self {
139                replies,
140                call: Arc::new(AtomicUsize::new(0)),
141            }
142        }
143    }
144
145    #[async_trait]
146    impl Runnable<Vec<Message>, LLMResult> for SeqMockJudge {
147        type Error = JudgeError;
148        async fn invoke(
149            &self,
150            _input: Vec<Message>,
151            _config: Option<RunnableConfig>,
152        ) -> Result<LLMResult, Self::Error> {
153            Err(JudgeError("use chat".into()))
154        }
155    }
156
157    #[async_trait]
158    impl BaseLanguageModel<Vec<Message>, LLMResult> for SeqMockJudge {
159        fn model_name(&self) -> &str {
160            "seq-mock"
161        }
162        fn get_num_tokens(&self, t: &str) -> usize {
163            t.len()
164        }
165        fn with_temperature(self, _: f32) -> Self {
166            self
167        }
168        fn with_max_tokens(self, _: usize) -> Self {
169            self
170        }
171    }
172
173    #[async_trait]
174    impl BaseChatModel for SeqMockJudge {
175        async fn chat(
176            &self,
177            _messages: Vec<Message>,
178            _config: Option<RunnableConfig>,
179        ) -> Result<LLMResult, Self::Error> {
180            let idx = self.call.fetch_add(1, Ordering::SeqCst);
181            let reply = self.replies.get(idx).cloned().unwrap_or_default();
182            Ok(LLMResult {
183                content: reply,
184                model: "seq-mock".to_string(),
185                token_usage: None,
186                tool_calls: None,
187                thinking_content: None,
188            })
189        }
190        async fn stream_chat(
191            &self,
192            _messages: Vec<Message>,
193            _config: Option<RunnableConfig>,
194        ) -> Result<Pin<Box<dyn Stream<Item = Result<String, Self::Error>> + Send>>, Self::Error>
195        {
196            Err(JudgeError("not supported".into()))
197        }
198    }
199
200    #[tokio::test]
201    async fn test_pairwise_a_wins() {
202        // 第一次(A在前)选第一个=A;第二次(B在前)选第二个=A => A赢
203        let judge = PairwiseJudge::new(SeqMockJudge::new(vec![
204            "第一个更好".into(),
205            "第二个更好".into(),
206        ]));
207        assert_eq!(judge.compare("q", "A", "B").await.unwrap(), Verdict::AWins);
208    }
209
210    #[tokio::test]
211    async fn test_pairwise_b_wins() {
212        // 第一次(A在前)选第二个=B;第二次(B在前)选第一个=B => B赢
213        let judge = PairwiseJudge::new(SeqMockJudge::new(vec![
214            "第二个更好".into(),
215            "第一个更好".into(),
216        ]));
217        assert_eq!(judge.compare("q", "A", "B").await.unwrap(), Verdict::BWins);
218    }
219
220    #[tokio::test]
221    async fn test_pairwise_position_bias_tie() {
222        // 裁判总选第一个(位置偏差):两次都选 first => 映射回不同答案 => 平局
223        let judge = PairwiseJudge::new(SeqMockJudge::new(vec![
224            "第一个更好".into(),
225            "第一个更好".into(),
226        ]));
227        assert_eq!(judge.compare("q", "A", "B").await.unwrap(), Verdict::Tie);
228    }
229
230    #[tokio::test]
231    async fn test_pairwise_explicit_tie() {
232        let judge = PairwiseJudge::new(SeqMockJudge::new(vec!["平局".into(), "平局".into()]));
233        assert_eq!(judge.compare("q", "A", "B").await.unwrap(), Verdict::Tie);
234    }
235
236    #[test]
237    fn test_parse_pick() {
238        assert_eq!(parse_pick("第一个更好"), Pick::First);
239        assert_eq!(parse_pick("第二个更好"), Pick::Second);
240        assert_eq!(parse_pick("平局"), Pick::Tie);
241        assert_eq!(parse_pick("两个一样好"), Pick::Tie);
242        assert_eq!(parse_pick("第二个比第一个好"), Pick::Second);
243        // 前者/后者、former/latter:LLM 不一定按"第一个"格式回
244        assert_eq!(parse_pick("前者更好"), Pick::First);
245        assert_eq!(parse_pick("后者更准确"), Pick::Second);
246        assert_eq!(parse_pick("the former is better"), Pick::First);
247        assert_eq!(parse_pick("the latter wins"), Pick::Second);
248    }
249}