diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index a9c20c31d..eea5817cd 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -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` 当成上游业务错误。 diff --git a/server-rs/crates/platform-llm/src/lib.rs b/server-rs/crates/platform-llm/src/lib.rs index 375c05f2a..71d9d6b0e 100644 --- a/server-rs/crates/platform-llm/src/lib.rs +++ b/server-rs/crates/platform-llm/src/lib.rs @@ -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 {