非流式工具参数半截 JSON 改为原样透传
Project CI / Repository checks (pull_request) Failing after 11s
Project CI / Backend tests (pull_request) Failing after 12s
Project CI / Frontend tests (pull_request) Failing after 23s
Project CI / Native shell tests (pull_request) Failing after 9m30s

b1ef45fbd 把“arguments 非空必须是完整 JSON”同时应用到流式和非流式,
越界了。两者的“参数不完整”语义不同:流式意味着流被截断,是传输层事实;
非流式的外层 body 已经完整,参数半截只说明模型输出有问题,属于内容层事实。

App 早有更好的处理——工具计划格式修复循环把畸形响应回灌给模型重写,比硬
报错再重跑整轮 Provider 有效得多。平台层拦下来会丢掉修复所需的 call id、
函数名和原始参数,导致 background_agent_runtime_repairs_malformed_native_
function_arguments 直接以 kind=deserialize 失败,第二次 repair 请求不再发出。

按流式与非流式区分该校验。b1ef45fbd 的其余部分不变:禁止静默丢弃、id 与
函数名必填、空参数归一为空对象、三协议共用一份策略。

原用例反转为断言原样透传,并补一条成对的流式用例锁住流式仍然报错。
This commit is contained in:
2026-07-27 07:00:21 +00:00
parent 214357b175
commit eb63f44271
2 changed files with 61 additions and 16 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。该聚合只负责解析,不表示工具执行并发。
工具调用归一只有一份策略,流式与非流式、三种协议共用:协议层只把各自 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 的强制校验,缺失必须在归一层重新拦截。
+58 -15
View File
@@ -653,9 +653,16 @@ struct RawToolCall {
// 唯一的归一策略点。已经被识别为工具调用却字段不全时必须显式失败:静默丢弃会把
// “上游给了工具调用但我们没解出来”伪装成“上游只回了正文”,调用方完全无从察觉,
// 而带 tool_choice=required 的请求还会因此退化成格式修复循环,审计里看不出真正成因。
//
// require_complete_arguments_json 区分流式与非流式,两者的“参数不完整”语义不同:
// 流式意味着流被截断,是传输层事实,平台层必须报错;非流式的外层 body 已经完整,
// 参数半截只说明模型输出有问题,属于内容层事实,应当原样交给调用方——调用方的格式
// 修复循环会把畸形响应回灌给模型重写,比平台层硬报错再重跑整轮 Provider 更有效,
// 平台层拦下来反而会毁掉修复所需的 call id、函数名和原始参数。
fn normalize_tool_calls(
raw: Vec<RawToolCall>,
context: &str,
require_complete_arguments_json: bool,
) -> Result<Vec<LlmToolCall>, 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::<serde_json::Value>(arguments).map_err(|error| {
LlmError::Deserialize(format!(
"LLM {context}工具调用参数不是完整 JSON:name={name}, error={error}"
))
})?;
if require_complete_arguments_json {
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,
@@ -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<String> {
@@ -2545,7 +2554,7 @@ fn extract_chat_tool_calls(choice: &ChatCompletionsChoice) -> Result<Vec<LlmTool
})
.collect();
normalize_tool_calls(raw, "Chat 非流式")
normalize_tool_calls(raw, "Chat 非流式", false)
}
fn extract_content_text(content: &ChatCompletionsContent) -> Option<String> {
@@ -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]