diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index eea5817cd..9bc515805 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。该聚合只负责解析,不表示工具执行并发。 -工具调用归一只有一份策略,流式与非流式、三种协议共用:协议层只把各自 DTO 映射成统一中间形态,接受与否全部由归一层判定。**被识别为工具调用(Chat 的 `tool_calls[]` 成员、Responses 的 `type=function_call`、Anthropic 的 `type=tool_use`)后,字段不全一律返回 `Deserialize`,不得静默丢弃。** 缺少 id 或函数名报错;arguments 缺省或空白归一为 `{}`(零参函数合法);arguments 非空则必须是完整 JSON,否则报错。这只是 JSON 语法完整性检查,不是按工具 `parameters` 执行 JSON Schema 校验。 +工具调用归一只有一份策略,流式与非流式、三种协议共用:协议层只把各自 DTO 映射成统一中间形态,接受与否全部由归一层判定。**被识别为工具调用(Chat 的 `tool_calls[]` 成员、Responses 的 `type=function_call`、Anthropic 的 `type=tool_use`)后,字段不全一律返回 `Deserialize`,不得静默丢弃。** 缺少 id 或函数名报错;arguments 缺省或空白归一为 `{}`(零参函数合法)。 + +arguments 是否必须是完整 JSON **按流式与非流式区分,两者的“参数不完整”语义不同**:流式意味着流被截断,是传输层事实,平台层必须返回 `Deserialize`;非流式的外层 body 已经完整,参数半截只说明模型输出有问题,属于内容层事实,必须**原样透传给调用方**。平台层不得在非流式路径拦截——调用方的工具计划格式修复循环要靠 call id、函数名和原始畸形参数把响应回灌给模型重写,这比硬报错再重跑整轮 Provider 有效得多;在平台层报错会把这三样信息一起丢掉。无论哪种,这都只是 JSON 语法完整性检查,不是按工具 `parameters` 执行 JSON Schema 校验。 静默丢弃是明确禁止的实现方式:它会把“上游给了工具调用但我们没解出来”伪装成“上游只回了正文”——响应同时带解说文本时更会被当作普通回复成功返回,而带 `tool_choice=required` 的请求随后退化为格式修复循环,审计里只能看到“模型没按协议调用工具”,看不出真正成因在解析层。非流式 DTO 为兼容流式分片把字段改成可选后尤其要注意:可选字段解除了 serde 的强制校验,缺失必须在归一层重新拦截。 diff --git a/server-rs/crates/platform-llm/src/lib.rs b/server-rs/crates/platform-llm/src/lib.rs index 71d9d6b0e..64e87aceb 100644 --- a/server-rs/crates/platform-llm/src/lib.rs +++ b/server-rs/crates/platform-llm/src/lib.rs @@ -653,9 +653,16 @@ struct RawToolCall { // 唯一的归一策略点。已经被识别为工具调用却字段不全时必须显式失败:静默丢弃会把 // “上游给了工具调用但我们没解出来”伪装成“上游只回了正文”,调用方完全无从察觉, // 而带 tool_choice=required 的请求还会因此退化成格式修复循环,审计里看不出真正成因。 +// +// require_complete_arguments_json 区分流式与非流式,两者的“参数不完整”语义不同: +// 流式意味着流被截断,是传输层事实,平台层必须报错;非流式的外层 body 已经完整, +// 参数半截只说明模型输出有问题,属于内容层事实,应当原样交给调用方——调用方的格式 +// 修复循环会把畸形响应回灌给模型重写,比平台层硬报错再重跑整轮 Provider 更有效, +// 平台层拦下来反而会毁掉修复所需的 call id、函数名和原始参数。 fn normalize_tool_calls( raw: Vec, context: &str, + require_complete_arguments_json: bool, ) -> Result, LlmError> { raw.into_iter() .map(|call| { @@ -677,8 +684,7 @@ fn normalize_tool_calls( .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() { @@ -688,11 +694,13 @@ fn normalize_tool_calls( arguments: "{}".to_string(), }); } - serde_json::from_str::(arguments).map_err(|error| { - LlmError::Deserialize(format!( - "LLM {context}工具调用参数不是完整 JSON:name={name}, error={error}" - )) - })?; + if require_complete_arguments_json { + serde_json::from_str::(arguments).map_err(|error| { + LlmError::Deserialize(format!( + "LLM {context}工具调用参数不是完整 JSON:name={name}, error={error}" + )) + })?; + } Ok(LlmToolCall { id, name, @@ -763,6 +771,7 @@ impl StreamAccumulation { }) .collect(), "流式", + true, ) } } @@ -2466,7 +2475,7 @@ fn extract_responses_tool_calls( }) .collect(); - normalize_tool_calls(raw, "Responses 非流式") + normalize_tool_calls(raw, "Responses 非流式", false) } fn extract_anthropic_tool_calls( @@ -2486,7 +2495,7 @@ fn extract_anthropic_tool_calls( }) .collect(); - normalize_tool_calls(raw, "Anthropic 非流式") + normalize_tool_calls(raw, "Anthropic 非流式", false) } fn extract_anthropic_text(parsed: &AnthropicResponseEnvelope) -> Option { @@ -2545,7 +2554,7 @@ fn extract_chat_tool_calls(choice: &ChatCompletionsChoice) -> Result Option { @@ -4975,16 +4984,50 @@ mod tests { } #[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( + async fn non_stream_chat_tool_call_passes_incomplete_arguments_through() { + // 上游 max_tokens 截断会给出合法外层 JSON 加半截 arguments 字符串。非流式的 + // 外层 body 已完整,参数半截是模型输出问题而非流被截断,平台层必须原样透传: + // 调用方的格式修复循环要靠 call id、函数名和原始参数把畸形响应回灌给模型重写, + // 在这里报错会把这些信息全部丢掉,退化成一轮无谓的 Provider 重试。 + 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_weather","arguments":"{\"city\":"}}]},"finish_reason":"length"}]}"#, ) .await - .expect_err("半截 arguments 必须失败"); + .expect("半截 arguments 必须原样透传给调用方"); - expect_tool_call_deserialize_error(error, "Chat 非流式工具调用参数不是完整 JSON"); + assert_eq!( + response.tool_calls, + vec![LlmToolCall { + id: "call_1".to_string(), + name: "get_weather".to_string(), + arguments: r#"{"city":"#.to_string(), + }] + ); + } + + #[tokio::test] + async fn stream_chat_tool_call_with_incomplete_arguments_json_still_fails() { + // 与上一条成对:流式的参数半截意味着流被截断,是传输层事实,必须报错。 + let server_url = spawn_mock_server(vec![MockResponse { + status_line: "200 OK", + content_type: "text/event-stream; charset=utf-8", + body: concat!( + r#"data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"get_weather","arguments":"{\"city\":"}}]}}]}"#, "\n\n", + r#"data: {"choices":[{"finish_reason":"length"}]}"#, "\n\n", + "data: [DONE]\n\n" + ) + .to_string(), + extra_headers: Vec::new(), + }]); + + let client = build_test_client(server_url, 0); + let error = client + .stream_run(weather_tool_request(LlmApiKind::OpenAiChat), |_| {}) + .await + .expect_err("流式半截 arguments 必须失败"); + + expect_tool_call_deserialize_error(error, "流式工具调用参数不是完整 JSON"); } #[tokio::test]