Skip to main content

lc_core/
judge.rs

1//! 共享 LLM 裁判基础设施:让 LLM 以结构化参数返回判定。
2//!
3//! 被 `lc-evaluation`(打分 / 成对 / 忠实度裁判)与 `lc-guardrails`(LLM 校验器)
4//! 复用:优先走 `bind_tools` 拿 `tool_calls` 结构化参数,模型不支持工具绑定或
5//! 仍返回纯文本时,回落调用方提供的文本解析。两个 crate 各自构造 prompt 与
6//! 解析规则,共享的是"绑定工具 → 拿结构化参数 → 回落文本"这条通用执行路径。
7
8use lc_schema::Message;
9use serde::de::DeserializeOwned;
10
11use crate::language_models::BaseChatModel;
12use crate::tools::ToolDefinition;
13
14/// 结构化裁判调用的错误:区分"LLM 调用失败"与"结构化解析失败",
15/// 由调用方映射到自己的错误域(如 `EvalError::PredictorError` /
16/// `EvalError::ParseError`)。
17#[derive(Debug, thiserror::Error)]
18pub enum StructuredJudgeError {
19    /// 底层 LLM 调用失败(网络 / 限流 / 返回异常)。
20    #[error("LLM 调用失败: {0}")]
21    Call(String),
22    /// 工具调用参数无法按目标类型反序列化,或 tool_calls 为空。
23    #[error("结构化解析失败: {0}")]
24    Parse(String),
25}
26
27/// 单次 LLM 调用,让裁判以结构化参数(T)返回判定。
28///
29/// 流程:
30/// 1. 若模型支持 `bind_tools`,绑定判定工具;响应含 `tool_calls` 则解析参数返回。
31/// 2. 绑定了工具但仍返回纯文本 → 用同一次响应的文本走 `text_fallback`。
32/// 3. 模型不支持 `bind_tools` → 回落文本解析(`text_fallback`),并 `log::warn!`。
33/// 4. 绑定了工具但 `tool_calls` 参数无法解析 → 显式 `StructuredJudgeError::Parse`,
34///    绝不静默默认。
35///
36/// 保证每次判定最多一次 LLM 往返:绑定路径不额外重打一次无工具调用。
37pub async fn structured_call<M, T, F>(
38    judge: &M,
39    tool: ToolDefinition,
40    messages: Vec<Message>,
41    text_fallback: F,
42) -> Result<T, StructuredJudgeError>
43where
44    M: BaseChatModel,
45    T: DeserializeOwned,
46    F: FnOnce(&str) -> Result<T, StructuredJudgeError>,
47{
48    if let Some(bound) = judge.bind_tools(vec![tool]) {
49        let result = bound
50            .chat(messages, None)
51            .await
52            .map_err(|e| StructuredJudgeError::Call(e.to_string()))?;
53        match result.tool_calls {
54            Some(calls) => {
55                let call = calls.first().ok_or_else(|| {
56                    StructuredJudgeError::Parse("裁判返回的 tool_calls 为空".to_string())
57                })?;
58                let parsed = call.parse_arguments::<T>().map_err(|e| {
59                    StructuredJudgeError::Parse(format!("裁判结构化参数解析失败: {}", e))
60                })?;
61                Ok(parsed)
62            }
63            None => {
64                log::warn!("裁判模型绑定了工具但返回纯文本,回落文本解析");
65                text_fallback(&result.content)
66            }
67        }
68    } else {
69        log::warn!("裁判模型不支持 bind_tools,回落文本解析");
70        let result = judge
71            .chat(messages, None)
72            .await
73            .map_err(|e| StructuredJudgeError::Call(e.to_string()))?;
74        text_fallback(&result.content)
75    }
76}
77
78/// 截断长文本用于错误信息,避免把整段 LLM 回复塞进错误。
79pub fn truncate(s: &str, max: usize) -> String {
80    if s.chars().count() <= max {
81        s.to_string()
82    } else {
83        let truncated: String = s.chars().take(max).collect();
84        format!("{}...", truncated)
85    }
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91    use async_trait::async_trait;
92    use futures_util::Stream;
93    use lc_schema::Message;
94    use std::pin::Pin;
95    use std::sync::atomic::{AtomicUsize, Ordering};
96    use std::sync::Arc;
97
98    use crate::language_models::LLMResult;
99    use crate::{BaseLanguageModel, Runnable, RunnableConfig};
100
101    #[derive(Debug)]
102    struct JudgeError(String);
103    impl std::fmt::Display for JudgeError {
104        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105            write!(f, "{}", self.0)
106        }
107    }
108    impl std::error::Error for JudgeError {}
109
110    /// 依次返回预设回复的 mock 裁判,记录收到的消息供断言。
111    struct SeqMockJudge {
112        replies: Vec<String>,
113        call: Arc<AtomicUsize>,
114    }
115    impl SeqMockJudge {
116        fn new(replies: Vec<String>) -> Self {
117            Self {
118                replies,
119                call: Arc::new(AtomicUsize::new(0)),
120            }
121        }
122    }
123
124    #[async_trait]
125    impl Runnable<Vec<Message>, LLMResult> for SeqMockJudge {
126        type Error = JudgeError;
127        async fn invoke(
128            &self,
129            _input: Vec<Message>,
130            _config: Option<RunnableConfig>,
131        ) -> Result<LLMResult, Self::Error> {
132            Err(JudgeError("use chat".into()))
133        }
134    }
135
136    #[async_trait]
137    impl BaseLanguageModel<Vec<Message>, LLMResult> for SeqMockJudge {
138        fn model_name(&self) -> &str {
139            "seq-mock"
140        }
141        fn get_num_tokens(&self, t: &str) -> usize {
142            t.len()
143        }
144        fn with_temperature(self, _: f32) -> Self {
145            self
146        }
147        fn with_max_tokens(self, _: usize) -> Self {
148            self
149        }
150    }
151
152    #[async_trait]
153    impl BaseChatModel for SeqMockJudge {
154        async fn chat(
155            &self,
156            _messages: Vec<Message>,
157            _config: Option<RunnableConfig>,
158        ) -> Result<LLMResult, Self::Error> {
159            let idx = self.call.fetch_add(1, Ordering::SeqCst);
160            let reply = self.replies.get(idx).cloned().unwrap_or_default();
161            Ok(LLMResult {
162                content: reply,
163                model: "seq-mock".to_string(),
164                token_usage: None,
165                tool_calls: None,
166                thinking_content: None,
167            })
168        }
169        async fn stream_chat(
170            &self,
171            _messages: Vec<Message>,
172            _config: Option<RunnableConfig>,
173        ) -> Result<Pin<Box<dyn Stream<Item = Result<String, Self::Error>> + Send>>, Self::Error>
174        {
175            Err(JudgeError("not supported".into()))
176        }
177    }
178
179    #[derive(serde::Deserialize, Debug)]
180    struct MockArgs {
181        verdict: String,
182    }
183
184    fn mock_tool() -> ToolDefinition {
185        ToolDefinition::new("mock_judge", "返回判定。")
186    }
187
188    #[tokio::test]
189    async fn test_fallback_on_text_only_model() {
190        // SeqMockJudge 不实现 bind_tools → 走文本回落,closure 解析纯文本。
191        let judge = SeqMockJudge::new(vec!["yes".into()]);
192        let messages = vec![Message::human("判断")];
193        let out = structured_call(&judge, mock_tool(), messages, |raw| {
194            Ok(MockArgs {
195                verdict: raw.trim().to_string(),
196            })
197        })
198        .await
199        .unwrap();
200        assert_eq!(out.verdict, "yes");
201    }
202
203    #[tokio::test]
204    async fn test_parse_error_raised_not_silently_defaulted() {
205        // 文本回落解析失败 → 显式 Parse,不静默默认。
206        let judge = SeqMockJudge::new(vec!["没法判断".into()]);
207        let messages = vec![Message::human("判断")];
208        let err = structured_call(
209            &judge,
210            mock_tool(),
211            messages,
212            |_raw: &str| -> Result<MockArgs, StructuredJudgeError> {
213                Err(StructuredJudgeError::Parse("解析失败".into()))
214            },
215        )
216        .await
217        .unwrap_err();
218        assert!(matches!(err, StructuredJudgeError::Parse(_)));
219    }
220}