修复: 上下文流式与工具签名域清债

- tool_signature 归一化补参,合法换参不再误触探索熔断
- SSE 超限截头改对齐事件边界保尾,流不再永久破损
- content_filter 纳入终态判定,无 DONE 流也收敛
- sanitize 占位补头前缀短路只吞 orphan 的盲区消除
- intent 关键词加词边界,history 不再误判
- 恢复重放后重置话题锚点,URL 图 token 地板不被稀释
This commit is contained in:
lxy
2026-10-03 15:35:05 +08:00
parent 499aa49e49
commit c1e5b96d75
10 changed files with 638 additions and 73 deletions
+67 -18
View File
@@ -196,8 +196,10 @@ pub fn sanitize_messages(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
/// 占位与非占位统一改"补头自愈":任何工具结果都不丢(除非无 tool_call_id 的异常数据,无法
/// 补头配对,只能保留——由协议层兜底)。
///
/// **未闭合 vs TOOL_MISSING_PREFIX 头**:已带 TOOL_MISSING_PREFIX 的 id 视为已配对(出口断言
/// 补的占位头),不重复补头(否则同 id 双头致 400)。
/// **未闭合 vs TOOL_MISSING_PREFIX 头**:判定只看 head_ids.contains——真配对(占位头在)由
/// contains 覆盖,不会重复补头;头不在的 prefix id result(前轮占位头被裁剪丢)即 orphan,
/// 同样补头自愈。补出的头 id 为前缀叠加(tool_missing_tool_missing_X,双层前缀),头与
/// result 改写后 id 一致,唯一配对闭合(双层前缀为已知无害副作用)。
pub fn drop_reverse_orphans(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
use std::collections::HashSet;
// 收集所有保留 assistant 头的 tool_call.id(正向 orphan 处理后残留的头里的 id)。
@@ -227,16 +229,14 @@ pub fn drop_reverse_orphans(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
out.push(m);
continue;
};
// 已配对(头在)→ 原样保留。
// 已配对(头在)→ 原样保留。TOOL_MISSING_PREFIX 前缀 id 无需特判:真配对(占位头在)
// 已被 contains 覆盖;头不在(前轮占位头被裁剪丢掉)即是 orphan,同样走补头自愈。
// 补出的头 id 为前缀叠加(tool_missing_tool_missing_X,双层前缀),头+result 改写后
// id 一致,唯一配对闭合,功能正确(双层前缀为已知无害副作用)。
if head_ids.contains(id) {
out.push(m);
continue;
}
// TOOL_MISSING_PREFIX 占位头 id:已被出口断言/上一轮补过,视为配对,不重复补头。
if id.starts_with(TOOL_MISSING_PREFIX) {
out.push(m);
continue;
}
// orphan tool_result(无头)→ **补占位头自愈**(不丢 result)。
// 头 id 复用 result 的 tool_call_id 精确配对(同 assert_placeholder_pairing 自愈模式)。
let head_id = format!("{}{}", TOOL_MISSING_PREFIX, id);
@@ -306,13 +306,16 @@ pub fn assert_placeholder_pairing(
.collect();
// 找 orphan tool_result(id 不在 head_ids)。占位/非占位都需补头(否则 400)。
// 注:TOOL_MISSING_PREFIX 前缀 id 不豁免——真配对(占位头在)已被 head_ids.contains
// 覆盖;头不在的 prefix id result(前轮占位头被裁剪丢)正是 orphan,必须补头闭合。
// 补出的头 id 为前缀叠加(tool_missing_tool_missing_X,双层前缀),唯一配对,功能正确。
// 单次遍历产出新 Vec:orphan result 前插一个占位头(同 id),其余原样保留保序。
let mut healed = 0u32;
let mut out: Vec<ChatMessage> = Vec::with_capacity(messages.len());
for m in messages.into_iter() {
if matches!(m.role, MessageRole::Tool) {
if let Some(id) = m.tool_call_id.as_deref() {
if !head_ids.contains(id) && !id.starts_with(TOOL_MISSING_PREFIX) {
if !head_ids.contains(id) {
// orphan result → 前补占位头(TOOL_MISSING_PREFIX + 原 id,确保唯一且与 result 配对)
let head_id = format!("{}{}", TOOL_MISSING_PREFIX, id);
let head = ChatMessage::assistant_with_tools(
@@ -419,6 +422,11 @@ pub fn ensure_sequence_legal(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
last.tool_calls.get_or_insert_with(Vec::new).extend(calls);
}
}
// 多模态 parts 随合并迁移:空 content 纯图消息的 content 拼接为空操作,
// parts 不迁移则图片片随整条消息丢弃,图对 LLM 隐身
if let Some(parts) = m.parts {
last.parts.get_or_insert_with(Vec::new).extend(parts);
}
merged += 1;
continue;
}
@@ -921,9 +929,11 @@ mod tests {
}
#[test]
fn drop_reverse_orphans_no_redundant_head_for_tool_missing_prefix_id() {
// 边界:orphan result 的 id 已是 TOOL_MISSING_PREFIX(出口断言/上轮补过)→ 视为已配对,
// 不重复补头(否则同 id 双头致 400)。
fn drop_reverse_orphans_heals_prefix_id_orphan_with_nested_prefix_head() {
// 边界(盲区修复回归):orphan result 的 id 已是 TOOL_MISSING_PREFIX 但占位头不在
// (前轮占位头被裁剪丢)→ 即 orphan,同样补头自愈(旧"前缀即已配对"豁免会吞掉此场景,
// 三元组不闭合直送 provider 400)。补出的头 id 为双层前缀叠加
// (tool_missing_tool_missing_X),头与 result 改写后 id 一致,唯一配对闭合。
let msgs = vec![
ChatMessage::user("问题"),
ChatMessage::tool_result("tool_missing_already", "已补过头的结果"),
@@ -936,17 +946,39 @@ mod tests {
.filter(|m| matches!(m.role, MessageRole::Tool))
.count(),
1,
"TOOL_MISSING_PREFIX id 的 result 应保留"
"TOOL_MISSING_PREFIX id 的 orphan result 应保留(补头自愈)"
);
// 不补新头(原样保留,无新 TOOL_MISSING_PREFIX 占位头插入)
let new_heads = sanitized
// 补双层前缀占位头(头 id 含原 id 且以前缀开头)
let healed_heads: Vec<&str> = sanitized
.iter()
.filter(|m| matches!(m.role, MessageRole::Assistant))
.filter_map(|m| m.tool_calls.as_ref())
.flatten()
.filter(|c| c.id.starts_with(TOOL_MISSING_PREFIX))
.count();
assert_eq!(new_heads, 0, "TOOL_MISSING_PREFIX id result 不应重复补头");
.map(|c| c.id.as_str())
.collect();
assert_eq!(
healed_heads.len(),
1,
"prefix id orphan 应补 1 个占位头, 实际 {:?}",
healed_heads
);
assert!(
healed_heads[0].starts_with(TOOL_MISSING_PREFIX)
&& healed_heads[0][TOOL_MISSING_PREFIX.len()..].starts_with(TOOL_MISSING_PREFIX),
"补的头 id 应为双层前缀叠加(tool_missing_tool_missing_X), 实际 {}",
healed_heads[0]
);
// result 的 tool_call_id 改写为占位头 id(闭合配对)
let result_id = sanitized
.iter()
.find(|m| matches!(m.role, MessageRole::Tool))
.and_then(|m| m.tool_call_id.clone())
.expect("应有 tool_result");
assert_eq!(
result_id,
healed_heads[0],
"result id 应与补的头 id 一致(闭合配对)"
);
}
#[test]
@@ -1205,4 +1237,21 @@ mod tests {
sanitized.first().map(|m| format!("{:?}", m.role))
);
}
/// 连续同 role 合并须迁移多模态 parts:空 content 纯图消息与相邻同 role 消息
/// 合并时,图片片并入前一条(此前只拼 content,纯图消息整条丢弃,图对 LLM 隐身)
#[test]
fn sequence_merge_moves_multimodal_parts() {
let pure_image = ChatMessage::user_parts(
"",
vec![crate::provider::ContentPart::image_url("https://x/a.png")],
);
let fixed = ensure_sequence_legal(vec![pure_image, ChatMessage::user("继续")]);
assert_eq!(fixed.len(), 1, "连续 user 应合并为一条");
let parts = fixed[0].parts.as_ref().expect("图片片应迁移到合并结果");
assert_eq!(parts.len(), 1);
assert!(parts[0].is_image(), "迁移的应是图片片");
assert_eq!(fixed[0].content, "继续");
}
}