修复: DeepSeek reasoning_content 全链路透传(跨6 crate补字段+映射+agentic loop累积回传)

This commit is contained in:
2026-06-17 18:08:04 +08:00
parent 1cd7652a34
commit 74003bc04f
11 changed files with 251 additions and 20 deletions

View File

@@ -36,6 +36,9 @@ pub struct CompletionRequest {
/// 工具调用策略: "auto" | "none" | {"type":"function","name":"xxx"}
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_choice: Option<serde_json::Value>,
/// DeepSeek thinking 模式:需把上一轮 response 的 reasoning_content 回传
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning_content: Option<String>,
}
/// 多模态消息内容片。Text 片为字符串Image 片可走 url 或 base64二选一base64 非空时 url 忽略)。
@@ -115,29 +118,32 @@ pub struct ChatMessage {
/// 默认 None向前兼容老 JSON 反序列化)。落库随 messages JSON 序列化,无需独立列。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status: Option<String>,
/// DeepSeek thinking 模式的推理内容(多轮需回传)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_content: Option<String>,
}
impl ChatMessage {
pub fn system(content: impl Into<String>) -> Self {
Self { role: MessageRole::System, content: content.into(), parts: None, tool_call_id: None, tool_calls: None, model: None, status: None }
Self { role: MessageRole::System, content: content.into(), parts: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None }
}
pub fn user(content: impl Into<String>) -> Self {
Self { role: MessageRole::User, content: content.into(), parts: None, tool_call_id: None, tool_calls: None, model: None, status: None }
Self { role: MessageRole::User, content: content.into(), parts: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None }
}
pub fn assistant(content: impl Into<String>) -> Self {
Self { role: MessageRole::Assistant, content: content.into(), parts: None, tool_call_id: None, tool_calls: None, model: None, status: None }
Self { role: MessageRole::Assistant, content: content.into(), parts: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None }
}
pub fn assistant_with_tools(content: impl Into<String>, tool_calls: Vec<ToolCall>) -> Self {
Self { role: MessageRole::Assistant, content: content.into(), parts: None, tool_call_id: None, tool_calls: Some(tool_calls), model: None, status: None }
Self { role: MessageRole::Assistant, content: content.into(), parts: None, tool_call_id: None, tool_calls: Some(tool_calls), model: None, status: None, reasoning_content: None }
}
pub fn tool_result(call_id: impl Into<String>, content: impl Into<String>) -> Self {
Self { role: MessageRole::Tool, content: content.into(), parts: None, tool_call_id: Some(call_id.into()), tool_calls: None, model: None, status: None }
Self { role: MessageRole::Tool, content: content.into(), parts: None, tool_call_id: Some(call_id.into()), tool_calls: None, model: None, status: None, reasoning_content: None }
}
/// 多模态 user 消息content 文本 + parts含 Image 片)。
/// content 作为人类可读文本(也作非 vision 端点降级载荷parts 透传给 vision 端点。
pub fn user_parts(content: impl Into<String>, parts: Vec<ContentPart>) -> Self {
Self { role: MessageRole::User, content: content.into(), parts: Some(parts), tool_call_id: None, tool_calls: None, model: None, status: None }
Self { role: MessageRole::User, content: content.into(), parts: Some(parts), tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None }
}
/// 是否含图片片(供 provider 判定走多模态分支)。
@@ -249,6 +255,9 @@ pub struct CompletionResponse {
/// AI 发起的工具调用(如有)
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<ToolCall>>,
/// DeepSeek thinking 模式响应中的推理内容(需回传到下一轮请求)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_content: Option<String>,
}
/// Token 用量
@@ -276,6 +285,9 @@ pub struct StreamChunk {
/// 非空表示流中途出错不应视为正常完成finished 路径),由 stream_llm 转 AiError。
#[serde(skip)]
pub error: Option<String>,
/// DeepSeek thinking 模式推理内容增量
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_content: Option<String>,
}
/// 工具调用增量(流式中的片段)
@@ -449,8 +461,98 @@ mod tests {
tool_calls: None,
model: None,
status: None,
reasoning_content: None,
};
assert_eq!(m.content, "字面量构造");
assert!(m.parts.is_none());
}
// ---------- DeepSeek reasoning_content ----------
/// 有 reasoning_content 时应序列化出来None 时不出现
#[test]
fn completion_request_reasoning_content_serialization() {
let req = CompletionRequest {
model: "deepseek-r1".to_string(),
messages: vec![ChatMessage::user("hello")],
temperature: None,
max_tokens: None,
stream: false,
tools: None,
tool_choice: None,
reasoning_content: Some("let me think...".to_string()),
};
let json = serde_json::to_string(&req).unwrap();
assert!(json.contains("reasoning_content"), "reasoning_content 应出现在 JSON 中, got: {}", json);
// None 时不应出现skip_serializing_if
let req_none = CompletionRequest { reasoning_content: None, ..req };
let json_none = serde_json::to_string(&req_none).unwrap();
assert!(!json_none.contains("reasoning_content"), "None 的 reasoning_content 不应序列化, got: {}", json_none);
}
/// 所有便捷构造函数默认 reasoning_content 为 None
#[test]
fn chat_message_reasoning_content_constructors() {
assert!(ChatMessage::system("sys").reasoning_content.is_none());
assert!(ChatMessage::user("hi").reasoning_content.is_none());
assert!(ChatMessage::assistant("resp").reasoning_content.is_none());
assert!(ChatMessage::tool_result("call_1", "result").reasoning_content.is_none());
}
/// ChatMessage reasoning_content 序列化 round-trip
#[test]
fn chat_message_reasoning_content_roundtrip() {
let m = ChatMessage {
role: MessageRole::Assistant,
content: "answer".to_string(),
parts: None,
tool_call_id: None,
tool_calls: None,
model: None,
status: None,
reasoning_content: Some("thinking process".to_string()),
};
let json = serde_json::to_string(&m).unwrap();
assert!(json.contains("reasoning_content"));
let deserialized: ChatMessage = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.reasoning_content, Some("thinking process".to_string()));
}
/// StreamChunk reasoning_content 序列化
#[test]
fn stream_chunk_reasoning_content() {
let chunk = StreamChunk {
delta: "text".to_string(),
finished: false,
tool_calls: None,
usage: None,
error: None,
reasoning_content: Some("thought".to_string()),
};
let json = serde_json::to_string(&chunk).unwrap();
assert!(json.contains("reasoning_content"));
let chunk_none = StreamChunk { reasoning_content: None, ..chunk };
let json_none = serde_json::to_string(&chunk_none).unwrap();
assert!(!json_none.contains("reasoning_content"));
}
/// CompletionResponse reasoning_content 序列化
#[test]
fn completion_response_reasoning_content() {
let resp = CompletionResponse {
text: "ok".to_string(),
model: "r1".to_string(),
usage: TokenUsage { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
tool_calls: None,
reasoning_content: Some("r1 thought".to_string()),
};
let json = serde_json::to_string(&resp).unwrap();
assert!(json.contains("reasoning_content"));
let deserialized: CompletionResponse = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.reasoning_content, Some("r1 thought".to_string()));
}
}

View File

@@ -109,7 +109,7 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
let v: serde_json::Value = match serde_json::from_str(data) {
Ok(v) => v,
Err(_) => {
return StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None }
return StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None }
}
};
let ty = v.get("type").and_then(|t| t.as_str()).unwrap_or("");
@@ -128,7 +128,7 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
total_tokens: inp as u32,
});
}
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None }
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None }
}
// 消息增量output_tokens 是累计值(非增量),直接覆盖 completion + 重算 total
"message_delta" => {
@@ -138,14 +138,14 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
acc.completion_tokens = out as u32;
acc.total_tokens = acc.prompt_tokens + acc.completion_tokens;
}
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None }
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None }
}
// 文本增量
"content_block_delta" => {
if let Some(delta) = v.get("delta") {
if delta.get("type").and_then(|t| t.as_str()) == Some("text_delta") {
let text = delta.get("text").and_then(|t| t.as_str()).unwrap_or("").to_string();
return StreamChunk { delta: text, finished: false, tool_calls: None, usage: None, error: None };
return StreamChunk { delta: text, finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None };
}
// 工具入参增量
if delta.get("type").and_then(|t| t.as_str()) == Some("input_json_delta") {
@@ -162,10 +162,11 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
}]),
usage: None,
error: None,
reasoning_content: None,
};
}
}
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None }
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None }
}
// 工具块开始:带 id + name
"content_block_start" => {
@@ -194,10 +195,11 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
}]),
usage: None,
error: None,
reasoning_content: None,
};
}
}
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None }
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None }
}
// 消息结束:带出累积 usage
"message_stop" => StreamChunk {
@@ -206,16 +208,17 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
tool_calls: None,
usage: usage_accum.take(),
error: None,
reasoning_content: None,
},
// 错误事件:流中途出错。不走 finished 完成路径(避免残缺响应被当正常完成入库),
// 改由 stream_llm 识别 error 非空 → 发 AiError + 丢弃残缺(与 OpenAI 路径 Err 一致)。
"error" => {
let msg = v.get("error").and_then(|e| e.get("message")).and_then(|m| m.as_str()).unwrap_or("stream error").to_string();
error!(%msg, "Anthropic 流式错误事件");
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: Some(msg) }
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: Some(msg), reasoning_content: None }
}
// content_block_stop / ping 等不产出 chunk
_ => StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None },
_ => StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None },
}
}
@@ -541,6 +544,7 @@ impl LlmProvider for AnthropicCompatProvider {
model: resp.model,
usage,
tool_calls: if tool_calls.is_empty() { None } else { Some(tool_calls) },
reasoning_content: None,
})
}
})
@@ -808,6 +812,7 @@ mod tests {
stream: false,
tools: None,
tool_choice: None,
reasoning_content: None,
};
let body = provider.convert_request(req);
// user 消息 content 应为数组形态
@@ -839,6 +844,7 @@ mod tests {
stream: false,
tools: None,
tool_choice: None,
reasoning_content: None,
};
let body = provider.convert_request(req);
let user_msg = body

View File

@@ -44,6 +44,9 @@ struct OpenAiRequest {
/// 流式时请求末 chunk 携带 usageOpenAI 官方 + DeepSeek/GLM 兼容)
#[serde(skip_serializing_if = "Option::is_none")]
stream_options: Option<serde_json::Value>,
/// DeepSeek thinking 模式
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning_content: Option<String>,
}
/// OpenAI 消息格式
@@ -58,6 +61,9 @@ struct OpenAiMessage {
tool_call_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
tool_calls: Option<Vec<serde_json::Value>>,
/// DeepSeek thinking 模式推理内容
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning_content: Option<String>,
}
/// OpenAI 同步响应
@@ -78,6 +84,9 @@ struct OpenAiChoice {
struct OpenAiMessageResp {
content: Option<String>,
tool_calls: Option<Vec<OpenAiToolCallResp>>,
/// DeepSeek thinking 模式响应中的推理内容
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_content: Option<String>,
}
#[derive(Debug, Deserialize)]
@@ -120,6 +129,9 @@ struct OpenAiStreamChoice {
struct OpenAiStreamDelta {
content: Option<String>,
tool_calls: Option<Vec<OpenAiStreamToolCall>>,
/// DeepSeek thinking 模式流式推理内容
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_content: Option<String>,
}
#[derive(Debug, Deserialize)]
@@ -158,6 +170,7 @@ pub(crate) fn apply_openai_sse(data: &str, usage_accum: &mut Option<TokenUsage>)
tool_calls: None,
usage: usage_accum.take(),
error: None,
reasoning_content: None,
};
}
@@ -195,6 +208,7 @@ pub(crate) fn apply_openai_sse(data: &str, usage_accum: &mut Option<TokenUsage>)
tool_calls,
usage: None,
error: None,
reasoning_content: choice.delta.reasoning_content,
}
} else {
// choices 为空 = usage-only chunk不输出文本usage 已累积)
@@ -204,6 +218,7 @@ pub(crate) fn apply_openai_sse(data: &str, usage_accum: &mut Option<TokenUsage>)
tool_calls: None,
usage: None,
error: None,
reasoning_content: None,
}
}
}
@@ -215,6 +230,7 @@ pub(crate) fn apply_openai_sse(data: &str, usage_accum: &mut Option<TokenUsage>)
tool_calls: None,
usage: None,
error: None,
reasoning_content: None,
}
}
}
@@ -371,6 +387,7 @@ impl OpenAICompatProvider {
content,
tool_call_id: m.tool_call_id,
tool_calls,
reasoning_content: m.reasoning_content,
}
})
.collect();
@@ -389,6 +406,7 @@ impl OpenAICompatProvider {
stream: req.stream,
tools,
tool_choice: req.tool_choice,
reasoning_content: req.reasoning_content,
// 流式请求末 chunk 带 usage同步调用 complete 不需要)
stream_options: if req.stream {
Some(serde_json::json!({ "include_usage": true }))
@@ -509,6 +527,7 @@ impl LlmProvider for OpenAICompatProvider {
model: body.model,
usage,
tool_calls,
reasoning_content: choice.message.reasoning_content,
})
}
})
@@ -755,6 +774,7 @@ mod tests {
stream: false,
tools: None,
tool_choice: None,
reasoning_content: None,
};
let out = provider.convert_request(req);
let msg = &out.messages[0];
@@ -783,6 +803,7 @@ mod tests {
stream: false,
tools: None,
tool_choice: None,
reasoning_content: None,
};
let out = provider.convert_request(req);
let msg = &out.messages[0];

View File

@@ -283,6 +283,7 @@ impl Node for AiNode {
stream: false,
tools: None,
tool_choice: None,
reasoning_content: None,
};
tracing::info!(
@@ -513,6 +514,7 @@ impl Node for AiSelfReviewNode {
stream: false,
tools: None,
tool_choice: None,
reasoning_content: None,
};
tracing::info!(
@@ -924,6 +926,7 @@ mod tests {
stream: false,
tools: None,
tool_choice: None,
reasoning_content: None,
};
let response = provider.complete(request).await.expect("GLM 调用失败");
assert!(!response.text.is_empty(), "GLM 返回空文本");