diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index bad6bcb7b..a9c20c31d 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -257,7 +257,9 @@ npm run check:server-rs-ddd 流式 `on_delta` 只发送文本增量、累计文本和完成原因,工具调用不进入回调。平台层按协议 slot 聚合并行工具片段:Chat 使用 `delta.tool_calls[].index`,Responses 使用 `output_index`,Anthropic 使用 content block `index`。Responses 的 `function_call_arguments.done` 和 `response.completed` 中的完整 arguments 覆盖此前分片;completed-only 恢复以 `response.output[]` 数组下标作为 slot。该聚合只负责解析,不表示工具执行并发。 -流式收尾时,缺少工具 id / name、或非空 arguments 不是完整 JSON,均返回 `Deserialize`;空 arguments 默认归一为 `{}`。这只是 JSON 语法完整性检查,不是按工具 `parameters` 执行 JSON Schema 校验。非流式 Anthropic 缺失 `tool_use.input` 时也归一为 `{}`;其它协议的非流式 arguments 仍按上游字段解析。 +工具调用归一只有一份策略,流式与非流式、三种协议共用:协议层只把各自 DTO 映射成统一中间形态,接受与否全部由归一层判定。**被识别为工具调用(Chat 的 `tool_calls[]` 成员、Responses 的 `type=function_call`、Anthropic 的 `type=tool_use`)后,字段不全一律返回 `Deserialize`,不得静默丢弃。** 缺少 id 或函数名报错;arguments 缺省或空白归一为 `{}`(零参函数合法);arguments 非空则必须是完整 JSON,否则报错。这只是 JSON 语法完整性检查,不是按工具 `parameters` 执行 JSON Schema 校验。 + +静默丢弃是明确禁止的实现方式:它会把“上游给了工具调用但我们没解出来”伪装成“上游只回了正文”——响应同时带解说文本时更会被当作普通回复成功返回,而带 `tool_choice=required` 的请求随后退化为格式修复循环,审计里只能看到“模型没按协议调用工具”,看不出真正成因在解析层。非流式 DTO 为兼容流式分片把字段改成可选后尤其要注意:可选字段解除了 serde 的强制校验,缺失必须在归一层重新拦截。 流式工具调用必须来自已收尾的流:只要聚合出过工具 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,改动前必须先确认所有在用网关的文本收尾行为。流在任何工具分片到达前就断掉时槽位为空,门禁无从触发,这是已知残留缺口。 diff --git a/server-rs/crates/platform-llm/src/lib.rs b/server-rs/crates/platform-llm/src/lib.rs index f90b323a4..375c05f2a 100644 --- a/server-rs/crates/platform-llm/src/lib.rs +++ b/server-rs/crates/platform-llm/src/lib.rs @@ -638,6 +638,70 @@ struct PendingToolCall { arguments: String, } +// 三协议、流式与非流式共用的工具调用中间形态。协议层只负责把自己的 DTO 映射成它, +// 不做任何取舍判断;要不要接受、缺省怎么补,全部由 normalize_tool_calls 决定。 +#[derive(Debug)] +struct RawToolCall { + // 流式为协议槽位(Chat / Anthropic 的 index、Responses 的 output_index), + // 非流式为所在数组的下标,仅用于定位报错。 + slot: u64, + id: Option, + name: Option, + arguments: Option, +} + +// 唯一的归一策略点。已经被识别为工具调用却字段不全时必须显式失败:静默丢弃会把 +// “上游给了工具调用但我们没解出来”伪装成“上游只回了正文”,调用方完全无从察觉, +// 而带 tool_choice=required 的请求还会因此退化成格式修复循环,审计里看不出真正成因。 +fn normalize_tool_calls( + raw: Vec, + context: &str, +) -> Result, LlmError> { + raw.into_iter() + .map(|call| { + let RawToolCall { + slot, + id, + name, + arguments, + } = call; + let id = id + .map(|id| id.trim().to_string()) + .filter(|id| !id.is_empty()) + .ok_or_else(|| { + LlmError::Deserialize(format!("LLM {context}工具调用缺少 id:slot={slot}")) + })?; + let name = name + .map(|name| name.trim().to_string()) + .filter(|name| !name.is_empty()) + .ok_or_else(|| { + LlmError::Deserialize(format!("LLM {context}工具调用缺少函数名:slot={slot}")) + })?; + // 缺省或空白参数归一为空对象(零参函数合法);非空则必须是完整 JSON—— + // 上游 max_tokens 截断会给出合法外层 JSON 加半截 arguments 字符串。 + let arguments = arguments.unwrap_or_default(); + let arguments = arguments.trim(); + if arguments.is_empty() { + return Ok(LlmToolCall { + id, + name, + arguments: "{}".to_string(), + }); + } + serde_json::from_str::(arguments).map_err(|error| { + LlmError::Deserialize(format!( + "LLM {context}工具调用参数不是完整 JSON:name={name}, error={error}" + )) + })?; + Ok(LlmToolCall { + id, + name, + arguments: arguments.to_string(), + }) + }) + .collect() +} + // 流式累加状态:文本、终止原因、用量与按槽位聚合的工具调用。 #[derive(Debug, Default)] struct StreamAccumulation { @@ -685,40 +749,21 @@ impl StreamAccumulation { } } - // 流结束后固化。参数必须是完整 JSON,否则说明流被截断,不能把半截参数交给业务层。 + // 流结束后固化,走与非流式相同的归一:缺 id / 函数名报错,空参数归一为 {}, + // 非空参数必须是完整 JSON,否则说明流被截断,不能把半截参数交给业务层。 fn finish_tool_calls(&self) -> Result, LlmError> { - self.tool_calls - .iter() - .map(|pending| { - let id = pending.id.clone().ok_or_else(|| { - LlmError::Deserialize(format!("LLM 流式工具调用缺少 id:slot={}", pending.slot)) - })?; - let name = pending.name.clone().ok_or_else(|| { - LlmError::Deserialize(format!( - "LLM 流式工具调用缺少函数名:slot={}", - pending.slot - )) - })?; - let arguments = pending.arguments.trim(); - if arguments.is_empty() { - return Ok(LlmToolCall { - id, - name, - arguments: "{}".to_string(), - }); - } - serde_json::from_str::(arguments).map_err(|error| { - LlmError::Deserialize(format!( - "LLM 流式工具调用参数不是完整 JSON:name={name}, error={error}" - )) - })?; - Ok(LlmToolCall { - id, - name, - arguments: arguments.to_string(), + normalize_tool_calls( + self.tool_calls + .iter() + .map(|pending| RawToolCall { + slot: pending.slot, + id: pending.id.clone(), + name: pending.name.clone(), + arguments: Some(pending.arguments.clone()), }) - }) - .collect() + .collect(), + "流式", + ) } } @@ -2295,7 +2340,7 @@ fn parse_chat_completions_response( .unwrap_or_default() .trim() .to_string(); - let tool_calls = extract_chat_tool_calls(first_choice); + let tool_calls = extract_chat_tool_calls(first_choice)?; if content.is_empty() && tool_calls.is_empty() { return Err(LlmError::EmptyResponse); @@ -2324,7 +2369,7 @@ fn parse_responses_response( .unwrap_or_default() .trim() .to_string(); - let tool_calls = extract_responses_tool_calls(&parsed); + let tool_calls = extract_responses_tool_calls(&parsed)?; if content.is_empty() && tool_calls.is_empty() { return Err(LlmError::EmptyResponse); @@ -2353,7 +2398,7 @@ fn parse_anthropic_response( let parsed: AnthropicResponseEnvelope = serde_json::from_str(raw_text).map_err(|error| { LlmError::Deserialize(format!("解析 LLM Anthropic JSON 响应失败:{error}")) })?; - let tool_calls = extract_anthropic_tool_calls(&parsed); + let tool_calls = extract_anthropic_tool_calls(&parsed)?; let content = extract_anthropic_text(&parsed) .unwrap_or_default() .trim() @@ -2399,38 +2444,43 @@ fn extract_responses_text(parsed: &ResponsesResponseEnvelope) -> Option }) } -fn extract_responses_tool_calls(parsed: &ResponsesResponseEnvelope) -> Vec { - parsed +fn extract_responses_tool_calls( + parsed: &ResponsesResponseEnvelope, +) -> Result, LlmError> { + let raw = parsed .output .iter() - .filter(|item| item.item_type.as_deref() == Some("function_call")) - .filter_map(|item| { - Some(LlmToolCall { - id: item.call_id.as_ref().or(item.id.as_ref())?.clone(), - name: item.name.as_ref()?.clone(), - arguments: item.arguments.as_ref()?.clone(), - }) + .enumerate() + .filter(|(_, item)| item.item_type.as_deref() == Some("function_call")) + .map(|(index, item)| RawToolCall { + slot: index as u64, + id: item.call_id.clone().or_else(|| item.id.clone()), + name: item.name.clone(), + arguments: item.arguments.clone(), }) - .collect() + .collect(); + + normalize_tool_calls(raw, "Responses 非流式") } -fn extract_anthropic_tool_calls(parsed: &AnthropicResponseEnvelope) -> Vec { - parsed +fn extract_anthropic_tool_calls( + parsed: &AnthropicResponseEnvelope, +) -> Result, LlmError> { + let raw = parsed .content .iter() - .filter(|block| block.block_type.as_deref() == Some("tool_use")) - .filter_map(|block| { - Some(LlmToolCall { - id: block.id.as_ref()?.clone(), - name: block.name.as_ref()?.clone(), - arguments: block - .input - .as_ref() - .map(serde_json::Value::to_string) - .unwrap_or_else(|| "{}".to_string()), - }) + .enumerate() + .filter(|(_, block)| block.block_type.as_deref() == Some("tool_use")) + .map(|(index, block)| RawToolCall { + slot: index as u64, + id: block.id.clone(), + name: block.name.clone(), + // input 是已解析的 JSON object,缺省时由归一层补空对象。 + arguments: block.input.as_ref().map(serde_json::Value::to_string), }) - .collect() + .collect(); + + normalize_tool_calls(raw, "Anthropic 非流式") } fn extract_anthropic_text(parsed: &AnthropicResponseEnvelope) -> Option { @@ -2460,8 +2510,8 @@ fn extract_message_text(choice: &ChatCompletionsChoice) -> Option { }) } -fn extract_chat_tool_calls(choice: &ChatCompletionsChoice) -> Vec { - choice +fn extract_chat_tool_calls(choice: &ChatCompletionsChoice) -> Result, LlmError> { + let raw = choice .message .as_ref() .and_then(|message| message.tool_calls.as_deref()) @@ -2474,15 +2524,22 @@ fn extract_chat_tool_calls(choice: &ChatCompletionsChoice) -> Vec { }) .unwrap_or_default() .iter() - .filter_map(|tool_call| { - let function = tool_call.function.as_ref()?; - Some(LlmToolCall { - id: tool_call.id.as_ref()?.clone(), - name: function.name.as_ref()?.clone(), - arguments: function.arguments.clone().unwrap_or_default(), - }) + .enumerate() + .map(|(index, tool_call)| RawToolCall { + slot: tool_call.index.unwrap_or(index as u64), + id: tool_call.id.clone(), + name: tool_call + .function + .as_ref() + .and_then(|function| function.name.clone()), + arguments: tool_call + .function + .as_ref() + .and_then(|function| function.arguments.clone()), }) - .collect() + .collect(); + + normalize_tool_calls(raw, "Chat 非流式") } fn extract_content_text(content: &ChatCompletionsContent) -> Option { @@ -4783,6 +4840,179 @@ mod tests { assert_eq!(response.finish_reason, None); } + async fn run_non_stream_tool_body( + api_kind: LlmApiKind, + body: &str, + ) -> Result { + let server_url = spawn_mock_server(vec![MockResponse { + status_line: "200 OK", + content_type: "application/json; charset=utf-8", + body: body.to_string(), + extra_headers: Vec::new(), + }]); + + build_test_client(server_url, 0) + .run(weather_tool_request(api_kind)) + .await + } + + fn expect_tool_call_deserialize_error(error: LlmError, expected_fragment: &str) { + let LlmError::Deserialize(message) = error else { + panic!("应报 Deserialize,实际 {error:?}"); + }; + assert!(message.contains(expected_fragment), "{message}"); + } + + #[tokio::test] + async fn non_stream_chat_tool_call_missing_id_fails_instead_of_returning_plain_text() { + // 回归锁:DTO 为兼容流式分片改成可选字段后,缺 id 的工具调用会被 filter_map 静默丢掉; + // 又因为正文非空,整个响应曾被当作普通文本回复成功返回,调用方完全察觉不到工具调用丢失。 + let error = run_non_stream_tool_body( + LlmApiKind::OpenAiChat, + r#"{"id":"resp_01","choices":[{"message":{"content":"我来帮你查一下。","tool_calls":[{"index":0,"function":{"name":"get_weather","arguments":"{\"city\":\"杭州\"}"}}]},"finish_reason":"tool_calls"}]}"#, + ) + .await + .expect_err("缺 id 的工具调用不能退化成纯文本回复"); + + expect_tool_call_deserialize_error(error, "Chat 非流式工具调用缺少 id"); + } + + #[tokio::test] + async fn non_stream_chat_tool_call_missing_function_fails() { + let error = run_non_stream_tool_body( + LlmApiKind::OpenAiChat, + r#"{"id":"resp_01","choices":[{"message":{"content":"正文","tool_calls":[{"index":0,"id":"call_1"}]},"finish_reason":"tool_calls"}]}"#, + ) + .await + .expect_err("缺 function 的工具调用必须失败"); + + expect_tool_call_deserialize_error(error, "Chat 非流式工具调用缺少函数名"); + } + + #[tokio::test] + async fn non_stream_chat_tool_call_missing_arguments_normalizes_to_empty_object() { + // 零参函数合法;空参数归一为 {},不能像以前那样给出空串让下游 from_str 炸。 + let response = run_non_stream_tool_body( + LlmApiKind::OpenAiChat, + r#"{"id":"resp_01","choices":[{"message":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"get_time"}}]},"finish_reason":"tool_calls"}]}"#, + ) + .await + .expect("缺 arguments 的零参调用应归一成功"); + + assert_eq!( + response.tool_calls, + vec![LlmToolCall { + id: "call_1".to_string(), + name: "get_time".to_string(), + arguments: "{}".to_string(), + }] + ); + } + + #[tokio::test] + async fn non_stream_chat_tool_call_with_incomplete_arguments_json_fails() { + // 上游 max_tokens 截断会给出合法外层 JSON 加半截 arguments 字符串。 + let error = run_non_stream_tool_body( + LlmApiKind::OpenAiChat, + r#"{"id":"resp_01","choices":[{"message":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"get_weather","arguments":"{\"city\":"}}]},"finish_reason":"length"}]}"#, + ) + .await + .expect_err("半截 arguments 必须失败"); + + expect_tool_call_deserialize_error(error, "Chat 非流式工具调用参数不是完整 JSON"); + } + + #[tokio::test] + async fn non_stream_responses_tool_call_missing_name_fails() { + let error = run_non_stream_tool_body( + LlmApiKind::OpenAiResponses, + r#"{"id":"resp_01","output":[{"type":"message","content":[{"type":"output_text","text":"我来查。"}]},{"type":"function_call","call_id":"call_1","arguments":"{}"}]}"#, + ) + .await + .expect_err("缺函数名的 function_call 必须失败"); + + expect_tool_call_deserialize_error(error, "Responses 非流式工具调用缺少函数名"); + } + + #[tokio::test] + async fn non_stream_responses_tool_call_missing_arguments_normalizes_to_empty_object() { + // 旧实现把缺 arguments 的整条 function_call 丢掉,零参函数因此无法送达。 + let response = run_non_stream_tool_body( + LlmApiKind::OpenAiResponses, + r#"{"id":"resp_01","output":[{"type":"function_call","call_id":"call_1","name":"get_time"}]}"#, + ) + .await + .expect("缺 arguments 的零参调用应归一成功"); + + assert_eq!( + response.tool_calls, + vec![LlmToolCall { + id: "call_1".to_string(), + name: "get_time".to_string(), + arguments: "{}".to_string(), + }] + ); + } + + #[tokio::test] + async fn non_stream_anthropic_tool_use_missing_id_fails() { + let error = run_non_stream_tool_body( + LlmApiKind::Anthropic, + r#"{"id":"msg_01","content":[{"type":"text","text":"我来查。"},{"type":"tool_use","name":"get_weather","input":{"city":"杭州"}}],"stop_reason":"tool_use"}"#, + ) + .await + .expect_err("缺 id 的 tool_use 必须失败"); + + expect_tool_call_deserialize_error(error, "Anthropic 非流式工具调用缺少 id"); + } + + #[tokio::test] + async fn non_stream_anthropic_tool_use_missing_input_normalizes_to_empty_object() { + let response = run_non_stream_tool_body( + LlmApiKind::Anthropic, + r#"{"id":"msg_01","content":[{"type":"tool_use","id":"call_1","name":"get_time"}],"stop_reason":"tool_use"}"#, + ) + .await + .expect("缺 input 的零参调用应归一成功"); + + assert_eq!( + response.tool_calls, + vec![LlmToolCall { + id: "call_1".to_string(), + name: "get_time".to_string(), + arguments: "{}".to_string(), + }] + ); + } + + #[tokio::test] + async fn non_stream_tool_call_field_loss_fails_consistently_across_protocols() { + // 三个协议共用同一套归一策略,同一种缺失形状必须给出同一类错误, + // 不能出现"Chat 报错、Responses 静默丢弃"这种口径分裂。 + let bodies = [ + ( + LlmApiKind::OpenAiChat, + r#"{"id":"r","choices":[{"message":{"content":"正文","tool_calls":[{"index":0,"function":{"name":"get_weather","arguments":"{}"}}]},"finish_reason":"tool_calls"}]}"#, + ), + ( + LlmApiKind::OpenAiResponses, + r#"{"id":"r","output":[{"type":"message","content":[{"type":"output_text","text":"正文"}]},{"type":"function_call","name":"get_weather","arguments":"{}"}]}"#, + ), + ( + LlmApiKind::Anthropic, + r#"{"id":"r","content":[{"type":"text","text":"正文"},{"type":"tool_use","name":"get_weather","input":{}}],"stop_reason":"tool_use"}"#, + ), + ]; + + for (api_kind, body) in bodies { + let error = run_non_stream_tool_body(api_kind, body) + .await + .err() + .unwrap_or_else(|| panic!("{api_kind:?} 缺 id 的工具调用必须失败,实际成功返回")); + expect_tool_call_deserialize_error(error, "工具调用缺少 id"); + } + } + #[tokio::test] async fn stream_run_falls_back_when_tool_use_yields_no_fragments() { // 上游说了本轮是工具调用,但事件形状不在已支持范围内,一个分片都没解出来。