按 id 重绑收窄到终态快照分片
Project CI / Repository checks (pull_request) Failing after 10s
Project CI / Backend tests (pull_request) Failing after 10s
Project CI / Frontend tests (pull_request) Successful in 2m48s
Project CI / Native shell tests (pull_request) Successful in 9m31s

57f355b7b 为修复终态载荷槽位错位引入了「按 id 归位」,但归位条件写成了无差别的
「同一个 id 落到两个槽位就合并」。注释里那句「只可能是上游用了不同的槽位基准」是个
不成立的穷尽性断言,代码照着它写,于是漏掉了第二种成因:上游自己重复使用 call id。

两种实测后果:

- 同一个终态载荷内两条相同 call_id、相同函数名、不同参数:被并成一条,后到的参数
  覆盖先到的,静默丢掉一次调用。修复前流式返回两条(与非流式一致,由调用方的
  call id 唯一性校验拒绝),修复后变成一条且参数是后到的那份——用「调用方拒绝」
  换来了「平台静默挑一份参数」,这个交换是亏的。
- 两次 output_item.added 用同一个 call_id:第二次被重绑到第一个槽位,它自己的参数
  事件随后落到一个没有身份的空槽位上,最终报 Err(Deserialize("缺少 id:slot=1"))
  ——失败关闭但完全指错方向,排查的人会去查为什么没 id,而那个槽位没 id 恰恰是被
  重绑逻辑拿走的。

改判据:ToolCallFragment 新增 from_terminal_snapshot,只有终态快照
(response.completed / incomplete 载荷)的分片允许按 id 重绑,因为只有它是对**已宣告
调用的重述**;增量宣告(output_item.added / content_block_start / Chat delta)永远是新
调用,绝不重绑。快照内部自身重复的 id 另行排除。

不用「是否跨事件」当判据:那只能挡住同事件形态,挡不住上面第二种跨事件形态。

平台层不承担 call id 唯一性判定。重复 id 原样透传,与非流式一致,由调用方统一拒绝
——重复 id 是内容层问题,透传下去调用方的格式修复循环才拿得到 call id、函数名和原始
参数把响应回灌给模型重写,在平台层报错会把这三样一起丢掉。与非流式半截 JSON 透传
同一条理由。

隔离验证两级:去掉 from_terminal_snapshot 门槛,跨事件用例转红;再去掉同事件重复
排除(回到 57f355b7b 状态),两条用例都转红。

platform-llm 109 passed(原 106)。新增同事件重复 id、跨事件重复 id 两条流式用例,
外加一条非流式同构载荷用例锁住两条路径的契约一致。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-28 03:24:20 +00:00
parent 57f355b7b6
commit 2fab4db4cc
2 changed files with 154 additions and 13 deletions
File diff suppressed because one or more lines are too long
+153 -12
View File
@@ -638,6 +638,12 @@ struct ToolCallFragment {
arguments_delta: Option<String>,
// 上游给出完整参数时(Responses 的 .done)直接覆盖,避免依赖分片拼接结果。
arguments_complete: Option<String>,
// 本分片来自终态快照(Responses 的 response.completed / incomplete 载荷),是对**已宣告
// 调用的重述**,而不是一次新宣告。只有这种分片允许按 id 重绑到已有槽位——快照没有
// output_index 字段,只能按数组下标重建槽位,会与增量事件错位,必须靠 id 纠回去。
// 增量事件(output_item.added / content_block_start)永远是在宣告新调用,绝不能重绑:
// 上游若在两次宣告里重复用了同一个 call id,重绑会把两次调用并成一条、静默丢掉一次。
from_terminal_snapshot: bool,
}
#[derive(Debug)]
@@ -806,21 +812,68 @@ fn merge_tool_identity(
}
}
impl StreamAccumulation {
fn push_tool_fragment(&mut self, fragment: ToolCallFragment) -> Result<(), LlmError> {
// 槽位只是传输层的归并键,真正的身份是 id。同一个 id 落到两个槽位只可能是上游在
// 不同事件里用了不同的槽位基准:Responses 的终态载荷按 output[] 数组下标重建槽位,
// 网关若在快照里省掉此前占用过某个 output_index 的条目(reasoning / message),
// 就会与增量事件的 output_index 错位。此时若按新槽位新建,会产出两条 id 完全相同的
// 重复调用——而且因为落进的是空槽位,merge_tool_identity 的冲突检测(只在同槽位
// 已有身份时比对)根本不会触发,全程无告警。所以先按 id 归位。
//
// 名字冲突仍由 merge_tool_identity 拦截:并进去之后两侧函数名不同会照常失败关闭。
let slot = fragment
// 单个事件内重复出现的非空 id。这些 id 不参与按 id 归并——见 push_tool_fragment 的成因二。
// 判定边界刻意取「同一个事件」:跨事件的同 id 是我们终态兜底造成的槽位错位,必须归并;
// 同事件内的同 id 是上游载荷自己就坏了,必须原样保留。
fn tool_fragment_ids_repeated_in_event(fragments: &[ToolCallFragment]) -> Vec<String> {
let mut seen: Vec<&str> = Vec::new();
let mut repeated: Vec<String> = Vec::new();
for fragment in fragments {
let Some(id) = fragment
.id
.as_deref()
.map(str::trim)
.filter(|id| !id.is_empty())
else {
continue;
};
if seen.contains(&id) {
if !repeated.iter().any(|value| value == id) {
repeated.push(id.to_string());
}
} else {
seen.push(id);
}
}
repeated
}
impl StreamAccumulation {
fn push_tool_fragment(
&mut self,
fragment: ToolCallFragment,
ids_repeated_in_event: &[String],
) -> Result<(), LlmError> {
// 槽位只是传输层的归并键,真正的身份是 id。但**不能**因此一律按 id 归并——同一个 id
// 落到两个槽位有两种成因,处置完全相反:
//
// 一、我们自己的终态兜底造成的槽位基准错位。快照没有 output_index,只能按 output[]
// 数组下标重建槽位,网关若在快照里省掉此前占用过某个 output_index 的 reasoning /
// message 条目就会错位。此时按新槽位新建会产出两条 id 完全相同的重复调用,而且
// 落进的是空槽位、merge_tool_identity 的冲突检测(只在同槽位已有身份时比对)根本
// 不触发,全程无告警。必须按 id 归位。
//
// 二、上游自己重复使用了 call id,两次宣告本就是两次调用。按 id 归并会把它们并成
// 一条、后到的参数覆盖先到的,静默丢掉一次;还会绕过调用方的 call id 唯一性校验
// ——非流式路径原样返回两条由调用方拒绝,流式却悄悄放行,两条路径契约就此分叉。
//
// 判据是 from_terminal_snapshot 而不是「是否跨事件」:只有终态快照是对已宣告调用的
// **重述**,才有重绑的正当性;增量宣告永远是新调用。用「跨事件」当判据会漏掉成因二
// 的跨事件形态——两次 output_item.added 用同一个 id 时,第二次会被重绑走,它自己的
// 参数事件随后落到一个没有身份的空槽位上,最终报出「缺少 id:slot=N」这种完全指错
// 方向的错误。
//
// 快照内部自己重复的 id 仍要排除:那同样是上游违反唯一性,不是错位。
//
// 名字冲突仍由 merge_tool_identity 拦截:归位之后两侧函数名不同会照常失败关闭。
let slot = fragment
.id
.as_deref()
.filter(|_| fragment.from_terminal_snapshot)
.map(str::trim)
.filter(|id| !id.is_empty())
.filter(|id| !ids_repeated_in_event.iter().any(|repeated| repeated == id))
.and_then(|id| {
self.tool_calls
.iter()
@@ -2034,8 +2087,9 @@ where
}
// 工具调用只累加,不进 on_delta:调用方的流式通道仍然只承载文本。
let ids_repeated_in_event = tool_fragment_ids_repeated_in_event(&tool_fragments);
for fragment in tool_fragments {
accumulation.push_tool_fragment(fragment)?;
accumulation.push_tool_fragment(fragment, &ids_repeated_in_event)?;
}
let mut delta_text = delta_text.unwrap_or_default();
@@ -2887,6 +2941,8 @@ fn extract_chat_tool_fragments(
.as_ref()
.and_then(|function| function.arguments.clone()),
arguments_complete: None,
// Chat 没有终态快照事件,[DONE] 不带载荷,永远是增量宣告。
from_terminal_snapshot: false,
})
})
.collect()
@@ -3064,6 +3120,7 @@ fn extract_responses_completed_tool_fragments(
"response.completed/incomplete",
)?
.filter(|arguments| !arguments.is_empty()),
from_terminal_snapshot: true,
..Default::default()
})
})
@@ -4969,6 +5026,90 @@ mod tests {
);
}
// 同一个终态载荷里两条相同 call_id、相同函数名、不同参数:上游违反了 call id 唯一性。
// 平台层不承担唯一性判定,必须原样保留两条交给调用方拒绝——按 id 归并会把它们并成
// 一条、后到的参数覆盖先到的,静默丢掉一次调用,还会绕过调用方的唯一性校验。
const DUPLICATE_CALL_ID_OUTPUT: &str = concat!(
r#"{"id":"fc_0","type":"function_call","call_id":"call_a","name":"get_weather","arguments":"{\"city\":\"杭州\"}"},"#,
r#"{"id":"fc_1","type":"function_call","call_id":"call_a","name":"get_weather","arguments":"{\"city\":\"苏州\"}"}"#
);
fn duplicate_call_id_expectation() -> Vec<LlmToolCall> {
vec![
LlmToolCall {
id: "call_a".to_string(),
name: "get_weather".to_string(),
arguments: r#"{"city":""}"#.to_string(),
},
LlmToolCall {
id: "call_a".to_string(),
name: "get_weather".to_string(),
arguments: r#"{"city":""}"#.to_string(),
},
]
}
#[tokio::test]
async fn stream_run_keeps_duplicate_call_ids_within_one_event_separate() {
let server_url = spawn_mock_server(vec![MockResponse {
status_line: "200 OK",
content_type: "text/event-stream; charset=utf-8",
body: format!(
r#"data: {{"type":"response.completed","response":{{"output":[{DUPLICATE_CALL_ID_OUTPUT}]}}}}"#
) + "\n\n",
extra_headers: Vec::new(),
}]);
let response = build_test_client(server_url, 0)
.stream_run(weather_tool_request(LlmApiKind::OpenAiResponses), |_| {})
.await
.expect("同事件内重复 id 不应被平台层拒绝,交由调用方判定");
assert_eq!(response.tool_calls, duplicate_call_id_expectation());
}
#[test]
fn non_stream_responses_keeps_duplicate_call_ids_separate() {
// 与上一条成对:同构载荷走非流式解析必须给出同样的两条,两条路径契约不能分叉。
let response = parse_responses_response(
LlmProvider::OpenAiCompatible,
"fallback",
&format!(
r#"{{"id":"resp_1","output":[{DUPLICATE_CALL_ID_OUTPUT}],"status":"completed"}}"#
),
)
.expect("非流式同样原样透传重复 id");
assert_eq!(response.tool_calls, duplicate_call_id_expectation());
}
#[tokio::test]
async fn stream_run_keeps_duplicate_call_ids_across_events_separate() {
// 两次 output_item.added 用了同一个 call_id:上游重复使用 id,两次宣告本就是两次调用。
// 增量宣告不允许按 id 重绑——否则第二次会被绑到第一个槽位,它自己的参数事件随后落到
// 一个没有身份的空槽位上,最终报出「缺少 id:slot=1」这种完全指错方向的错误。
let server_url = spawn_mock_server(vec![MockResponse {
status_line: "200 OK",
content_type: "text/event-stream; charset=utf-8",
body: concat!(
r#"data: {"type":"response.output_item.added","item":{"id":"fc_0","type":"function_call","call_id":"call_a","name":"get_weather"},"output_index":0}"#, "\n\n",
r#"data: {"type":"response.function_call_arguments.done","item_id":"fc_0","output_index":0,"arguments":"{\"city\":\"杭州\"}"}"#, "\n\n",
r#"data: {"type":"response.output_item.added","item":{"id":"fc_1","type":"function_call","call_id":"call_a","name":"get_weather"},"output_index":1}"#, "\n\n",
r#"data: {"type":"response.function_call_arguments.done","item_id":"fc_1","output_index":1,"arguments":"{\"city\":\"苏州\"}"}"#, "\n\n",
r#"data: {"type":"response.completed"}"#, "\n\n"
)
.to_string(),
extra_headers: Vec::new(),
}]);
let response = build_test_client(server_url, 0)
.stream_run(weather_tool_request(LlmApiKind::OpenAiResponses), |_| {})
.await
.expect("跨事件重复 id 不应被平台层拒绝,交由调用方判定");
assert_eq!(response.tool_calls, duplicate_call_id_expectation());
}
#[tokio::test]
async fn stream_run_merges_completed_event_rebased_slot_by_tool_call_id() {
// completed 载荷按 output[] 数组下标重建槽位,网关若省掉此前占用 output_index=0 的