Skip to main content

lellm_core/
prompt.rs

1//! 分层 Prompt — 统一 System Prompt 表示,最大化前缀缓存命中率。
2//!
3//! # 设计
4//!
5//! - **Prompt** 只产出 `Message::System`,Provider 不感知 Prompt 的存在
6//! - **断点放置**:最后一个 `stable` layer 自动获得 `CacheControl::Breakpoint`
7//! - **Provider 消费**:Anthropic 直接使用 `cache_control`;OpenAI/Gemini 调用 `ContentBlock::flatten_text()`
8//!
9//! # 缓存层级(稳定性递减)
10//!
11//! | 层级 | 内容 | 变化频率 | 缓存收益 |
12//! |------|------|---------|---------|
13//! | L1 | 核心身份 | 永不 | 最高 |
14//! | L2 | 工具指南 | 极少 | 高 |
15//! | L3 | 项目规则 | 偶尔 | 中 |
16//! | L4 | 注入记忆 | 每轮 | 低 |
17//! | L5 | 会话上下文 | 频繁 | 最低(通常不缓存)|
18
19use crate::{CacheControl, ContentBlock, Message};
20
21/// Prompt 层 — 一段文本 + 稳定性标记。
22#[derive(Debug, Clone)]
23struct PromptLayer {
24    text: String,
25    /// 是否属于稳定前缀(参与缓存)。
26    stable: bool,
27}
28
29/// 统一的 Prompt 表示 — 同时是 Builder。
30///
31/// `Prompt::new()` 创建空 Prompt,链式添加层后 `build()` 产出 `Message::System`。
32/// 简单文本通过 `From<&str>` / `From<String>` 自动转换。
33///
34/// # 示例
35///
36/// ```
37/// use lellm_core::{Prompt, Message};
38///
39/// // 分层构建 — 最大化前缀缓存
40/// let msg = Prompt::new()
41///     .stable("核心身份…")
42///     .stable("工具指南…")
43///     .dynamic("会话上下文: …")
44///     .build();
45///
46/// // 简单文本 — 自动转换
47/// let msg: Message = Prompt::from("hello").build();
48/// ```
49#[derive(Debug, Clone)]
50pub struct Prompt {
51    layers: Vec<PromptLayer>,
52}
53
54impl Prompt {
55    /// 创建空 Prompt。
56    pub fn new() -> Self {
57        Self { layers: vec![] }
58    }
59
60    /// 添加稳定层 — 内容不常变化,参与缓存前缀。
61    ///
62    /// 用于核心身份、工具指南、项目规则等。
63    /// 最后一个 stable 层会自动获得 `CacheControl::Breakpoint`。
64    pub fn stable(mut self, text: impl Into<String>) -> Self {
65        self.layers.push(PromptLayer {
66            text: text.into(),
67            stable: true,
68        });
69        self
70    }
71
72    /// 添加动态层 — 内容频繁变化,不参与缓存前缀。
73    ///
74    /// 用于会话上下文、临时注入信息等。
75    pub fn dynamic(mut self, text: impl Into<String>) -> Self {
76        self.layers.push(PromptLayer {
77            text: text.into(),
78            stable: false,
79        });
80        self
81    }
82
83    /// 构建为 `Message::System`。
84    ///
85    /// 断点放置策略:只在最后一个 `stable` layer 上放置 `CacheControl::Breakpoint`。
86    /// Anthropic 每个请求最多 4 个断点,中间断点不产生独立缓存段,纯属浪费。
87    pub fn build(self) -> Message {
88        let last_stable_idx = self
89            .layers
90            .iter()
91            .enumerate()
92            .rev()
93            .find(|(_, layer)| layer.stable)
94            .map(|(idx, _)| idx);
95
96        let content: Vec<ContentBlock> = self
97            .layers
98            .iter()
99            .enumerate()
100            .map(|(idx, layer)| {
101                if Some(idx) == last_stable_idx {
102                    ContentBlock::text_with_cache(layer.text.clone(), CacheControl::Breakpoint)
103                } else {
104                    ContentBlock::text(&layer.text)
105                }
106            })
107            .collect();
108
109        Message::System { content }
110    }
111
112    /// 是否为空(无层或所有层为空)。
113    pub fn is_empty(&self) -> bool {
114        self.layers.iter().all(|l| l.text.is_empty())
115    }
116}
117
118impl Default for Prompt {
119    fn default() -> Self {
120        Self::new()
121    }
122}
123
124impl From<String> for Prompt {
125    fn from(s: String) -> Self {
126        Self::new().dynamic(s)
127    }
128}
129
130impl From<&str> for Prompt {
131    fn from(s: &str) -> Self {
132        Self::new().dynamic(s)
133    }
134}
135
136impl From<&String> for Prompt {
137    fn from(s: &String) -> Self {
138        Self::new().dynamic(s.clone())
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    #[test]
147    fn test_prompt_from_string() {
148        let prompt: Prompt = "hello".into();
149        let msg = prompt.build();
150        assert_eq!(msg.content().len(), 1);
151        assert_eq!(msg.content()[0].as_text(), Some("hello"));
152        if let ContentBlock::Text(t) = &msg.content()[0] {
153            assert!(t.cache_control.is_none());
154        }
155    }
156
157    #[test]
158    fn test_prompt_from_str() {
159        let s = "world";
160        let prompt: Prompt = s.into();
161        let msg = prompt.build();
162        assert_eq!(msg.content()[0].as_text(), Some("world"));
163    }
164
165    #[test]
166    fn test_prompt_new_and_build() {
167        let msg = Prompt::new()
168            .stable("layer1")
169            .stable("layer2")
170            .dynamic("dynamic")
171            .build();
172
173        let blocks = msg.content();
174        assert_eq!(blocks.len(), 3);
175
176        // Layer 1 — stable, but NO breakpoint (not the last stable)
177        if let ContentBlock::Text(t) = &blocks[0] {
178            assert_eq!(t.text, "layer1");
179            assert!(
180                t.cache_control.is_none(),
181                "Intermediate stable layer should NOT have breakpoint"
182            );
183        } else {
184            panic!("expected Text block");
185        }
186
187        // Layer 2 — stable, HAS breakpoint (last stable layer before dynamic)
188        if let ContentBlock::Text(t) = &blocks[1] {
189            assert_eq!(t.text, "layer2");
190            assert!(
191                t.cache_control.is_some(),
192                "Last stable layer should have breakpoint"
193            );
194        } else {
195            panic!("expected Text block");
196        }
197
198        // Layer 3 — dynamic (no cache)
199        if let ContentBlock::Text(t) = &blocks[2] {
200            assert_eq!(t.text, "dynamic");
201            assert!(t.cache_control.is_none());
202        } else {
203            panic!("expected Text block");
204        }
205    }
206
207    #[test]
208    fn test_prompt_is_empty() {
209        let empty = Prompt::default();
210        assert!(empty.is_empty());
211
212        let nonempty: Prompt = "x".into();
213        assert!(!nonempty.is_empty());
214    }
215
216    #[test]
217    fn test_breakpoint_only_on_last_stable() {
218        let msg = Prompt::new()
219            .stable("L1")
220            .stable("L2")
221            .stable("L3")
222            .stable("L4")
223            .stable("L5")
224            .dynamic("D")
225            .build();
226
227        let blocks = msg.content();
228        assert_eq!(blocks.len(), 6);
229
230        let breakpoint_count = blocks
231            .iter()
232            .filter(|b| {
233                if let ContentBlock::Text(t) = b {
234                    t.cache_control.is_some()
235                } else {
236                    false
237                }
238            })
239            .count();
240        assert_eq!(
241            breakpoint_count, 1,
242            "Should have exactly 1 breakpoint (on last stable layer)"
243        );
244
245        // Verify it's on L5 (index 4)
246        if let ContentBlock::Text(t) = &blocks[4] {
247            assert!(t.cache_control.is_some());
248        }
249    }
250
251    #[test]
252    fn test_all_stable_single_breakpoint() {
253        let msg = Prompt::new().stable("A").stable("B").build();
254
255        let blocks = msg.content();
256        if let ContentBlock::Text(t) = &blocks[0] {
257            assert!(t.cache_control.is_none(), "A should not have breakpoint");
258        }
259        if let ContentBlock::Text(t) = &blocks[1] {
260            assert!(t.cache_control.is_some(), "B should have breakpoint");
261        }
262    }
263
264    #[test]
265    fn test_empty_prompt_produces_empty_message() {
266        let msg = Prompt::new().build();
267        assert!(msg.content().is_empty());
268    }
269
270    #[test]
271    fn test_flatten_text_ignores_cache_control() {
272        let blocks = vec![
273            ContentBlock::text_with_cache("cached part".into(), CacheControl::Breakpoint),
274            ContentBlock::text("dynamic part"),
275        ];
276        assert_eq!(
277            ContentBlock::flatten_text(&blocks),
278            "cached part\n\ndynamic part"
279        );
280    }
281
282    #[test]
283    fn test_flatten_text_single_block() {
284        let blocks = vec![ContentBlock::text("hello")];
285        assert_eq!(ContentBlock::flatten_text(&blocks), "hello");
286    }
287
288    #[test]
289    fn test_flatten_text_empty() {
290        let blocks: Vec<ContentBlock> = vec![];
291        assert_eq!(ContentBlock::flatten_text(&blocks), "");
292    }
293}