修复: 知识提炼截断打捞与水位保节奏
- 提炼额度 2048 提至 4096,prompt 封顶五条降序 - 截断 JSON 字符串感知打捞,只收校验通过前缀 - finish_reason 截断留痕,空输出分开口径 - 失败改记节奏位,灭逐轮重提炼风暴
This commit is contained in:
@@ -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)]
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user