feat(token): 新增缓存占比统计,聚合查询收敛到共享列清单
- 解析: OpenAIUsage 支持 DeepSeek prompt_cache_hit_tokens + OpenAI prompt_tokens_details.cached_tokens fallback;Anthropic 接入 cache_read_input_tokens - 存储: messages 表新增 cached_tokens 列(幂等迁移),贯穿 INSERT/SELECT/聚合 - 解耦加固: 抽取 MESSAGE_LOAD_COLUMNS 收敛 6 处消息加载 SELECT;抽取 USAGE_SUM_COLUMNS + read_usage_sum_row 收敛 topic/session 两份重复 SUM 聚合 - 协议: SessionTokenStats/TopicTokenStats 新增 cached_tokens 字段 - 前端: 面板新增缓存命中行 + 缓存命中率进度条(命中率越高越绿,与 ctx 占用方向相反)
This commit is contained in:
parent
82b6a882a2
commit
b26a2c2512
@ -3353,6 +3353,7 @@ mod tests {
|
|||||||
prompt_tokens: 10,
|
prompt_tokens: 10,
|
||||||
completion_tokens: 10,
|
completion_tokens: 10,
|
||||||
total_tokens: 20,
|
total_tokens: 20,
|
||||||
|
cached_tokens: 0,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -78,6 +78,10 @@ pub struct MessageUsage {
|
|||||||
pub prompt_tokens: u32,
|
pub prompt_tokens: u32,
|
||||||
pub completion_tokens: u32,
|
pub completion_tokens: u32,
|
||||||
pub total_tokens: u32,
|
pub total_tokens: u32,
|
||||||
|
/// 输入中命中服务端缓存的 tokens 数(DeepSeek/Anthropic 等)。
|
||||||
|
/// 用于计算缓存命中率,反映成本节省效率。
|
||||||
|
#[serde(default)]
|
||||||
|
pub cached_tokens: u32,
|
||||||
/// 本次调用所用模型的上下文窗口大小(来自 AgentRuntimeConfig)。
|
/// 本次调用所用模型的上下文窗口大小(来自 AgentRuntimeConfig)。
|
||||||
/// 与 prompt_tokens 一起持久化,用于计算上下文占用率。
|
/// 与 prompt_tokens 一起持久化,用于计算上下文占用率。
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
@ -90,6 +94,7 @@ impl MessageUsage {
|
|||||||
prompt_tokens: u.prompt_tokens,
|
prompt_tokens: u.prompt_tokens,
|
||||||
completion_tokens: u.completion_tokens,
|
completion_tokens: u.completion_tokens,
|
||||||
total_tokens: u.total_tokens,
|
total_tokens: u.total_tokens,
|
||||||
|
cached_tokens: u.cached_tokens,
|
||||||
context_window_tokens: None,
|
context_window_tokens: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,6 +19,8 @@ pub struct TopicTokenStats {
|
|||||||
pub prompt_tokens: u64,
|
pub prompt_tokens: u64,
|
||||||
pub completion_tokens: u64,
|
pub completion_tokens: u64,
|
||||||
pub total_tokens: u64,
|
pub total_tokens: u64,
|
||||||
|
/// 累计缓存命中的输入 tokens 数(老数据为 0)
|
||||||
|
pub cached_tokens: u64,
|
||||||
pub last_prompt_tokens: Option<u32>,
|
pub last_prompt_tokens: Option<u32>,
|
||||||
pub context_window_tokens: u32,
|
pub context_window_tokens: u32,
|
||||||
}
|
}
|
||||||
@ -76,6 +78,7 @@ pub fn build_topic_summaries(
|
|||||||
prompt_tokens: s.prompt_tokens,
|
prompt_tokens: s.prompt_tokens,
|
||||||
completion_tokens: s.completion_tokens,
|
completion_tokens: s.completion_tokens,
|
||||||
total_tokens: s.total_tokens,
|
total_tokens: s.total_tokens,
|
||||||
|
cached_tokens: s.cached_tokens,
|
||||||
last_prompt_tokens: s.last_prompt_tokens,
|
last_prompt_tokens: s.last_prompt_tokens,
|
||||||
context_window_tokens: s.context_window_tokens.unwrap_or(0),
|
context_window_tokens: s.context_window_tokens.unwrap_or(0),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -110,6 +110,7 @@ async fn handle_load_task_messages(
|
|||||||
prompt_tokens: s.prompt_tokens,
|
prompt_tokens: s.prompt_tokens,
|
||||||
completion_tokens: s.completion_tokens,
|
completion_tokens: s.completion_tokens,
|
||||||
total_tokens: s.total_tokens,
|
total_tokens: s.total_tokens,
|
||||||
|
cached_tokens: s.cached_tokens,
|
||||||
last_prompt_tokens: s.last_prompt_tokens,
|
last_prompt_tokens: s.last_prompt_tokens,
|
||||||
context_window_tokens: s.context_window_tokens.unwrap_or(0),
|
context_window_tokens: s.context_window_tokens.unwrap_or(0),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -248,6 +248,9 @@ enum AnthropicContent {
|
|||||||
struct AnthropicUsage {
|
struct AnthropicUsage {
|
||||||
input_tokens: u32,
|
input_tokens: u32,
|
||||||
output_tokens: u32,
|
output_tokens: u32,
|
||||||
|
/// 从服务端缓存读取的输入 tokens 数(命中缓存部分)
|
||||||
|
#[serde(default)]
|
||||||
|
cache_read_input_tokens: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@ -388,6 +391,7 @@ impl LLMProvider for AnthropicProvider {
|
|||||||
prompt_tokens: anthropic_resp.usage.input_tokens,
|
prompt_tokens: anthropic_resp.usage.input_tokens,
|
||||||
completion_tokens: anthropic_resp.usage.output_tokens,
|
completion_tokens: anthropic_resp.usage.output_tokens,
|
||||||
total_tokens: anthropic_resp.usage.input_tokens + anthropic_resp.usage.output_tokens,
|
total_tokens: anthropic_resp.usage.input_tokens + anthropic_resp.usage.output_tokens,
|
||||||
|
cached_tokens: anthropic_resp.usage.cache_read_input_tokens.unwrap_or(0),
|
||||||
};
|
};
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
|
|||||||
@ -134,11 +134,13 @@ impl StreamingAccumulator {
|
|||||||
prompt_tokens: u.prompt_tokens,
|
prompt_tokens: u.prompt_tokens,
|
||||||
completion_tokens: u.completion_tokens,
|
completion_tokens: u.completion_tokens,
|
||||||
total_tokens: u.total_tokens,
|
total_tokens: u.total_tokens,
|
||||||
|
cached_tokens: u.cached_tokens(),
|
||||||
})
|
})
|
||||||
.unwrap_or(Usage {
|
.unwrap_or(Usage {
|
||||||
prompt_tokens: 0,
|
prompt_tokens: 0,
|
||||||
completion_tokens: 0,
|
completion_tokens: 0,
|
||||||
total_tokens: 0,
|
total_tokens: 0,
|
||||||
|
cached_tokens: 0,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -726,6 +728,7 @@ impl OpenAIProvider {
|
|||||||
prompt_tokens: openai_resp.usage.prompt_tokens,
|
prompt_tokens: openai_resp.usage.prompt_tokens,
|
||||||
completion_tokens: openai_resp.usage.completion_tokens,
|
completion_tokens: openai_resp.usage.completion_tokens,
|
||||||
total_tokens: openai_resp.usage.total_tokens,
|
total_tokens: openai_resp.usage.total_tokens,
|
||||||
|
cached_tokens: openai_resp.usage.cached_tokens(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1077,6 +1080,32 @@ struct OpenAIUsage {
|
|||||||
completion_tokens: u32,
|
completion_tokens: u32,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
total_tokens: u32,
|
total_tokens: u32,
|
||||||
|
/// DeepSeek 原生缓存字段:本次请求输入中命中缓存的 tokens 数
|
||||||
|
#[serde(default)]
|
||||||
|
prompt_cache_hit_tokens: Option<u32>,
|
||||||
|
/// OpenAI 兼容嵌套字段:prompt_tokens_details.cached_tokens
|
||||||
|
#[serde(default)]
|
||||||
|
prompt_tokens_details: Option<OpenAIPromptTokensDetails>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Default, Clone, Debug)]
|
||||||
|
struct OpenAIPromptTokensDetails {
|
||||||
|
#[serde(default)]
|
||||||
|
cached_tokens: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OpenAIUsage {
|
||||||
|
/// 缓存命中的输入 tokens 数。
|
||||||
|
/// 两种 API 形态互斥:优先 DeepSeek 顶层字段,回退 OpenAI 嵌套字段。
|
||||||
|
fn cached_tokens(&self) -> u32 {
|
||||||
|
self.prompt_cache_hit_tokens
|
||||||
|
.or_else(|| {
|
||||||
|
self.prompt_tokens_details
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|d| d.cached_tokens)
|
||||||
|
})
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@ -1238,6 +1267,7 @@ impl LLMProvider for OpenAIProvider {
|
|||||||
prompt_tokens: openai_resp.usage.prompt_tokens,
|
prompt_tokens: openai_resp.usage.prompt_tokens,
|
||||||
completion_tokens: openai_resp.usage.completion_tokens,
|
completion_tokens: openai_resp.usage.completion_tokens,
|
||||||
total_tokens: openai_resp.usage.total_tokens,
|
total_tokens: openai_resp.usage.total_tokens,
|
||||||
|
cached_tokens: openai_resp.usage.cached_tokens(),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -136,6 +136,10 @@ pub struct Usage {
|
|||||||
pub prompt_tokens: u32,
|
pub prompt_tokens: u32,
|
||||||
pub completion_tokens: u32,
|
pub completion_tokens: u32,
|
||||||
pub total_tokens: u32,
|
pub total_tokens: u32,
|
||||||
|
/// 输入中命中服务端缓存的 tokens 数(DeepSeek prompt_cache_hit_tokens /
|
||||||
|
/// OpenAI prompt_tokens_details.cached_tokens)。不支持缓存的 provider 为 0。
|
||||||
|
#[serde(default)]
|
||||||
|
pub cached_tokens: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 流式响应中的增量事件
|
/// 流式响应中的增量事件
|
||||||
|
|||||||
@ -73,6 +73,13 @@ pub(super) fn ensure_messages_schema(conn: &Connection) -> Result<(), StorageErr
|
|||||||
"ALTER TABLE messages ADD COLUMN context_window_tokens INTEGER",
|
"ALTER TABLE messages ADD COLUMN context_window_tokens INTEGER",
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
|
// 缓存命中的输入 tokens 数(老数据为 NULL,聚合时 COALESCE 为 0)
|
||||||
|
if !has_column(conn, "messages", "cached_tokens")? {
|
||||||
|
add_column_if_missing(
|
||||||
|
conn,
|
||||||
|
"ALTER TABLE messages ADD COLUMN cached_tokens INTEGER",
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
// is_compacted: 1 表示该消息是被压缩消费掉的原始消息(前端可见、LLM 不可见)。
|
// is_compacted: 1 表示该消息是被压缩消费掉的原始消息(前端可见、LLM 不可见)。
|
||||||
// 压缩摘要消息 is_compacted=0(LLM 可见),通过 system_context='history_compaction*'
|
// 压缩摘要消息 is_compacted=0(LLM 可见),通过 system_context='history_compaction*'
|
||||||
|
|||||||
@ -111,6 +111,7 @@ impl SessionStore {
|
|||||||
completion_tokens INTEGER,
|
completion_tokens INTEGER,
|
||||||
total_tokens INTEGER,
|
total_tokens INTEGER,
|
||||||
context_window_tokens INTEGER,
|
context_window_tokens INTEGER,
|
||||||
|
cached_tokens INTEGER,
|
||||||
created_at INTEGER NOT NULL,
|
created_at INTEGER NOT NULL,
|
||||||
FOREIGN KEY(session_id) REFERENCES sessions(id) ON DELETE CASCADE,
|
FOREIGN KEY(session_id) REFERENCES sessions(id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY(topic_id) REFERENCES topics(id) ON DELETE SET NULL,
|
FOREIGN KEY(topic_id) REFERENCES topics(id) ON DELETE SET NULL,
|
||||||
@ -648,8 +649,8 @@ impl SessionStore {
|
|||||||
"
|
"
|
||||||
INSERT INTO messages (
|
INSERT INTO messages (
|
||||||
id, session_id, topic_id, seq, role, content,
|
id, session_id, topic_id, seq, role, content,
|
||||||
system_context, reasoning_content, media_refs_json, tool_call_id, tool_name, tool_calls_json, tool_duration_ms, prompt_tokens, completion_tokens, total_tokens, context_window_tokens, created_at
|
system_context, reasoning_content, media_refs_json, tool_call_id, tool_name, tool_calls_json, tool_duration_ms, prompt_tokens, completion_tokens, total_tokens, context_window_tokens, cached_tokens, created_at
|
||||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)
|
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)
|
||||||
",
|
",
|
||||||
params![
|
params![
|
||||||
message.id,
|
message.id,
|
||||||
@ -669,6 +670,7 @@ impl SessionStore {
|
|||||||
message.usage.as_ref().map(|u| u.completion_tokens as i64),
|
message.usage.as_ref().map(|u| u.completion_tokens as i64),
|
||||||
message.usage.as_ref().map(|u| u.total_tokens as i64),
|
message.usage.as_ref().map(|u| u.total_tokens as i64),
|
||||||
message.usage.as_ref().and_then(|u| u.context_window_tokens.map(|v| v as i64)),
|
message.usage.as_ref().and_then(|u| u.context_window_tokens.map(|v| v as i64)),
|
||||||
|
message.usage.as_ref().map(|u| u.cached_tokens as i64),
|
||||||
message.timestamp,
|
message.timestamp,
|
||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
@ -730,8 +732,8 @@ impl SessionStore {
|
|||||||
INSERT INTO messages (
|
INSERT INTO messages (
|
||||||
id, session_id, topic_id, seq, role, content,
|
id, session_id, topic_id, seq, role, content,
|
||||||
system_context, reasoning_content, media_refs_json,
|
system_context, reasoning_content, media_refs_json,
|
||||||
tool_call_id, tool_name, tool_calls_json, tool_duration_ms, prompt_tokens, completion_tokens, total_tokens, context_window_tokens, created_at
|
tool_call_id, tool_name, tool_calls_json, tool_duration_ms, prompt_tokens, completion_tokens, total_tokens, context_window_tokens, cached_tokens, created_at
|
||||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)
|
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)
|
||||||
",
|
",
|
||||||
params![
|
params![
|
||||||
message.id,
|
message.id,
|
||||||
@ -751,6 +753,7 @@ impl SessionStore {
|
|||||||
message.usage.as_ref().map(|u| u.completion_tokens as i64),
|
message.usage.as_ref().map(|u| u.completion_tokens as i64),
|
||||||
message.usage.as_ref().map(|u| u.total_tokens as i64),
|
message.usage.as_ref().map(|u| u.total_tokens as i64),
|
||||||
message.usage.as_ref().and_then(|u| u.context_window_tokens.map(|v| v as i64)),
|
message.usage.as_ref().and_then(|u| u.context_window_tokens.map(|v| v as i64)),
|
||||||
|
message.usage.as_ref().map(|u| u.cached_tokens as i64),
|
||||||
message.timestamp,
|
message.timestamp,
|
||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
@ -1707,14 +1710,14 @@ impl SessionStore {
|
|||||||
let conn = self.pool.get()?;
|
let conn = self.pool.get()?;
|
||||||
|
|
||||||
if let Some(sid) = session_id {
|
if let Some(sid) = session_id {
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(&format!(
|
||||||
"
|
"
|
||||||
SELECT id, role, content, system_context, reasoning_content, media_refs_json, created_at, tool_call_id, tool_name, tool_calls_json, tool_duration_ms, prompt_tokens, completion_tokens, total_tokens, context_window_tokens
|
SELECT {MESSAGE_LOAD_COLUMNS}
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE topic_id = ?1 AND session_id = ?2 AND is_compacted = 0
|
WHERE topic_id = ?1 AND session_id = ?2 AND is_compacted = 0
|
||||||
ORDER BY seq ASC
|
ORDER BY seq ASC
|
||||||
",
|
",
|
||||||
)?;
|
))?;
|
||||||
let rows = stmt.query_map(params![topic_id, sid], map_chat_message_row)?;
|
let rows = stmt.query_map(params![topic_id, sid], map_chat_message_row)?;
|
||||||
let mut messages = Vec::new();
|
let mut messages = Vec::new();
|
||||||
for row in rows {
|
for row in rows {
|
||||||
@ -1722,14 +1725,14 @@ impl SessionStore {
|
|||||||
}
|
}
|
||||||
Ok(messages)
|
Ok(messages)
|
||||||
} else {
|
} else {
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(&format!(
|
||||||
"
|
"
|
||||||
SELECT id, role, content, system_context, reasoning_content, media_refs_json, created_at, tool_call_id, tool_name, tool_calls_json, tool_duration_ms, prompt_tokens, completion_tokens, total_tokens, context_window_tokens
|
SELECT {MESSAGE_LOAD_COLUMNS}
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE topic_id = ?1 AND is_compacted = 0
|
WHERE topic_id = ?1 AND is_compacted = 0
|
||||||
ORDER BY seq ASC
|
ORDER BY seq ASC
|
||||||
",
|
",
|
||||||
)?;
|
))?;
|
||||||
let rows = stmt.query_map(params![topic_id], map_chat_message_row)?;
|
let rows = stmt.query_map(params![topic_id], map_chat_message_row)?;
|
||||||
let mut messages = Vec::new();
|
let mut messages = Vec::new();
|
||||||
for row in rows {
|
for row in rows {
|
||||||
@ -1750,15 +1753,15 @@ impl SessionStore {
|
|||||||
let conn = self.pool.get()?;
|
let conn = self.pool.get()?;
|
||||||
|
|
||||||
if let Some(sid) = session_id {
|
if let Some(sid) = session_id {
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(&format!(
|
||||||
"
|
"
|
||||||
SELECT id, role, content, system_context, reasoning_content, media_refs_json, created_at, tool_call_id, tool_name, tool_calls_json, tool_duration_ms, prompt_tokens, completion_tokens, total_tokens, context_window_tokens
|
SELECT {MESSAGE_LOAD_COLUMNS}
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE topic_id = ?1 AND session_id = ?2
|
WHERE topic_id = ?1 AND session_id = ?2
|
||||||
AND (system_context IS NULL OR system_context NOT LIKE 'history_compaction%')
|
AND (system_context IS NULL OR system_context NOT LIKE 'history_compaction%')
|
||||||
ORDER BY seq ASC
|
ORDER BY seq ASC
|
||||||
",
|
",
|
||||||
)?;
|
))?;
|
||||||
let rows = stmt.query_map(params![topic_id, sid], map_chat_message_row)?;
|
let rows = stmt.query_map(params![topic_id, sid], map_chat_message_row)?;
|
||||||
let mut messages = Vec::new();
|
let mut messages = Vec::new();
|
||||||
for row in rows {
|
for row in rows {
|
||||||
@ -1766,15 +1769,15 @@ impl SessionStore {
|
|||||||
}
|
}
|
||||||
Ok(messages)
|
Ok(messages)
|
||||||
} else {
|
} else {
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(&format!(
|
||||||
"
|
"
|
||||||
SELECT id, role, content, system_context, reasoning_content, media_refs_json, created_at, tool_call_id, tool_name, tool_calls_json, tool_duration_ms, prompt_tokens, completion_tokens, total_tokens, context_window_tokens
|
SELECT {MESSAGE_LOAD_COLUMNS}
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE topic_id = ?1
|
WHERE topic_id = ?1
|
||||||
AND (system_context IS NULL OR system_context NOT LIKE 'history_compaction%')
|
AND (system_context IS NULL OR system_context NOT LIKE 'history_compaction%')
|
||||||
ORDER BY seq ASC
|
ORDER BY seq ASC
|
||||||
",
|
",
|
||||||
)?;
|
))?;
|
||||||
let rows = stmt.query_map(params![topic_id], map_chat_message_row)?;
|
let rows = stmt.query_map(params![topic_id], map_chat_message_row)?;
|
||||||
let mut messages = Vec::new();
|
let mut messages = Vec::new();
|
||||||
for row in rows {
|
for row in rows {
|
||||||
@ -1842,12 +1845,10 @@ impl SessionStore {
|
|||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(", ");
|
.join(", ");
|
||||||
// topic_id IN (...) 自动排除 NULL topic_id 的旧消息;
|
// topic_id IN (...) 自动排除 NULL topic_id 的旧消息;
|
||||||
// session_id NOT LIKE 'sub:%' 排除子代理消息(其 topic_id=父 topic_id)
|
// session_id NOT LIKE 'sub:%' 排除子代理消息(其 topic_id=父 topic_id)。
|
||||||
|
// SUM 列清单与行映射见 USAGE_SUM_COLUMNS / read_usage_sum_row(共享于子代理查询)。
|
||||||
let sum_sql = format!(
|
let sum_sql = format!(
|
||||||
"SELECT topic_id, \
|
"SELECT topic_id, {USAGE_SUM_COLUMNS} \
|
||||||
COALESCE(SUM(prompt_tokens), 0) AS sum_prompt, \
|
|
||||||
COALESCE(SUM(completion_tokens), 0) AS sum_completion, \
|
|
||||||
COALESCE(SUM(total_tokens), 0) AS sum_total \
|
|
||||||
FROM messages \
|
FROM messages \
|
||||||
WHERE topic_id IN ({placeholders}) AND role = 'assistant' \
|
WHERE topic_id IN ({placeholders}) AND role = 'assistant' \
|
||||||
AND session_id NOT LIKE 'sub:%' \
|
AND session_id NOT LIKE 'sub:%' \
|
||||||
@ -1860,16 +1861,7 @@ impl SessionStore {
|
|||||||
.map(|s| s as &dyn rusqlite::ToSql)
|
.map(|s| s as &dyn rusqlite::ToSql)
|
||||||
.collect();
|
.collect();
|
||||||
let sum_rows = stmt.query_map(params.as_slice(), |row| {
|
let sum_rows = stmt.query_map(params.as_slice(), |row| {
|
||||||
Ok((
|
Ok((row.get::<_, String>(0)?, read_usage_sum_row(row, 1)?))
|
||||||
row.get::<_, String>(0)?,
|
|
||||||
SessionTokenStats {
|
|
||||||
prompt_tokens: row.get::<_, i64>(1)? as u64,
|
|
||||||
completion_tokens: row.get::<_, i64>(2)? as u64,
|
|
||||||
total_tokens: row.get::<_, i64>(3)? as u64,
|
|
||||||
last_prompt_tokens: None,
|
|
||||||
context_window_tokens: None,
|
|
||||||
},
|
|
||||||
))
|
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let mut stats: HashMap<String, SessionTokenStats> = HashMap::new();
|
let mut stats: HashMap<String, SessionTokenStats> = HashMap::new();
|
||||||
@ -1918,6 +1910,7 @@ impl SessionStore {
|
|||||||
prompt_tokens: 0,
|
prompt_tokens: 0,
|
||||||
completion_tokens: 0,
|
completion_tokens: 0,
|
||||||
total_tokens: 0,
|
total_tokens: 0,
|
||||||
|
cached_tokens: 0,
|
||||||
last_prompt_tokens: None,
|
last_prompt_tokens: None,
|
||||||
context_window_tokens: None,
|
context_window_tokens: None,
|
||||||
});
|
});
|
||||||
@ -1942,25 +1935,22 @@ impl SessionStore {
|
|||||||
) -> Result<Option<SessionTokenStats>, StorageError> {
|
) -> Result<Option<SessionTokenStats>, StorageError> {
|
||||||
let conn = self.pool.get()?;
|
let conn = self.pool.get()?;
|
||||||
|
|
||||||
// 1. SUM 查询:累计 prompt/completion/total
|
// 1. SUM 查询:累计 prompt/completion/total/cached
|
||||||
let sum_sql = "SELECT \
|
// 列清单与行映射复用 USAGE_SUM_COLUMNS / read_usage_sum_row(与 topic 聚合共享)
|
||||||
COALESCE(SUM(prompt_tokens), 0), \
|
let sum_sql = format!(
|
||||||
COALESCE(SUM(completion_tokens), 0), \
|
"SELECT {USAGE_SUM_COLUMNS} \
|
||||||
COALESCE(SUM(total_tokens), 0) \
|
|
||||||
FROM messages \
|
FROM messages \
|
||||||
WHERE session_id = ?1 AND role = 'assistant'";
|
WHERE session_id = ?1 AND role = 'assistant'"
|
||||||
let mut stmt = conn.prepare(sum_sql)?;
|
);
|
||||||
let sum_row = stmt.query_row(params![session_id], |row| {
|
let mut stmt = conn.prepare(&sum_sql)?;
|
||||||
Ok((
|
let sum_stats = stmt.query_row(params![session_id], |row| read_usage_sum_row(row, 0))?;
|
||||||
row.get::<_, i64>(0)? as u64,
|
|
||||||
row.get::<_, i64>(1)? as u64,
|
|
||||||
row.get::<_, i64>(2)? as u64,
|
|
||||||
))
|
|
||||||
})?;
|
|
||||||
let (prompt_tokens, completion_tokens, total_tokens) = sum_row;
|
|
||||||
|
|
||||||
// 无 assistant 消息时直接返回 None
|
// 无 assistant 消息时直接返回 None
|
||||||
if total_tokens == 0 && prompt_tokens == 0 && completion_tokens == 0 {
|
if sum_stats.total_tokens == 0
|
||||||
|
&& sum_stats.prompt_tokens == 0
|
||||||
|
&& sum_stats.completion_tokens == 0
|
||||||
|
&& sum_stats.cached_tokens == 0
|
||||||
|
{
|
||||||
// 需要二次确认是否真的没有 assistant 消息(usage 全 0 也可能是合法的)
|
// 需要二次确认是否真的没有 assistant 消息(usage 全 0 也可能是合法的)
|
||||||
let count_sql =
|
let count_sql =
|
||||||
"SELECT COUNT(*) FROM messages WHERE session_id = ?1 AND role = 'assistant'";
|
"SELECT COUNT(*) FROM messages WHERE session_id = ?1 AND role = 'assistant'";
|
||||||
@ -1988,9 +1978,10 @@ impl SessionStore {
|
|||||||
};
|
};
|
||||||
|
|
||||||
Ok(Some(SessionTokenStats {
|
Ok(Some(SessionTokenStats {
|
||||||
prompt_tokens,
|
prompt_tokens: sum_stats.prompt_tokens,
|
||||||
completion_tokens,
|
completion_tokens: sum_stats.completion_tokens,
|
||||||
total_tokens,
|
total_tokens: sum_stats.total_tokens,
|
||||||
|
cached_tokens: sum_stats.cached_tokens,
|
||||||
last_prompt_tokens,
|
last_prompt_tokens,
|
||||||
context_window_tokens,
|
context_window_tokens,
|
||||||
}))
|
}))
|
||||||
@ -2365,14 +2356,14 @@ fn load_messages_between(
|
|||||||
start_seq_exclusive: i64,
|
start_seq_exclusive: i64,
|
||||||
end_seq_inclusive: i64,
|
end_seq_inclusive: i64,
|
||||||
) -> Result<Vec<ChatMessage>, StorageError> {
|
) -> Result<Vec<ChatMessage>, StorageError> {
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(&format!(
|
||||||
"
|
"
|
||||||
SELECT id, role, content, system_context, reasoning_content, media_refs_json, created_at, tool_call_id, tool_name, tool_calls_json, tool_duration_ms, prompt_tokens, completion_tokens, total_tokens, context_window_tokens
|
SELECT {MESSAGE_LOAD_COLUMNS}
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE session_id = ?1 AND seq > ?2 AND seq <= ?3
|
WHERE session_id = ?1 AND seq > ?2 AND seq <= ?3
|
||||||
ORDER BY seq ASC
|
ORDER BY seq ASC
|
||||||
",
|
",
|
||||||
)?;
|
))?;
|
||||||
|
|
||||||
let rows = stmt.query_map(
|
let rows = stmt.query_map(
|
||||||
params![session_id, start_seq_exclusive, end_seq_inclusive],
|
params![session_id, start_seq_exclusive, end_seq_inclusive],
|
||||||
@ -2413,7 +2404,7 @@ fn load_messages_between(
|
|||||||
tool_state: None,
|
tool_state: None,
|
||||||
tool_duration_ms: row.get::<_, Option<i64>>(10)?.map(|v| v as u64),
|
tool_duration_ms: row.get::<_, Option<i64>>(10)?.map(|v| v as u64),
|
||||||
tool_calls,
|
tool_calls,
|
||||||
usage: map_usage_row(row, 11, 12, 13, 14)?,
|
usage: map_usage_row(row, 11, 12, 13, 14, 15)?,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
)?;
|
)?;
|
||||||
@ -2430,14 +2421,14 @@ fn load_messages_after(
|
|||||||
session_id: &str,
|
session_id: &str,
|
||||||
cutoff_seq: i64,
|
cutoff_seq: i64,
|
||||||
) -> Result<Vec<ChatMessage>, StorageError> {
|
) -> Result<Vec<ChatMessage>, StorageError> {
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(&format!(
|
||||||
"
|
"
|
||||||
SELECT id, role, content, system_context, reasoning_content, media_refs_json, created_at, tool_call_id, tool_name, tool_calls_json, tool_duration_ms, prompt_tokens, completion_tokens, total_tokens, context_window_tokens
|
SELECT {MESSAGE_LOAD_COLUMNS}
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE session_id = ?1 AND seq > ?2
|
WHERE session_id = ?1 AND seq > ?2
|
||||||
ORDER BY seq ASC
|
ORDER BY seq ASC
|
||||||
",
|
",
|
||||||
)?;
|
))?;
|
||||||
|
|
||||||
let rows = stmt.query_map(params![session_id, cutoff_seq], |row| {
|
let rows = stmt.query_map(params![session_id, cutoff_seq], |row| {
|
||||||
let media_refs_json: String = row.get(5)?;
|
let media_refs_json: String = row.get(5)?;
|
||||||
@ -2475,7 +2466,7 @@ fn load_messages_after(
|
|||||||
tool_state: None,
|
tool_state: None,
|
||||||
tool_duration_ms: row.get::<_, Option<i64>>(10)?.map(|v| v as u64),
|
tool_duration_ms: row.get::<_, Option<i64>>(10)?.map(|v| v as u64),
|
||||||
tool_calls,
|
tool_calls,
|
||||||
usage: map_usage_row(row, 11, 12, 13, 14)?,
|
usage: map_usage_row(row, 11, 12, 13, 14, 15)?,
|
||||||
})
|
})
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
|||||||
@ -145,6 +145,8 @@ pub struct SessionTokenStats {
|
|||||||
pub prompt_tokens: u64,
|
pub prompt_tokens: u64,
|
||||||
pub completion_tokens: u64,
|
pub completion_tokens: u64,
|
||||||
pub total_tokens: u64,
|
pub total_tokens: u64,
|
||||||
|
/// 累计缓存命中的输入 tokens 数(老数据为 0)
|
||||||
|
pub cached_tokens: u64,
|
||||||
pub last_prompt_tokens: Option<u32>,
|
pub last_prompt_tokens: Option<u32>,
|
||||||
pub context_window_tokens: Option<u32>,
|
pub context_window_tokens: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,33 +12,69 @@ use crate::bus::message::MessageUsage;
|
|||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
MemoryRecord, SchedulerJobRecord, SchedulerJobState, SchedulerJobStatus, SessionRecord,
|
MemoryRecord, SchedulerJobRecord, SchedulerJobState, SchedulerJobStatus, SessionRecord,
|
||||||
SkillEventRecord, StorageError,
|
SessionTokenStats, SkillEventRecord, StorageError,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// 从指定列索引读取 token usage 四元组(含 context_window_tokens)。
|
/// 消息加载查询的共享列清单(列序与 map_chat_message_row / map_usage_row 的下标一一对应)。
|
||||||
|
/// 新增 usage 列时只需改这里 + map_usage_row,无需逐条 SELECT 手工对齐。
|
||||||
|
pub(super) const MESSAGE_LOAD_COLUMNS: &str = "id, role, content, system_context, reasoning_content, media_refs_json, created_at, tool_call_id, tool_name, tool_calls_json, tool_duration_ms, prompt_tokens, completion_tokens, total_tokens, context_window_tokens, cached_tokens";
|
||||||
|
|
||||||
|
/// 从指定列索引读取 token usage 五元组(含 context_window_tokens、cached_tokens)。
|
||||||
pub(super) fn map_usage_row(
|
pub(super) fn map_usage_row(
|
||||||
row: &rusqlite::Row<'_>,
|
row: &rusqlite::Row<'_>,
|
||||||
prompt_idx: usize,
|
prompt_idx: usize,
|
||||||
completion_idx: usize,
|
completion_idx: usize,
|
||||||
total_idx: usize,
|
total_idx: usize,
|
||||||
context_window_idx: usize,
|
context_window_idx: usize,
|
||||||
|
cached_idx: usize,
|
||||||
) -> rusqlite::Result<Option<MessageUsage>> {
|
) -> rusqlite::Result<Option<MessageUsage>> {
|
||||||
let prompt: Option<i64> = row.get(prompt_idx)?;
|
let prompt: Option<i64> = row.get(prompt_idx)?;
|
||||||
let completion: Option<i64> = row.get(completion_idx)?;
|
let completion: Option<i64> = row.get(completion_idx)?;
|
||||||
let total: Option<i64> = row.get(total_idx)?;
|
let total: Option<i64> = row.get(total_idx)?;
|
||||||
let context_window: Option<i64> = row.get(context_window_idx)?;
|
let context_window: Option<i64> = row.get(context_window_idx)?;
|
||||||
if prompt.is_none() && completion.is_none() && total.is_none() && context_window.is_none() {
|
let cached: Option<i64> = row.get(cached_idx)?;
|
||||||
|
if prompt.is_none()
|
||||||
|
&& completion.is_none()
|
||||||
|
&& total.is_none()
|
||||||
|
&& context_window.is_none()
|
||||||
|
&& cached.is_none()
|
||||||
|
{
|
||||||
Ok(None)
|
Ok(None)
|
||||||
} else {
|
} else {
|
||||||
Ok(Some(MessageUsage {
|
Ok(Some(MessageUsage {
|
||||||
prompt_tokens: prompt.unwrap_or(0) as u32,
|
prompt_tokens: prompt.unwrap_or(0) as u32,
|
||||||
completion_tokens: completion.unwrap_or(0) as u32,
|
completion_tokens: completion.unwrap_or(0) as u32,
|
||||||
total_tokens: total.unwrap_or(0) as u32,
|
total_tokens: total.unwrap_or(0) as u32,
|
||||||
|
cached_tokens: cached.unwrap_or(0) as u32,
|
||||||
context_window_tokens: context_window.map(|v| v as u32),
|
context_window_tokens: context_window.map(|v| v as u32),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// token 用量聚合的共享 SUM 列清单:batch_topic_token_stats 与
|
||||||
|
/// get_session_token_stats 共用。新增累计指标只需改这里 + read_usage_sum_row,
|
||||||
|
/// 避免两份聚合 SQL 手工对齐(shotgun surgery)。
|
||||||
|
pub(super) const USAGE_SUM_COLUMNS: &str = "COALESCE(SUM(prompt_tokens), 0), \
|
||||||
|
COALESCE(SUM(completion_tokens), 0), \
|
||||||
|
COALESCE(SUM(total_tokens), 0), \
|
||||||
|
COALESCE(SUM(cached_tokens), 0)";
|
||||||
|
|
||||||
|
/// 从聚合行读取累计 usage 字段(SUM 列从 offset 开始)。
|
||||||
|
/// last_* 瞬时字段不在 SUM 中,此处置 None,由调用方在 last 查询后回填。
|
||||||
|
pub(super) fn read_usage_sum_row(
|
||||||
|
row: &rusqlite::Row<'_>,
|
||||||
|
offset: usize,
|
||||||
|
) -> rusqlite::Result<SessionTokenStats> {
|
||||||
|
Ok(SessionTokenStats {
|
||||||
|
prompt_tokens: row.get::<_, i64>(offset)? as u64,
|
||||||
|
completion_tokens: row.get::<_, i64>(offset + 1)? as u64,
|
||||||
|
total_tokens: row.get::<_, i64>(offset + 2)? as u64,
|
||||||
|
cached_tokens: row.get::<_, i64>(offset + 3)? as u64,
|
||||||
|
last_prompt_tokens: None,
|
||||||
|
context_window_tokens: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn get_session_with_conn(
|
pub(super) fn get_session_with_conn(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
session_id: &str,
|
session_id: &str,
|
||||||
@ -172,7 +208,7 @@ pub(super) fn map_chat_message_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<
|
|||||||
tool_state: None,
|
tool_state: None,
|
||||||
tool_duration_ms: row.get::<_, Option<i64>>(10)?.map(|v| v as u64),
|
tool_duration_ms: row.get::<_, Option<i64>>(10)?.map(|v| v as u64),
|
||||||
tool_calls,
|
tool_calls,
|
||||||
usage: map_usage_row(row, 11, 12, 13, 14)?,
|
usage: map_usage_row(row, 11, 12, 13, 14, 15)?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,12 @@
|
|||||||
import { Coins } from 'lucide-react';
|
import { Coins } from 'lucide-react';
|
||||||
import type { TopicTokenStats } from '../../types/protocol';
|
import type { TopicTokenStats } from '../../types/protocol';
|
||||||
import { formatTokenCount, contextOccupancyPct, occupancyColor } from '../../utils/tokenStats';
|
import {
|
||||||
|
formatTokenCount,
|
||||||
|
contextOccupancyPct,
|
||||||
|
occupancyColor,
|
||||||
|
cacheHitRatePct,
|
||||||
|
cacheHitColor,
|
||||||
|
} from '../../utils/tokenStats';
|
||||||
|
|
||||||
interface TopicTokenStatsPanelProps {
|
interface TopicTokenStatsPanelProps {
|
||||||
tokenStats?: TopicTokenStats | null;
|
tokenStats?: TopicTokenStats | null;
|
||||||
@ -21,6 +27,7 @@ export function TopicTokenStatsPanel({ tokenStats }: TopicTokenStatsPanelProps)
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pct = contextOccupancyPct(tokenStats);
|
const pct = contextOccupancyPct(tokenStats);
|
||||||
|
const cachePct = cacheHitRatePct(tokenStats);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="shrink-0 border-b border-[var(--border-color)] p-3">
|
<div className="shrink-0 border-b border-[var(--border-color)] p-3">
|
||||||
@ -64,7 +71,37 @@ export function TopicTokenStatsPanel({ tokenStats }: TopicTokenStatsPanelProps)
|
|||||||
{formatTokenCount(tokenStats.context_window_tokens)}
|
{formatTokenCount(tokenStats.context_window_tokens)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex justify-between col-span-2">
|
||||||
|
<span className="text-[var(--text-muted)]">缓存命中</span>
|
||||||
|
<span className="text-[var(--text-secondary)] font-mono">
|
||||||
|
{formatTokenCount(tokenStats.cached_tokens ?? 0)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 缓存命中率 + 进度条(越高越省钱 → 绿) */}
|
||||||
|
{cachePct != null && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<div className="flex items-center justify-between text-xs mb-1">
|
||||||
|
<span className="text-[var(--text-muted)]">缓存命中率</span>
|
||||||
|
<span className={`font-mono font-medium ${cacheHitColor(cachePct)}`}>
|
||||||
|
cache {cachePct}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 rounded-full bg-[var(--overlay-subtle)] overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full transition-all ${
|
||||||
|
cachePct >= 50
|
||||||
|
? 'bg-[var(--accent-green)]'
|
||||||
|
: cachePct > 0
|
||||||
|
? 'bg-[var(--accent-amber)]'
|
||||||
|
: 'bg-[var(--overlay-subtle)]'
|
||||||
|
}`}
|
||||||
|
style={{ width: `${Math.max(cachePct, 1)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 上下文占用百分比 + 进度条 */}
|
{/* 上下文占用百分比 + 进度条 */}
|
||||||
{pct != null && (
|
{pct != null && (
|
||||||
|
|||||||
@ -153,6 +153,8 @@ export interface TopicTokenStats {
|
|||||||
prompt_tokens: number;
|
prompt_tokens: number;
|
||||||
completion_tokens: number;
|
completion_tokens: number;
|
||||||
total_tokens: number;
|
total_tokens: number;
|
||||||
|
/** 累计缓存命中的输入 tokens 数(老数据为 0) */
|
||||||
|
cached_tokens: number;
|
||||||
last_prompt_tokens?: number;
|
last_prompt_tokens?: number;
|
||||||
context_window_tokens: number;
|
context_window_tokens: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,9 +15,23 @@ export function contextOccupancyPct(stats: TopicTokenStats): number | null {
|
|||||||
return Math.min(100, Math.round((last / stats.context_window_tokens) * 100));
|
return Math.min(100, Math.round((last / stats.context_window_tokens) * 100));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 根据占用率返回颜色 class */
|
/** 根据占用率返回颜色 class(占用越高越危险 → 红) */
|
||||||
export function occupancyColor(pct: number): string {
|
export function occupancyColor(pct: number): string {
|
||||||
if (pct >= 80) return 'text-[rgb(242,90,90)]';
|
if (pct >= 80) return 'text-[rgb(242,90,90)]';
|
||||||
if (pct >= 50) return 'text-[var(--accent-amber)]';
|
if (pct >= 50) return 'text-[var(--accent-amber)]';
|
||||||
return 'text-[var(--accent-green)]';
|
return 'text-[var(--accent-green)]';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 计算累计缓存命中率:SUM(cached_tokens) / SUM(prompt_tokens),返回 0-100 */
|
||||||
|
export function cacheHitRatePct(stats: TopicTokenStats): number | null {
|
||||||
|
if (!stats.prompt_tokens || stats.prompt_tokens === 0) return null;
|
||||||
|
return Math.min(100, Math.round(((stats.cached_tokens ?? 0) / stats.prompt_tokens) * 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 缓存命中率颜色(与 ctx 占用方向相反:命中率越高越省钱 → 绿)。
|
||||||
|
* 0% 返回 muted 色(新话题首轮必然为 0,不算异常) */
|
||||||
|
export function cacheHitColor(pct: number): string {
|
||||||
|
if (pct <= 0) return 'text-[var(--text-muted)]';
|
||||||
|
if (pct >= 50) return 'text-[var(--accent-green)]';
|
||||||
|
return 'text-[var(--accent-amber)]';
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user