From 0635ddfdb13ba74d54106b8f5e2f29fee1bfe947 Mon Sep 17 00:00:00 2001 From: Linghong Date: Mon, 14 Sep 2026 12:10:59 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=20Provider=20reasoning=20?= =?UTF-8?q?=E5=8D=8F=E8=AE=AE=E8=A7=A3=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 解析 Chat 与 Responses 的 reasoning 字段和流式事件 保持正文、工具调用与 GameAgent 默认行为不变 补充 reasoning 分流、终态快照与兼容开关测试 --- ...划】Provider reasoning协议解析-2026-09-14.md | 53 +++ server-rs/crates/platform-llm/src/lib.rs | 309 ++++++++++++++++-- 2 files changed, 343 insertions(+), 19 deletions(-) create mode 100644 docs/project-memory/plans/【实施计划】Provider reasoning协议解析-2026-09-14.md diff --git a/docs/project-memory/plans/【实施计划】Provider reasoning协议解析-2026-09-14.md b/docs/project-memory/plans/【实施计划】Provider reasoning协议解析-2026-09-14.md new file mode 100644 index 000000000..141e05ed3 --- /dev/null +++ b/docs/project-memory/plans/【实施计划】Provider reasoning协议解析-2026-09-14.md @@ -0,0 +1,53 @@ +# 【实施计划】Provider reasoning 协议解析 + +| 字段 | 值 | +| --- | --- | +| Milestone | `docs/project-memory/plans/【里程碑】Provider推理与正文分离及策划Agent展示-2026-09-14.md` | +| Status | awaiting-review | +| Owner | Codex | + +## 修改边界 + +本轮只修改 `server-rs/crates/platform-llm/src/lib.rs` 的共享 Provider 解析、流式累计和相关单元测试;不接入策划 Runtime、Tauri 事件、前端 UI,也不修改 GameAgent、HTTP、OpenAPI、SpacetimeDB 或持久化 schema。 + +## 实现顺序 + +1. 扩展 Chat 与 Responses 的内部响应 DTO/事件提取器,识别 reasoning 文本并继续过滤出正文。 +2. 将 reasoning 作为独立事件字段进入流式累计;仅在 `capture_reasoning=true` 时写入累计值和回调,默认关闭保持空值。 +3. 在非流式 Responses/Chat 终态响应中填充 `LlmRunResponse.reasoning`,解析异常降级为空且不影响正文和工具调用。 +4. 增加协议级测试,覆盖增量顺序、终态快照、content parts、无 reasoning、工具调用共存和默认关闭兼容。 + +## 验收标准 + +- `reasoning_delta` 与 `delta_text` 分离,`accumulated_text` 永不包含 reasoning。 +- `capture_reasoning=false` 时 `LlmStreamDelta` 与 `LlmRunResponse` 的 reasoning 字段为空。 +- `capture_reasoning=true` 时 Chat 的 `reasoning`/`reasoning_content`/reasoning parts、Responses 的 reasoning summary 增量和终态 output 均可累计。 +- reasoning 缺失或形状未知时不影响正文、工具调用、finish reason 和 Responses 原生 output。 +- 相关 Rust 测试、格式、编码和 diff 检查通过。 + +## 本轮验收证据 + +| 证据 | 结果 | +| --- | --- | +| `cargo test --manifest-path server-rs/Cargo.toml -p platform-llm` | PASS,151 个单元测试通过;真实 Provider 测试按仓库约定保持 ignored | +| `cargo fmt --manifest-path server-rs/Cargo.toml --all -- --check` | PASS | +| `npm run check:encoding` | PASS,4560 个文件 | +| `npm run check:doc-index` | PASS | +| `git diff --check` | PASS | + +未验证项:未连接真实 Provider;策划 Runtime、Tauri 事件和前端 UI 属于后续里程碑,本轮未接入。 + +## 验证命令 + +```text +cargo test --manifest-path server-rs/Cargo.toml -p platform-llm +cargo fmt --manifest-path server-rs/Cargo.toml --all -- --check +npm run check:encoding +git diff --check +``` + +## 风险与回滚点 + +- Provider 兼容网关可能把 reasoning 放在未建模字段;提取器采用可选 JSON 读取,失败只丢弃 reasoning。 +- 终态 summary 可能重复增量;累计逻辑按快照与已有前缀去重。 +- 若发现正文或工具调用回归,回退本轮解析提交即可,第一轮共享契约仍可独立保留。 diff --git a/server-rs/crates/platform-llm/src/lib.rs b/server-rs/crates/platform-llm/src/lib.rs index 5fce9cb5d..cd7d08da5 100644 --- a/server-rs/crates/platform-llm/src/lib.rs +++ b/server-rs/crates/platform-llm/src/lib.rs @@ -535,6 +535,14 @@ where Ok(Option::>::deserialize(deserializer)?.unwrap_or_default()) } +fn deserialize_optional_string<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + Ok(Option::::deserialize(deserializer)? + .and_then(|value| value.as_str().map(str::to_string))) +} + #[derive(Deserialize)] struct ChatCompletionsChoice { #[serde(default)] @@ -551,6 +559,10 @@ struct ChatCompletionsMessage { content: Option, #[serde(default)] tool_calls: Option>, + #[serde(default, deserialize_with = "deserialize_optional_string")] + reasoning: Option, + #[serde(default, deserialize_with = "deserialize_optional_string")] + reasoning_content: Option, } // 流式分片只有首片带 id / name,后续片仅有 index 与 arguments 片段,因此字段全部可选。 @@ -583,7 +595,7 @@ enum ChatCompletionsContent { struct ChatCompletionsContentPart { #[serde(rename = "type")] part_type: Option, - #[serde(default)] + #[serde(default, deserialize_with = "deserialize_optional_string")] text: Option, } @@ -615,13 +627,15 @@ struct ResponsesOutputItem { name: Option, #[serde(default)] arguments: Option, + #[serde(default)] + summary: Option, } #[derive(Deserialize)] struct ResponsesOutputContentPart { #[serde(rename = "type")] part_type: Option, - #[serde(default)] + #[serde(default, deserialize_with = "deserialize_optional_string")] text: Option, } @@ -695,6 +709,8 @@ struct OpenAiCompatibleSseParser { #[derive(Debug, Default)] struct ParsedStreamEvent { delta_text: Option, + reasoning_delta: Option, + reasoning_snapshot: Option, responses_output: Option>, // 终态事件携带的完整正文快照。必须与 delta_text 分开:它不是增量,按增量累加会让 // 正文翻倍。只有 Responses 的 completed / incomplete 会填——Chat 的 [DONE] 与 @@ -860,6 +876,7 @@ fn normalize_tool_calls( #[derive(Debug, Default)] struct StreamAccumulation { text: String, + reasoning: String, responses_output: Vec, finish_reason: Option, usage: Option, @@ -1636,6 +1653,7 @@ impl LlmClient { request.api_kind, self.config.provider(), &resolved_model, + request.capture_reasoning, raw_text.as_str(), ) .map_err(|error| { @@ -1746,6 +1764,7 @@ impl LlmClient { stream_terminated = consume_stream_parser_result( parser.push_chunk(chunk_text.as_ref()), &mut accumulation, + request.capture_reasoning, emit_finish_only_delta, &mut on_delta, ) @@ -1795,6 +1814,7 @@ impl LlmClient { stream_terminated = consume_stream_parser_result( parser.push_chunk(trailing_text), &mut accumulation, + request.capture_reasoning, emit_finish_only_delta, &mut on_delta, ) @@ -1816,6 +1836,7 @@ impl LlmClient { consume_stream_parser_result( parser.finish(), &mut accumulation, + request.capture_reasoning, emit_finish_only_delta, &mut on_delta, ) @@ -1929,7 +1950,11 @@ impl LlmClient { provider: self.config.provider(), model: resolved_model, text: content, - reasoning: String::new(), + reasoning: if request.capture_reasoning { + accumulation.reasoning + } else { + String::new() + }, finish_reason: accumulation.finish_reason, response_id, usage: accumulation.usage, @@ -2203,6 +2228,7 @@ impl OpenAiCompatibleSseParser { fn consume_stream_parser_result( result: Result, SseEventDrainError>, accumulation: &mut StreamAccumulation, + capture_reasoning: bool, emit_finish_only_delta: bool, on_delta: &mut F, ) -> Result @@ -2214,8 +2240,13 @@ where Err(error) => (error.parsed_events, Some(error.error)), }; // 槽位身份冲突比尾部错误更根本:累加出的工具调用已不可信,不能再走保留路径。 - let stream_terminated = - consume_stream_events(events, accumulation, emit_finish_only_delta, on_delta)?; + let stream_terminated = consume_stream_events( + events, + accumulation, + capture_reasoning, + emit_finish_only_delta, + on_delta, + )?; if stream_terminated { return Ok(true); @@ -2269,6 +2300,7 @@ fn retain_completed_stream_after_tail_error( fn consume_stream_events( events: Vec, accumulation: &mut StreamAccumulation, + capture_reasoning: bool, emit_finish_only_delta: bool, on_delta: &mut F, ) -> Result @@ -2278,6 +2310,8 @@ where for event in events { let ParsedStreamEvent { delta_text, + reasoning_delta, + reasoning_snapshot, responses_output, text_snapshot, finish_reason: event_finish_reason, @@ -2299,6 +2333,32 @@ where accumulation.completion_observed = true; } + let mut reasoning_delta = if capture_reasoning { + reasoning_delta.unwrap_or_default() + } else { + String::new() + }; + let mut reasoning_snapshot_applied = false; + if capture_reasoning { + if let Some(snapshot) = reasoning_snapshot.filter(|text| !text.trim().is_empty()) { + if snapshot != accumulation.reasoning { + reasoning_delta = if accumulation.reasoning.is_empty() { + snapshot.clone() + } else { + snapshot + .strip_prefix(accumulation.reasoning.as_str()) + .unwrap_or_default() + .to_string() + }; + accumulation.reasoning = snapshot; + reasoning_snapshot_applied = true; + } + } + if !reasoning_snapshot_applied && !reasoning_delta.is_empty() { + accumulation.reasoning.push_str(reasoning_delta.as_str()); + } + } + if let Some(event_usage) = event_usage { accumulation.usage = Some(match accumulation.usage.take() { Some(previous) => { @@ -2372,22 +2432,26 @@ where if let Some(event_finish_reason) = event_finish_reason { accumulation.finish_reason = Some(event_finish_reason.clone()); - if has_delta || emit_finish_only_delta || snapshot_corrected { + if has_delta + || !reasoning_delta.is_empty() + || emit_finish_only_delta + || snapshot_corrected + { let update = LlmStreamDelta { accumulated_text: accumulation.text.clone(), delta_text, - accumulated_reasoning: String::new(), - reasoning_delta: String::new(), + accumulated_reasoning: accumulation.reasoning.clone(), + reasoning_delta, finish_reason: Some(event_finish_reason), }; on_delta(&update); } - } else if has_delta { + } else if has_delta || !reasoning_delta.is_empty() { let update = LlmStreamDelta { accumulated_text: accumulation.text.clone(), delta_text, - accumulated_reasoning: String::new(), - reasoning_delta: String::new(), + accumulated_reasoning: accumulation.reasoning.clone(), + reasoning_delta, finish_reason: None, }; on_delta(&update); @@ -3207,21 +3271,40 @@ fn parse_text_response( api_kind: LlmApiKind, provider: LlmProvider, fallback_model: &str, + capture_reasoning: bool, raw_text: &str, ) -> Result { match api_kind { - LlmApiKind::OpenAiChat => { - parse_chat_completions_response(provider, fallback_model, raw_text) - } - LlmApiKind::OpenAiResponses => parse_responses_response(provider, fallback_model, raw_text), + LlmApiKind::OpenAiChat => parse_chat_completions_response_with_capture( + provider, + fallback_model, + capture_reasoning, + raw_text, + ), + LlmApiKind::OpenAiResponses => parse_responses_response_with_capture( + provider, + fallback_model, + capture_reasoning, + raw_text, + ), LlmApiKind::Anthropic => parse_anthropic_response(provider, fallback_model, raw_text), } } +#[allow(dead_code)] fn parse_chat_completions_response( provider: LlmProvider, fallback_model: &str, raw_text: &str, +) -> Result { + parse_chat_completions_response_with_capture(provider, fallback_model, false, raw_text) +} + +fn parse_chat_completions_response_with_capture( + provider: LlmProvider, + fallback_model: &str, + capture_reasoning: bool, + raw_text: &str, ) -> Result { let parsed: ChatCompletionsResponsePayload = serde_json::from_str(raw_text) .map_err(|error| LlmError::Deserialize(format!("解析 LLM JSON 响应失败:{error}")))?; @@ -3252,9 +3335,16 @@ fn parse_chat_completions_response( Ok(LlmRunResponse { provider, - model: parsed.model.unwrap_or_else(|| fallback_model.to_string()), + model: parsed + .model + .clone() + .unwrap_or_else(|| fallback_model.to_string()), text: content, - reasoning: String::new(), + reasoning: if capture_reasoning { + extract_message_reasoning(first_choice).unwrap_or_default() + } else { + String::new() + }, finish_reason: first_choice.finish_reason.clone(), response_id: parsed.id, usage: parsed.usage, @@ -3263,10 +3353,20 @@ fn parse_chat_completions_response( }) } +#[allow(dead_code)] fn parse_responses_response( provider: LlmProvider, fallback_model: &str, raw_text: &str, +) -> Result { + parse_responses_response_with_capture(provider, fallback_model, false, raw_text) +} + +fn parse_responses_response_with_capture( + provider: LlmProvider, + fallback_model: &str, + capture_reasoning: bool, + raw_text: &str, ) -> Result { let raw: serde_json::Value = serde_json::from_str(raw_text).map_err(|error| { LlmError::Deserialize(format!("解析 LLM Responses JSON 响应失败:{error}")) @@ -3297,9 +3397,16 @@ fn parse_responses_response( Ok(LlmRunResponse { provider, - model: parsed.model.unwrap_or_else(|| fallback_model.to_string()), + model: parsed + .model + .clone() + .unwrap_or_else(|| fallback_model.to_string()), text: content, - reasoning: String::new(), + reasoning: if capture_reasoning { + extract_responses_reasoning(&parsed).unwrap_or_default() + } else { + String::new() + }, finish_reason: parsed.status, response_id: parsed.id, usage: parsed.usage.map(|usage| LlmTokenUsage { @@ -3370,6 +3477,36 @@ fn extract_responses_text(parsed: &ResponsesResponseEnvelope) -> Option }) } +fn append_reasoning(target: &mut String, value: Option<&str>) { + let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else { + return; + }; + target.push_str(value); +} + +fn extract_responses_reasoning(parsed: &ResponsesResponseEnvelope) -> Option { + let mut reasoning = String::new(); + for item in &parsed.output { + if item.item_type.as_deref() != Some("reasoning") { + continue; + } + if let Some(parts) = item.summary.as_ref().and_then(serde_json::Value::as_array) { + for part in parts { + append_reasoning( + &mut reasoning, + part.get("text").and_then(serde_json::Value::as_str), + ); + } + } + for part in &item.content { + if is_hidden_reasoning_part(part.part_type.as_deref()) { + append_reasoning(&mut reasoning, part.text.as_deref()); + } + } + } + (!reasoning.is_empty()).then_some(reasoning) +} + fn extract_responses_tool_calls( parsed: &ResponsesResponseEnvelope, ) -> Result, LlmError> { @@ -3436,6 +3573,25 @@ fn extract_message_text(choice: &ChatCompletionsChoice) -> Option { }) } +fn extract_message_reasoning(choice: &ChatCompletionsChoice) -> Option { + let mut reasoning = String::new(); + for message in [choice.message.as_ref(), choice.delta.as_ref()] + .into_iter() + .flatten() + { + append_reasoning(&mut reasoning, message.reasoning.as_deref()); + append_reasoning(&mut reasoning, message.reasoning_content.as_deref()); + if let Some(ChatCompletionsContent::Parts(parts)) = message.content.as_ref() { + for part in parts { + if is_hidden_reasoning_part(part.part_type.as_deref()) { + append_reasoning(&mut reasoning, part.text.as_deref()); + } + } + } + } + (!reasoning.is_empty()).then_some(reasoning) +} + fn extract_chat_tool_calls(choice: &ChatCompletionsChoice) -> Result, LlmError> { let raw = choice .message @@ -3582,6 +3738,7 @@ fn parse_sse_event_block( Ok(Some(ParsedStreamEvent { delta_text: extract_message_text(first_choice), + reasoning_delta: extract_message_reasoning(first_choice), finish_reason: first_choice.finish_reason.clone(), usage: parsed.usage, // Chat 的收尾信号是非空 finish_reason,不能只认 [DONE]:部分兼容网关(MiniMax) @@ -3651,6 +3808,20 @@ fn parse_responses_sse_event(data: &str) -> Result, Ll .map(str::to_string), ..Default::default() })), + "response.reasoning_summary_text.delta" => Ok(Some(ParsedStreamEvent { + reasoning_delta: parsed + .get("delta") + .and_then(serde_json::Value::as_str) + .map(str::to_string), + ..Default::default() + })), + "response.reasoning_summary_text.done" => Ok(Some(ParsedStreamEvent { + reasoning_snapshot: parsed + .get("text") + .and_then(serde_json::Value::as_str) + .map(str::to_string), + ..Default::default() + })), // completed 事件携带完整 output;有的网关只发它而不发增量事件,这里再取一遍, // 槽位沿用 output 数组下标,与 output_index 语义一致,可安全覆盖增量拼接结果。 // 整体收尾信号只有 completed 与 incomplete 两个;单个 item 的 @@ -3679,6 +3850,7 @@ fn parse_responses_sse_event(data: &str) -> Result, Ll // 工具会让纯文本的 completed-only 响应变成 EmptyResponse,让「正文 + 工具」 // 响应静默丢掉模型的前置说明。 text_snapshot: extract_responses_terminal_text(&parsed), + reasoning_snapshot: extract_responses_terminal_reasoning(&parsed), responses_output: extract_responses_output_array(&parsed), tool_fragments: extract_responses_completed_tool_fragments(&parsed)?, ..Default::default() @@ -3754,6 +3926,12 @@ fn extract_responses_terminal_text(parsed: &serde_json::Value) -> Option extract_responses_text(&envelope).filter(|text| !text.trim().is_empty()) } +fn extract_responses_terminal_reasoning(parsed: &serde_json::Value) -> Option { + let response = parsed.get("response")?; + let envelope: ResponsesResponseEnvelope = serde_json::from_value(response.clone()).ok()?; + extract_responses_reasoning(&envelope) +} + fn extract_responses_output_array(parsed: &serde_json::Value) -> Option> { parsed .pointer("/response/output") @@ -4861,6 +5039,7 @@ mod tests { .expect("tool-only response should parse"); assert_eq!(response.text, ""); + assert!(response.reasoning.is_empty()); assert_eq!(response.finish_reason.as_deref(), Some("tool_use")); assert_eq!( response.tool_calls, @@ -5155,6 +5334,20 @@ mod tests { assert_eq!(response.text, ""); } + #[test] + fn chat_response_captures_reasoning_without_mixing_into_text() { + let response = parse_chat_completions_response_with_capture( + LlmProvider::OpenAiCompatible, + "fallback-model", + true, + r#"{"choices":[{"message":{"reasoning_content":"先分析。","content":[{"type":"reasoning","text":"再检查。"},{"type":"text","text":"答案"}]},"finish_reason":"stop"}]}"#, + ) + .expect("chat reasoning should parse"); + + assert_eq!(response.text, "答案"); + assert_eq!(response.reasoning, "先分析。再检查。"); + } + #[test] fn chat_response_filters_reasoning_parts_and_preserves_visible_parts() { let response = parse_chat_completions_response( @@ -5199,6 +5392,49 @@ mod tests { assert_eq!(response.text, "最终答案"); } + #[test] + fn responses_response_captures_reasoning_summary() { + let response = parse_responses_response_with_capture( + LlmProvider::OpenAiCompatible, + "fallback-model", + true, + r#"{"id":"resp_reasoning","output":[{"type":"reasoning","summary":[{"type":"summary_text","text":"先判断。"},{"type":"summary_text","text":"再回答。"}]},{"type":"message","content":[{"type":"output_text","text":"答案"}]}],"status":"completed"}"#, + ) + .expect("Responses reasoning should parse"); + + assert_eq!(response.text, "答案"); + assert_eq!(response.reasoning, "先判断。再回答。"); + } + + #[test] + fn stream_events_capture_reasoning_delta_and_terminal_snapshot() { + let chat = parse_sse_event_block( + LlmApiKind::OpenAiChat, + r#"data: {"choices":[{"delta":{"reasoning_content":"思考","content":"答案"}}]}"#, + ) + .expect("chat SSE should parse") + .expect("chat event should exist"); + assert_eq!(chat.reasoning_delta.as_deref(), Some("思考")); + assert_eq!(chat.delta_text.as_deref(), Some("答案")); + + let responses = parse_sse_event_block( + LlmApiKind::OpenAiResponses, + r#"data: {"type":"response.reasoning_summary_text.delta","delta":"推理"}"#, + ) + .expect("Responses SSE should parse") + .expect("Responses event should exist"); + assert_eq!(responses.reasoning_delta.as_deref(), Some("推理")); + + let terminal = parse_sse_event_block( + LlmApiKind::OpenAiResponses, + r#"data: {"type":"response.completed","response":{"output":[{"type":"reasoning","summary":[{"type":"summary_text","text":"完整推理"}]},{"type":"message","content":[{"type":"output_text","text":"正文"}]}]}}"#, + ) + .expect("terminal SSE should parse") + .expect("terminal event should exist"); + assert_eq!(terminal.reasoning_snapshot.as_deref(), Some("完整推理")); + assert_eq!(terminal.text_snapshot.as_deref(), Some("正文")); + } + #[tokio::test] async fn run_accepts_chat_tool_calls_without_text_content() { let server_url = spawn_mock_server(vec![MockResponse { @@ -6927,6 +7163,41 @@ mod tests { assert_eq!(response.text, "杭州今天多云。"); } + #[tokio::test] + async fn stream_run_captures_reasoning_separately_when_enabled() { + let server_url = spawn_mock_server(vec![MockResponse { + status_line: "200 OK", + content_type: "text/event-stream; charset=utf-8", + body: concat!( + r#"data: {"type":"response.reasoning_summary_text.delta","delta":"先判断。"}"#, "\n\n", + r#"data: {"type":"response.output_text.delta","delta":"答案"}"#, "\n\n", + r#"data: {"type":"response.completed","response":{"output":[{"type":"reasoning","summary":[{"type":"summary_text","text":"先判断。"}]},{"type":"message","content":[{"type":"output_text","text":"答案"}]}]}}"#, "\n\n" + ) + .to_string(), + extra_headers: Vec::new(), + }]); + + let mut updates = Vec::new(); + let response = build_test_client(server_url, 0) + .stream_run( + LlmRunRequest::single_turn("系统", "用户") + .with_openai_responses() + .with_reasoning_capture(true), + |delta| updates.push((delta.delta_text.clone(), delta.reasoning_delta.clone())), + ) + .await + .expect("stream reasoning should parse"); + + assert_eq!(response.text, "答案"); + assert_eq!(response.reasoning, "先判断。"); + assert!(updates.iter().any(|(_, reasoning)| reasoning == "先判断。")); + assert!( + updates + .iter() + .all(|(text, reasoning)| !(text.contains("先判断") || reasoning.contains("答案"))) + ); + } + #[tokio::test] async fn stream_run_accumulates_parallel_anthropic_tool_calls() { let server_url = spawn_mock_server(vec![MockResponse {