非流式工具参数半截 JSON 改为原样透传
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:
@@ -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 的强制校验,缺失必须在归一层重新拦截。
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user