File size: 7,133 Bytes
c55968e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
//! # perch-ultrafast
//!
//! **混合流 + Ultrafast 推理框架**——把"用户感觉慢"从架构层面解决。
//!
//! 从 [栖木 Perch](https://huggingface.co/hongcoding/perch)(macOS 本地优先 AI 工作台)
//! 中提取的可复用 Rust  crate。
//!
//! ## 解决的问题
//!
//! 云端大模型首 token 延迟 0.5~2s,用户盯着空屏幕等。本机小模型首 token 0.05~0.2s。
//!
//! **混合流**:两条流同时开——本机先出草稿(用户立刻有东西读),云端一到就接棒
//! (质量升级)。首字延迟 = 本机延迟,最终质量 = 云端质量。
//!
//! ## 快速开始
//!
//! ```rust,no_run
//! use std::sync::Arc;
//! use perch_ultrafast::{hybrid::{run_hybrid, HybridConfig}, types::*};
//! use tokio_util::sync::CancellationToken;
//!
//! # async fn demo(local: Arc<dyn Provider>, cloud: Arc<dyn Provider>, req: ChatRequest) {
//! let cancel = CancellationToken::new();
//! let result = run_hybrid(
//!     local.clone(),
//!     cloud,
//!     ChatRequest { think: Some(ThinkLevel::Off), ..req.clone() }, // 本机:关思考求快
//!     req,                                                        // 云端:原样
//!     cancel,
//!     HybridConfig::default(),
//!     |chunk| match chunk {
//!         StreamChunk::Delta { text } => print!("{text}"),
//!         StreamChunk::Replaced { .. } => println!("\n[云端接棒]"),
//!         StreamChunk::Done { .. } => println!("\n[done]"),
//!         _ => {}
//!     },
//! )
//! .await;
//! println!("最终 {} 字,云端接棒 = {}", result.content.chars().count(), result.used_cloud);
//! # }
//! ```
//!
//! ## 两个模块
//!
//! - [`hybrid`]:混合流 mux(本机草稿 → 云端接棒,含双向降级)
//! - [`ultrafast`]:加速规则(关思考 / 回复预算 / 简洁提示 / **模型锁红线**)
//! - [`pool`]:连接池单例(复用 TCP + TLS,启用压缩)
//!
//! ## 实测数据(栖木 Perch,MiMo 云端 + 本机 qwen3-0.6B)
//!
//! | 场景 | 首字延迟 | 说明 |
//! |---|---|---|
//! | 纯云端 | 0.88~1.39s | 用户盯着空屏幕 |
//! | 本机热身 | **0.04~0.18s** | 混合流草稿 |
//! | 混合流首字 | **< 0.2s** | 本机草稿立刻可读 |
//! | 云端接棒 | ~0.9s | 无缝替换,质量升级 |
//!
//! ## 设计红线
//!
//! - **加速绝不换模型**(不偷偷路由到小模型)
//! - **只改本次下发值**(不动用户持久化档位)
//! - **云端失败保留草稿**(不把用户的答案变成错误)
//! - **本机失败静默降级**(行为等同没用混合流,不打扰用户)

pub mod hybrid;
pub mod pool;
pub mod types;
pub mod ultrafast;

pub use hybrid::{run_hybrid, HybridConfig, HybridResult};
pub use types::{
    BoxFuture, ChatMessage, ChatRequest, MessageRole, Provider, ProviderStream, StreamChunk,
    ThinkLevel,
};
pub use ultrafast::{apply_ultrafast, UltrafastConfig};

#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::Arc;
    use tokio_util::sync::CancellationToken;

    /// 测试用 provider:按给定脚本吐 chunk(真异步,不阻塞 runtime)。
    struct ScriptedProvider {
        chunks: Vec<StreamChunk>,
        delay_ms: u64,
    }

    impl Provider for ScriptedProvider {
        fn chat_stream(
            &self,
            _request: ChatRequest,
            _cancel: CancellationToken,
        ) -> BoxFuture<Result<ProviderStream, String>> {
            let chunks = self.chunks.clone();
            let delay = std::time::Duration::from_millis(self.delay_ms);
            Box::pin(async move {
                let (tx, rx) = tokio::sync::mpsc::channel::<StreamChunk>(16);
                tokio::spawn(async move {
                    for chunk in chunks {
                        tokio::time::sleep(delay).await;
                        if tx.send(chunk).await.is_err() {
                            return;
                        }
                    }
                });
                let s = futures::stream::unfold(rx, |mut rx| async move {
                    rx.recv().await.map(|chunk| (chunk, rx))
                });
                Ok(Box::pin(s) as ProviderStream)
            })
        }
    }

    fn request() -> ChatRequest {
        ChatRequest {
            model: "m".to_string(),
            messages: vec![],
            think: None,
            max_tokens: None,
            tools: None,
        }
    }

    #[tokio::test]
    async fn cloud_takes_over_and_replaces_draft() {
        let local = Arc::new(ScriptedProvider {
            chunks: vec![
                StreamChunk::Delta { text: "draft".into() },
                StreamChunk::Done {
                    prompt_tokens: None,
                    completion_tokens: None,
                    eval_ms: None,
                    cached_tokens: None,
                },
            ],
            delay_ms: 0,
        });
        let cloud = Arc::new(ScriptedProvider {
            chunks: vec![
                StreamChunk::Delta { text: "final".into() },
                StreamChunk::Done {
                    prompt_tokens: None,
                    completion_tokens: None,
                    eval_ms: None,
                    cached_tokens: None,
                },
            ],
            delay_ms: 30,
        });
        let mut events = Vec::new();
        let result = run_hybrid(
            local,
            cloud,
            request(),
            request(),
            CancellationToken::new(),
            HybridConfig::default(),
            |c| events.push(c),
        )
        .await;
        assert!(result.used_cloud, "云端应该接棒");
        assert_eq!(result.content, "final");
        let replaced = events.iter().any(|c| matches!(c, StreamChunk::Replaced { .. }));
        assert!(replaced, "接棒时应下发 Replaced 让前端清草稿");
    }

    #[tokio::test]
    async fn cloud_failure_falls_back_to_local() {
        struct FailingProvider;
        impl Provider for FailingProvider {
            fn chat_stream(
                &self,
                _request: ChatRequest,
                _cancel: CancellationToken,
            ) -> BoxFuture<Result<ProviderStream, String>> {
                Box::pin(async { Err("cloud down".to_string()) })
            }
        }
        let local = Arc::new(ScriptedProvider {
            chunks: vec![
                StreamChunk::Delta { text: "local only".into() },
                StreamChunk::Done {
                    prompt_tokens: None,
                    completion_tokens: None,
                    eval_ms: None,
                    cached_tokens: None,
                },
            ],
            delay_ms: 0,
        });
        let result = run_hybrid(
            local,
            Arc::new(FailingProvider),
            request(),
            request(),
            CancellationToken::new(),
            HybridConfig::default(),
            |_| {},
        )
        .await;
        assert!(!result.used_cloud);
        assert_eq!(result.content, "local only", "云端挂了应保留本地草稿");
        assert!(result.finished);
    }
}