尾部错误后保留已收尾的纯工具调用响应
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:
@@ -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` 当成上游业务错误。
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user