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:
oudecheng 2026-08-15 19:21:51 +08:00
parent 82b6a882a2
commit b26a2c2512
14 changed files with 201 additions and 64 deletions

View File

@ -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,
}, },
} }
} }

View File

@ -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,
} }
} }

View File

@ -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),
}); });

View File

@ -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),
}); });

View File

@ -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!(

View File

@ -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(),
}, },
}) })
} }

View File

@ -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,
} }
/// 流式响应中的增量事件 /// 流式响应中的增量事件

View File

@ -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=0LLM 可见),通过 system_context='history_compaction*' // 压缩摘要消息 is_compacted=0LLM 可见),通过 system_context='history_compaction*'

View File

@ -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)?,
}) })
})?; })?;

View File

@ -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>,
} }

View File

@ -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)?,
}) })
} }

View File

@ -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 && (

View File

@ -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;
} }

View File

@ -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)]';
}