Skip to content

Commit a6056eb

Browse files
committed
fix(ai): max_tokens 显式上限 + 默认超时 300s——修复 DeepSeek 输出截断
DeepSeek 官方不传 max_tokens 时默认 8192 token 硬切(finish_reason=length),JSON 截断 → 精修 invalid 失败;20k token 生成实测 110s,60s 超时也会切断。payload 显式 max_tokens(env SILICONFLOW_MAX_TOKENS/AI_TEXT_FILTER_MAX_TOKENS 覆盖)+ 超时默认 300s。
1 parent 3dd29ca commit a6056eb

4 files changed

Lines changed: 75 additions & 13 deletions

File tree

‎app/src-tauri/src/ai_client.rs‎

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,30 @@
88
//! @ai-context: 既有 ai_text_filter.rs(REQ-085 文本复核)与 M2/M3 新增的
99
//! ai_note_refine.rs/ai_enrich.rs 共用本 client;审计/配额/缓存
1010
//! 挂钩由 command 层完成(现有模式:锁内 read-modify-write,
11-
//! 网络调用不持全局锁——防 60s 超时阻塞其他 AI 命令)。
11+
//! 网络调用不持全局锁——防 300s 超时阻塞其他 AI 命令)。
1212
//! @ai-context: 网络路径不单测(与 model_downloader 同口径);payload 构建/
1313
//! 响应提取/JSON 解析为纯函数可单测。
1414
1515
use crate::ai_settings::AiSettings;
1616

1717
/// 单请求超时默认(秒;env SILICONFLOW_TIMEOUT_SECS 可覆盖)。
18-
pub const DEFAULT_TIMEOUT_SECS: u64 = 60;
18+
///
19+
/// @ai-context: 300s——2026-08-21 真机排查:DeepSeek 长输出实测 20k token
20+
/// 需 ~110s,60s 默认会把长生成切断(响应体截断 → JSON 解析
21+
/// EOF → 精修 invalid 失败),按 5 倍余量上调。
22+
pub const DEFAULT_TIMEOUT_SECS: u64 = 300;
1923
/// 重试次数默认(429/5xx/传输错误;env SILICONFLOW_RETRIES 可覆盖)。
2024
pub const DEFAULT_MAX_RETRIES: u32 = 2;
25+
/// 输出 token 上限默认(env SILICONFLOW_MAX_TOKENS 可覆盖)。
26+
///
27+
/// @ai-context: 20000——2026-08-21 真机排查:DeepSeek 官方不传 max_tokens 时
28+
/// 默认 8192 token 硬切(finish_reason=length,JSON 截断 →
29+
/// Parse EOF);精修单片 ≤8000 字输入 → 结构化输出最坏
30+
/// ~15k token,20000 给足余量。其他提供商若拒绝超限值,
31+
/// 用 env 调小(OpenAI 兼容 clamp/报错行为不一)。
32+
pub const DEFAULT_MAX_TOKENS: u32 = 20000;
33+
/// max_tokens env 覆盖键。
34+
const MAX_TOKENS_ENV: &str = "SILICONFLOW_MAX_TOKENS";
2135

2236
/// 共享 client 配置(resolve 聚合:环境变量 > 设置 > 内置默认)。
2337
#[derive(Debug, Clone, PartialEq)]
@@ -27,6 +41,7 @@ pub struct AiClientConfig {
2741
pub model: String,
2842
pub timeout_secs: u64,
2943
pub max_retries: u32,
44+
pub max_tokens: u32,
3045
}
3146

3247
/// 归一化 AI 错误(REQ-145 失败原因四类 + 服务端/解析补充)。
@@ -87,6 +102,7 @@ impl AiClient {
87102
model,
88103
timeout_secs: env_parse("SILICONFLOW_TIMEOUT_SECS", DEFAULT_TIMEOUT_SECS),
89104
max_retries: env_parse("SILICONFLOW_RETRIES", DEFAULT_MAX_RETRIES as u64) as u32,
105+
max_tokens: env_parse(MAX_TOKENS_ENV, DEFAULT_MAX_TOKENS as u64) as u32,
90106
})
91107
}
92108

@@ -103,7 +119,7 @@ impl AiClient {
103119
if self.config.api_key.trim().is_empty() {
104120
return Err(AiClientError::Auth("未配置 API 密钥(设置页保存或配置环境变量)".to_string()));
105121
}
106-
let payload = build_chat_payload(&self.config.model, system, user);
122+
let payload = build_chat_payload(&self.config.model, system, user, self.config.max_tokens);
107123
let url = chat_completions_url(&self.config.base_url);
108124
let agent = ureq::AgentBuilder::new()
109125
.timeout(std::time::Duration::from_secs(self.config.timeout_secs.max(5)))
@@ -193,18 +209,23 @@ pub fn chat_completions_url(base_url: &str) -> String {
193209
format!("{}/chat/completions", base_url.trim_end_matches('/'))
194210
}
195211

196-
/// 构建 chat/completions payload(纯函数可单测;temperature=0 + json_object)。
212+
/// 构建 chat/completions payload(纯函数可单测;temperature=0 + json_object +
213+
/// max_tokens 显式上限)。
197214
///
198215
/// @ai-context: R1 系推理模型带 no_think=true 关闭思考标签(保 JSON 输出
199216
/// 稳定——2026-08 实测选型注意点,与 ai_text_filter 同款)。
200-
pub fn build_chat_payload(model: &str, system: &str, user: &str) -> serde_json::Value {
217+
/// @ai-context: max_tokens 显式传值(2026-08-21 真机排查):DeepSeek 官方
218+
/// 缺省 8192 token 硬切 → JSON 截断;调用方经 AiClientConfig
219+
/// 注入(env SILICONFLOW_MAX_TOKENS 可覆盖)。
220+
pub fn build_chat_payload(model: &str, system: &str, user: &str, max_tokens: u32) -> serde_json::Value {
201221
let mut body = serde_json::json!({
202222
"model": model,
203223
"messages": [
204224
{"role": "system", "content": system},
205225
{"role": "user", "content": user}
206226
],
207227
"temperature": 0,
228+
"max_tokens": max_tokens,
208229
"response_format": {"type": "json_object"}
209230
});
210231
if model.to_lowercase().contains("r1") {

‎app/src-tauri/src/ai_client_tests.rs‎

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,22 +30,23 @@ fn chat_url_preserves_v1_segment() {
3030

3131
#[test]
3232
fn payload_has_expected_shape() {
33-
// temperature=0 + json_object + system/user 消息
34-
let p = build_chat_payload("acme/model", "sys", "usr");
33+
// temperature=0 + json_object + max_tokens 显式上限 + system/user 消息
34+
let p = build_chat_payload("acme/model", "sys", "usr", 20000);
3535
assert_eq!(p["model"], "acme/model");
3636
assert_eq!(p["messages"][0]["role"], "system");
3737
assert_eq!(p["messages"][0]["content"], "sys");
3838
assert_eq!(p["messages"][1]["content"], "usr");
3939
assert_eq!(p["temperature"], 0);
40+
assert_eq!(p["max_tokens"], 20000);
4041
assert_eq!(p["response_format"]["type"], "json_object");
4142
}
4243

4344
#[test]
4445
fn payload_r1_disables_think() {
4546
// R1 系推理模型带 no_think=true(保 JSON 稳定);非 R1 不带
46-
let r1 = build_chat_payload("deepseek-ai/DeepSeek-R1-0528-Qwen3-8B", "s", "u");
47+
let r1 = build_chat_payload("deepseek-ai/DeepSeek-R1-0528-Qwen3-8B", "s", "u", 20000);
4748
assert_eq!(r1["no_think"], true);
48-
let other = build_chat_payload("qwen/qwen3", "s", "u");
49+
let other = build_chat_payload("qwen/qwen3", "s", "u", 20000);
4950
assert!(other.get("no_think").is_none());
5051
}
5152

@@ -81,14 +82,42 @@ fn parse_json_object_invalid_is_parse_error() {
8182

8283
#[test]
8384
fn from_settings_resolves_defaults() {
84-
// 无 env/凭据 → 设置值;密钥为空
85+
// Arrange:清除相关环境变量(防宿主机泄漏——本机 User 级已设 DeepSeek
86+
// 配置;沿用 ai_text_filter_tests 的 save/remove/restore 模式,末尾还原)
87+
let keys = [
88+
"SILICONFLOW_API_KEY",
89+
"SILICONFLOW_BASE_URL",
90+
"SILICONFLOW_MODEL",
91+
"SILICONFLOW_TIMEOUT_SECS",
92+
"SILICONFLOW_RETRIES",
93+
"SILICONFLOW_MAX_TOKENS",
94+
];
95+
let saved: Vec<(String, Option<String>)> =
96+
keys.iter().map(|k| (k.to_string(), std::env::var(k).ok())).collect();
97+
for k in &keys {
98+
std::env::remove_var(k);
99+
}
100+
// Act:无 env/凭据 → 设置值;密钥为空
85101
let s = AiSettings::default();
86102
let c = AiClient::from_settings(&s, None).config;
103+
// Assert:2026-08-21 真机排查后默认值——长生成超时 300s + 输出上限 20000 token
87104
assert_eq!(c.base_url, s.base_url);
88105
assert_eq!(c.model, s.model);
89106
assert!(c.api_key.is_empty());
90-
assert_eq!(c.timeout_secs, 60);
107+
assert_eq!(c.timeout_secs, 300);
91108
assert_eq!(c.max_retries, 2);
109+
assert_eq!(c.max_tokens, 20000);
110+
// env 覆盖 max_tokens(顺序执行,无并行竞争)
111+
std::env::set_var("SILICONFLOW_MAX_TOKENS", "4096");
112+
let c2 = AiClient::from_settings(&AiSettings::default(), None).config;
113+
assert_eq!(c2.max_tokens, 4096);
114+
// 清理(还原宿主环境,防污染其他测试)
115+
for (k, v) in saved {
116+
match v {
117+
Some(val) => std::env::set_var(k, val),
118+
None => std::env::remove_var(k),
119+
}
120+
}
92121
}
93122

94123
#[test]
@@ -108,6 +137,7 @@ fn chat_json_without_key_is_auth_error() {
108137
model: "m".to_string(),
109138
timeout_secs: 5,
110139
max_retries: 0,
140+
max_tokens: 20000,
111141
});
112142
match client.chat_json("sys", "usr") {
113143
Err(AiClientError::Auth(_)) => {}

‎app/src-tauri/src/ai_text_filter.rs‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ pub struct AiTextFilterConfig {
3434
pub timeout_secs: u64,
3535
/// 重试次数(429/5xx/传输错误;指数退避)
3636
pub max_retries: u32,
37+
/// 输出 token 上限(2026-08-21 真机排查:缺省时 DeepSeek 8192 硬切
38+
/// JSON 截断——与共享 client 同默认;AI_TEXT_FILTER_MAX_TOKENS 覆盖)
39+
pub max_tokens: u32,
3740
/// 批量上限(段/请求;超量分批)
3841
pub batch_size: usize,
3942
pub prompt: TextFilterPrompt,
@@ -117,6 +120,10 @@ impl AiTextFilterConfig {
117120
enabled,
118121
timeout_secs: env_parse("AI_TEXT_FILTER_TIMEOUT_SECS", 60),
119122
max_retries: env_parse("AI_TEXT_FILTER_RETRIES", 2) as u32,
123+
max_tokens: env_parse(
124+
"AI_TEXT_FILTER_MAX_TOKENS",
125+
crate::ai_client::DEFAULT_MAX_TOKENS as u64,
126+
) as u32,
120127
batch_size: (env_parse("AI_TEXT_FILTER_BATCH", 30) as usize).clamp(1, 100),
121128
prompt: TextFilterPrompt::bundled(),
122129
}
@@ -158,6 +165,9 @@ impl AiTextFilterAdapter {
158165
model: self.config.model.clone(),
159166
timeout_secs: self.config.timeout_secs,
160167
max_retries: self.config.max_retries,
168+
// 2026-08-21 真机排查:显式输出上限(缺省时 DeepSeek 8192 token
169+
// 硬切 JSON 截断)——与共享 client 同默认,env 可覆盖
170+
max_tokens: self.config.max_tokens,
161171
});
162172
let raw = client
163173
.chat_text(&system, &user)

‎app/src-tauri/src/ai_text_filter_tests.rs‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ fn config() -> AiTextFilterConfig {
1414
enabled: true,
1515
timeout_secs: 30,
1616
max_retries: 2,
17+
max_tokens: 20000,
1718
batch_size: 30,
1819
prompt: TextFilterPrompt::bundled(),
1920
}
@@ -60,7 +61,7 @@ fn payload_has_zero_temperature_and_json_mode() {
6061
let cfg = config();
6162
let system = build_system_prompt(&cfg.prompt);
6263
let user = serde_json::to_string(&request().segments).unwrap();
63-
let payload = crate::ai_client::build_chat_payload(&cfg.model, &system, &user);
64+
let payload = crate::ai_client::build_chat_payload(&cfg.model, &system, &user, 20000);
6465
// Assert
6566
assert_eq!(payload["temperature"], 0);
6667
assert_eq!(payload["response_format"]["type"], "json_object");
@@ -78,7 +79,7 @@ fn non_r1_model_no_think_absent() {
7879
// Arrange:非推理模型(Qwen3-30B 付费档)
7980
let model = "Qwen/Qwen3-30B-A3B-Instruct-2507".to_string();
8081
// Act
81-
let payload = crate::ai_client::build_chat_payload(&model, "sys", "usr");
82+
let payload = crate::ai_client::build_chat_payload(&model, "sys", "usr", 20000);
8283
// Assert:不注入 no_think(未知参数可能被严格端点拒绝)
8384
assert!(payload.get("no_think").is_none());
8485
}

0 commit comments

Comments
 (0)