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

- 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
+33 -5
View File
@@ -191,6 +191,10 @@ impl From<OpenAiUsageRaw> for OpenAiUsage {
/// SSE 流式响应 chunk
#[derive(Debug, Deserialize)]
pub(crate) struct OpenAiStreamChunk {
/// 流式 choice 数组。标准 error 帧(`{"error":{..}}`)与 usage 尾帧均不带 choices 键,
/// 缺省为空数组才能整帧解析成功;必填会让这类合法帧 serde 失败走空 chunk 分支,
/// 流呈无声截断。
#[serde(default)]
pub choices: Vec<OpenAiStreamChoice>,
/// 末 chunk(choices 为空)携带的累计 usage
#[serde(default)]
@@ -204,11 +208,13 @@ pub(crate) struct OpenAiStreamChunk {
#[derive(Debug, Deserialize)]
pub(crate) struct OpenAiStreamChoice {
/// 部分 usage 尾帧的 choice 仅带 finish_reason 无 delta 键,缺省按空 delta 处理
#[serde(default)]
pub delta: OpenAiStreamDelta,
pub finish_reason: Option<String>,
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Default, Deserialize)]
pub(crate) struct OpenAiStreamDelta {
pub content: Option<String>,
pub tool_calls: Option<Vec<OpenAiStreamToolCall>>,
@@ -219,6 +225,8 @@ pub(crate) struct OpenAiStreamDelta {
#[derive(Debug, Deserialize)]
pub(crate) struct OpenAiStreamToolCall {
/// 部分端点 tool_call 增量省略 index,缺省 0(单工具调用场景语义等价)
#[serde(default)]
pub index: u32,
pub id: Option<String>,
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() {
let delta_text = choice.delta.content.unwrap_or_default();
// "length" = max_tokens 截断,属正常终止(非断连),纳入 finished
let finished = choice.finish_reason.as_deref() == Some("stop")
|| choice.finish_reason.as_deref() == Some("tool_calls")
|| choice.finish_reason.as_deref() == Some("length");
// 终态 finish_reason:stop(正常结束)/ tool_calls(转交工具执行)/
// length(max_tokens 截断,属正常终止非断连)/ content_filter(内容审查拦截)。
// content_filter 的流部分 provider 不发 [DONE] 帧,不纳入终态会一直悬挂
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| {
tcs.into_iter()
@@ -411,4 +422,21 @@ mod tests {
assert_eq!(usage.prompt_cache_hit_tokens, 64);
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 不应终结");
}
}