拒绝上游宣告未完成的工具调用
Project CI / Backend tests (pull_request) Successful in 4m19s
Project CI / Native shell tests (pull_request) Failing after 7m11s
Project CI / Frontend tests (pull_request) Failing after 23s
Project CI / Repository checks (pull_request) Failing after 49s

流式的收尾门禁只防传输层截断,把模型侧截断当成了正常收尾: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 放行。
This commit is contained in:
2026-07-27 08:11:40 +00:00
parent 95b8197235
commit 5daea56e32
2 changed files with 174 additions and 1 deletions
@@ -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,这条尾部路径是常态而非边缘情况。
+172 -1
View File
@@ -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() {
// 与上一条成对:流式的参数半截意味着流被截断,是传输层事实,必须报错。