修复: 知识提炼截断打捞与水位保节奏

- 提炼额度 2048 提至 4096,prompt 封顶五条降序
- 截断 JSON 字符串感知打捞,只收校验通过前缀
- finish_reason 截断留痕,空输出分开口径
- 失败改记节奏位,灭逐轮重提炼风暴
This commit is contained in:
lxy
2026-10-01 19:35:22 +08:00
parent 423acfdd00
commit b8416dea63
2 changed files with 160 additions and 18 deletions
+136 -13
View File
@@ -40,7 +40,7 @@ const KNOWLEDGE_QUERY_MIN_ASCII_LEN: usize = 2;
/// 自动提炼窗口:取最近 N 条 user/assistant 消息送 LLM 提炼
///
/// 原 6 条窗口过窄,长会话后半程的经验不在提炼视野内;40 条覆盖近期完整上下文,
/// 同时不撑爆提炼调用的 token 预算(max_tokens 2048 的输出侧不受影响,输入侧由
/// 同时不撑爆提炼调用的 token 预算(max_tokens 4096 的输出侧不受影响,输入侧由
/// 提炼模型上下文窗口兜底)。
const KNOWLEDGE_EXTRACT_WINDOW: usize = 40;
/// 自动提炼频次:距上次提炼至少新增 N 条用户消息才再提炼
@@ -733,28 +733,42 @@ pub(crate) async fn maybe_spawn_extraction(
"[knowledge] 提炼成功,水位记为当前进度,新增用户消息达阈值后才再提炼"
);
} else {
// 0 条:清哨兵 + 回退水位,允许下次自动触发重试(对话可能后续补充了有价值内容)。
// 0 条:清哨兵回滚预置位;水位记 Done 保节奏(原回退水位会让下一轮对话结束
// 立即重提炼,同窗口反复失败时形成逐轮风暴;记进度后须再攒够阈值条新用户消息)。
let mut session = session_arc.lock().await;
let rounds = session
.conv_read(&conv_id)
.map(|c| count_user_rounds(c.messages.iter()))
.unwrap_or(0);
session.conv(&conv_id).knowledge_extracted = false;
EXTRACT_WATERMARKS
.lock()
.expect("提炼水位互斥锁中毒")
.remove(&conv_id);
.insert(conv_id.clone(), ExtractWatermark::Done(rounds));
tracing::info!(
conv_id = %conv_id,
"[knowledge] 提炼 0 条(无可提炼内容),清哨兵与水位允许下次自动重试"
conv_id = %conv_id, rounds,
"[knowledge] 提炼 0 条(无可提炼内容),水位记为当前进度,新增用户消息达阈值后才再试"
);
}
}
Err(e) => {
// 失败:清哨兵 + 回退水位,允许下次触发重试(预置位已在,须回滚否则会话被锁死)。
// 失败:清哨兵回滚预置位;水位同样记 Done 保节奏(原回退水位在持续故障
// (截断/网络/密钥)时逐轮重试刷爆;达阈值后才再试,手动按钮仍可立即重提炼)。
tracing::warn!("知识提取失败(非阻断): {}", e);
let mut session = session_arc.lock().await;
let rounds = session
.conv_read(&conv_id)
.map(|c| count_user_rounds(c.messages.iter()))
.unwrap_or(0);
session.conv(&conv_id).knowledge_extracted = false;
EXTRACT_WATERMARKS
.lock()
.expect("提炼水位互斥锁中毒")
.remove(&conv_id);
.insert(conv_id.clone(), ExtractWatermark::Done(rounds));
tracing::info!(
conv_id = %conv_id, rounds,
"[knowledge] 提炼失败,水位记为当前进度,新增用户消息达阈值后才再试"
);
}
}
});
@@ -821,7 +835,8 @@ const EXTRACTION_SYSTEM_PROMPT: &str = "你是知识提炼引擎,从 AI 对话
1. 如果适用范围有限制(如仅适用特定语言/框架/场景),必须在 content 或 tags 中明确标注\n\
2. confidence: high=对话中可直接观察的明确模式, medium=合理推断, low=推测性弱信号\n\
3. 无可提炼内容时返回空数组 []\n\
4. 输出纯 JSON,无任何额外文字";
4. 输出纯 JSON,无任何额外文字\n\
5. 最多提取 5 条,按复用价值降序排列(只保留高价值条目,宁缺毋滥)";
/// 从对话中提炼知识,产出 candidate 写入知识库
///
@@ -947,7 +962,7 @@ async fn extract_knowledge_from_conversation(
model: extract_model,
messages: extract_messages,
temperature: Some(0.3),
max_tokens: Some(2048),
max_tokens: Some(4096),
stream: false,
tools: None,
tool_choice: None,
@@ -970,10 +985,28 @@ async fn extract_knowledge_from_conversation(
let json_str = strip_code_fence(raw);
let items: Vec<serde_json::Value> = match serde_json::from_str(json_str) {
Ok(v) => v,
Err(e) => {
tracing::warn!("知识提炼 JSON 解析失败,整批丢弃(非阻断): {} | 原始: {}", e, raw);
return Ok(0);
}
Err(e) => match salvage_truncated_json_array(json_str) {
// 截断打捞:max_tokens 截断常把数组拦腰砍断,先救回已完整闭合的条目
Some(salvaged) if !salvaged.is_empty() => {
tracing::warn!("知识提炼截断打捞: 救回 {} 条,丢弃尾部残条", salvaged.len());
salvaged
}
// 打捞后 0 条才整批丢弃;空输出(content 空/null)与解析失败单独留痕区分
_ => {
if raw.is_empty() {
tracing::warn!(
"知识提炼输出为空(content 空或 null),整批丢弃, completion_tokens={}",
resp.usage.completion_tokens
);
} else {
tracing::warn!(
"知识提炼 JSON 解析失败且打捞无果,整批丢弃(非阻断): {} | completion_tokens={} | 原始: {}",
e, resp.usage.completion_tokens, raw
);
}
return Ok(0);
}
},
};
let knowledge_repo = KnowledgeRepo::new(db);
@@ -1058,6 +1091,53 @@ fn strip_code_fence(s: &str) -> &str {
s
}
/// 截断 JSON 打捞 — 从被截断的数组输出中救回已完整闭合的顶层条目。
///
/// 背景:提炼输出被 max_tokens 拦腰截断(`[{"a":1},{"b":` )时 serde 整批报错,
/// 此前整批丢弃。扫描跳过字符串字面量内部(含 `\"` 转义)的括号,记录数组元素层
/// (depth 归 1)最后一次 `}` 的位置 — 等价于"从尾向前扫到最后一个完整顶层 `}`"
/// (引号开闭与转义只有正向扫描可可靠判定,正向记录末次闭合与反向扫描结果一致)。
/// 截到该位补 `]` 重解析,serde 通过且非空才由调用方入库;空串/纯垃圾/无完整条目
/// 返回 None(调用方走原丢弃路径)。
fn salvage_truncated_json_array(raw: &str) -> Option<Vec<serde_json::Value>> {
let bytes = raw.as_bytes();
let mut in_string = false;
let mut escaped = false;
let mut depth: i64 = 0;
let mut last_elem_close: Option<usize> = None;
for (i, &b) in bytes.iter().enumerate() {
if in_string {
if escaped {
escaped = false; // 转义字符本身,不参与状态判定
} else if b == b'\\' {
escaped = true;
} else if b == b'"' {
in_string = false;
}
continue;
}
match b {
b'"' => in_string = true,
b'{' | b'[' => depth += 1,
b'}' | b']' => {
depth -= 1;
if depth == 1 && b == b'}' {
// 数组元素层闭合:最后一个可打捞条目的右边界
last_elem_close = Some(i);
}
if depth <= 0 {
// 顶层 `]`(完整数组)或深度为负(垃圾前缀):数组层已到界,停止
break;
}
}
_ => {}
}
}
let cut = last_elem_close?;
let candidate = format!("{}]", &raw[..=cut]);
serde_json::from_str(&candidate).ok()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1271,3 +1351,46 @@ mod tests {
assert!(out.contains(&"失败".to_string()));
}
}
#[cfg(test)]
mod salvage_tests {
use super::*;
/// 截断点在第二个元素中段:已完整闭合的首条目应救回
#[test]
fn salvages_complete_prefix_of_truncated_array() {
let raw = r#"[{"kind":"pitfall","title":"t1","content":"c1"},{"kind":"review_r"#;
let out = salvage_truncated_json_array(raw).expect("应打捞出 1 条");
assert_eq!(out.len(), 1);
assert_eq!(out[0]["title"], "t1");
}
/// 括号与引号出现在字符串字面量内(含转义):不得误判为结构边界
#[test]
fn salvage_respects_string_literals_and_escapes() {
let raw = r#"[{"title":"含}花括号与\"引号","content":"a\\"},{"kind":"pit"#;
let out = salvage_truncated_json_array(raw).expect("应打捞出 1 条");
assert_eq!(out.len(), 1);
assert_eq!(out[0]["title"], "含}花括号与\"引号");
assert_eq!(out[0]["content"], "a\\");
}
/// 完整数组 + 尾部脏字符:全部条目救回(等价重解析)
#[test]
fn complete_array_with_trailing_garbage_salvages_all() {
let raw = r#"[{"kind":"pitfall","title":"t1"},{"kind":"diagnosis","title":"t2"}] 附带文字"#;
let out = salvage_truncated_json_array(raw).expect("应打捞 2 条");
assert_eq!(out.len(), 2);
assert_eq!(out[1]["title"], "t2");
}
/// 垃圾/空输出不误收:返回 None 走原丢弃路径
#[test]
fn garbage_or_empty_yields_none() {
assert!(salvage_truncated_json_array("").is_none(), "空输出无物可打捞");
assert!(salvage_truncated_json_array("模型输出了一段散文,没有 JSON").is_none());
assert!(salvage_truncated_json_array(r#"}{"a":1}"#).is_none(), "垃圾前缀深度为负");
assert!(salvage_truncated_json_array(r#"{"kind":"pitfall""#).is_none(), "无完整闭合元素");
assert!(salvage_truncated_json_array("[").is_none());
}
}