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

- 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
+236 -19
View File
@@ -66,7 +66,8 @@ pub struct AiTool {
// - approval_hint:审批卡文案模板 + 关键参数键 // - approval_hint:审批卡文案模板 + 关键参数键
// - display_verb:轻量中文动作标签 // - display_verb:轻量中文动作标签
// - goal:从工具调用推理目标描述(动词 + 参数键 + 取值风格 + 缺参兜底文案) // - goal:从工具调用推理目标描述(动词 + 参数键 + 取值风格 + 缺参兜底文案)
// - sig:漂移检测签名取哪些参数组(组内多键为取值回退序,数值键按 JSON 形态序列化) // - sig:漂移检测签名取哪些参数组(组内多键为取值回退序,数值键按 JSON 形态序列化;
// 注册点未覆盖但换值即换操作的键由 SIG_EXTRA_GROUPS 补参,见 tool_signature)
// ============================================================ // ============================================================
/// 取值风格(goal 维度):从参数值到展示文本的取法 /// 取值风格(goal 维度):从参数值到展示文本的取法
@@ -98,14 +99,16 @@ pub struct GoalMeta {
pub enum SigValKind { pub enum SigValKind {
/// 字符串取值(as_str,缺失为空串) /// 字符串取值(as_str,缺失为空串)
Str, Str,
/// Value::to_string 形态(数字 50 -> "50",缺失为空串) /// Value::to_string 形态(数字 50 -> "50",布尔 true -> "true");组内全键参与归一化
/// (排序拼接,缺失键空占位)
Json, Json,
} }
/// 漂移检测签名元数据:tool_call_signature 消费 /// 漂移检测签名元数据:tool_call_signature 消费
#[derive(Debug, Clone, Copy, PartialEq)] #[derive(Debug, Clone, Copy, PartialEq)]
pub struct SigMeta { pub struct SigMeta {
/// 有序参数组;每组内多键表示取值回退序(如 pattern 缺失落 query) /// 有序参数组;组内多键:Str 组为取值回退序(如 pattern 缺失落 query),Json 组为
/// 全键参与(排序拼接,防同组其他键换值被压成同签名)
pub groups: &'static [(&'static [&'static str], SigValKind)], pub groups: &'static [(&'static [&'static str], SigValKind)],
} }
@@ -227,36 +230,135 @@ pub fn goal_text(goal: GoalMeta, args: Option<&Value>) -> Option<String> {
rendered.or_else(|| goal.fallback.map(|f| f.to_string())) rendered.or_else(|| goal.fallback.map(|f| f.to_string()))
} }
/// 签名体:每组渲染 k=v,组间逗号连接。标签恒为首键(与旧实现 pair("pattern", ...) 一致, /// 组渲染口径:Always = 基础组逐组渲染,缺键空值占位(消费方按固定串断言,形态不可漂移);
/// 取值来自回退键时标签不变)。Str 组取组内首个字符串值键的值,全非字符串落末键; /// Present = 补参口径,只渲染 args 中实际出现的键,键未出现视同未换参,不渲染空占位。
/// Json 组按 Value::to_string(缺失为空串)。 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub fn sig_body(sig: SigMeta, args: &Value) -> String { enum GroupPresence {
let arg_str = |key: &str| -> String { Always,
args.get(key).and_then(|v| v.as_str()).map(|s| s.to_string()).unwrap_or_default() Present,
}; }
sig.groups
/// 组渲染:每组产出 k=v 片段,组间逗号连接。Str 组标签恒为首键(与旧实现
/// pair("pattern", ...) 一致,取值来自回退键时标签不变),取组内首个字符串值键的值,
/// 全非字符串落末键。Json 组全键参与归一化:数值/布尔键换值即换操作,只取 keys[0] 会把
/// 同组其他键换值的不同调用压成同一签名(G2 熔断以签名为重复判据,压缩即误判重复),
/// 排序后拼接保证与键序无关的确定性。
fn render_groups(
groups: &'static [(&'static [&'static str], SigValKind)],
args: &Value,
presence: GroupPresence,
) -> String {
groups
.iter() .iter()
.map(|(keys, kind)| { .filter_map(|(keys, kind)| match kind {
let value = match kind { SigValKind::Json => {
SigValKind::Json => args.get(keys[0]).map(|v| v.to_string()).unwrap_or_default(), let mut parts: Vec<String> = keys
.iter()
.copied()
.filter(|k| presence == GroupPresence::Always || args.get(*k).is_some())
.map(|k| match args.get(k) {
Some(v) => format!("{}={}", k, v.to_string()),
None => format!("{}=", k),
})
.collect();
parts.sort_unstable();
if parts.is_empty() {
None
} else {
Some(parts.join("|"))
}
}
SigValKind::Str => { SigValKind::Str => {
if presence == GroupPresence::Present {
// 补参组恒单键:键未出现整组跳过,值非字符串视同缺失
return args
.get(keys[0])
.and_then(|v| v.as_str())
.map(|s| format!("{}={}", keys[0], s));
}
let key = keys let key = keys
.iter() .iter()
.find(|k| args.get(**k).and_then(|v| v.as_str()).is_some()) .find(|k| args.get(**k).and_then(|v| v.as_str()).is_some())
.unwrap_or(&keys[keys.len() - 1]); .unwrap_or(&keys[keys.len() - 1]);
arg_str(key) Some(format!(
"{}={}",
keys[0],
args.get(*key).and_then(|v| v.as_str()).unwrap_or("")
))
} }
};
format!("{}={}", keys[0], value)
}) })
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(",") .join(",")
} }
/// 工具调用签名:有 SigMeta 元数据按组渲染,未注册回退「工具名 + 全参 JSON」保守兜底。 /// 签名体:基础组按 Always 口径渲染。
pub fn sig_body(sig: SigMeta, args: &Value) -> String {
render_groups(sig.groups, args, GroupPresence::Always)
}
/// 签名补参表:注册点 SigMeta 组未覆盖、但「换值即换操作」的键,按工具名追加进签名。
///
/// 为什么在 df-ai 签名层:G2 熔断以签名为重复判据,签名过粗会把合法换参误判成
/// 「同一操作反复」(同工具换参是不同操作)。注册点 SigMeta 声明主参数组,换参敏感
/// 但未覆盖的键由本表统一补参;若日后注册点把这些键并入 groups,须同步从本表移除
/// (双源会重复渲染同键,不改变同异判定但产生冗余片段)。
///
/// 渲染走 Present 口径(键出现在 args 才进签名):补参键缺省 = 未换参,既有签名逐字
/// 不变。纯格式/开关类键(-n/-C/context_chars/timeout_secs)不补,变体不构成意图变化,
/// 补进签名只会稀释重复判据。
static SIG_EXTRA_GROUPS: &[(&str, &'static [(&'static [&'static str], SigValKind)])] = &[
// read_file:同 path 换 search 是换词定位,非重复读
("read_file", &[(&["search"], SigValKind::Str)]),
// search_files:换 recursive 换搜索范围,换 offset/limit 是翻页续搜
// (schema 无 -i/output_mode,大小写不敏感是内建语义非参数)
(
"search_files",
&[
(&["recursive"], SigValKind::Json),
(&["offset"], SigValKind::Json),
(&["limit"], SigValKind::Json),
],
),
// grep:换 -i 是敏感/不敏感两种匹配,换 output_mode 是行内容/文件名/计数三种形态,
// 换 max_results 是扩容重取(schema 无 offset/limit,数量参数实为 max_results)
(
"grep",
&[
(&["-i"], SigValKind::Json),
(&["output_mode"], SigValKind::Str),
(&["max_results"], SigValKind::Json),
],
),
// run_command:同命令不同 working_dir 是在不同目录执行
("run_command", &[(&["working_dir"], SigValKind::Str)]),
];
/// 按工具名取补参组(未列名返回 None)
fn sig_extra_groups(name: &str) -> Option<&'static [(&'static [&'static str], SigValKind)]> {
SIG_EXTRA_GROUPS
.iter()
.find(|(n, _)| *n == name)
.map(|(_, g)| *g)
}
/// 工具调用签名:有 SigMeta 元数据按组渲染并追加补参组,未注册回退「工具名 + 全参 JSON」
/// 保守兜底(全参已含补参键,无需追加)。
///
/// G2 判定语义:同工具同参连续 = 同签名,计重复;同工具换参 = 补参键进签名,签名不同
/// 不累计(正常探索)。
pub fn tool_signature(name: &str, args: &Value) -> String { pub fn tool_signature(name: &str, args: &Value) -> String {
match sig_of(name) { match sig_of(name) {
Some(sig) => format!("{}:{}", name, sig_body(sig, args)), Some(sig) => {
let mut body = render_groups(sig.groups, args, GroupPresence::Always);
let extra = sig_extra_groups(name)
.map(|g| render_groups(g, args, GroupPresence::Present))
.unwrap_or_default();
if !extra.is_empty() {
body.push(',');
body.push_str(&extra);
}
format!("{}:{}", name, body)
}
None => format!("{}:name={},args={}", name, name, args.to_string()), None => format!("{}:name={},args={}", name, name, args.to_string()),
} }
} }
@@ -545,4 +647,119 @@ mod tests {
let sig = tool_signature("custom_tool", &json!({"x": 1})); let sig = tool_signature("custom_tool", &json!({"x": 1}));
assert!(sig.starts_with("custom_tool:name=custom_tool,args=")); assert!(sig.starts_with("custom_tool:name=custom_tool,args="));
} }
/// Json 组全键参与:同组多键排序拼接(与 args 键序无关),任一键换值即换片段。
#[test]
fn sig_body_json_group_includes_all_keys() {
use serde_json::json;
let page = SigMeta { groups: &[(&["offset", "limit"], SigValKind::Json)] };
// 排序拼接:limit < offset 字典序,与 args 内键书写顺序无关
assert_eq!(sig_body(page, &json!({"offset": 50, "limit": 100})), "limit=100|offset=50");
assert_eq!(sig_body(page, &json!({"limit": 100, "offset": 50})), "limit=100|offset=50");
// 任一键换值 → 不同片段
assert_ne!(
sig_body(page, &json!({"offset": 50, "limit": 100})),
sig_body(page, &json!({"offset": 60, "limit": 100}))
);
// 全缺失逐键空占位(既有缺省口径);布尔键按 JSON 形态
assert_eq!(sig_body(page, &json!({})), "limit=|offset=");
let flag = SigMeta { groups: &[(&["-i"], SigValKind::Json)] };
assert_eq!(sig_body(flag, &json!({"-i": true})), "-i=true");
}
/// 注册与生产同形态的探针工具。df-ai 单测进程 GLOBAL_TOOL_META 无生产注册,
/// 不种入则 tool_signature 走未注册兜底,测不到「注册点组 + 补参」合流分支。
/// 进程级表跨测试共享,各处注册值恒相同,幂等无顺序依赖。
fn seed_signature_probe_tools() {
let read_sig = SigMeta {
groups: &[
(&["path"], SigValKind::Str),
(&["offset"], SigValKind::Json),
(&["limit"], SigValKind::Json),
],
};
let search_sig = SigMeta {
groups: &[
(&["pattern", "query"], SigValKind::Str),
(&["path", "glob"], SigValKind::Str),
],
};
let cmd_sig = SigMeta { groups: &[(&["command"], SigValKind::Str)] };
let mut reg = AiToolRegistry::new();
reg.register_meta("read_file", "读取", object_schema(vec![]), RiskLevel::Low,
ToolMeta { sig: Some(read_sig), ..Default::default() }, dummy_handler());
reg.register_meta("search_files", "搜索文件", object_schema(vec![]), RiskLevel::Low,
ToolMeta { sig: Some(search_sig), ..Default::default() }, dummy_handler());
reg.register_meta("grep", "跨文件内容搜索", object_schema(vec![]), RiskLevel::Low,
ToolMeta { sig: Some(search_sig), ..Default::default() }, dummy_handler());
reg.register_meta("run_command", "执行命令", object_schema(vec![]), RiskLevel::High,
ToolMeta { sig: Some(cmd_sig), ..Default::default() }, dummy_handler());
}
/// 补参:合法换参 → 签名不同(G2 目标语义:换参不共享签名,不再误判重复);
/// 同参 → 签名相同(重复判据正例仍成立)。
#[test]
fn tool_signature_extra_params_distinguish_calls() {
use serde_json::json;
seed_signature_probe_tools();
// read_file 同 path 换 search:不同签名
let s1 = tool_signature("read_file", &json!({"path": "a.rs", "search": "fn sig"}));
let s2 = tool_signature("read_file", &json!({"path": "a.rs", "search": "struct cfg"}));
assert_ne!(s1, s2, "read_file 换 search 应不同签名: {} vs {}", s1, s2);
assert!(s1.contains("search=fn sig"), "read_file 签名应含 search: {}", s1);
// search_files 只差 offset(翻页续搜):不同签名
let s1 = tool_signature("search_files", &json!({"pattern": "todo", "path": "src", "offset": 0}));
let s2 = tool_signature("search_files", &json!({"pattern": "todo", "path": "src", "offset": 50}));
assert_ne!(s1, s2, "search_files 换 offset 应不同签名: {} vs {}", s1, s2);
// grep 只差 -i / output_mode / max_results(schema 无 offset/limit,数量参数实为
// max_results):换匹配方式/输出形态/扩容重取都是不同操作
let base = tool_signature("grep", &json!({"pattern": "err", "path": "src"}));
let ci = tool_signature("grep", &json!({"pattern": "err", "path": "src", "-i": true}));
let mode = tool_signature("grep", &json!({"pattern": "err", "path": "src", "output_mode": "files_with_matches"}));
let capped = tool_signature("grep", &json!({"pattern": "err", "path": "src", "max_results": 200}));
assert_ne!(base, ci, "grep 换 -i 应不同签名: {} vs {}", base, ci);
assert_ne!(base, mode, "grep 换 output_mode 应不同签名: {} vs {}", base, mode);
assert_ne!(base, capped, "grep 换 max_results 应不同签名: {} vs {}", base, capped);
// run_command 同命令换 working_dir:不同签名
let s1 = tool_signature("run_command", &json!({"command": "cargo test"}));
let s2 = tool_signature("run_command", &json!({"command": "cargo test", "working_dir": "crates/df-ai"}));
assert_ne!(s1, s2, "run_command 换 working_dir 应不同签名: {} vs {}", s1, s2);
// 同工具同参:签名相同(同签名连续才算 G2 重复)
assert_eq!(
tool_signature("grep", &json!({"pattern": "err", "path": "src"})),
base
);
}
/// 补参键缺席时签名与既有格式逐字一致(向后兼容:消费方按固定串断言,空补参不占位)。
#[test]
fn tool_signature_without_extra_keys_unchanged() {
use serde_json::json;
seed_signature_probe_tools();
assert_eq!(
tool_signature("read_file", &json!({"path": "big.log", "offset": 50, "limit": 100})),
"read_file:path=big.log,offset=50,limit=100"
);
assert_eq!(
tool_signature("read_file", &json!({})),
"read_file:path=,offset=,limit="
);
assert_eq!(
tool_signature("grep", &json!({"pattern": "MAX", "path": "src", "case_sensitive": true})),
"grep:pattern=MAX,path=src"
);
assert_eq!(
tool_signature("search_files", &json!({"query": "TODO", "glob": "**/*.rs"})),
"search_files:pattern=TODO,path=**/*.rs"
);
assert_eq!(
tool_signature("run_command", &json!({"command": "ls -la", "timeout": 5000})),
"run_command:command=ls -la"
);
}
} }
+13 -6
View File
@@ -626,16 +626,23 @@ impl LlmProvider for AnthropicCompatProvider {
error!(%status, %text, "Anthropic API 调用失败(不可重试)"); error!(%status, %text, "Anthropic API 调用失败(不可重试)");
return AttemptOutcome::Fatal(msg); return AttemptOutcome::Fatal(msg);
} }
let resp: AnthropicResponse = match resp.json().await { // body 解析:text 先取再 serde 分层判定:读取中断(text Err,reqwest 归
Ok(r) => r, // is_decode 类,complete 重发幂等安全)可重试;JSON 结构错属 Fatal(响应已
// serde 结构错属 Fatal(重试同败);body 读取中途断连同归 is_decode,可重试。 // 完整送达,重试同败)。Response::json 把 serde 错同包成 is_decode,先 text
// 后 from_str 才能区分二者,避免确定性结构错被重试白烧预算。
let text = match resp.text().await {
Ok(t) => t,
Err(e) => { Err(e) => {
if e.is_decode() { if is_reqwest_error_retryable(&e) || e.is_decode() {
return AttemptOutcome::Retryable(format!("响应解码失败(可重试): {}", e)); return AttemptOutcome::Retryable(format!("响应读取失败(可重试): {}", e));
} }
return AttemptOutcome::Fatal(format!("响应解析失败: {}", e)); return AttemptOutcome::Fatal(format!("响应读取失败(不可重试): {}", e));
} }
}; };
let resp: AnthropicResponse = match serde_json::from_str(&text) {
Ok(r) => r,
Err(e) => return AttemptOutcome::Fatal(format!("响应解析失败: {}", e)),
};
// content 块中拼接 text,收集 tool_use,累积 thinking // content 块中拼接 text,收集 tool_use,累积 thinking
let mut text = String::new(); let mut text = String::new();
let mut reasoning = String::new(); let mut reasoning = String::new();
+29 -1
View File
@@ -51,7 +51,7 @@ fn estimate_message_counts_parts_tokens() {
mm_tokens mm_tokens
); );
// url 模式(无字节)也按 URL 长度估算,不爆 // url 模式(无字节)按每图 ~1600 token 地板估(生效于 ×0.35 折扣之后,防预算漏裁)
let url_msg = ChatMessage::user_parts( let url_msg = ChatMessage::user_parts(
"t", "t",
vec![crate::provider::ContentPart::image_url("https://example.com/x.png")], vec![crate::provider::ContentPart::image_url("https://example.com/x.png")],
@@ -777,6 +777,34 @@ fn topic_marker_take_is_idempotent_and_clears() {
assert!(mgr.take_topic_marker().is_none(), "marker 一次性消费, 二次 take 必空"); assert!(mgr.take_topic_marker().is_none(), "marker 一次性消费, 二次 take 必空");
} }
#[test]
fn topic_marker_reset_after_restore_from_messages() {
// restore_from_messages(DB 加载)重放历史时 push 会照常跑主题切换检测,
// 历史里"当时已发生"的切换不得残留成 pending marker——否则加载后首轮 take
// 拿到假话题锚点,后续话题归属被误判为切换、多插一次系统标记。
let history = vec![
ChatMessage::user("帮我重构这段代码"), // topic=code(高置信)
ChatMessage::assistant("好的"),
ChatMessage::user("创建项目并绑定目录"), // topic=project(高置信)→ 重放中会置位 marker
];
let mut mgr = ContextManager::new(cfg(100_000));
mgr.restore_from_messages(history);
assert!(
mgr.take_topic_marker().is_none(),
"重放历史不得残留 pending_topic_marker(假话题锚点)"
);
// restore 之后的实时 push 检测不受影响:末条 user topic=project,新 push code → 触发
mgr.push(ChatMessage::user("帮我重构这段代码"));
let marker = mgr
.take_topic_marker()
.expect("restore 后的实时切换仍应触发");
assert!(
marker.contains("project") && marker.contains("code"),
"marker: {}",
marker
);
}
// ============================================================ // ============================================================
// msg-split-phase1:增量写路径基线(persisted_msg_count)+ 全量重写标志(needs_full_rewrite) // msg-split-phase1:增量写路径基线(persisted_msg_count)+ 全量重写标志(needs_full_rewrite)
// ============================================================ // ============================================================
+4
View File
@@ -316,6 +316,10 @@ impl ContextManager {
} }
self.persisted_msg_count = len; self.persisted_msg_count = len;
self.needs_full_rewrite = false; self.needs_full_rewrite = false;
// 重放历史时 push 会照常跑主题切换检测,历史里"当时已发生"的切换会残留进 marker;
// 留着它,加载后首轮 take_topic_marker 就拿到假话题锚点、误插一次切换标记。
// 恢复即重置:切换检测只对 restore 之后的实时 push 生效。
self.pending_topic_marker = None;
} }
/// 就地替换某条 tool_result 的内容(兼容审批 replace_tool_result) /// 就地替换某条 tool_result 的内容(兼容审批 replace_tool_result)
+67 -18
View File
@@ -196,8 +196,10 @@ pub fn sanitize_messages(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
/// 占位与非占位统一改"补头自愈":任何工具结果都不丢(除非无 tool_call_id 的异常数据,无法 /// 占位与非占位统一改"补头自愈":任何工具结果都不丢(除非无 tool_call_id 的异常数据,无法
/// 补头配对,只能保留——由协议层兜底)。 /// 补头配对,只能保留——由协议层兜底)。
/// ///
/// **未闭合 vs TOOL_MISSING_PREFIX 头**:已带 TOOL_MISSING_PREFIX 的 id 视为已配对(出口断言 /// **未闭合 vs TOOL_MISSING_PREFIX 头**:判定只看 head_ids.contains——真配对(占位头在)由
/// 补的占位头),不重复补头(否则同 id 双头致 400)。 /// contains 覆盖,不会重复补头;头不在的 prefix id result(前轮占位头被裁剪丢)即 orphan,
/// 同样补头自愈。补出的头 id 为前缀叠加(tool_missing_tool_missing_X,双层前缀),头与
/// result 改写后 id 一致,唯一配对闭合(双层前缀为已知无害副作用)。
pub fn drop_reverse_orphans(messages: Vec<ChatMessage>) -> Vec<ChatMessage> { pub fn drop_reverse_orphans(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
use std::collections::HashSet; use std::collections::HashSet;
// 收集所有保留 assistant 头的 tool_call.id(正向 orphan 处理后残留的头里的 id)。 // 收集所有保留 assistant 头的 tool_call.id(正向 orphan 处理后残留的头里的 id)。
@@ -227,16 +229,14 @@ pub fn drop_reverse_orphans(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
out.push(m); out.push(m);
continue; continue;
}; };
// 已配对(头在)→ 原样保留。 // 已配对(头在)→ 原样保留。TOOL_MISSING_PREFIX 前缀 id 无需特判:真配对(占位头在)
// 已被 contains 覆盖;头不在(前轮占位头被裁剪丢掉)即是 orphan,同样走补头自愈。
// 补出的头 id 为前缀叠加(tool_missing_tool_missing_X,双层前缀),头+result 改写后
// id 一致,唯一配对闭合,功能正确(双层前缀为已知无害副作用)。
if head_ids.contains(id) { if head_ids.contains(id) {
out.push(m); out.push(m);
continue; continue;
} }
// TOOL_MISSING_PREFIX 占位头 id:已被出口断言/上一轮补过,视为配对,不重复补头。
if id.starts_with(TOOL_MISSING_PREFIX) {
out.push(m);
continue;
}
// orphan tool_result(无头)→ **补占位头自愈**(不丢 result)。 // orphan tool_result(无头)→ **补占位头自愈**(不丢 result)。
// 头 id 复用 result 的 tool_call_id 精确配对(同 assert_placeholder_pairing 自愈模式)。 // 头 id 复用 result 的 tool_call_id 精确配对(同 assert_placeholder_pairing 自愈模式)。
let head_id = format!("{}{}", TOOL_MISSING_PREFIX, id); let head_id = format!("{}{}", TOOL_MISSING_PREFIX, id);
@@ -306,13 +306,16 @@ pub fn assert_placeholder_pairing(
.collect(); .collect();
// 找 orphan tool_result(id 不在 head_ids)。占位/非占位都需补头(否则 400)。 // 找 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),其余原样保留保序。 // 单次遍历产出新 Vec:orphan result 前插一个占位头(同 id),其余原样保留保序。
let mut healed = 0u32; let mut healed = 0u32;
let mut out: Vec<ChatMessage> = Vec::with_capacity(messages.len()); let mut out: Vec<ChatMessage> = Vec::with_capacity(messages.len());
for m in messages.into_iter() { for m in messages.into_iter() {
if matches!(m.role, MessageRole::Tool) { if matches!(m.role, MessageRole::Tool) {
if let Some(id) = m.tool_call_id.as_deref() { 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 配对) // orphan result → 前补占位头(TOOL_MISSING_PREFIX + 原 id,确保唯一且与 result 配对)
let head_id = format!("{}{}", TOOL_MISSING_PREFIX, id); let head_id = format!("{}{}", TOOL_MISSING_PREFIX, id);
let head = ChatMessage::assistant_with_tools( 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); 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; merged += 1;
continue; continue;
} }
@@ -921,9 +929,11 @@ mod tests {
} }
#[test] #[test]
fn drop_reverse_orphans_no_redundant_head_for_tool_missing_prefix_id() { fn drop_reverse_orphans_heals_prefix_id_orphan_with_nested_prefix_head() {
// 边界:orphan result 的 id 已是 TOOL_MISSING_PREFIX(出口断言/上轮补过)→ 视为已配对, // 边界(盲区修复回归):orphan result 的 id 已是 TOOL_MISSING_PREFIX 但占位头不在
// 不重复补头(否则同 id 双头致 400)。 // (前轮占位头被裁剪丢)→ 即 orphan,同样补头自愈(旧"前缀即已配对"豁免会吞掉此场景,
// 三元组不闭合直送 provider 400)。补出的头 id 为双层前缀叠加
// (tool_missing_tool_missing_X),头与 result 改写后 id 一致,唯一配对闭合。
let msgs = vec![ let msgs = vec![
ChatMessage::user("问题"), ChatMessage::user("问题"),
ChatMessage::tool_result("tool_missing_already", "已补过头的结果"), ChatMessage::tool_result("tool_missing_already", "已补过头的结果"),
@@ -936,17 +946,39 @@ mod tests {
.filter(|m| matches!(m.role, MessageRole::Tool)) .filter(|m| matches!(m.role, MessageRole::Tool))
.count(), .count(),
1, 1,
"TOOL_MISSING_PREFIX id 的 result 应保留" "TOOL_MISSING_PREFIX id 的 orphan result 应保留(补头自愈)"
); );
// 不补新头(原样保留,无新 TOOL_MISSING_PREFIX 占位头插入) // 补双层前缀占位头(头 id 含原 id 且以前缀开头)
let new_heads = sanitized let healed_heads: Vec<&str> = sanitized
.iter() .iter()
.filter(|m| matches!(m.role, MessageRole::Assistant)) .filter(|m| matches!(m.role, MessageRole::Assistant))
.filter_map(|m| m.tool_calls.as_ref()) .filter_map(|m| m.tool_calls.as_ref())
.flatten() .flatten()
.filter(|c| c.id.starts_with(TOOL_MISSING_PREFIX)) .map(|c| c.id.as_str())
.count(); .collect();
assert_eq!(new_heads, 0, "TOOL_MISSING_PREFIX id result 不应重复补头"); 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] #[test]
@@ -1205,4 +1237,21 @@ mod tests {
sanitized.first().map(|m| format!("{:?}", m.role)) 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, "继续");
}
} }
+61 -5
View File
@@ -48,6 +48,8 @@ impl Default for TokenEstimator {
// 2026-08-05(F-260801 Phase4):url 模式无字节可估,仅按 URL 长度估(几十字符≈几 token)会致含图 // 2026-08-05(F-260801 Phase4):url 模式无字节可估,仅按 URL 长度估(几十字符≈几 token)会致含图
// 消息 history_tokens 严重低估 → 预算裁剪不触发 → 多图/长会话超 provider 上限 400。地板按每图 // 消息 history_tokens 严重低估 → 预算裁剪不触发 → 多图/长会话超 provider 上限 400。地板按每图
// ~1600 token 保守估(对齐 Anthropic 图片 token 成本量级),宁可高估触发裁剪也不低估漏裁。 // ~1600 token 保守估(对齐 Anthropic 图片 token 成本量级),宁可高估触发裁剪也不低估漏裁。
// 注意单位是 token 不是字符:地板必须在 ×chars_ratio 折扣之后直接加 token,若混进 char_count
// 会被 0.35 稀释成 560,保底名存实亡。
const URL_IMAGE_TOKEN_FLOOR: usize = 1600; const URL_IMAGE_TOKEN_FLOOR: usize = 1600;
impl TokenEstimator { impl TokenEstimator {
@@ -59,6 +61,10 @@ impl TokenEstimator {
/// 这里把 parts 的文本/base64 按同一 chars_ratio 粗估累加(base64 视为密集字符,0.35 偏保守)。 /// 这里把 parts 的文本/base64 按同一 chars_ratio 粗估累加(base64 视为密集字符,0.35 偏保守)。
pub fn estimate_message(&self, msg: &ChatMessage) -> u32 { pub fn estimate_message(&self, msg: &ChatMessage) -> u32 {
let mut char_count = msg.content.chars().count(); let mut char_count = msg.content.chars().count();
// url 图地板单独按 token 累加:地板语义是"每图至少 ~1600 token",必须生效于
// ×chars_ratio 折扣之后——若折进 char_count 会被 0.35 稀释成 560,1600 保底失效,
// 含 url 图消息的 history_tokens 低估 → 预算裁剪漏触发 → provider 超限 400。
let mut url_image_floor_tokens: u32 = 0;
if let Some(parts) = &msg.parts { if let Some(parts) = &msg.parts {
for p in parts { for p in parts {
match p { match p {
@@ -66,19 +72,20 @@ impl TokenEstimator {
crate::provider::ContentPart::Image { base64, url, .. } => { crate::provider::ContentPart::Image { base64, url, .. } => {
// 0.35 按 base64 字节数粗估,显著高于厂商实际(OpenAI 按像素非字节)。 // 0.35 按 base64 字节数粗估,显著高于厂商实际(OpenAI 按像素非字节)。
// 偏保守致含图消息 token 高估、过度裁剪;降值(如 0.10~0.15)需独立评估裁剪边界,本次不改值仅标注。 // 偏保守致含图消息 token 高估、过度裁剪;降值(如 0.10~0.15)需独立评估裁剪边界,本次不改值仅标注。
// base64 优先(多模态主载荷),url 次之;url 模式无字节,仅按 URL 长度估 // base64 优先(多模态主载荷),url 次之;url 模式无字节,按地板直接计 token
if let Some(b) = base64 { if let Some(b) = base64 {
char_count += b.chars().count(); char_count += b.chars().count();
} else if url.is_some() { } else if url.is_some() {
// F-260801 Phase4:url 模式按地板估(URL 长度估严重低估,见 URL_IMAGE_TOKEN_FLOOR 注)。 // F-260801 Phase4:url 模式按地板估(URL 长度估严重低估,见 URL_IMAGE_TOKEN_FLOOR 注)。
char_count += URL_IMAGE_TOKEN_FLOOR; url_image_floor_tokens = url_image_floor_tokens
.saturating_add(URL_IMAGE_TOKEN_FLOOR as u32);
} }
} }
} }
} }
} }
let content_tokens = (char_count as f32 * self.chars_ratio).ceil() as u32; let content_tokens = (char_count as f32 * self.chars_ratio).ceil() as u32;
let mut total = content_tokens + self.per_message_overhead; let mut total = content_tokens + url_image_floor_tokens + self.per_message_overhead;
// tool_calls 的 JSON 结构开销(role=Assistant 时可能有) // tool_calls 的 JSON 结构开销(role=Assistant 时可能有)
if let Some(ref calls) = msg.tool_calls { if let Some(ref calls) = msg.tool_calls {
@@ -833,8 +840,14 @@ impl WorkingContext {
parts.push(format!("当前步骤: {} [{:?}]", step.step_name, step.status)); parts.push(format!("当前步骤: {} [{:?}]", step.step_name, step.status));
} }
// L1b: 过去 N 轮内有变更才注入 // L1b: 过去 N 轮内有变更才注入。窗口语义 = 最近 N 轮 [current_turn-N+1, current_turn]:
let threshold = self.version.current_turn.saturating_sub(recent_turns); // 变更落在第 current_turn-N 轮(即 N+1 轮前)已出窗。原实现下界取 current_turn-N 且
// 比较 >=,把 N+1 轮前的变更也划进窗口(off-by-one 多注一轮,脏段晚一轮才过期)。
let threshold = self
.version
.current_turn
.saturating_sub(recent_turns)
.saturating_add(1);
let has_active = self.goals.iter().any(|g| g.status == GoalStatus::Active); let has_active = self.goals.iter().any(|g| g.status == GoalStatus::Active);
if has_active && self.version.goals_changed_at >= threshold { if has_active && self.version.goals_changed_at >= threshold {
let active: Vec<&str> = self.goals.iter() let active: Vec<&str> = self.goals.iter()
@@ -899,6 +912,12 @@ impl WorkingContext {
if !items.is_empty() { if !items.is_empty() {
self.decisions = items; self.decisions = items;
} }
} else {
// 解析失败不吞:压缩后决策回填静默丢失会让"决策段凭空消失"无从排查,warn 留痕
tracing::warn!(
"sync_after_compress: 摘要 JSON 卡片解析失败,decisions 未恢复(卡片 {} 字节)",
json_card.len()
);
} }
self.version.reset_all(json_card, self.version.current_turn); self.version.reset_all(json_card, self.version.current_turn);
} }
@@ -1674,4 +1693,41 @@ mod tests {
let out = ctx.build_injection(3); let out = ctx.build_injection(3);
assert!(out.is_empty(), "过期后所有段均不注入, 实际: {}", out); assert!(out.is_empty(), "过期后所有段均不注入, 实际: {}", out);
} }
#[test]
fn working_context_injection_window_is_exactly_n_turns() {
// 边界:脏窗口 = 最近 N 轮。turn=1 的变更、窗口 3:
// current_turn=3 → 第 1 轮是第 3 轮前,恰在窗内(1,2,3 共 3 轮),注入;
// current_turn=4 → 第 1 轮是第 4 轮前,出窗不注入(off-by-one bug 下会误注)。
let mut ctx = WorkingContext::new();
ctx.begin_turn("窗口边界目标", 100); // current_turn=1, goals_changed_at=1
ctx.version.current_turn = 3;
assert!(
ctx.build_injection(3).contains("窗口边界目标"),
"第 N 轮前的变更恰在窗内,应注入"
);
ctx.version.current_turn = 4;
assert!(
ctx.build_injection(3).is_empty(),
"第 N+1 轮前的变更应出窗,不注入"
);
}
#[test]
fn url_image_token_floor_survives_ratio_discount() {
// url 图地板语义是"每图至少 ~1600 token",必须生效于 ×chars_ratio 折扣之后:
// 若地板折进 char_count 再乘 0.35,1600 被稀释成 ~560,保底失效 → 预算裁剪漏触发。
let est = TokenEstimator::default();
let msg = ChatMessage::user_parts(
"t",
vec![crate::provider::ContentPart::image_url("https://example.com/x.png")],
);
let tokens = est.estimate_message(&msg);
assert!(
tokens >= URL_IMAGE_TOKEN_FLOOR as u32,
"url 图消息 token({}) 应 >= 地板 {},稀释 bug 下仅 ~565",
tokens,
URL_IMAGE_TOKEN_FLOOR
);
}
} }
+53 -1
View File
@@ -420,7 +420,7 @@ fn best_in_group(message: &str, group: &[IntentGroup]) -> Option<(Intent, f32)>
for rule in cand.rules.iter() { for rule in cand.rules.iter() {
// 英文关键词用小写匹配;中文不受 to_lowercase 影响(no-op)。 // 英文关键词用小写匹配;中文不受 to_lowercase 影响(no-op)。
let needle = rule.kw.to_lowercase(); let needle = rule.kw.to_lowercase();
if lower.contains(&needle) { if kw_matches(&lower, &needle) {
score += rule.weight; score += rule.weight;
matched = true; matched = true;
} }
@@ -437,6 +437,26 @@ fn best_in_group(message: &str, group: &[IntentGroup]) -> Option<(Intent, f32)>
best best
} }
/// 关键词命中判定:ASCII 关键词要求两侧词边界(词字符 = ASCII 字母数字 + 下划线,与正则
/// `\b` 语义一致),防子串误判——"history" 不命中 "hi"(误判 Chat)、"rapid" 不命中
/// "api"(误判 Http)。非 ASCII(中文)关键词保持子串语义:中文无词边界概念,直接 contains。
fn kw_matches(haystack: &str, needle: &str) -> bool {
if !needle.is_ascii() {
return haystack.contains(needle);
}
let is_word_char = |c: char| c.is_ascii_alphanumeric() || c == '_';
haystack.match_indices(needle).any(|(pos, m)| {
haystack[..pos]
.chars()
.next_back()
.map_or(true, |c| !is_word_char(c))
&& haystack[pos + m.len()..]
.chars()
.next()
.map_or(true, |c| !is_word_char(c))
})
}
// ---- Intent → 工具子集映射 -------------------------------------------------- // ---- Intent → 工具子集映射 --------------------------------------------------
/// 按 Intent 返回工具名子集。 /// 按 Intent 返回工具名子集。
@@ -854,6 +874,38 @@ mod tests {
assert_eq!(i, Intent::Http); assert_eq!(i, Intent::Http);
} }
// --- ASCII 关键词词边界(防子串误判) ---
#[test]
fn word_boundary_history_not_chat() {
// "history" 内嵌 "hi" 不再判 Chat(无其他关键词 → Unknown)
let (i, c) = IntentRecognizer::recognize("show me the history");
assert_eq!(i, Intent::Unknown, "history 不应命中 hi, 实际 conf={}", c);
}
#[test]
fn word_boundary_rapid_not_http() {
// "rapid" 内嵌 "api" 不再判 Http
let (i, c) = IntentRecognizer::recognize("rapid growth");
assert_eq!(i, Intent::Unknown, "rapid 不应命中 api, 实际 conf={}", c);
}
#[test]
fn standalone_english_keywords_still_hit() {
// 词边界不误伤独立词命中
let (i, _) = IntentRecognizer::recognize("call the api");
assert_eq!(i, Intent::Http);
let (i, _) = IntentRecognizer::recognize("hi");
assert_eq!(i, Intent::Chat);
}
#[test]
fn cjk_adjacent_ascii_keyword_still_hits() {
// 中文紧邻 ASCII 关键词:非 ASCII 字符算边界,不破坏中文场景命中
let (i, _) = IntentRecognizer::recognize("调用这个api");
assert_eq!(i, Intent::Http);
}
// --- tool_subset_for --- // --- tool_subset_for ---
#[test] #[test]
+46 -7
View File
@@ -522,17 +522,23 @@ impl LlmProvider for OpenAICompatProvider {
error!(%status, %body, "LLM API 调用失败(不可重试)"); error!(%status, %body, "LLM API 调用失败(不可重试)");
return AttemptOutcome::Fatal(msg); return AttemptOutcome::Fatal(msg);
} }
// body 解析: serde 结构错属 Fatal(响应已成功送达,重试同样失败); // body 解析:text 先取再 serde 分层判定:读取中断(text Err,reqwest 归
// body 读取中途断连在 reqwest 同归 is_decode,重发幂等安全 → 可重试。 // is_decode 类,重发幂等安全)可重试;JSON 结构错属 Fatal(响应已完整送达,
let body: OpenAiResponse = match resp.json().await { // 重试同败)。Response::json 把 serde 错同包成 is_decode,先 text 后
Ok(b) => b, // from_str 才能区分二者,避免确定性结构错被重试白烧预算。
let text = match resp.text().await {
Ok(t) => t,
Err(e) => { Err(e) => {
if e.is_decode() { if is_reqwest_error_retryable(&e) || e.is_decode() {
return AttemptOutcome::Retryable(format!("响应解码失败(可重试): {}", e)); 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() { let choice = match body.choices.into_iter().next() {
Some(c) => c, Some(c) => c,
None => return AttemptOutcome::Fatal("LLM 响应无 choices".to_string()), None => return AttemptOutcome::Fatal("LLM 响应无 choices".to_string()),
@@ -858,6 +864,39 @@ mod tests {
assert_eq!(c.error.as_deref(), Some("stream error"), "无 message 字段应兜底"); 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 ---------- // ---------- 多模态 convert_request ----------
/// 含图消息 → content 数组(text + image_url data URI);纯文本 → 字符串简写 /// 含图消息 → content 数组(text + image_url data URI);纯文本 → 字符串简写
+33 -5
View File
@@ -191,6 +191,10 @@ impl From<OpenAiUsageRaw> for OpenAiUsage {
/// SSE 流式响应 chunk /// SSE 流式响应 chunk
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub(crate) struct OpenAiStreamChunk { pub(crate) struct OpenAiStreamChunk {
/// 流式 choice 数组。标准 error 帧(`{"error":{..}}`)与 usage 尾帧均不带 choices 键,
/// 缺省为空数组才能整帧解析成功;必填会让这类合法帧 serde 失败走空 chunk 分支,
/// 流呈无声截断。
#[serde(default)]
pub choices: Vec<OpenAiStreamChoice>, pub choices: Vec<OpenAiStreamChoice>,
/// 末 chunk(choices 为空)携带的累计 usage /// 末 chunk(choices 为空)携带的累计 usage
#[serde(default)] #[serde(default)]
@@ -204,11 +208,13 @@ pub(crate) struct OpenAiStreamChunk {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub(crate) struct OpenAiStreamChoice { pub(crate) struct OpenAiStreamChoice {
/// 部分 usage 尾帧的 choice 仅带 finish_reason 无 delta 键,缺省按空 delta 处理
#[serde(default)]
pub delta: OpenAiStreamDelta, pub delta: OpenAiStreamDelta,
pub finish_reason: Option<String>, pub finish_reason: Option<String>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Default, Deserialize)]
pub(crate) struct OpenAiStreamDelta { pub(crate) struct OpenAiStreamDelta {
pub content: Option<String>, pub content: Option<String>,
pub tool_calls: Option<Vec<OpenAiStreamToolCall>>, pub tool_calls: Option<Vec<OpenAiStreamToolCall>>,
@@ -219,6 +225,8 @@ pub(crate) struct OpenAiStreamDelta {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub(crate) struct OpenAiStreamToolCall { pub(crate) struct OpenAiStreamToolCall {
/// 部分端点 tool_call 增量省略 index,缺省 0(单工具调用场景语义等价)
#[serde(default)]
pub index: u32, pub index: u32,
pub id: Option<String>, pub id: Option<String>,
pub function: Option<OpenAiStreamFunction>, pub function: Option<OpenAiStreamFunction>,
@@ -299,10 +307,13 @@ pub(crate) fn apply_openai_sse(data: &str, usage_accum: &mut Option<TokenUsage>)
} }
if let Some(choice) = chunk.choices.into_iter().next() { if let Some(choice) = chunk.choices.into_iter().next() {
let delta_text = choice.delta.content.unwrap_or_default(); let delta_text = choice.delta.content.unwrap_or_default();
// "length" = max_tokens 截断,属正常终止(非断连),纳入 finished // 终态 finish_reason:stop(正常结束)/ tool_calls(转交工具执行)/
let finished = choice.finish_reason.as_deref() == Some("stop") // length(max_tokens 截断,属正常终止非断连)/ content_filter(内容审查拦截)。
|| choice.finish_reason.as_deref() == Some("tool_calls") // content_filter 的流部分 provider 不发 [DONE] 帧,不纳入终态会一直悬挂
|| choice.finish_reason.as_deref() == Some("length"); let finished = matches!(
choice.finish_reason.as_deref(),
Some("stop") | Some("tool_calls") | Some("length") | Some("content_filter")
);
let tool_calls = choice.delta.tool_calls.map(|tcs| { let tool_calls = choice.delta.tool_calls.map(|tcs| {
tcs.into_iter() tcs.into_iter()
@@ -411,4 +422,21 @@ mod tests {
assert_eq!(usage.prompt_cache_hit_tokens, 64); assert_eq!(usage.prompt_cache_hit_tokens, 64);
assert_eq!(usage.prompt_cache_miss_tokens, 36); assert_eq!(usage.prompt_cache_miss_tokens, 36);
} }
/// content_filter 属终态:敏感内容拦截的流部分 provider 不发 [DONE] 帧,
/// 必须靠 finish_reason 收敛,否则悬挂;未列入终态的 finish_reason 保持非终态
#[test]
fn finish_reason_content_filter_is_terminal() {
let data = r#"{"choices":[{"delta":{"content":""},"finish_reason":"content_filter"}]}"#;
let mut accum = None;
let chunk = apply_openai_sse(data, &mut accum);
assert!(chunk.finished, "content_filter 应收敛为终态,不再悬挂");
let mut accum2 = None;
let chunk2 = apply_openai_sse(
r#"{"choices":[{"delta":{"content":"x"},"finish_reason":null}]}"#,
&mut accum2,
);
assert!(!chunk2.finished, "无终态 finish_reason 的普通 delta 不应终结");
}
} }
+91 -6
View File
@@ -17,7 +17,8 @@
//! data: 前缀提取) //! data: 前缀提取)
//! - 多字节续接:事件体只在定位到完整分隔符后才解码,跨 chunk 的多字节字符在 //! - 多字节续接:事件体只在定位到完整分隔符后才解码,跨 chunk 的多字节字符在
//! buffer 中天然拼接复原(分隔符为 ASCII,不落在多字节序列中间) //! buffer 中天然拼接复原(分隔符为 ASCII,不落在多字节序列中间)
//! - 容错:解析失败时跳过该事件继续,不中断流;buffer 设 `BUF_MAX` 上限防 OOM //! - 容错:解析失败时跳过该事件继续,不中断流;buffer 设 `BUF_MAX` 上限防 OOM,
//! 超限时按事件边界对齐保尾裁剪(丢最旧的超限事件整体),不产出半截破损事件
//! - 返回 Vec<String>(每个元素是一个事件 data 字段拼接内容) //! - 返回 Vec<String>(每个元素是一个事件 data 字段拼接内容)
use futures::Stream; use futures::Stream;
@@ -35,6 +36,9 @@ const BUF_MAX: usize = 1 << 20; // 1 MiB
pub struct SseStream<S> { pub struct SseStream<S> {
inner: S, inner: S,
buffer: Vec<u8>, buffer: Vec<u8>,
/// 超限裁剪置位:无边界的超限事件已整体丢弃,但其尾部字节仍在后续 chunk 中,
/// 到达收尾 boundary 前的数据须静默吞掉,避免残缺事件体被当完整事件产出
skip_partial: bool,
} }
impl<S> SseStream<S> impl<S> SseStream<S>
@@ -45,6 +49,7 @@ where
Self { Self {
inner, inner,
buffer: Vec::with_capacity(8192), buffer: Vec::with_capacity(8192),
skip_partial: false,
} }
} }
@@ -62,6 +67,17 @@ where
/// 字符在 buffer 中天然拼接复原(分隔符为 ASCII,不会落在多字节序列中间)。 /// 字符在 buffer 中天然拼接复原(分隔符为 ASCII,不会落在多字节序列中间)。
/// 解码用 `String::from_utf8`(失败再降级 lossy),避免把合法多字节误判为残缺。 /// 解码用 `String::from_utf8`(失败再降级 lossy),避免把合法多字节误判为残缺。
fn parse_events(&mut self) -> Vec<SseEvent> { fn parse_events(&mut self) -> Vec<SseEvent> {
// 超限裁剪收尾:被整体丢弃的残缺事件,其后续字节以首个 boundary 收尾,
// 此处静默吞掉不产出,之后的正常事件恢复正常解析
if self.skip_partial {
match Self::find_event_boundary(&self.buffer) {
Some((pos, len)) => {
self.buffer.drain(..pos + len);
self.skip_partial = false;
}
None => return Vec::new(),
}
}
let mut events = Vec::new(); let mut events = Vec::new();
loop { loop {
// 同时识别三种分隔符,取最早出现的位置与该分隔符的字节长度 // 同时识别三种分隔符,取最早出现的位置与该分隔符的字节长度
@@ -149,10 +165,23 @@ where
Poll::Ready(Some(Ok(chunk))) => { Poll::Ready(Some(Ok(chunk))) => {
self.buffer.extend_from_slice(&chunk); self.buffer.extend_from_slice(&chunk);
// 防御:缓冲区不应无限增长。正常情况下 parse_events 会持续消费, // 防御:缓冲区不应无限增长。正常情况下 parse_events 会持续消费,
// 仅当上游持续不发分隔符(畸形流)时才触发,此处裁掉头部旧数据避免 OOM。 // 超限只发生在单个事件体超过 BUF_MAX 的场景(畸形上游不发分隔符,
// 或超大单事件)。按字节盲截头部会把半截事件体留给后续解析,产出
// 破损 JSON 且无法复原;改为对齐事件边界的保尾裁剪:
// - 已有完整分隔符 → 最旧的一个事件(超限源头)整体丢弃,保留的
// 数据从干净的事件起点开始,后续事件解析不受损;
// - 全程无分隔符 → 整体丢弃并置 skip_partial,该事件的收尾字节
// 到达时静默吞掉。
if self.buffer.len() > BUF_MAX { if self.buffer.len() > BUF_MAX {
let drop_n = self.buffer.len() - BUF_MAX; match Self::find_event_boundary(&self.buffer) {
self.buffer.drain(..drop_n); Some((pos, len)) => {
self.buffer.drain(..pos + len);
}
None => {
self.buffer.clear();
self.skip_partial = true;
}
}
} }
continue; continue;
} }
@@ -160,8 +189,9 @@ where
return Poll::Ready(Some(Err(format!("SSE 流读取错误: {}", e)))); return Poll::Ready(Some(Err(format!("SSE 流读取错误: {}", e))));
} }
Poll::Ready(None) => { Poll::Ready(None) => {
// 流结束,处理 buffer 中的剩余数据(可能没有 \n\n 结束的最后一段) // 流结束,处理 buffer 中的剩余数据(可能没有 \n\n 结束的最后一段);
if !self.buffer.is_empty() { // skip_partial 置位时剩余数据是被丢弃残缺事件的尾段,不得溢出为事件
if !self.buffer.is_empty() && !self.skip_partial {
// 解码:优先严格 UTF-8(EOF 无下一 chunk 可拼接, // 解码:优先严格 UTF-8(EOF 无下一 chunk 可拼接,
// 残缺多字节尾部只能尽力而为,降级 lossy 不崩) // 残缺多字节尾部只能尽力而为,降级 lossy 不崩)
let mut remaining = match String::from_utf8(self.buffer.clone()) { let mut remaining = match String::from_utf8(self.buffer.clone()) {
@@ -185,3 +215,58 @@ where
} }
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use futures::stream;
/// 构造只含 Ok chunk 的内存流,跑完整个 SseStream,返回拍平的事件序列
fn run_chunks(chunks: Vec<String>) -> Vec<SseEvent> {
use futures::StreamExt;
let items: Vec<Result<bytes::Bytes, reqwest::Error>> = chunks
.into_iter()
.map(|c| Ok(bytes::Bytes::from(c)))
.collect();
let mut s = SseStream::new(stream::iter(items));
futures::executor::block_on(async {
let mut out = Vec::new();
while let Some(item) = s.next().await {
out.extend(item.expect("测试流不应产生错误"));
}
out
})
}
/// 回归:正常多事件流照常解析
#[test]
fn normal_events_parse() {
let events = run_chunks(vec![
"data: {\"a\":1}\n\n".into(),
"data: {\"b\":2}\n\n".into(),
]);
assert_eq!(events, vec!["{\"a\":1}".to_string(), "{\"b\":2}".to_string()]);
}
/// 超限单事件(末尾带完整分隔符)整体丢弃:不产出半截破损事件,后续正常事件照常解析。
/// 旧按字节截头实现会把残留尾部 "data: TAIL" 行当伪事件产出
#[test]
fn oversize_event_with_boundary_dropped_whole() {
let giant = format!("data: {}\ndata: TAIL\n\n", "x".repeat(BUF_MAX));
let events = run_chunks(vec![giant, "data: {\"ok\":1}\n\n".into()]);
assert_eq!(events, vec!["{\"ok\":1}".to_string()]);
}
/// 验收:超限注入(无边界大块)丢弃后,残缺事件收尾静默吞掉,后续正常事件仍可解析。
/// 旧按字节截头实现会把 STALE-TAIL 残段当伪事件产出
#[test]
fn oversize_boundaryless_blob_then_normal_events_still_parse() {
let events = run_chunks(vec![
"x".repeat(BUF_MAX + 100),
"data: STALE-TAIL\n\n".into(),
"data: {\"ok\":1}\n\n".into(),
"data: [DONE]\n\n".into(),
]);
assert_eq!(events, vec!["{\"ok\":1}".to_string(), "[DONE]".to_string()]);
}
}