统一三协议工具调用归一,禁止静默丢弃

非流式 DTO 为兼容流式分片改成可选字段后,filter_map 会把缺 id / name /
function 的工具调用逐个丢掉;响应同时带解说文本时整个响应被当作普通文本
回复成功返回,带 tool_choice=required 的请求随后退化为格式修复循环,审计
里看不出成因在解析层。Responses 与 Anthropic 的提取有同类缺陷。

把策略收进单一入口 normalize_tool_calls:协议层只做字段映射,识别为工具
调用后字段不全一律返回 Deserialize;arguments 缺省或空白归一为空对象,
非空则必须是完整 JSON。流式收尾改为委派同一入口,行为不变。

顺带修正两处口径不一致:Chat 缺 arguments 从空串改为空对象,Responses
缺 arguments 从整条丢弃改为空对象(零参函数合法)。非流式 arguments 半截
JSON 由此改为显式失败,上游 max_tokens 截断会命中。

补 9 个非流式用例,含缺 id 且有正文不得退化成纯文本回复的回归锁,以及三
协议同一缺失形状必须同类报错的一致性用例。
This commit is contained in:
2026-07-27 05:59:28 +00:00
parent 9eca3bd657
commit b1ef45fbd2
2 changed files with 302 additions and 70 deletions
@@ -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,改动前必须先确认所有在用网关的文本收尾行为。流在任何工具分片到达前就断掉时槽位为空,门禁无从触发,这是已知残留缺口。
+299 -69
View File
@@ -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<String>,
name: Option<String>,
arguments: Option<String>,
}
// 唯一的归一策略点。已经被识别为工具调用却字段不全时必须显式失败:静默丢弃会把
// “上游给了工具调用但我们没解出来”伪装成“上游只回了正文”,调用方完全无从察觉,
// 而带 tool_choice=required 的请求还会因此退化成格式修复循环,审计里看不出真正成因。
fn normalize_tool_calls(
raw: Vec<RawToolCall>,
context: &str,
) -> Result<Vec<LlmToolCall>, 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}工具调用缺少 idslot={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::<serde_json::Value>(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<Vec<LlmToolCall>, LlmError> {
self.tool_calls
.iter()
.map(|pending| {
let id = pending.id.clone().ok_or_else(|| {
LlmError::Deserialize(format!("LLM 流式工具调用缺少 idslot={}", 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::<serde_json::Value>(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<String>
})
}
fn extract_responses_tool_calls(parsed: &ResponsesResponseEnvelope) -> Vec<LlmToolCall> {
parsed
fn extract_responses_tool_calls(
parsed: &ResponsesResponseEnvelope,
) -> Result<Vec<LlmToolCall>, 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<LlmToolCall> {
parsed
fn extract_anthropic_tool_calls(
parsed: &AnthropicResponseEnvelope,
) -> Result<Vec<LlmToolCall>, 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<String> {
@@ -2460,8 +2510,8 @@ fn extract_message_text(choice: &ChatCompletionsChoice) -> Option<String> {
})
}
fn extract_chat_tool_calls(choice: &ChatCompletionsChoice) -> Vec<LlmToolCall> {
choice
fn extract_chat_tool_calls(choice: &ChatCompletionsChoice) -> Result<Vec<LlmToolCall>, 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<LlmToolCall> {
})
.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<String> {
@@ -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<LlmRunResponse, LlmError> {
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() {
// 上游说了本轮是工具调用,但事件形状不在已支持范围内,一个分片都没解出来。