修复: 上下文流式与工具签名域清债
- tool_signature 归一化补参,合法换参不再误触探索熔断 - SSE 超限截头改对齐事件边界保尾,流不再永久破损 - content_filter 纳入终态判定,无 DONE 流也收敛 - sanitize 占位补头前缀短路只吞 orphan 的盲区消除 - intent 关键词加词边界,history 不再误判 - 恢复重放后重置话题锚点,URL 图 token 地板不被稀释
This commit is contained in:
@@ -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 不应终结");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user