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

- 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
+46 -7
View File
@@ -522,17 +522,23 @@ impl LlmProvider for OpenAICompatProvider {
error!(%status, %body, "LLM API 调用失败(不可重试)");
return AttemptOutcome::Fatal(msg);
}
// body 解析: serde 结构错属 Fatal(响应已成功送达,重试同样失败);
// body 读取中途断连在 reqwest 同归 is_decode,重发幂等安全 → 可重试。
let body: OpenAiResponse = match resp.json().await {
Ok(b) => b,
// body 解析:text 先取再 serde 分层判定:读取中断(text Err,reqwest 归
// is_decode 类,重发幂等安全)可重试;JSON 结构错属 Fatal(响应已完整送达,
// 重试同败)。Response::json 把 serde 错同包成 is_decode,先 text 后
// from_str 才能区分二者,避免确定性结构错被重试白烧预算。
let text = match resp.text().await {
Ok(t) => t,
Err(e) => {
if e.is_decode() {
return AttemptOutcome::Retryable(format!("响应解码失败(可重试): {}", e));
if is_reqwest_error_retryable(&e) || e.is_decode() {
return AttemptOutcome::Retryable(format!("响应读取失败(可重试): {}", e));
}
return AttemptOutcome::Fatal(format!("响应解析失败: {}", e));
return AttemptOutcome::Fatal(format!("响应读取失败(不可重试): {}", e));
}
};
let body: OpenAiResponse = match serde_json::from_str(&text) {
Ok(b) => b,
Err(e) => return AttemptOutcome::Fatal(format!("响应解析失败: {}", e)),
};
let choice = match body.choices.into_iter().next() {
Some(c) => c,
None => return AttemptOutcome::Fatal("LLM 响应无 choices".to_string()),
@@ -858,6 +864,39 @@ mod tests {
assert_eq!(c.error.as_deref(), Some("stream error"), "无 message 字段应兜底");
}
/// 标准 error 帧不带 choices 键 → 仍须解析出 error(此前 choices 必填,
/// 整帧 serde 失败走空 chunk 分支,error 被吞、流呈无声截断)
#[test]
fn openai_sse_error_frame_without_choices_key_parses() {
let mut acc: Option<TokenUsage> = None;
let data = r#"{"error":{"message":"insufficient quota","type":"insufficient_quota"}}"#;
let c = apply_openai_sse(data, &mut acc);
assert!(!c.finished, "error 帧不应走 finished 完成路径");
assert_eq!(c.delta, "");
assert_eq!(c.error.as_deref(), Some("insufficient quota"));
assert!(c.usage.is_none());
}
/// usage 尾帧不带 choices 键(OpenAI include_usage 标准形态)→ 累加进 usage,
/// [DONE] 终态带出(此前 choices 必填,该帧被整帧丢弃,usage 永远拿不到)
#[test]
fn openai_sse_usage_tail_frame_without_choices_key_accumulates() {
let mut acc: Option<TokenUsage> = None;
let data = r#"{"usage":{"prompt_tokens":7,"completion_tokens":3,"total_tokens":10}}"#;
let c = apply_openai_sse(data, &mut acc);
assert_eq!(c.delta, "");
assert!(!c.finished);
let acc_after_tail = acc.as_ref().expect("usage 尾帧应写入累加器");
assert_eq!(acc_after_tail.prompt_tokens, 7);
assert_eq!(acc_after_tail.completion_tokens, 3);
let done = apply_openai_sse("[DONE]", &mut acc);
assert!(done.finished);
let usage = done.usage.expect("[DONE] 应带出累计 usage");
assert_eq!(usage.prompt_tokens, 7);
assert_eq!(usage.total_tokens, 10);
}
// ---------- 多模态 convert_request ----------
/// 含图消息 → content 数组(text + image_url data URI);纯文本 → 字符串简写