修复: AI 对话/工具可靠性(sanitize 三元组 + G2 签名重复 + handshake 不杀 loop + 空 tool_call id 兜底)

治 5 个对话停止/工具失败根因:sanitize 三元组按 id 配对治 400;G2 探索熔断从结果空
改签名重复判定(治误停正常探索);handshake 删越权强杀活 loop(generating 归 guard 单源);
空 tool_call id 兜底 gen_<index>(治 SenseNova 工具结果路由错位)。
This commit is contained in:
lxy
2026-08-02 02:21:30 +08:00
parent c76e77bd3c
commit 57d6a2d066
8 changed files with 991 additions and 185 deletions
+468 -27
View File
@@ -11,7 +11,8 @@ use reqwest::Client;
use tracing::{debug, error, warn};
use crate::provider::{
CompletionRequest, CompletionResponse, LlmProvider, StreamResult, TokenUsage, ToolCall,
tool_call_id_or_fallback, CompletionRequest, CompletionResponse, LlmProvider, StreamResult,
TokenUsage, ToolCall,
};
// ChatMessage 仅单测构造 CompletionRequest 用,避免非 test 构建的 unused import 警告。
#[cfg(test)]
@@ -182,30 +183,25 @@ impl OpenAICompatProvider {
// assistant 的序列(会话恢复/续发/片段截取),补 user 占位保留上下文,首条合法。
Self::ensure_leading_user(&mut messages);
// 治 DeepSeek 400「insufficient tool messages」:扫描所有 assistant 消息,
// 若某条 assistant tool_calls 但下一条不是 tool,则剥离其 tool_calls。
// 正常流程 tool 结果先于下一轮 LLM 请求推入历史,此守卫仅兜底异常截断/恢复场景的残末尾
// 注意:合法的三元组形如:assistant(tc=[a]) → tool(a) → assistant(tc=[b]) → tool(b)。
// 若最后一条是 assistant(tc=...) 也无下一条 tool,同样剥离。
for i in 0..messages.len() {
let role = messages[i].role.clone();
if role != "assistant" {
continue;
}
let has_tc = messages[i].tool_calls.is_some();
if !has_tc {
continue;
}
let next_is_tool = i + 1 < messages.len()
&& matches!(messages[i + 1].role.as_str(), "tool");
if !next_is_tool {
messages[i].tool_calls = None;
tracing::warn!(
"[openai] assistant(#{} role={}) 含 tool_calls 但下一条非 tool,已自动剥离(防 400)",
i, role,
);
}
}
// 治 DeepSeek/OpenAI 400(三元组完整性 P0)。OpenAI 协议铁律:
// (a) assistant 的每个 tool_call.id 必须有后续 tool(role=tool, tool_call_id 匹配)响应,
// 否则 "insufficient tool messages" 400(assistant 调了工具但无结果)
// (b) 反之,每条 tool 消息必须紧跟一个含 tool_calls(同 tool_call_id)的 assistant,
// 否则 "Messages with role tool must be a response to a preceding message
// with tool_calls" 400(tool 无配对头)。
//
// 旧逻辑只检查「下一条 role 是否为 tool」(粗粒度),漏两类 orphan:
// 1) 部分 tool_call 无响应:assistant(tc=[a,b]) → tool(a)(b 丢失)→ 旧逻辑因下一条是
// tool 不剥 → 发出未闭合的 b → 400。修法:按 tool_call_id 精确配对,剥未闭合 id。
// 2) orphan tool_result(tool 无前置 assistant tool_calls 配对):DB/直构造路径绕过
// ContextManager::sanitize_messages(标题/知识注入/工作流节点),tool 残留无头 →
// 旧逻辑不处理 → 400。修法:剥 assistant tool_calls 时同步丢弃同 id 的 orphan
// tool(一致性:不留无头 result),并对独立 orphan tool(全程无配对头)直接丢弃。
//
// 正常三元组形如:assistant(tc=[a]) → tool(a) → assistant(tc=[b]) → tool(b),各 id 闭合,
// 本守卫零介入。仅异常截断/恢复/直构造路径触发(防 400 兜底)。
// view-only:仅改发送视图(本函数消费 req.messages 所有权),持久化由调用方/上层 sanitize 全量保留。
sanitize_openai_triplets(&mut messages);
let tools = req.tools.map(|defs| {
defs.into_iter()
@@ -304,15 +300,149 @@ impl OpenAICompatProvider {
);
}
/// 解析同步响应中的工具调用
/// 解析同步响应中的工具调用
///
/// 兜底(CR-空 id):id 空时按数组 index 生成 `gen_tool_{index}` fallback。
/// SenseNova 等兼容缺陷 provider 发空 id,多 tool_call 同 id 致结果路由全落首个。
/// 详见 `tool_call_id_or_fallback`。正常 provider id 非空原样透传。
fn parse_tool_calls(calls: Vec<OpenAiToolCallResp>) -> Vec<ToolCall> {
calls
.into_iter()
.map(|c| ToolCall::new(c.id, c.function.name, c.function.arguments))
.enumerate()
.map(|(i, c)| {
let id = tool_call_id_or_fallback(&c.id, i, "gen_tool");
ToolCall::new(id, c.function.name, c.function.arguments)
})
.collect()
}
}
/// 从 OpenAiMessage 的 tool_calls 数组里取每个 call 的 id(tool_calls 形如
/// [{id, type, function:{name, arguments}}, ...])。非数组 / 缺 id 的条目跳过。
fn extract_tool_call_ids(msg: &OpenAiMessage) -> Vec<String> {
let Some(arr) = msg.tool_calls.as_ref() else {
return Vec::new();
};
arr.iter()
.filter_map(|tc| tc.get("id").and_then(|v| v.as_str()).map(|s| s.to_string()))
.collect()
}
/// 三元组一致性自愈(view-only,发送视图):保证 OpenAI 协议 tool_call/tool_result
/// 双向闭合,防 DeepSeek/OpenAI 400。详见 [`OpenAICompatProvider::convert_request`] 调用处注释。
///
/// 两轮扫描:
/// 1) 收集 resolved_ids = 所有 tool 消息的 tool_call_id(这些 id 有 result 响应)。
/// 2) assistant(tool_calls):剥未在 resolved_ids 内的 call.id;剥空则 tool_calls=None。
/// (头被剥后,其 tool_call.id 不再进 head_ids,故 step3 会同步丢弃对应 orphan tool。)
/// 3) tool:tool_call_id 不在任何保留 assistant 头(任意 assistant 仍含此 id)→ orphan
/// tool_result,丢弃。这覆盖「头被剥后残留的 tool」与「全程无配对头的 tool」两类。
///
/// 一致性:剥 assistant tool_call → 该 id 不进 head_ids → 对应 tool 在 step3 被丢;
/// 反之剥 orphan tool 不动 assistant(若 assistant 的所有 id 都被剥则 tool_calls=None)。
/// 正常三元组(各 id 闭合)零介入。
///
/// 设计取舍:OpenAI 协议 assistant(tool_calls) 需有效函数结构,补头(像 Anthropic
/// TOOL_MISSING_PREFIX)风险高于丢弃——故选「丢弃 orphan」而非「补头」。Anthropic
/// 路径由 drop_reverse_orphans 补头自愈(保留 LLM 可见的工具结果);OpenAI 路径走丢弃,
/// 二者各自适配协议特性(Anthropic 严格交替 + 补头可行;OpenAI tool 必须紧跟 tool_calls)。
fn sanitize_openai_triplets(messages: &mut Vec<OpenAiMessage>) {
use std::collections::HashSet;
// step 1:resolved_ids = 所有 tool 消息提供的 tool_call_id(有 result 响应的 id)。
let resolved_ids: HashSet<String> = messages
.iter()
.filter(|m| m.role == "tool")
.filter_map(|m| m.tool_call_id.clone())
.collect();
let mut stripped_heads = 0u32;
let mut total_stripped = 0u32;
// step 2:assistant 剥未闭合 tool_call(无对应 tool result 响应)。
for m in messages.iter_mut() {
if m.role != "assistant" {
continue;
}
let Some(calls) = m.tool_calls.as_ref() else {
continue;
};
if calls.is_empty() {
continue;
}
let kept: Vec<serde_json::Value> = calls
.iter()
.filter(|tc| {
tc.get("id")
.and_then(|v| v.as_str())
.is_some_and(|id| resolved_ids.contains(id))
})
.cloned()
.collect();
let stripped_count = calls.len() - kept.len();
if stripped_count == 0 {
continue;
}
m.tool_calls = if kept.is_empty() { None } else { Some(kept) };
stripped_heads += 1;
total_stripped += stripped_count as u32;
tracing::warn!(
stripped_count,
"[openai] assistant 含未闭合 tool_calls(无对应 tool result),已剥离 {} 个(防 insufficient tool messages 400)",
stripped_count,
);
}
// step 3:head_ids = step2 后仍保留在任意 assistant 头的 id(有头配对的 tool 才保留)。
let head_ids: HashSet<String> = messages
.iter()
.filter(|m| m.role == "assistant")
.flat_map(extract_tool_call_ids)
.collect();
let original_len = messages.len();
let mut dropped_orphan_tools = 0u32;
messages.retain(|m| {
if m.role != "tool" {
return true;
}
let id = match m.tool_call_id.as_deref() {
None => {
// 无 tool_call_id 的 tool 消息(异常数据):无法配对,丢弃(发出去必 400)。
dropped_orphan_tools += 1;
tracing::warn!(
"[openai] tool 消息缺少 tool_call_id,已丢弃(无 id 无法配对 assistant tool_calls,防 400)"
);
return false;
}
Some(id) => id,
};
if head_ids.contains(id) {
// 有配对头 → 保留(正常三元组)。
return true;
}
// 无配对头(id 不在任何保留 assistant 头内)→ orphan tool_result,丢弃。
// 含两类:(a) assistant 头被 step2 剥后残留的 tool;(b) 全程无配对头的直构造/DB 残留。
dropped_orphan_tools += 1;
tracing::warn!(
tool_call_id = %id,
"[openai] orphan tool result(无配对 assistant tool_calls),已丢弃(防 'tool must be response to preceding tool_calls' 400)",
);
false
});
if stripped_heads > 0 || dropped_orphan_tools > 0 {
tracing::warn!(
stripped_heads,
total_stripped,
dropped_orphan_tools,
before = original_len,
after = messages.len(),
"[openai] tool_call 三元组自愈(view-only, 持久化不受影响)"
);
}
}
#[async_trait]
impl LlmProvider for OpenAICompatProvider {
/// 文本嵌入: POST /v1/embeddings(OpenAI 兼容,智谱/阿里百炼/OpenAI 通用)
@@ -801,4 +931,315 @@ mod tests {
assert_eq!(out.messages.len(), 2, "正常序列不补占位");
assert_eq!(out.messages[0].role.as_str(), "user");
}
// ---------- 三元组一致性自愈(P0:治 DeepSeek/OpenAI 400) ----------
/// 辅助:取 assistant 消息的 tool_call id 列表(发出去的形态)。
fn openai_tool_call_ids(m: &OpenAiMessage) -> Vec<String> {
m.tool_calls
.as_ref()
.map(|arr| {
arr.iter()
.filter_map(|tc| tc.get("id").and_then(|v| v.as_str()).map(String::from))
.collect()
})
.unwrap_or_default()
}
/// 正常三元组(各 id 闭合)零介入:assistant(tc=[a]) → tool(a) → assistant(tc=[b]) → tool(b)。
/// 约束铁律:不破正常三元组。
#[test]
fn openai_sanitize_keeps_closed_triplets() {
let provider = OpenAICompatProvider::new("https://api.deepseek.com", "k", "deepseek-chat");
let req = CompletionRequest {
model: "deepseek-chat".into(),
messages: vec![
ChatMessage::user("查天气"),
ChatMessage::assistant_with_tools(
"调用中",
vec![ToolCall::new("call_a", "get_weather", "{}")],
),
ChatMessage::tool_result("call_a", ""),
ChatMessage::assistant_with_tools(
"再查",
vec![ToolCall::new("call_b", "get_weather", "{}")],
),
ChatMessage::tool_result("call_b", ""),
],
temperature: None,
max_tokens: None,
stream: false,
tools: None,
tool_choice: None,
reasoning_content: None,
};
let out = provider.convert_request(req);
// 5 条全保留(正常三元组不剥不丢)。
assert_eq!(out.messages.len(), 5, "正常三元组零介入,不应剥/丢任何消息");
// 两个 assistant 头的 tool_calls 完整保留。
let heads: Vec<&OpenAiMessage> = out
.messages
.iter()
.filter(|m| m.role == "assistant")
.collect();
assert_eq!(openai_tool_call_ids(heads[0]), vec!["call_a".to_string()]);
assert_eq!(openai_tool_call_ids(heads[1]), vec!["call_b".to_string()]);
}
/// 末尾 assistant tool_calls 无 result(残末尾)→ 剥离 tool_calls(保留 assistant 文本)。
/// 防 "insufficient tool messages" 400。
#[test]
fn openai_sanitize_strips_tail_unresolved_tool_calls() {
let provider = OpenAICompatProvider::new("https://api.deepseek.com", "k", "deepseek-chat");
let req = CompletionRequest {
model: "deepseek-chat".into(),
messages: vec![
ChatMessage::user("查天气"),
ChatMessage::assistant_with_tools(
"调工具但 result 还没回来",
vec![ToolCall::new("call_x", "get_weather", "{}")],
),
],
temperature: None,
max_tokens: None,
stream: false,
tools: None,
tool_choice: None,
reasoning_content: None,
};
let out = provider.convert_request(req);
// assistant 保留(content 不丢),但 tool_calls 被剥。
let asst = out
.messages
.iter()
.find(|m| m.role == "assistant")
.expect("assistant 应保留");
assert!(
asst.tool_calls.is_none(),
"未闭合 tool_calls 应被剥离"
);
}
/// orphan tool_result(无配对 assistant tool_calls 头)→ 丢弃。
/// 直构造/DB 残留路径绕过 ContextManager::sanitize_messages 时由本守卫兜底。
/// 防 "Messages with role tool must be a response to a preceding message with tool_calls" 400。
#[test]
fn openai_sanitize_drops_orphan_tool_result_no_head() {
let provider = OpenAICompatProvider::new("https://api.deepseek.com", "k", "deepseek-chat");
let req = CompletionRequest {
model: "deepseek-chat".into(),
messages: vec![
ChatMessage::user(""),
// 无头的 orphan tool_result(头被裁剪/丢失)。
ChatMessage::tool_result("orphan_id", "结果"),
ChatMessage::assistant("回复"),
],
temperature: None,
max_tokens: None,
stream: false,
tools: None,
tool_choice: None,
reasoning_content: None,
};
let out = provider.convert_request(req);
// orphan tool 被丢弃,剩 user + assistant。
let tools: Vec<&OpenAiMessage> = out
.messages
.iter()
.filter(|m| m.role == "tool")
.collect();
assert!(tools.is_empty(), "无配对头的 orphan tool_result 应丢弃, 实际 {:?}", tools);
assert_eq!(out.messages.len(), 2, "应剩 user + assistant");
}
/// assistant tool_calls 剥离后,对应 orphan tool_result 同步丢弃(一致性)。
/// 场景:assistant(tc=[a,b]) → tool(a)(b 的 result 丢失)。旧逻辑因下一条是 tool
/// 不剥 → 发出未闭合 b → 400。新逻辑按 id 精确配对:剥 b(保留 a),tool(a) 保留。
#[test]
fn openai_sanitize_partial_triplet_strips_unresolved_id() {
let provider = OpenAICompatProvider::new("https://api.deepseek.com", "k", "deepseek-chat");
let req = CompletionRequest {
model: "deepseek-chat".into(),
messages: vec![
ChatMessage::user(""),
ChatMessage::assistant_with_tools(
"调两工具",
vec![
ToolCall::new("call_a", "tool_a", "{}"),
ToolCall::new("call_b", "tool_b", "{}"),
],
),
// 只回了 call_a,call_b 的 result 丢失。
ChatMessage::tool_result("call_a", "a 结果"),
],
temperature: None,
max_tokens: None,
stream: false,
tools: None,
tool_choice: None,
reasoning_content: None,
};
let out = provider.convert_request(req);
let asst = out
.messages
.iter()
.find(|m| m.role == "assistant")
.expect("assistant 应保留");
// 只保留 call_a(已闭合),剥 call_b(未闭合)。
assert_eq!(
openai_tool_call_ids(asst),
vec!["call_a".to_string()],
"部分闭合头应只留已闭合 call_a, 剥未闭合 call_b"
);
// tool(call_a) 保留(有配对头)。
let tools: Vec<&OpenAiMessage> = out
.messages
.iter()
.filter(|m| m.role == "tool")
.collect();
assert_eq!(tools.len(), 1, "call_a 的 tool_result 应保留");
}
/// 全未闭合三元组:assistant(tc=[a]) 但全程无 tool(a) → 剥 tool_calls,
/// 且不残留任何 orphan tool(本就无 tool 消息)。
#[test]
fn openai_sanitize_fully_unresolved_strips_all() {
let provider = OpenAICompatProvider::new("https://api.deepseek.com", "k", "deepseek-chat");
let req = CompletionRequest {
model: "deepseek-chat".into(),
messages: vec![
ChatMessage::user(""),
ChatMessage::assistant_with_tools(
"调工具无结果",
vec![
ToolCall::new("call_y", "tool_y", "{}"),
ToolCall::new("call_z", "tool_z", "{}"),
],
),
ChatMessage::assistant("纯文本续"),
],
temperature: None,
max_tokens: None,
stream: false,
tools: None,
tool_choice: None,
reasoning_content: None,
};
let out = provider.convert_request(req);
let heads: Vec<&OpenAiMessage> = out
.messages
.iter()
.filter(|m| m.role == "assistant")
.collect();
// 第一个 assistant(原含 tool_calls)应被剥空;第二个纯文本不变。
assert!(
heads[0].tool_calls.is_none(),
"全未闭合 tool_calls 应全部剥离"
);
assert!(heads[1].tool_calls.is_none(), "纯文本 assistant 无 tool_calls");
}
/// 无 tool_call_id 的 tool 消息(异常数据)→ 丢弃(发出去必 400)。
#[test]
fn openai_sanitize_drops_tool_without_call_id() {
let provider = OpenAICompatProvider::new("https://api.deepseek.com", "k", "deepseek-chat");
let mut bad_tool = ChatMessage::tool_result("temp", "结果");
bad_tool.tool_call_id = None; // 异常:无 id
let req = CompletionRequest {
model: "deepseek-chat".into(),
messages: vec![ChatMessage::user(""), bad_tool],
temperature: None,
max_tokens: None,
stream: false,
tools: None,
tool_choice: None,
reasoning_content: None,
};
let out = provider.convert_request(req);
let tools: Vec<&OpenAiMessage> = out
.messages
.iter()
.filter(|m| m.role == "tool")
.collect();
assert!(
tools.is_empty(),
"无 tool_call_id 的 tool 消息应丢弃, 实际 {:?}", tools
);
}
/// CR-空 idparse_tool_calls 对空 id 按 index 生成 gen_tool_{i} fallback,非空原样。
/// 根因:SenseNova 等兼容缺陷 provider 发空 tool_call.id,多 tool_call 同 id(空串)
/// 致 audit/mod.rs:203 seen_ids 去重只留首个 → 所有工具结果路由到首个。
#[test]
fn openai_parse_tool_calls_empty_id_fallback_unique() {
let calls = vec![
OpenAiToolCallResp {
id: String::new(),
call_type: "function".into(),
function: OpenAiFunctionResp { name: "list_dir".into(), arguments: r#"{"path":"docs"}"#.into() },
},
OpenAiToolCallResp {
id: String::new(),
call_type: "function".into(),
function: OpenAiFunctionResp { name: "list_dir".into(), arguments: r#"{"path":"crates"}"#.into() },
},
OpenAiToolCallResp {
id: "call_abc123".into(),
call_type: "function".into(),
function: OpenAiFunctionResp { name: "read_file".into(), arguments: r#"{"path":""}"#.into() },
},
];
let parsed = OpenAICompatProvider::parse_tool_calls(calls);
assert_eq!(parsed.len(), 3);
// 空 id → fallback(按 index),保证唯一
assert_eq!(parsed[0].id, "gen_tool_0");
assert_eq!(parsed[1].id, "gen_tool_1");
// 非空 id 原样透传
assert_eq!(parsed[2].id, "call_abc123");
// name/args 透传无损
assert_eq!(parsed[0].function.name, "list_dir");
assert_eq!(parsed[1].function.arguments, r#"{"path":"crates"}"#);
// 关键:所有 id 互异(去重后不丢工具)
let mut ids: Vec<&str> = parsed.iter().map(|c| c.id.as_str()).collect();
ids.sort();
let unique: Vec<&str> = {
let mut u = ids.clone();
u.dedup();
u
};
assert_eq!(ids.len(), unique.len(), "id 应全部唯一,实际 {:?}", ids);
}
/// CR-空 id 流式:SSE chunk 携带 `"id":""`SenseNova 兼容缺陷)→ ToolCallDelta.id
/// 转为 `gen_stream_{index}` fallback(非 None),保证下游 accumulate_tool_calls 写入
/// draft.id 非空。chunk 完全无 id 字段(None)保持 NoneOpenAI 协议:仅首 chunk 有 id
/// 后续 chunk 无 id 不应覆盖首 chunk 权威 id),由 agentic 转换点兜底。
#[test]
fn openai_stream_chunk_empty_id_fallback() {
let mut acc: Option<TokenUsage> = None;
// chunk 1: tool_call index=0, id="" → fallback gen_stream_0
let data1 = r#"{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"","type":"function","function":{"name":"list_dir","arguments":"{\"path\":\"docs\"}"}}]}}]}"#;
let c1 = apply_openai_sse(data1, &mut acc);
let tc1 = c1.tool_calls.as_ref().expect("应有 tool_calls").first().unwrap();
assert_eq!(tc1.index, 0);
assert_eq!(tc1.id.as_deref(), Some("gen_stream_0"), "空 id 应转 fallback");
// chunk 2: tool_call index=1, id="" → fallback gen_stream_1(与 index=0 不同,唯一)
let data2 = r#"{"choices":[{"delta":{"tool_calls":[{"index":1,"id":"","type":"function","function":{"name":"read_file","arguments":""}}]}}]}"#;
let c2 = apply_openai_sse(data2, &mut acc);
let tc2 = c2.tool_calls.as_ref().expect("应有 tool_calls").first().unwrap();
assert_eq!(tc2.id.as_deref(), Some("gen_stream_1"), "不同 index fallback 应不同");
// chunk 3: tool_call index=0, 无 id 字段(None)→ 保持 None(不覆盖首 chunk
let data3 = r#"{"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"更多参数"}}]}}]}"#;
let c3 = apply_openai_sse(data3, &mut acc);
let tc3 = c3.tool_calls.as_ref().expect("应有 tool_calls").first().unwrap();
assert!(tc3.id.is_none(), "无 id 字段 chunk 应保持 None,不覆盖首 chunk 权威 id");
// chunk 4: tool_call 非空 id → 原样透传
let data4 = r#"{"choices":[{"delta":{"tool_calls":[{"index":2,"id":"call_xyz","type":"function","function":{"name":"write"}}]}}]}"#;
let c4 = apply_openai_sse(data4, &mut acc);
let tc4 = c4.tool_calls.as_ref().expect("应有 tool_calls").first().unwrap();
assert_eq!(tc4.id.as_deref(), Some("call_xyz"), "非空 id 原样透传");
}
}