|
| 1 | +//! 精修输出预算化(REQ-290 ②,v0.19.7)——档位缩放纯函数。 |
| 2 | +//! |
| 3 | +//! @ai-context: 精修"1 分钟+"的物理大头=单片非流式生成(DeepSeek 长输出实测 |
| 4 | +//! ~110s@20k token,ai_client 注释证据)。原单请求 max_tokens=20000 |
| 5 | +//! 只为最坏档(≤8000 字输入 → 结构化输出最坏 ~15k token)服务, |
| 6 | +//! 常态生成远短——收紧上限 + 提示词引导字数可显著减少长尾空转。 |
| 7 | +//! 预算只做上限收窄(≤ 原 20000),不改变档位语义与协议校验。 |
| 8 | +
|
| 9 | +/// 输出预算(按档位从切片输入字符换算;见 output_budget)。 |
| 10 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 11 | +pub struct OutputBudget { |
| 12 | + /// 单请求 max_tokens(后端硬顶,防长尾空转;≤ 原 20000 上限) |
| 13 | + pub max_tokens: u32, |
| 14 | + /// 提示词引导字数(中文正文量级——含列表/表格原文长度) |
| 15 | + pub guidance_chars: usize, |
| 16 | +} |
| 17 | + |
| 18 | +/// 档位输出缩放(相对切片输入长度)与 token 上限。 |
| 19 | +/// |
| 20 | +/// @ai-context 依据(2026-08-21 真机 + ai_client 注释证据):单片 ≤8000 字输入、 |
| 21 | +/// 结构化输出最坏 ~15k token——20k 上限只为最坏档服务,常态生成 |
| 22 | +/// 远短,模型长尾空转吃掉大量"1 分钟+"。按档位收紧:忠实/标准 |
| 23 | +/// 保持高上限(防截断毁 JSON 校验),极简提取激进收敛。 |
| 24 | +fn budget_params(preset_id: &str) -> (f64, u32) { |
| 25 | + match preset_id { |
| 26 | + // faithful=忠实整理(输出≈输入长度)· deep=深度改写(转述+导语≈输入) |
| 27 | + "faithful" => (1.0, 16_000), |
| 28 | + "standard" | "" => (0.9, 16_000), |
| 29 | + "deep" => (0.95, 16_000), |
| 30 | + "minimal" => (0.3, 9_000), |
| 31 | + // custom 无档位语义 → 按标准兜底(REQ-279 已声明后端兜底口径) |
| 32 | + _ => (0.9, 16_000), |
| 33 | + } |
| 34 | +} |
| 35 | + |
| 36 | +/// 输出预算换算(纯函数可单测): |
| 37 | +/// guidance ≈ 输入字符 × 档位缩放(下限 200,防空输入给出 0 引导); |
| 38 | +/// max_tokens = guidance × 2 + 1500(中文 ~1.5–2 token/字 + JSON 包装余量), |
| 39 | +/// 下限 4000(结构化响应安全垫,防截断致 Parse 失败),按档位上限截断。 |
| 40 | +pub fn output_budget(preset_id: &str, content_chars: usize) -> OutputBudget { |
| 41 | + let (scale, cap) = budget_params(preset_id); |
| 42 | + let guidance_chars = ((content_chars as f64 * scale).round() as usize).max(200); |
| 43 | + let tokens = ((guidance_chars as u64) * 2 + 1500).clamp(4_000, cap as u64); |
| 44 | + OutputBudget { max_tokens: tokens as u32, guidance_chars } |
| 45 | +} |
| 46 | + |
| 47 | +/// 档位输出引导段(追加在 system 提示词尾部——所见即所发预览不含本段: |
| 48 | +/// 预算为逐片动态值,预览为静态系统文本,二者不承诺一致,注释如实)。 |
| 49 | +pub fn guidance_suffix(budget: &OutputBudget) -> String { |
| 50 | + format!( |
| 51 | + "【输出长度】本片请把内容(含列表/表格原文)控制在约 {} 字以内:删除冗余表达,但保留全部事实、术语与原文结构;宁精炼不堆叠。", |
| 52 | + budget.guidance_chars |
| 53 | + ) |
| 54 | +} |
| 55 | + |
| 56 | +#[cfg(test)] |
| 57 | +mod tests { |
| 58 | + use super::*; |
| 59 | + |
| 60 | + #[test] |
| 61 | + fn empty_content_still_gets_safety_floor() { |
| 62 | + let b = output_budget("standard", 0); |
| 63 | + assert_eq!(b.guidance_chars, 200); |
| 64 | + assert!(b.max_tokens >= 4000, "结构化响应安全垫下限"); |
| 65 | + } |
| 66 | + |
| 67 | + #[test] |
| 68 | + fn minimal_preset_converges_hardest() { |
| 69 | + // 4000 字输入:standard≈9000 token 上限 vs minimal 显著更紧 |
| 70 | + let standard = output_budget("standard", 4000); |
| 71 | + let minimal = output_budget("minimal", 4000); |
| 72 | + assert!(standard.guidance_chars > minimal.guidance_chars); |
| 73 | + assert!(standard.max_tokens > minimal.max_tokens); |
| 74 | + assert_eq!(minimal.guidance_chars, 1200); |
| 75 | + } |
| 76 | + |
| 77 | + #[test] |
| 78 | + fn caps_never_exceed_legacy_20000() { |
| 79 | + for preset in ["faithful", "standard", "deep", "minimal", "custom", "unknown-x"] { |
| 80 | + let b = output_budget(preset, 8000); |
| 81 | + assert!(b.max_tokens <= 20000, "preset={} tokens={}", preset, b.max_tokens); |
| 82 | + } |
| 83 | + } |
| 84 | + |
| 85 | + #[test] |
| 86 | + fn guidance_suffix_contains_budget_chars() { |
| 87 | + let b = output_budget("minimal", 3000); |
| 88 | + let suffix = guidance_suffix(&b); |
| 89 | + assert!(suffix.contains(&b.guidance_chars.to_string())); |
| 90 | + } |
| 91 | +} |
0 commit comments