From 5daea56e3232e1d913daf958aa4f703988902fdb Mon Sep 17 00:00:00 2001 From: Linghong Date: Mon, 27 Jul 2026 08:11:40 +0000 Subject: [PATCH] =?UTF-8?q?=E6=8B=92=E7=BB=9D=E4=B8=8A=E6=B8=B8=E5=AE=A3?= =?UTF-8?q?=E5=91=8A=E6=9C=AA=E5=AE=8C=E6=88=90=E7=9A=84=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E8=B0=83=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 流式的收尾门禁只防传输层截断,把模型侧截断当成了正常收尾:is_completion 只 判断 finish_reason 非空,length / content_filter / max_tokens 一律放行;三条 非流式路径同样原样接受 Chat finish_reason、Responses status 和 Anthropic stop_reason。下游没有任何一处对该字段做分支,因此参数恰好闭合成合法 JSON 的 截断调用会被真的执行。格式修复循环对这种形态无从察觉,它看到的 JSON 是合法的。 新增 reject_incomplete_tool_calls,流式与三条非流式路径统一拦截已知的截断、 过滤和失败终态:Chat 的 length / content_filter,Responses 的 incomplete / failed / cancelled,Anthropic 的 max_tokens / pause_turn / refusal。 刻意用黑名单而非白名单,未知值与缺失一律放行,否则会误杀不发或自定义该字段的 兼容网关。只在存在工具调用时生效——正文被 max_tokens 截断仍是可用的降级结果, 一并拒绝会打死所有触及输出上限的长文本回答。 与非流式畸形参数透传同时成立时本检查优先,原用例的 finish_reason 相应改为 tool_calls 以只验证透传本身。补 6 个用例覆盖三协议截断、纯文本截断放行和未知 finish_reason 放行。 --- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 2 + server-rs/crates/platform-llm/src/lib.rs | 173 +++++++++++++++++- 2 files changed, 174 insertions(+), 1 deletion(-) diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 9bc515805..ce0fab7d6 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -263,6 +263,8 @@ arguments 是否必须是完整 JSON **按流式与非流式区分,两者的 静默丢弃是明确禁止的实现方式:它会把“上游给了工具调用但我们没解出来”伪装成“上游只回了正文”——响应同时带解说文本时更会被当作普通回复成功返回,而带 `tool_choice=required` 的请求随后退化为格式修复循环,审计里只能看到“模型没按协议调用工具”,看不出真正成因在解析层。非流式 DTO 为兼容流式分片把字段改成可选后尤其要注意:可选字段解除了 serde 的强制校验,缺失必须在归一层重新拦截。 +工具调用还必须来自**没有被上游宣告为未完成**的响应。上游给出明确的截断 / 过滤 / 失败终态时,即使参数恰好闭合成合法 JSON 也必须返回 `Deserialize`:字节完整不代表模型把本轮工具计划表达完了,而下游拿到 `tool_calls` 就会真的去执行,格式修复循环对"参数合法但内容被砍断"完全无从察觉。已知终态为——Chat 的 `length` / `content_filter`,Responses 的 `incomplete` / `failed` / `cancelled`,Anthropic 的 `max_tokens` / `pause_turn` / `refusal`。这里必须用黑名单而非白名单,未知值与缺失一律放行,否则会误杀不发或自定义该字段的兼容网关。该检查**只在存在工具调用时生效**:正文被 `max_tokens` 截断仍是可用的降级结果,一并拒绝会打死所有触及输出上限的长文本回答。与非流式畸形参数透传同时成立时,本检查优先。 + 流式工具调用必须来自已收尾的流:只要聚合出过工具 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,这条尾部路径是常态而非边缘情况。 diff --git a/server-rs/crates/platform-llm/src/lib.rs b/server-rs/crates/platform-llm/src/lib.rs index 64e87aceb..47a41a246 100644 --- a/server-rs/crates/platform-llm/src/lib.rs +++ b/server-rs/crates/platform-llm/src/lib.rs @@ -638,6 +638,49 @@ struct PendingToolCall { arguments: String, } +// 上游明确表示本轮输出被截断、过滤或失败的终态。这里刻意用黑名单而不是白名单: +// 兼容网关常常不发这个字段或发自定义值,白名单会把它们全部误杀,而“保留缺失 reason +// 的兼容路径”是本项检查的前提。新增上游时按需补充已知值即可。 +fn is_incomplete_finish_reason(api_kind: LlmApiKind, finish_reason: &str) -> bool { + let reason = finish_reason.trim().to_ascii_lowercase(); + match api_kind { + LlmApiKind::OpenAiChat => matches!(reason.as_str(), "length" | "content_filter"), + LlmApiKind::OpenAiResponses => { + matches!(reason.as_str(), "incomplete" | "failed" | "cancelled") + } + LlmApiKind::Anthropic => { + matches!(reason.as_str(), "max_tokens" | "pause_turn" | "refusal") + } + } +} + +// 上游已经说了这一轮没写完,工具调用就不可信:参数恰好闭合成合法 JSON 只说明字节完整, +// 不代表模型把本轮工具计划表达完了,而下游拿到 tool_calls 就会真的去执行。仅在存在工具 +// 调用时拒绝——正文被 max_tokens 截断仍是可用的降级结果,砍掉它会打死所有长文本回答。 +// +// 与畸形参数透传(非流式)的关系:两者同时成立时本检查优先。畸形参数交给调用方的格式 +// 修复循环是因为那时“不完整”可被察觉;截断且参数恰好合法时修复循环察觉不到,只会直接执行。 +fn reject_incomplete_tool_calls( + api_kind: LlmApiKind, + finish_reason: Option<&str>, + tool_calls: &[LlmToolCall], + context: &str, +) -> Result<(), LlmError> { + if tool_calls.is_empty() { + return Ok(()); + } + let Some(reason) = finish_reason else { + return Ok(()); + }; + if !is_incomplete_finish_reason(api_kind, reason) { + return Ok(()); + } + Err(LlmError::Deserialize(format!( + "LLM {context}工具调用来自未完成的响应:finish_reason={reason}, calls={}", + tool_calls.len() + ))) +} + // 三协议、流式与非流式共用的工具调用中间形态。协议层只负责把自己的 DTO 映射成它, // 不做任何取舍判断;要不要接受、缺省怎么补,全部由 normalize_tool_calls 决定。 #[derive(Debug)] @@ -1518,6 +1561,24 @@ impl LlmClient { error })?; + reject_incomplete_tool_calls( + request.api_kind, + accumulation.finish_reason.as_deref(), + &tool_calls, + "流式", + ) + .map_err(|error| { + log_llm_raw_failure( + &self.config, + &request, + true, + 1, + "stream_tool_calls_incomplete_finish", + parser.raw_text().as_str(), + ); + error + })?; + // 一致性断言:上游已表明本轮是工具调用,却一个都没累加出来,说明该网关的事件形状 // 不在已支持范围内。此时必须显式失败让调用方回退非流式,不能静默丢掉调用。 if tool_calls.is_empty() @@ -2356,6 +2417,12 @@ fn parse_chat_completions_response( .trim() .to_string(); let tool_calls = extract_chat_tool_calls(first_choice)?; + reject_incomplete_tool_calls( + LlmApiKind::OpenAiChat, + first_choice.finish_reason.as_deref(), + &tool_calls, + "Chat 非流式", + )?; if content.is_empty() && tool_calls.is_empty() { return Err(LlmError::EmptyResponse); @@ -2385,6 +2452,12 @@ fn parse_responses_response( .trim() .to_string(); let tool_calls = extract_responses_tool_calls(&parsed)?; + reject_incomplete_tool_calls( + LlmApiKind::OpenAiResponses, + parsed.status.as_deref(), + &tool_calls, + "Responses 非流式", + )?; if content.is_empty() && tool_calls.is_empty() { return Err(LlmError::EmptyResponse); @@ -2414,6 +2487,12 @@ fn parse_anthropic_response( LlmError::Deserialize(format!("解析 LLM Anthropic JSON 响应失败:{error}")) })?; let tool_calls = extract_anthropic_tool_calls(&parsed)?; + reject_incomplete_tool_calls( + LlmApiKind::Anthropic, + parsed.stop_reason.as_deref(), + &tool_calls, + "Anthropic 非流式", + )?; let content = extract_anthropic_text(&parsed) .unwrap_or_default() .trim() @@ -4989,9 +5068,11 @@ mod tests { // 外层 body 已完整,参数半截是模型输出问题而非流被截断,平台层必须原样透传: // 调用方的格式修复循环要靠 call id、函数名和原始参数把畸形响应回灌给模型重写, // 在这里报错会把这些信息全部丢掉,退化成一轮无谓的 Provider 重试。 + // finish_reason 用 tool_calls 而非 length:本用例只验证参数透传。上游明确报截断 + // 时由 reject_incomplete_tool_calls 优先拦截,那条由下面的用例单独锁定。 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"}]}"#, + r#"{"id":"resp_01","choices":[{"message":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"get_weather","arguments":"{\"city\":"}}]},"finish_reason":"tool_calls"}]}"#, ) .await .expect("半截 arguments 必须原样透传给调用方"); @@ -5006,6 +5087,96 @@ mod tests { ); } + #[tokio::test] + async fn non_stream_chat_rejects_tool_calls_truncated_by_length() { + // 参数恰好闭合成合法 JSON,但上游已明确报 length:这正是修复循环察觉不到、 + // 会被直接执行的危险形态,必须在平台层拦下。 + 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("length 截断的工具调用必须失败"); + + expect_tool_call_deserialize_error(error, "工具调用来自未完成的响应"); + } + + #[tokio::test] + async fn non_stream_responses_rejects_tool_calls_with_incomplete_status() { + let error = run_non_stream_tool_body( + LlmApiKind::OpenAiResponses, + r#"{"id":"resp_01","status":"incomplete","output":[{"type":"function_call","call_id":"call_1","name":"get_weather","arguments":"{\"city\":\"杭州\"}"}]}"#, + ) + .await + .expect_err("incomplete 状态的工具调用必须失败"); + + expect_tool_call_deserialize_error(error, "工具调用来自未完成的响应"); + } + + #[tokio::test] + async fn non_stream_anthropic_rejects_tool_calls_stopped_by_max_tokens() { + let error = run_non_stream_tool_body( + LlmApiKind::Anthropic, + r#"{"id":"msg_01","content":[{"type":"tool_use","id":"call_1","name":"get_weather","input":{"city":"杭州"}}],"stop_reason":"max_tokens"}"#, + ) + .await + .expect_err("max_tokens 截断的工具调用必须失败"); + + expect_tool_call_deserialize_error(error, "工具调用来自未完成的响应"); + } + + #[tokio::test] + async fn non_stream_truncated_text_without_tool_calls_still_succeeds() { + // 作用域守卫:截断拦截只针对工具调用。正文被 max_tokens 砍断仍是可用的降级结果, + // 一并拒绝会打死所有触及输出上限的长文本回答。 + let response = run_non_stream_tool_body( + LlmApiKind::OpenAiChat, + r#"{"id":"resp_01","choices":[{"message":{"content":"杭州今天多云,气温"},"finish_reason":"length"}]}"#, + ) + .await + .expect("截断的纯文本回复仍应成功返回"); + + assert_eq!(response.text, "杭州今天多云,气温"); + assert!(response.tool_calls.is_empty()); + assert_eq!(response.finish_reason.as_deref(), Some("length")); + } + + #[tokio::test] + async fn non_stream_tool_calls_with_unknown_finish_reason_still_succeed() { + // 兼容网关路径:只拒绝已知的截断 / 过滤 / 失败终态,未知值与缺失一律放行。 + 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":"vendor_specific_done"}]}"#, + ) + .await + .expect("未知 finish_reason 不应被误杀"); + + assert_eq!(response.tool_calls.len(), 1); + } + + #[tokio::test] + async fn stream_chat_rejects_tool_calls_truncated_by_length() { + 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("流式 length 截断的工具调用必须失败"); + + expect_tool_call_deserialize_error(error, "流式工具调用来自未完成的响应"); + } + #[tokio::test] async fn stream_chat_tool_call_with_incomplete_arguments_json_still_fails() { // 与上一条成对:流式的参数半截意味着流被截断,是传输层事实,必须报错。