Skip to main content

lc_testkit/
replay.rs

1//! `ReplayProvider`:从录制文件按 FIFO 顺序回放,零网络。
2//!
3//! 回放**不做消息匹配**(LLMChain 渲染出的 prompt 逐次可变),只按顺序弹出录播。
4//! 队列耗尽返回 [`TestkitError::ReplayExhausted`]。
5
6use std::collections::VecDeque;
7use std::io::BufRead;
8use std::path::Path;
9use std::pin::Pin;
10use std::sync::{Arc, Mutex};
11
12use async_trait::async_trait;
13use futures_util::Stream;
14use lc_core::language_models::{BaseChatModel, BaseLanguageModel, LLMResult, StreamChunk};
15use lc_core::runnables::{Runnable, RunnableConfig};
16use lc_core::tools::ToolDefinition;
17use lc_schema::Message;
18
19use crate::error::TestkitError;
20use crate::recording::RecordedExchange;
21
22/// 回放策略:决定并发/乱序场景下一条请求取哪条录播。
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
24pub enum ReplayStrategy {
25    /// 并发 FIFO:按录制顺序弹出,原子 pop 无 UB,但"哪条请求拿到哪条响应"不确定。
26    ///
27    /// 适合串行/顺序稳定的链;并行场景配**顺序无关断言**(只断言回放全部耗尽、
28    /// 各响应结构非空),不断言"第 N 条是工具 A 的响应"。
29    #[default]
30    Fifo,
31    /// 按工具名路由:绑定工具时携带名字(`bind_tools`),回放时从队列里挑
32    /// 工具名匹配的第一条录播(请求侧 `exchange.tools` 或响应侧 `tool_calls`)。
33    ///
34    /// 覆盖"不同工具响应不同、需要精确对应"的并行场景——每个并行分支各绑
35    /// 各的工具,拿到各自正确的响应。比"整段消息签名匹配"弱但可复现。
36    ByToolName,
37    /// 按请求消息**完整签名**精确匹配:请求 `messages` 与录播 `exchange.messages`
38    /// **逐条完全相等**(`Message` 的 `PartialEq` 全字段,含 `content` /
39    /// `message_type` / `name` / `additional_kwargs` / `tool_calls` 等)才命中。
40    ///
41    /// 并行乱序下每个请求精确取到自己的响应;录播中无匹配 → 返回明确
42    /// [`TestkitError::ReplayNoMatch`],**不做静默 FIFO 兜底**(避免"拿错响应"
43    /// 伪装成成功)。适合"同一消息序列必定复现同一响应"的确定性重放——录播
44    /// 与请求在任意字段上不一致(如运行时给 `id` 赋了每次不同的值)都会视为
45    /// 不匹配,fixture 需保证这些字段稳定。
46    Exact,
47}
48
49/// 从录制文件回放的零网络 `BaseChatModel`。
50///
51/// 队列用 `Arc<Mutex<VecDeque>>` 共享,`bind_tools` 返回携带工具集的新实例
52/// 时与原件共享同一队列(FIFO 顺序在分支间一致)。
53#[derive(Clone)]
54pub struct ReplayProvider {
55    queue: Arc<Mutex<VecDeque<RecordedExchange>>>,
56    model_name: String,
57    strategy: ReplayStrategy,
58    /// 本实例绑定的工具定义(`ByToolName` 路由的匹配键)。
59    tools: Option<Vec<ToolDefinition>>,
60}
61
62impl ReplayProvider {
63    /// 读 JSONL 录制文件(缺文件/坏行 → `Err`)。
64    pub fn from_file(path: impl AsRef<Path>) -> Result<Self, TestkitError> {
65        let file = std::fs::File::open(path)?;
66        let reader = std::io::BufReader::new(file);
67        let mut queue = VecDeque::new();
68        for line in reader.lines() {
69            let line = line?.trim().to_string();
70            if line.is_empty() {
71                continue;
72            }
73            let exchange: RecordedExchange = serde_json::from_str(&line).map_err(|e| {
74                TestkitError::Io(std::io::Error::new(
75                    std::io::ErrorKind::InvalidData,
76                    format!("invalid recording line: {e}"),
77                ))
78            })?;
79            queue.push_back(exchange);
80        }
81        Ok(Self {
82            queue: Arc::new(Mutex::new(queue)),
83            model_name: "replay".to_string(),
84            strategy: ReplayStrategy::Fifo,
85            tools: None,
86        })
87    }
88
89    /// 内存构造(手写录播 = MockProvider 的等价物)。
90    pub fn from_exchanges(exchanges: Vec<RecordedExchange>) -> Self {
91        Self {
92            queue: Arc::new(Mutex::new(exchanges.into())),
93            model_name: "replay".to_string(),
94            strategy: ReplayStrategy::Fifo,
95            tools: None,
96        }
97    }
98
99    /// 单一固定响应:任意请求都返回同一 `response`(最简 mock)。
100    pub fn single(response: LLMResult) -> Self {
101        Self::from_exchanges(vec![RecordedExchange {
102            messages: Vec::new(),
103            response,
104            tools: None,
105        }])
106    }
107
108    /// 设置回放策略(默认 FIFO;`ByToolName` 按工具名路由)。
109    pub fn with_strategy(mut self, strategy: ReplayStrategy) -> Self {
110        self.strategy = strategy;
111        self
112    }
113
114    /// 绑定工具:返回携带该工具集的新实例(共享同一回放队列)。
115    ///
116    /// 回放侧绑工具只是让 agent"绑了工具的循环"前提成立——录播里已含
117    /// 请求侧 `tools` 与响应侧 `tool_calls`,回放逻辑无需改。`ByToolName`
118    /// 策略下,`tools` 同时是路由的匹配键。
119    pub fn bind_tools(&self, tools: Vec<ToolDefinition>) -> Self {
120        Self {
121            queue: self.queue.clone(),
122            model_name: self.model_name.clone(),
123            strategy: self.strategy,
124            tools: Some(tools),
125        }
126    }
127
128    /// 剩余录播条数。
129    pub fn len(&self) -> usize {
130        self.queue.lock().unwrap_or_else(|e| e.into_inner()).len()
131    }
132
133    /// 是否已无剩余录播。
134    pub fn is_empty(&self) -> bool {
135        self.len() == 0
136    }
137}
138
139/// `ByToolName` 的匹配:请求侧绑定的工具名或响应侧请求的工具调用名命中即真。
140fn exchange_matches(exchange: &RecordedExchange, tool_name: &str) -> bool {
141    if let Some(tools) = &exchange.tools {
142        if tools.iter().any(|t| t.function.name == tool_name) {
143            return true;
144        }
145    }
146    if let Some(calls) = &exchange.response.tool_calls {
147        if calls.iter().any(|c| c.name() == tool_name) {
148            return true;
149        }
150    }
151    false
152}
153
154/// `Exact` 的匹配:请求消息序列与录播消息序列**逐条完全相等**。
155///
156/// `Vec<Message>` 的 `PartialEq` 按长度 + 逐元素比较,等价于"消息条数与每条
157/// 消息的全部字段(含 `id` / `name` / `additional_kwargs` / `tool_calls`)一致"。
158fn messages_match(request: &[Message], recorded: &[Message]) -> bool {
159    request == recorded
160}
161
162#[async_trait]
163impl Runnable<Vec<Message>, LLMResult> for ReplayProvider {
164    type Error = TestkitError;
165
166    async fn invoke(
167        &self,
168        input: Vec<Message>,
169        config: Option<RunnableConfig>,
170    ) -> Result<LLMResult, Self::Error> {
171        self.chat(input, config).await
172    }
173}
174
175impl BaseLanguageModel<Vec<Message>, LLMResult> for ReplayProvider {
176    fn model_name(&self) -> &str {
177        &self.model_name
178    }
179
180    fn get_num_tokens(&self, text: &str) -> usize {
181        // 估算:约 4 字符/ token。
182        text.chars().count() / 4 + 1
183    }
184
185    fn temperature(&self) -> Option<f32> {
186        None
187    }
188
189    fn max_tokens(&self) -> Option<usize> {
190        None
191    }
192
193    fn with_temperature(self, _temp: f32) -> Self {
194        self
195    }
196
197    fn with_max_tokens(self, _max: usize) -> Self {
198        self
199    }
200}
201
202#[async_trait]
203impl BaseChatModel for ReplayProvider {
204    async fn chat(
205        &self,
206        messages: Vec<Message>,
207        _config: Option<RunnableConfig>,
208    ) -> Result<LLMResult, Self::Error> {
209        let mut queue = self.queue.lock().unwrap_or_else(|e| e.into_inner());
210        let exchange = match self.strategy {
211            ReplayStrategy::Fifo => queue.pop_front(),
212            ReplayStrategy::ByToolName => {
213                let want = self
214                    .tools
215                    .as_ref()
216                    .and_then(|tools| tools.first().map(|t| t.function.name.clone()));
217                match want {
218                    // 按工具名从队列里挑匹配录播;未绑定工具 → 退化为 FIFO。
219                    Some(name) => queue
220                        .iter()
221                        .position(|ex| exchange_matches(ex, &name))
222                        .map(|i| queue.remove(i).expect("position 必有元素")),
223                    None => queue.pop_front(),
224                }
225            }
226            ReplayStrategy::Exact => {
227                // 按完整消息签名精确匹配,允许并行乱序;无匹配 → 明确报错,
228                // 绝不静默 FIFO 兜底(否则"拿错响应"会伪装成成功)。
229                match queue
230                    .iter()
231                    .position(|ex| messages_match(&messages, &ex.messages))
232                {
233                    Some(i) => Some(queue.remove(i).expect("position 必有元素")),
234                    None => {
235                        return Err(TestkitError::ReplayNoMatch { left: queue.len() });
236                    }
237                }
238            }
239        };
240        let Some(exchange) = exchange else {
241            return Err(TestkitError::ReplayExhausted {
242                requested: messages.len(),
243            });
244        };
245        Ok(exchange.response)
246    }
247
248    async fn stream_chat(
249        &self,
250        messages: Vec<Message>,
251        config: Option<RunnableConfig>,
252    ) -> Result<Pin<Box<dyn Stream<Item = Result<StreamChunk, Self::Error>> + Send>>, Self::Error>
253    {
254        let response = self.chat(messages, config).await?;
255        let stream = futures_util::stream::iter(vec![Ok(StreamChunk {
256            text: response.content,
257            token_usage: response.token_usage,
258        })]);
259        Ok(Box::pin(stream))
260    }
261
262    fn bind_tools(
263        &self,
264        tools: Vec<ToolDefinition>,
265    ) -> Option<Box<dyn BaseChatModel<Error = Self::Error> + Send + Sync>> {
266        // 委托给 inherent `bind_tools`:共享队列、记录工具集、返回新实例。
267        Some(Box::new(self.bind_tools(tools)))
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274    use lc_core::language_models::TokenUsage;
275    use lc_core::tools::{ToolCall, ToolDefinition};
276
277    fn exchange(content: &str) -> RecordedExchange {
278        RecordedExchange {
279            messages: vec![Message::system("ping")],
280            response: LLMResult {
281                content: content.to_string(),
282                model: "replay".to_string(),
283                token_usage: Some(TokenUsage {
284                    prompt_tokens: 1,
285                    completion_tokens: 2,
286                    total_tokens: 3,
287                }),
288                ..Default::default()
289            },
290            tools: None,
291        }
292    }
293
294    /// 带响应侧工具调用名的录播(模拟模型请求调用某工具)。
295    fn exchange_with_tool_call(tool_name: &str, content: &str) -> RecordedExchange {
296        let mut response = exchange(content).response;
297        response.tool_calls = Some(vec![ToolCall::builder("call_1")
298            .name(tool_name)
299            .arguments("{}".to_string())
300            .build()]);
301        RecordedExchange {
302            response,
303            ..exchange(content)
304        }
305    }
306
307    /// 带请求侧工具定义名的录播(模拟绑定了某工具后发起请求)。
308    fn exchange_with_bound_tool(tool_name: &str, content: &str) -> RecordedExchange {
309        RecordedExchange {
310            tools: Some(vec![ToolDefinition::new(tool_name, "a tool")]),
311            ..exchange(content)
312        }
313    }
314
315    #[tokio::test]
316    async fn single_returns_fixed_response_for_any_request() {
317        let provider = ReplayProvider::single(exchange("hello").response);
318        let result = provider
319            .chat(vec![Message::system("any")], None)
320            .await
321            .unwrap();
322        assert_eq!(result.content, "hello");
323    }
324
325    #[tokio::test]
326    async fn replay_is_fifo_ordered() {
327        let provider = ReplayProvider::from_exchanges(vec![exchange("first"), exchange("second")]);
328        let first = provider
329            .chat(vec![Message::system("a")], None)
330            .await
331            .unwrap();
332        let second = provider
333            .chat(vec![Message::system("b")], None)
334            .await
335            .unwrap();
336        assert_eq!(first.content, "first");
337        assert_eq!(second.content, "second");
338    }
339
340    #[tokio::test]
341    async fn replay_exhausted_returns_error() {
342        let provider = ReplayProvider::from_exchanges(vec![exchange("only")]);
343        provider
344            .chat(vec![Message::system("a")], None)
345            .await
346            .unwrap();
347        let err = provider
348            .chat(vec![Message::system("b")], None)
349            .await
350            .unwrap_err();
351        assert!(matches!(
352            err,
353            TestkitError::ReplayExhausted { requested: 1 }
354        ));
355    }
356
357    #[test]
358    fn bind_tools_returns_some_and_carries_tools() {
359        let provider = ReplayProvider::from_exchanges(vec![exchange("x")]);
360        // inherent bind_tools 返回携带工具集的新实例(共享队列)。
361        let bound = provider.bind_tools(vec![ToolDefinition::new("calculator", "calc")]);
362        assert!(bound.tools.is_some());
363        assert_eq!(bound.tools.as_ref().unwrap()[0].function.name, "calculator");
364        assert_eq!(provider.len(), 1);
365        assert_eq!(bound.len(), 1);
366        // trait bind_tools(agent 通过 `Box<dyn BaseChatModel>` 调用)恒为 Some。
367        let trait_bound = BaseChatModel::bind_tools(&provider, vec![ToolDefinition::new("x", "y")]);
368        assert!(trait_bound.is_some());
369    }
370
371    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
372    async fn parallel_replay_fifo_is_order_independent() {
373        // 并发 FIFO:两条请求同时到达,哪条拿哪条响应不确定,但都能拿到、
374        // 且队列最终耗尽。断言用"集合相等"而非"第 N 条是 xx"。
375        let provider = ReplayProvider::from_exchanges(vec![
376            exchange("first"),
377            exchange("second"),
378            exchange("third"),
379        ]);
380        let provider = std::sync::Arc::new(provider);
381
382        let mut handles = Vec::new();
383        for _ in 0..3 {
384            let p = provider.clone();
385            handles.push(tokio::spawn(async move {
386                p.chat(vec![Message::system("parallel")], None)
387                    .await
388                    .expect("并发回放不应失败")
389            }));
390        }
391        let mut contents: Vec<String> = Vec::new();
392        for handle in handles {
393            contents.push(handle.await.unwrap().content);
394        }
395        contents.sort();
396        assert_eq!(
397            contents,
398            vec![
399                "first".to_string(),
400                "second".to_string(),
401                "third".to_string()
402            ]
403        );
404        assert!(provider.is_empty(), "并发回放应恰好耗尽全部录播");
405    }
406
407    #[tokio::test]
408    async fn by_tool_name_routes_to_matching_exchange() {
409        // 每个并行分支各绑各的工具,应各拿到各自正确的响应——顺序无关。
410        let provider = ReplayProvider::from_exchanges(vec![
411            exchange_with_tool_call("search", "search result"),
412            exchange_with_tool_call("calc", "calc result"),
413        ])
414        .with_strategy(ReplayStrategy::ByToolName);
415
416        let search =
417            BaseChatModel::bind_tools(&provider, vec![ToolDefinition::new("search", "s")]).unwrap();
418        let calc =
419            BaseChatModel::bind_tools(&provider, vec![ToolDefinition::new("calc", "c")]).unwrap();
420
421        let calc_res = calc.chat(vec![Message::system("q")], None).await.unwrap();
422        let search_res = search.chat(vec![Message::system("q")], None).await.unwrap();
423
424        assert_eq!(search_res.content, "search result");
425        assert_eq!(calc_res.content, "calc result");
426        assert!(provider.is_empty());
427    }
428
429    #[tokio::test]
430    async fn by_tool_name_matches_request_side_tools() {
431        // 请求侧 `exchange.tools` 也参与匹配(录制自绑定工具后发起的请求)。
432        let provider = ReplayProvider::from_exchanges(vec![
433            exchange_with_bound_tool("weather", "sunny"),
434            exchange_with_bound_tool("news", "headlines"),
435        ])
436        .with_strategy(ReplayStrategy::ByToolName);
437
438        let weather =
439            BaseChatModel::bind_tools(&provider, vec![ToolDefinition::new("weather", "w")])
440                .unwrap();
441        let res = weather
442            .chat(vec![Message::system("q")], None)
443            .await
444            .unwrap();
445        assert_eq!(res.content, "sunny");
446    }
447
448    /// 指定请求消息序列的录播(供 `Exact` 策略按完整签名匹配)。
449    fn exchange_with_messages(messages: Vec<Message>, content: &str) -> RecordedExchange {
450        RecordedExchange {
451            messages,
452            ..exchange(content)
453        }
454    }
455
456    #[tokio::test]
457    async fn exact_strategy_matches_by_full_signature_out_of_order() {
458        // 两条请求消息序列不同;乱序到达,各自精确取到自己的响应。
459        let provider = ReplayProvider::from_exchanges(vec![
460            exchange_with_messages(vec![Message::system("ping")], "pong"),
461            exchange_with_messages(vec![Message::human("hello")], "hi"),
462        ])
463        .with_strategy(ReplayStrategy::Exact);
464
465        let hello_res = provider
466            .chat(vec![Message::human("hello")], None)
467            .await
468            .unwrap();
469        let ping_res = provider
470            .chat(vec![Message::system("ping")], None)
471            .await
472            .unwrap();
473
474        assert_eq!(hello_res.content, "hi");
475        assert_eq!(ping_res.content, "pong");
476        assert!(provider.is_empty(), "两条请求应精确消耗两条录播");
477    }
478
479    #[tokio::test]
480    async fn exact_strategy_matches_full_message_sequence() {
481        // 多轮对话历史(系统 + 用户 + AI)作为整体签名,按完整序列命中。
482        let msgs = vec![
483            Message::system("You are a calculator."),
484            Message::human("2 + 2"),
485            Message::ai("I'll compute that."),
486        ];
487        let provider = ReplayProvider::from_exchanges(vec![
488            exchange_with_messages(vec![Message::human("other")], "wrong"),
489            exchange_with_messages(msgs.clone(), "42"),
490        ])
491        .with_strategy(ReplayStrategy::Exact);
492
493        let res = provider.chat(msgs, None).await.unwrap();
494        assert_eq!(res.content, "42");
495    }
496
497    #[tokio::test]
498    async fn exact_strategy_no_match_returns_explicit_error() {
499        // 请求签名在录播中无匹配 → 明确报错(不是静默 FIFO 取错响应)。
500        let provider = ReplayProvider::from_exchanges(vec![exchange_with_messages(
501            vec![Message::system("ping")],
502            "pong",
503        )])
504        .with_strategy(ReplayStrategy::Exact);
505
506        let err = provider
507            .chat(vec![Message::system("different")], None)
508            .await
509            .unwrap_err();
510        assert!(
511            matches!(err, TestkitError::ReplayNoMatch { left: 1 }),
512            "无匹配应返回 ReplayNoMatch,剩余录播保留"
513        );
514    }
515
516    #[tokio::test]
517    async fn exact_strategy_distinguishes_message_type() {
518        // 相同文本、不同消息类型 = 不同签名。
519        let provider = ReplayProvider::from_exchanges(vec![
520            exchange_with_messages(vec![Message::human("q")], "human response"),
521            exchange_with_messages(vec![Message::system("q")], "system response"),
522        ])
523        .with_strategy(ReplayStrategy::Exact);
524
525        let human = provider
526            .chat(vec![Message::human("q")], None)
527            .await
528            .unwrap();
529        assert_eq!(human.content, "human response");
530
531        let system = provider
532            .chat(vec![Message::system("q")], None)
533            .await
534            .unwrap();
535        assert_eq!(system.content, "system response");
536    }
537
538    #[tokio::test]
539    async fn exact_strategy_distinguishes_tool_calls() {
540        // 携带工具调用的消息与纯文本消息 = 不同签名。
541        let call = ToolCall::builder("call_1")
542            .name("weather")
543            .arguments("{}".to_string())
544            .build();
545        let provider = ReplayProvider::from_exchanges(vec![
546            exchange_with_messages(vec![Message::ai("q")], "plain"),
547            exchange_with_messages(
548                vec![Message::ai_with_tool_calls("q", vec![call.clone()])],
549                "with tool",
550            ),
551        ])
552        .with_strategy(ReplayStrategy::Exact);
553
554        let plain = provider.chat(vec![Message::ai("q")], None).await.unwrap();
555        assert_eq!(plain.content, "plain");
556
557        let with_tool = provider
558            .chat(vec![Message::ai_with_tool_calls("q", vec![call])], None)
559            .await
560            .unwrap();
561        assert_eq!(with_tool.content, "with tool");
562    }
563}