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

- 提炼额度 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
+24 -5
View File
@@ -65,11 +65,30 @@ pub(crate) struct OpenAiResponse {
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub(crate) struct OpenAiChoice { pub struct OpenAiChoice {
pub message: OpenAiMessageResp, pub(crate) message: OpenAiMessageResp,
// OpenAI 响应反序列化字段,保留以备调试/未来消费(如日志记录调用终止原因),标注意图消除 dead_code warning /// 调用终止原因。`length` = 输出被 max_tokens 截断(不完整),由 [`FinishReason`]
#[allow(dead_code)] /// 的 Drop 侧效应打 warn 留痕 — 此前该字段无人读、截断不可见,截断的 JSON 会被
pub finish_reason: Option<String>, /// 下游当解析失败整批丢弃而无任何日志线索。
#[serde(default)]
pub finish_reason: FinishReason,
}
/// finish_reason 载体(透明 NewType)。
///
/// 消费方只取 message,本字段值随 choice 整体 drop 时触发截断告警;对外公开
/// (pub 模块下的 pub 结构属库 API 面)同时免去字段 dead_code 豁免。
/// serde 透明,线上 JSON 形态与 Option<String> 一致。
#[derive(Debug, Default, Deserialize)]
#[serde(transparent)]
pub struct FinishReason(Option<String>);
impl Drop for FinishReason {
fn drop(&mut self) {
if self.0.as_deref() == Some("length") {
tracing::warn!("[OpenAI] 同步响应因 max_tokens 截断(finish_reason=length),输出不完整");
}
}
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
+136 -13
View File
@@ -40,7 +40,7 @@ const KNOWLEDGE_QUERY_MIN_ASCII_LEN: usize = 2;
/// 自动提炼窗口:取最近 N 条 user/assistant 消息送 LLM 提炼 /// 自动提炼窗口:取最近 N 条 user/assistant 消息送 LLM 提炼
/// ///
/// 原 6 条窗口过窄,长会话后半程的经验不在提炼视野内;40 条覆盖近期完整上下文, /// 原 6 条窗口过窄,长会话后半程的经验不在提炼视野内;40 条覆盖近期完整上下文,
/// 同时不撑爆提炼调用的 token 预算(max_tokens 2048 的输出侧不受影响,输入侧由 /// 同时不撑爆提炼调用的 token 预算(max_tokens 4096 的输出侧不受影响,输入侧由
/// 提炼模型上下文窗口兜底)。 /// 提炼模型上下文窗口兜底)。
const KNOWLEDGE_EXTRACT_WINDOW: usize = 40; const KNOWLEDGE_EXTRACT_WINDOW: usize = 40;
/// 自动提炼频次:距上次提炼至少新增 N 条用户消息才再提炼 /// 自动提炼频次:距上次提炼至少新增 N 条用户消息才再提炼
@@ -733,28 +733,42 @@ pub(crate) async fn maybe_spawn_extraction(
"[knowledge] 提炼成功,水位记为当前进度,新增用户消息达阈值后才再提炼" "[knowledge] 提炼成功,水位记为当前进度,新增用户消息达阈值后才再提炼"
); );
} else { } else {
// 0 条:清哨兵 + 回退水位,允许下次自动触发重试(对话可能后续补充了有价值内容)。 // 0 条:清哨兵回滚预置位;水位记 Done 保节奏(原回退水位会让下一轮对话结束
// 立即重提炼,同窗口反复失败时形成逐轮风暴;记进度后须再攒够阈值条新用户消息)。
let mut session = session_arc.lock().await; 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; session.conv(&conv_id).knowledge_extracted = false;
EXTRACT_WATERMARKS EXTRACT_WATERMARKS
.lock() .lock()
.expect("提炼水位互斥锁中毒") .expect("提炼水位互斥锁中毒")
.remove(&conv_id); .insert(conv_id.clone(), ExtractWatermark::Done(rounds));
tracing::info!( tracing::info!(
conv_id = %conv_id, conv_id = %conv_id, rounds,
"[knowledge] 提炼 0 条(无可提炼内容),清哨兵与水位允许下次自动重试" "[knowledge] 提炼 0 条(无可提炼内容),水位记为当前进度,新增用户消息达阈值后才再试"
); );
} }
} }
Err(e) => { Err(e) => {
// 失败:清哨兵 + 回退水位,允许下次触发重试(预置位已在,须回滚否则会话被锁死)。 // 失败:清哨兵回滚预置位;水位同样记 Done 保节奏(原回退水位在持续故障
// (截断/网络/密钥)时逐轮重试刷爆;达阈值后才再试,手动按钮仍可立即重提炼)。
tracing::warn!("知识提取失败(非阻断): {}", e); tracing::warn!("知识提取失败(非阻断): {}", e);
let mut session = session_arc.lock().await; 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; session.conv(&conv_id).knowledge_extracted = false;
EXTRACT_WATERMARKS EXTRACT_WATERMARKS
.lock() .lock()
.expect("提炼水位互斥锁中毒") .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\ 1. 如果适用范围有限制(如仅适用特定语言/框架/场景),必须在 content 或 tags 中明确标注\n\
2. confidence: high=对话中可直接观察的明确模式, medium=合理推断, low=推测性弱信号\n\ 2. confidence: high=对话中可直接观察的明确模式, medium=合理推断, low=推测性弱信号\n\
3. 无可提炼内容时返回空数组 []\n\ 3. 无可提炼内容时返回空数组 []\n\
4. 输出纯 JSON,无任何额外文字"; 4. 输出纯 JSON,无任何额外文字\n\
5. 最多提取 5 条,按复用价值降序排列(只保留高价值条目,宁缺毋滥)";
/// 从对话中提炼知识,产出 candidate 写入知识库 /// 从对话中提炼知识,产出 candidate 写入知识库
/// ///
@@ -947,7 +962,7 @@ async fn extract_knowledge_from_conversation(
model: extract_model, model: extract_model,
messages: extract_messages, messages: extract_messages,
temperature: Some(0.3), temperature: Some(0.3),
max_tokens: Some(2048), max_tokens: Some(4096),
stream: false, stream: false,
tools: None, tools: None,
tool_choice: None, tool_choice: None,
@@ -970,10 +985,28 @@ async fn extract_knowledge_from_conversation(
let json_str = strip_code_fence(raw); let json_str = strip_code_fence(raw);
let items: Vec<serde_json::Value> = match serde_json::from_str(json_str) { let items: Vec<serde_json::Value> = match serde_json::from_str(json_str) {
Ok(v) => v, Ok(v) => v,
Err(e) => { Err(e) => match salvage_truncated_json_array(json_str) {
tracing::warn!("知识提炼 JSON 解析失败,整批丢弃(非阻断): {} | 原始: {}", e, raw); // 截断打捞:max_tokens 截断常把数组拦腰砍断,先救回已完整闭合的条目
return Ok(0); 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); let knowledge_repo = KnowledgeRepo::new(db);
@@ -1058,6 +1091,53 @@ fn strip_code_fence(s: &str) -> &str {
s 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -1271,3 +1351,46 @@ mod tests {
assert!(out.contains(&"失败".to_string())); 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());
}
}