feat(llm): 添加 tracing 日志与 ContentField 扩展

为 OpenAI 消息类型引入 ContentField 以支持 string 和 array 两种 content 格式,新增 reasoning_content 字段;添加 tracing 日志初始化函数及请求
/响应日志;修正多处文件末尾换行与 import 顺序。
This commit is contained in:
徐涛
2026-05-14 09:00:22 +08:00
parent e22c176643
commit 28635e28d5
12 changed files with 141 additions and 62 deletions
+4
View File
@@ -11,3 +11,7 @@ serde_json = "1"
thiserror = "2"
async-trait = "0.1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
[dev-dependencies]
dotenvy = "0.15.7"
+15
View File
@@ -4,3 +4,18 @@
//! 提示词工程、记忆系统、工具调用、Agent 运行时等领域。
pub mod llm;
use tracing_subscriber::{EnvFilter, fmt, prelude::*};
static INIT: std::sync::Once = std::sync::Once::new();
pub fn init_tracing() {
INIT.call_once(|| {
let filter =
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("agcore=info"));
tracing_subscriber::registry()
.with(fmt::layer())
.with(filter)
.init();
});
}
+5 -2
View File
@@ -107,7 +107,9 @@ impl LlmCycle {
let mut messages = self.messages.clone();
if let Some(sys_prompt) = &self.system_prompt
&& !messages.iter().any(|m| matches!(m, OpenaiChatMessage::System { .. }))
&& !messages
.iter()
.any(|m| matches!(m, OpenaiChatMessage::System { .. }))
{
messages.insert(0, OpenaiChatMessage::system_text(sys_prompt));
}
@@ -116,7 +118,8 @@ impl LlmCycle {
None
} else {
Some(
tools.iter()
tools
.iter()
.map(|t| OpenaiTool::Function {
function: t.clone(),
})
+3 -1
View File
@@ -1 +1,3 @@
pub use crate::llm::types::usage::{CompletionTokensDetails, CostTracker, PromptTokensDetails, Usage, Usage as LlmUsage};
pub use crate::llm::types::usage::{
CompletionTokensDetails, CostTracker, PromptTokensDetails, Usage, Usage as LlmUsage,
};
+19 -6
View File
@@ -2,10 +2,11 @@ use std::time::Duration;
use async_trait::async_trait;
use reqwest::Client;
use tracing::{debug, error, info};
use super::LlmProvider;
use crate::llm::error::LlmError;
use crate::llm::types::{ChatRequest, ChatResponse, OpenaiChatResponse};
use super::LlmProvider;
pub struct OpenaiProvider {
http_client: Client,
@@ -50,6 +51,8 @@ impl LlmProvider for OpenaiProvider {
async fn chat(&self, request: ChatRequest) -> Result<ChatResponse, LlmError> {
let url = format!("{}/chat/completions", self.base_url.trim_end_matches('/'));
info!(model = %request.model, max_tokens = request.max_tokens, temperature = request.temperature, "发送 LLM 请求");
let response = self
.http_client
.post(&url)
@@ -57,7 +60,10 @@ impl LlmProvider for OpenaiProvider {
.json(&request)
.send()
.await
.map_err(Self::map_reqwest_error)?;
.map_err(|e| {
error!(error = %e, "请求失败");
Self::map_reqwest_error(e)
})?;
let status = response.status();
let status_code: u16 = status.as_u16();
@@ -71,6 +77,8 @@ impl LlmProvider for OpenaiProvider {
.map(Duration::from_secs);
let body_text = response.text().await.unwrap_or_default();
error!(status = status_code, body = %body_text, "请求失败");
return match status_code {
401 => Err(LlmError::Authentication(body_text)),
429 => Err(LlmError::RateLimit { retry_after }),
@@ -91,10 +99,15 @@ impl LlmProvider for OpenaiProvider {
};
}
let chat_response: OpenaiChatResponse = response
.json()
.await
.map_err(|e| LlmError::Other(format!("响应解析失败: {}", e)))?;
let body_text = response.text().await.unwrap_or_default();
debug!(body = %body_text, "收到响应体");
let chat_response: OpenaiChatResponse = serde_json::from_str(&body_text).map_err(|e| {
error!(error = %e, body = %body_text, "响应解析失败");
LlmError::Other(format!("响应解析失败: {}", e))
})?;
debug!(response = ?chat_response, "收到 LLM 响应");
Ok(ChatResponse::from(chat_response))
}
+55 -12
View File
@@ -1,6 +1,46 @@
use crate::llm::types::shared::{AudioFormat, ImageDetail};
use crate::llm::types::tool::OpenaiToolCall;
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone)]
pub enum ContentField {
String(String),
Array(Vec<OpenaiContentPart>),
}
impl<'de> Deserialize<'de> for ContentField {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = Value::deserialize(deserializer)?;
match value {
Value::String(s) => Ok(ContentField::String(s)),
Value::Array(arr) => {
let parts: Result<Vec<OpenaiContentPart>, _> =
serde_json::from_value(Value::Array(arr));
match parts {
Ok(parts) => Ok(ContentField::Array(parts)),
Err(e) => Err(serde::de::Error::custom(e)),
}
}
_ => Err(serde::de::Error::custom("content must be string or array")),
}
}
}
impl Serialize for ContentField {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
match self {
ContentField::String(s) => s.serialize(serializer),
ContentField::Array(arr) => arr.serialize(serializer),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImageURL {
@@ -50,22 +90,24 @@ pub enum OpenaiContentPart {
#[serde(rename_all = "snake_case", tag = "role")]
pub enum OpenaiChatMessage {
Developer {
content: Vec<OpenaiContentPart>,
content: ContentField,
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
},
System {
content: Vec<OpenaiContentPart>,
content: ContentField,
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
},
User {
content: Vec<OpenaiContentPart>,
content: ContentField,
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
},
Assistant {
content: Vec<OpenaiContentPart>,
content: ContentField,
#[serde(skip_serializing_if = "Option::is_none")]
reasoning_content: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
refusal: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -74,11 +116,11 @@ pub enum OpenaiChatMessage {
tool_calls: Option<Vec<OpenaiToolCall>>,
},
Tool {
content: Vec<OpenaiContentPart>,
content: ContentField,
tool_call_id: String,
},
Function {
content: Vec<OpenaiContentPart>,
content: ContentField,
name: String,
},
}
@@ -86,14 +128,15 @@ pub enum OpenaiChatMessage {
impl OpenaiChatMessage {
pub fn user_text<S: Into<String>>(text: S) -> Self {
OpenaiChatMessage::User {
content: vec![OpenaiContentPart::Text { text: text.into() }],
content: ContentField::Array(vec![OpenaiContentPart::Text { text: text.into() }]),
name: None,
}
}
pub fn assistant_text<S: Into<String>>(text: S) -> Self {
OpenaiChatMessage::Assistant {
content: vec![OpenaiContentPart::Text { text: text.into() }],
content: ContentField::String(text.into()),
reasoning_content: None,
refusal: None,
name: None,
tool_calls: None,
@@ -102,23 +145,23 @@ impl OpenaiChatMessage {
pub fn system_text<S: Into<String>>(text: S) -> Self {
OpenaiChatMessage::System {
content: vec![OpenaiContentPart::Text { text: text.into() }],
content: ContentField::Array(vec![OpenaiContentPart::Text { text: text.into() }]),
name: None,
}
}
pub fn developer_text<S: Into<String>>(text: S) -> Self {
OpenaiChatMessage::Developer {
content: vec![OpenaiContentPart::Text { text: text.into() }],
content: ContentField::Array(vec![OpenaiContentPart::Text { text: text.into() }]),
name: None,
}
}
pub fn tool_result<S: Into<String>>(tool_call_id: String, content: S) -> Self {
OpenaiChatMessage::Tool {
content: vec![OpenaiContentPart::Text {
content: ContentField::Array(vec![OpenaiContentPart::Text {
text: content.into(),
}],
}]),
tool_call_id,
}
}
+6 -9
View File
@@ -6,16 +6,16 @@ pub mod tool;
pub mod usage;
pub use message::{
FileData, ImageURL, InputAudio, OpenaiChatMessage, OpenaiContentPart,
ContentField, FileData, ImageURL, InputAudio, OpenaiChatMessage, OpenaiContentPart,
};
pub use request::{OpenaiChatRequest, OpenaiTool, StreamOptions, ToolChoice};
pub use response::{
Annotation, Choice, ChunkChoice, Delta, Logprobs, OpenaiAudio,
OpenaiChatChunk, OpenaiChatResponse, TokenLogprob, TopLogprob, URLCitation,
Annotation, Choice, ChunkChoice, Delta, Logprobs, OpenaiAudio, OpenaiChatChunk,
OpenaiChatResponse, TokenLogprob, TopLogprob, URLCitation,
};
pub use shared::{
AudioFormat, FinishReason, ImageDetail, Modality, ResponseFormat, Role,
ServiceTier, StopSequence,
AudioFormat, FinishReason, ImageDetail, Modality, ResponseFormat, Role, ServiceTier,
StopSequence,
};
pub use tool::{FunctionCall, OpenaiToolCall, OpenaiToolDefinition};
pub use usage::{CompletionTokensDetails, CostTracker, PromptTokensDetails, Usage};
@@ -34,10 +34,7 @@ impl From<OpenaiChatResponse> for ChatResponse {
.first()
.map(|c| c.message.clone())
.unwrap_or_else(|| OpenaiChatMessage::assistant_text(""));
let stop_reason = response
.choices
.first()
.and_then(|c| c.finish_reason);
let stop_reason = response.choices.first().and_then(|c| c.finish_reason);
ChatResponse {
message,
usage: response.usage,
+23 -18
View File
@@ -1,7 +1,7 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::llm::types::shared::{ResponseFormat, ServiceTier, StopSequence};
use crate::llm::types::tool::OpenaiToolDefinition;
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StreamOptions {
@@ -16,12 +16,8 @@ pub enum ToolChoice {
None,
Auto,
Required,
Named {
name: String,
},
AllowedTools {
tool_names: Vec<String>,
},
Named { name: String },
AllowedTools { tool_names: Vec<String> },
}
impl Serialize for ToolChoice {
@@ -62,25 +58,36 @@ impl<'de> Deserialize<'de> for ToolChoice {
"none" => Ok(ToolChoice::None),
"auto" => Ok(ToolChoice::Auto),
"required" => Ok(ToolChoice::Required),
_ => Err(serde::de::Error::custom(format!("unknown tool choice: {s}"))),
_ => Err(serde::de::Error::custom(format!(
"unknown tool choice: {s}"
))),
},
Value::Object(obj) => {
let typ = obj.get("type").and_then(|v| v.as_str()).ok_or_else(|| {
serde::de::Error::custom("missing 'type' field in tool_choice")
})?;
if typ == "function" {
let func = obj.get("function").and_then(|v| v.as_object()).ok_or_else(|| {
serde::de::Error::custom("missing 'function' field in tool_choice")
})?;
let func =
obj.get("function")
.and_then(|v| v.as_object())
.ok_or_else(|| {
serde::de::Error::custom("missing 'function' field in tool_choice")
})?;
let name = func.get("name").and_then(|v| v.as_str()).ok_or_else(|| {
serde::de::Error::custom("missing 'function.name' in tool_choice")
})?;
Ok(ToolChoice::Named { name: name.to_string() })
Ok(ToolChoice::Named {
name: name.to_string(),
})
} else {
Err(serde::de::Error::custom(format!("unknown tool_choice type: {typ}")))
Err(serde::de::Error::custom(format!(
"unknown tool_choice type: {typ}"
)))
}
}
_ => Err(serde::de::Error::custom("tool_choice must be a string or object")),
_ => Err(serde::de::Error::custom(
"tool_choice must be a string or object",
)),
}
}
}
@@ -88,9 +95,7 @@ impl<'de> Deserialize<'de> for ToolChoice {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "type")]
pub enum OpenaiTool {
Function {
function: OpenaiToolDefinition,
},
Function { function: OpenaiToolDefinition },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
+2 -2
View File
@@ -1,8 +1,8 @@
use serde::{Deserialize, Serialize};
use crate::llm::types::shared::{FinishReason, ServiceTier};
use crate::llm::types::message::OpenaiChatMessage;
use crate::llm::types::shared::{FinishReason, ServiceTier};
use crate::llm::types::tool::OpenaiToolCall;
use crate::llm::types::usage::Usage;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TokenLogprob {
+1 -4
View File
@@ -20,8 +20,5 @@ pub struct FunctionCall {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "type")]
pub enum OpenaiToolCall {
Function {
id: String,
function: FunctionCall,
},
Function { id: String, function: FunctionCall },
}