尾部错误后保留已收尾的纯工具调用响应
Project CI / Repository checks (pull_request) Failing after 1m0s
Project CI / Frontend tests (pull_request) Successful in 2m45s
Project CI / Backend tests (pull_request) Successful in 4m53s
Project CI / Native shell tests (pull_request) Failing after 6m49s

retain_completed_stream_after_tail_error 用“正文非空”判断有没有值得保留的
结果,这个判据来自该函数引入时,那时响应就等于正文。工具调用成为一等公民后
它失效了:纯工具调用响应的正文本来就是空的,MiniMax 的 Anthropic 工具流实测
恒定如此,于是每一次这样的响应遇到尾部传输或解析错误都会被丢弃,白跑一轮
Provider 重试。

Anthropic 的 message_stop 与 Responses 的 response.completed 只标记完成、不
标记流终止,收尾事件之后仍会读到 EOF,因此这条尾部路径是常态而非边缘情况。

改为正文或工具调用任一非空即视为有可保留内容。安全性由其余判据保证:协议完成
信号已到、finish_reason 已到、工具参数完整、错误属可容忍尾部错误,与正常路径
一致。补一个纯工具调用遇 body-read 尾部错误的用例,退回旧判据时该用例失败。
This commit is contained in:
2026-07-27 06:14:54 +00:00
parent b1ef45fbd2
commit 214357b175
2 changed files with 68 additions and 1 deletions
@@ -263,6 +263,8 @@ npm run check:server-rs-ddd
流式工具调用必须来自已收尾的流:只要聚合出过工具 slot,收尾时就必须已观察到本协议的完成信号,否则按截断返回 `Deserialize`。完成信号按协议判定——Chat 为非空 `choices[].finish_reason``data: [DONE]`Responses 为 `response.completed`Anthropic 为带 `stop_reason``message_delta``message_stop`。不能用 `data: [DONE]` 作为统一判据:MiniMax 兼容层不发该标记,只发 `finish_reason`。也不能只用 “参数是合法 JSON” 当完成证明——顶层花括号闭合只说明单个参数对象字节完整,说明不了模型是否还要发下一个工具块,更说明不了上游随后会不会报 `max_tokens` 或 error;代理超时、网关自行掐断和 HTTP/2 提前 `END_STREAM` 都表现为干净 EOF,与正常收尾在字节层无法区分。该门禁当前只覆盖工具路径;纯文本响应缺完成信号仍按成功返回并打 warn,改动前必须先确认所有在用网关的文本收尾行为。流在任何工具分片到达前就断掉时槽位为空,门禁无从触发,这是已知残留缺口。
反过来,已经收尾的流遇到尾部传输 / 解析错误时必须保留结果,不能重跑 Provider。判断“有没有值得保留的东西”要看正文或工具调用任一非空,不能只看正文——纯工具调用响应的正文本来就是空的(MiniMax 的 Anthropic 工具流恒定如此),只看正文会让这类响应每次都被丢弃,白白多跑一轮往返。保留的安全性由“协议完成信号已到 + `finish_reason` 已到 + 工具参数完整 + 错误属可容忍尾部错误”共同保证,与正常路径判据一致。Anthropic 的 `message_stop` 与 Responses 的 `response.completed` 目前只标记完成、不标记流终止,因此收尾事件之后仍会读到 EOF,这条尾部路径是常态而非边缘情况。
错误边界固定如下:`StreamUnavailable` 只表示流式响应已给出 `tool_use` / `tool_calls` 完成原因但没有聚合出任何工具 slot,供调用方回退非流式,它不承担截断语义;`EmptyResponse` 表示最终文本和工具调用都为空,纯工具响应合法;`Deserialize` 覆盖 JSON / SSE / UTF-8 解析失败、缺少 `choices[0]`、流式工具身份缺失、流式参数不完整,以及上述工具流未收尾截断。Anthropic 仍不支持 `web_search`、图片内容和纯 system 消息,必须至少有一条非 system 文本消息。
- 图片生成:VectorEngine `gpt-image-2` 图片 provider 归属 `platform-image`,密钥只在后端环境变量中;`api-server` 内的 `openai_image_generation.rs` 只是兼容调用面和外部失败审计桥接,不再承载 provider 协议实现。实际外部生成运行记录统一落 `tracking_event``event_key = external_generation_run`metadata 记录开始 / 结束时间、耗时、状态、成功标记、失败原因、provider task id 和结果摘要,不再写回过时的 `ai_task`。DashScope 只按仍在使用的历史能力单独处理,不作为 GPT-image-2 兜底。VectorEngine `/v1/images/generations``/v1/images/edits` 上游 POST 使用 `libcurl` 发送;`reqwest` 只保留给参考图 URL 下载和响应中图片 URL 下载。`/v1/images/edits` 的 multipart 参考图必须作为 libcurl 文件上传 part 发送,字段名为 `image`,实现上使用 `Form::buffer(file_name, bytes)` 并设置 `Content-Type`;不能只用 `contents(...).filename(...)`,否则上游会把请求转码为缺少图片并返回 `image is required``request_send` 阶段的 curl timeout / connect error 按可重试传输错误处理,最多尝试 5 次,并使用指数退避加短抖动;排障时优先看 `attempt``max_attempts``retry_delay_ms``reference_image_bytes_total``request_params`,不要把 `SendRequest` 当成上游业务错误。
+66 -1
View File
@@ -1843,7 +1843,13 @@ fn retain_completed_stream_after_tail_error(
);
// 工具调用尚未拼完整时不能保留:半截参数比直接失败更危险。
let tool_calls_complete = accumulation.finish_tool_calls().is_ok();
let retain_response = !accumulation.text.trim().is_empty()
// 判断“有没有值得保留的东西”不能只看正文:纯工具调用响应的正文本来就是空的
// Anthropic 工具流恒定如此),只看正文会让每一次这样的响应都在尾部错误时被丢掉,
// 白白多跑一轮 Provider 往返。保留的安全性由下面三项保证——协议收尾信号已到、
// finish_reason 已到、工具参数完整,与正常路径的判据完全一致。
let has_retainable_payload =
!accumulation.text.trim().is_empty() || !accumulation.tool_calls.is_empty();
let retain_response = has_retainable_payload
&& accumulation.completion_observed
&& accumulation.finish_reason.is_some()
&& tool_calls_complete
@@ -4161,6 +4167,65 @@ mod tests {
server_handle.join().expect("server thread should join");
}
#[tokio::test]
async fn stream_run_keeps_completed_pure_tool_call_after_body_read_tail_error() {
// 纯工具调用响应的正文为空——MiniMax 的 Anthropic 工具流恒定如此。收尾信号、
// finish_reason 和完整参数都已到手时,尾部传输错误不能让这份可证完整的结果被丢掉,
// 否则每一次这样的响应都要白跑一轮 Provider 重试。
let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
let address = listener.local_addr().expect("listener should have addr");
let server_handle = thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("request should connect");
read_request(&mut stream);
let completed_sse = concat!(
r#"data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"call_1","name":"get_weather","input":{}}}"#,
"\n\n",
r#"data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"city\":\"杭州\"}"}}"#,
"\n\n",
r#"data: {"type":"content_block_stop","index":0}"#,
"\n\n",
r#"data: {"type":"message_delta","delta":{"stop_reason":"tool_use"}}"#,
"\n\n",
r#"data: {"type":"message_stop"}"#,
"\n\n"
);
let raw_response = format!(
concat!(
"HTTP/1.1 200 OK\r\n",
"Content-Type: text/event-stream; charset=utf-8\r\n",
"Transfer-Encoding: chunked\r\n",
"Connection: close\r\n\r\n",
"{:X}\r\n{}\r\n",
"not-a-chunk-size\r\n"
),
completed_sse.len(),
completed_sse
);
stream
.write_all(raw_response.as_bytes())
.expect("malformed chunked response should be written");
stream.flush().expect("stream response should flush");
});
let client = build_test_client(format!("http://{address}"), 0);
let response = client
.stream_run(weather_tool_request(LlmApiKind::Anthropic), |_| {})
.await
.expect("completed pure tool call should survive a body-read tail error");
assert!(response.text.is_empty());
assert_eq!(response.finish_reason.as_deref(), Some("tool_use"));
assert_eq!(
response.tool_calls,
vec![LlmToolCall {
id: "call_1".to_string(),
name: "get_weather".to_string(),
arguments: r#"{"city":""}"#.to_string(),
}]
);
server_handle.join().expect("server thread should join");
}
#[tokio::test]
async fn stream_run_emits_chat_finish_only_delta_without_repeating_text() {
let server_url = spawn_mock_server(vec![MockResponse {