修复: 三元组相邻性归位保住工具轮

- df-ai sanitize 新增 step 0.5 reorder_interlopers_after_tool_run: 楔入 head 与 tool 响应间的异类消息(压缩摘要/用户插话/警示)移到该组响应之后,只重排不删内容,工具轮不再被剥除
- 幂等;仅处理单头+夹层+紧随连续 tool 段,其余畸形仍走剥除兜底
- 单测: 旧剥除用例改为归位保轮 + 新增幂等用例;cargo test -p df-ai --lib context 156 passed
- 实证更正: 2026-08-04 报告「实证4」被推翻——已定位原始会话 b4d6b4e0,首轮裸问候即发两个 tool_calls;同步更新看板 909 状态
This commit is contained in:
lxy
2026-10-10 00:27:47 +08:00
parent d918237327
commit a95e205eb6
3 changed files with 118 additions and 15 deletions
+102 -14
View File
@@ -42,6 +42,12 @@ pub fn sanitize_messages(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
.filter(|m| m.is_active())
.collect();
// step 0.5(相邻性归位,909②):把楔入 assistant(tool_calls) 头与其 tool 响应之间的异类消息
// (后台压缩摘要 / 用户插话 / 警示 system)整体移到该组响应**之后**——只重排不删内容。
// 归位后三元组恢复相邻,step1 的邻接判定即可正确识别为已闭合,不再走「未邻接即剥除」而**保住
// 工具轮**(避免丢结果致模型重跑/误判)。真正无法归位者(头无任何响应)仍由 step2/3 剥除兜底。
let messages = reorder_interlopers_after_tool_run(messages);
// step 1:相邻性判定的依据——每个 assistant 头「紧后 tool 连续段」覆盖的 id 集合。
// 口径与 openai_compat::sanitize_openai_triplets 一致(2026-10-09 收紧):
// 协议要求 tool 响应必须紧随其 assistant(tool_calls);原实现按「id 全局存在」判定
@@ -197,6 +203,55 @@ pub fn sanitize_messages(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
ensure_sequence_legal(after_reverse_orphan)
}
/// 相邻性归位(909②,view-only):assistant 头与其 tool 响应之间夹有异类消息时,把夹层整体移到
/// 该头「紧后连续 tool 段」之后。
///
/// 根因:协议不变式要求「tool 响应必须紧随其 assistant(tool_calls)」。后台压缩摘要在 head 已入、
/// 结果未回的窗口内楔入,或用户在执行期插话,都会产生 `[head, 异类, tool…]`。原逻辑对此按「未邻接
/// = 未闭合」剥除头与其响应,**丢掉整轮工具交互**(有损)。本步改为**归位**——夹层后移,头与其响应
/// 恢复相邻,工具轮保留;夹层内容亦不丢。仅处理「单头 + 夹层 + 紧随该头的连续 tool 段」这一可确定
/// 归位的形态;遇到下一个 tool_call 头即停止收集(不跨单元搬运),其余畸形仍由 step2/3 剥除兜底。
///
/// 幂等:归位后序列已相邻,再跑本函数无夹层可移,输出不变。
fn reorder_interlopers_after_tool_run(msgs: Vec<ChatMessage>) -> Vec<ChatMessage> {
fn is_tool_call_head(m: &ChatMessage) -> bool {
matches!(m.role, MessageRole::Assistant)
&& m.tool_calls.as_ref().is_some_and(|c| !c.is_empty())
}
fn is_tool(m: &ChatMessage) -> bool {
matches!(m.role, MessageRole::Tool)
}
let mut out: Vec<ChatMessage> = Vec::with_capacity(msgs.len());
let mut i = 0usize;
while i < msgs.len() {
if is_tool_call_head(&msgs[i]) {
// 收集头与首个 tool 之间的夹层(遇下一个 tool_call 头即停,不跨单元)
let mut j = i + 1;
let mut interlopers: Vec<ChatMessage> = Vec::new();
while j < msgs.len() && !is_tool(&msgs[j]) && !is_tool_call_head(&msgs[j]) {
interlopers.push(msgs[j].clone());
j += 1;
}
let run_start = j;
while j < msgs.len() && is_tool(&msgs[j]) {
j += 1;
}
// 有夹层且紧随其后确有 tool 段 → 头 + 响应在前,夹层后移(只重排)
if !interlopers.is_empty() && run_start < j {
out.push(msgs[i].clone());
out.extend(msgs[run_start..j].iter().cloned());
out.extend(interlopers);
i = j;
continue;
}
}
out.push(msgs[i].clone());
i += 1;
}
out
}
/// step 3.5:反向 orphan 检测(view-only)—— **补头自愈**(对 orphan tool_result 就地补占位
/// assistant tool_call 头,而非丢弃 tool_result)。
///
@@ -570,12 +625,11 @@ mod tests {
// ── 占位配对完整性(解 400 orphan):反向 orphan 检测 + 出口自愈 ──
/// 邻接性回归(2026-10-09 400 根因):tool 响应与 head 之间被夹层(system/user)隔开时,
/// 闭合判定按「紧后 tool 连续段覆盖」而非「id 全局存在」——被隔开的 call 视同未闭合
/// (剥除/整头丢),其游离响应随 orphaned_ids 一并丢弃,保证发送序列满足「head 后紧邻其
/// 响应」。防 OpenAI 400「insufficient tool messages following tool_calls message」。
/// 相邻性归位(909②):夹层(system/user)隔开 head 与其 tool 响应时,把夹层移到响应之后
/// (只重排不删),保住整轮工具交互;同时保证发送序列满足「head 后紧邻其响应」。
/// 旧行为(逐字剥除工具轮)已被归位取代——本用例即旧行为的有损对照。
#[test]
fn sanitize_strips_calls_when_response_separated_by_interloper() {
fn sanitize_reorders_interloper_to_keep_tool_round() {
// [user] [assistant(c1,c2)] [system 夹层] [tool c1] [tool c2]
let msgs = vec![
ChatMessage::user("go"),
@@ -591,21 +645,55 @@ mod tests {
ChatMessage::tool_result("c2", "r2"),
];
let out = sanitize_messages(msgs);
// 夹层隔开 → 两 call 均不在紧后连续段 → 无任何带 tool_calls 的 assistant 存活
// 零丢失:5 条全在(归位只重排,不删内容)
assert_eq!(out.len(), 5, "归位不得丢内容, 实际 {:?}", out);
// 头保留其两个 tool_call(工具轮不丢)
let head_idx = out
.iter()
.position(|m| matches!(m.role, MessageRole::Assistant))
.expect("head 应保留");
assert_eq!(
out[head_idx].tool_calls.as_ref().map(|c| c.len()),
Some(2),
"头应保留两个 tool_call, 实际 {:?}",
out
);
// 头后紧邻两条 tool 响应(协议不变式成立)
assert!(
matches!(out[head_idx + 1].role, MessageRole::Tool)
&& matches!(out[head_idx + 2].role, MessageRole::Tool),
"head 后应紧邻两条 tool 响应, 实际 {:?}",
out
);
// 夹层内容保留(后移到响应之后)
assert!(
out.iter()
.all(|m| m.tool_calls.as_ref().map_or(true, |c| c.is_empty())),
"被夹层隔开的 call 应被剥除(不得残留 head+tool_calls), 实际 {:?}",
out
);
// 游离响应无头配对 → 丢弃;序列内不得残留 role=Tool
assert!(
out.iter().all(|m| !matches!(m.role, MessageRole::Tool)),
"游离 tool 响应应被丢弃, 实际 {:?}",
.any(|m| matches!(m.role, MessageRole::System) && m.content == "夹层提示"),
"夹层内容不得丢, 实际 {:?}",
out
);
}
#[test]
fn sanitize_reorder_is_idempotent() {
let msgs = vec![
ChatMessage::user("go"),
ChatMessage::assistant_with_tools(
"",
vec![ToolCall::new("c1".to_string(), "t", "{}")],
),
ChatMessage::system("夹层提示"),
ChatMessage::tool_result("c1", "r1"),
];
let once = sanitize_messages(msgs);
let twice = sanitize_messages(once.clone());
// MessageRole 未实现 PartialEq → 用 Debug 签名比较序列(角色+内容)是否一致
let sig = |v: &[ChatMessage]| -> Vec<String> {
v.iter().map(|m| format!("{:?}|{}", m.role, m.content)).collect()
};
assert_eq!(sig(&once), sig(&twice), "归位应幂等(角色序与内容序不变)");
}
#[test]
fn sanitize_heals_reverse_orphan_tool_result_with_placeholder_head() {
// 三元组完整性 P0:反向 orphan(tool_result 无对应 tool_call 头)→ 补占位头自愈(不丢)。