fix(web): token 统计按 topic_id 聚合,避免同 session 多 topic 共享总和

batch_session_token_stats 重命名为 batch_topic_token_stats,SQL 从 GROUP BY session_id 改为 GROUP BY topic_id,修复同一 session 下多个 topic 显示相同 token 总和的问题。

子代理天然隔离:子代理消息的 topic_id 属于子代理自身的 topic,不在主 topic 列表中。topic_id IN (...) 自动排除 NULL topic_id 的旧消息。

对抗性审查补充:latest assistant 消息查询的 JOIN 条件 m.topic_id = latest.topic_id AND m.seq = latest.max_seq 可能匹配到子代理消息(seq 是 session 级递增,主/子 session 可能有相同 seq)。在外层 JOIN 添加 WHERE m.session_id NOT LIKE 'sub:%',避免重复行导致 stats.entry(tid) 被覆盖、结果不确定。
This commit is contained in:
oudecheng 2026-08-05 09:43:09 +08:00
parent 235f57c9ae
commit deab3699fe
2 changed files with 53 additions and 34 deletions

View File

@ -40,9 +40,10 @@ pub struct TopicSummary {
/// 构建 TopicSummary 列表的公共函数。 /// 构建 TopicSummary 列表的公共函数。
/// ///
/// 一次批量查询所有 topic 对应 session 的 token 统计,避免 N 次 RTT。 /// 一次批量查询所有 topic 的 token 统计,按 `topic_id` 聚合,
/// 按 `session_id` 聚合天然分离主 agent 与子 agent子代理 session_id 形如 /// 避免按 session_id 聚合时同 session 下多个 topic 共享同一总和。
/// `sub:...`,不在此查询的主 session_id 列表中)。 /// 子代理天然隔离:子代理消息的 topic_id 属于子代理自身的 topic
/// 不在主 topic 列表中。
/// ///
/// `context_window_tokens` 来自最新 assistant 消息记录LLM 调用时持久化), /// `context_window_tokens` 来自最新 assistant 消息记录LLM 调用时持久化),
/// 无需从配置链路注入,保持存储层与配置解耦。 /// 无需从配置链路注入,保持存储层与配置解耦。
@ -54,24 +55,24 @@ pub fn build_topic_summaries(
return Ok(Vec::new()); return Ok(Vec::new());
} }
// 收集所有 topic 的 session_id去重 // 收集所有 topic_id去重
let mut session_ids: Vec<String> = Vec::new(); let mut topic_ids: Vec<String> = Vec::new();
for t in &topics { for t in &topics {
if !session_ids.contains(&t.session_id) { if !topic_ids.contains(&t.id) {
session_ids.push(t.session_id.clone()); topic_ids.push(t.id.clone());
} }
} }
let session_id_refs: Vec<&str> = session_ids.iter().map(|s| s.as_str()).collect(); let topic_id_refs: Vec<&str> = topic_ids.iter().map(|s| s.as_str()).collect();
// 一次批量查询 token 统计 // 一次批量查询 token 统计(按 topic_id 聚合)
let stats_map: HashMap<String, SessionTokenStats> = store let stats_map: HashMap<String, SessionTokenStats> = store
.batch_session_token_stats(&session_id_refs) .batch_topic_token_stats(&topic_id_refs)
.map_err(|e| CommandError::new("TOKEN_STATS_ERROR", e.to_string()))?; .map_err(|e| CommandError::new("TOKEN_STATS_ERROR", e.to_string()))?;
let summaries = topics let summaries = topics
.into_iter() .into_iter()
.map(|t| { .map(|t| {
let token_stats = stats_map.get(&t.session_id).map(|s| TopicTokenStats { let token_stats = stats_map.get(&t.id).map(|s| TopicTokenStats {
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,
@ -140,8 +141,6 @@ async fn handle_list_topics(
.list_topics(&session_id) .list_topics(&session_id)
.map_err(|e| CommandError::new("LIST_TOPICS_ERROR", e.to_string()))?; .map_err(|e| CommandError::new("LIST_TOPICS_ERROR", e.to_string()))?;
// context_window_tokens 当前未从配置链路注入(前端可从已有 config 接口获取),
// 此处传 0 表示"后端不提供上限",前端按需隐藏百分比。
let summaries = build_topic_summaries(handler.store.as_ref(), topics)?; let summaries = build_topic_summaries(handler.store.as_ref(), topics)?;
let topics_json = serde_json::to_string(&summaries) let topics_json = serde_json::to_string(&summaries)

View File

@ -1630,32 +1630,44 @@ impl SessionStore {
.map_err(StorageError::from) .map_err(StorageError::from)
} }
/// 批量查询多个 session 的 token 消耗统计cost 累计 + context 瞬时)。 /// 批量查询多个 topic 的 token 消耗统计cost 累计 + context 瞬时)。
pub fn batch_session_token_stats( ///
/// 按 `topic_id` 聚合而非 `session_id`:一个 session 可包含多个 topic
/// 若按 session_id 聚合会导致同 session 下的所有 topic 显示相同的总和。
///
/// 子代理隔离:子代理消息持久化时 session_id='sub:...'topic_id=父 topic_id
/// (见 task::runtime PersistingEmittedMessageHandler 构造),因此不能仅靠
/// topic_id 隔离。此处用 `session_id NOT LIKE 'sub:%'` 显式排除子代理消息,
/// 与项目约定一致session 列表同样过滤 'sub:%')。子代理 token 不计入父 topic
/// 保持"子代理分别计算"语义。
pub fn batch_topic_token_stats(
&self, &self,
session_ids: &[&str], topic_ids: &[&str],
) -> Result<HashMap<String, SessionTokenStats>, StorageError> { ) -> Result<HashMap<String, SessionTokenStats>, StorageError> {
if session_ids.is_empty() { if topic_ids.is_empty() {
return Ok(HashMap::new()); return Ok(HashMap::new());
} }
let conn = self.pool.get()?; let conn = self.pool.get()?;
let placeholders = (0..session_ids.len()) let placeholders = (0..topic_ids.len())
.map(|i| format!("?{}", i + 1)) .map(|i| format!("?{}", i + 1))
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(", "); .join(", ");
// topic_id IN (...) 自动排除 NULL topic_id 的旧消息;
// session_id NOT LIKE 'sub:%' 排除子代理消息(其 topic_id=父 topic_id
let sum_sql = format!( let sum_sql = format!(
"SELECT session_id, \ "SELECT topic_id, \
COALESCE(SUM(prompt_tokens), 0) AS sum_prompt, \ COALESCE(SUM(prompt_tokens), 0) AS sum_prompt, \
COALESCE(SUM(completion_tokens), 0) AS sum_completion, \ COALESCE(SUM(completion_tokens), 0) AS sum_completion, \
COALESCE(SUM(total_tokens), 0) AS sum_total \ COALESCE(SUM(total_tokens), 0) AS sum_total \
FROM messages \ FROM messages \
WHERE session_id IN ({placeholders}) AND role = 'assistant' \ WHERE topic_id IN ({placeholders}) AND role = 'assistant' \
GROUP BY session_id" AND session_id NOT LIKE 'sub:%' \
GROUP BY topic_id"
); );
let mut stmt = conn.prepare(&sum_sql)?; let mut stmt = conn.prepare(&sum_sql)?;
let params: Vec<&dyn rusqlite::ToSql> = session_ids let params: Vec<&dyn rusqlite::ToSql> = topic_ids
.iter() .iter()
.map(|s| s as &dyn rusqlite::ToSql) .map(|s| s as &dyn rusqlite::ToSql)
.collect(); .collect();
@ -1674,26 +1686,34 @@ impl SessionStore {
let mut stats: HashMap<String, SessionTokenStats> = HashMap::new(); let mut stats: HashMap<String, SessionTokenStats> = HashMap::new();
for row in sum_rows { for row in sum_rows {
let (sid, s) = row?; let (tid, s) = row?;
stats.insert(sid, s); stats.insert(tid, s);
} }
// 查找每个 session 中最新的**有 usage 数据的** assistant 消息, // 查找每个 topic 中最新的**有 usage 数据的** assistant 消息,
// 读取其 prompt_tokens 和 context_window_tokens。 // 读取其 prompt_tokens 和 context_window_tokens。
// 过滤 prompt_tokens IS NOT NULL 确保跳过 error/cancel 消息usage 为 NULL // 过滤 prompt_tokens IS NOT NULL 确保跳过 error/cancel 消息usage 为 NULL
// session_id NOT LIKE 'sub:%' 排除子代理消息,避免取到子代理的 context_window。
//
// 注意seq 是 session 级递增(见 append_message_with_topic主 session 与
// 子代理 session 各自独立计数,可能存在相同 seq。外层 WHERE 必须再次过滤
// session_id NOT LIKE 'sub:%',否则 JOIN 会同时匹配主消息和子代理消息,
// 导致重复行并使 stats.entry(tid) 被覆盖,结果不确定。
let last_sql = format!( let last_sql = format!(
"SELECT m.session_id, m.prompt_tokens, m.context_window_tokens \ "SELECT m.topic_id, m.prompt_tokens, m.context_window_tokens \
FROM messages m \ FROM messages m \
INNER JOIN ( \ INNER JOIN ( \
SELECT session_id, MAX(seq) AS max_seq \ SELECT topic_id, MAX(seq) AS max_seq \
FROM messages \ FROM messages \
WHERE session_id IN ({placeholders}) AND role = 'assistant' \ WHERE topic_id IN ({placeholders}) AND role = 'assistant' \
AND prompt_tokens IS NOT NULL \ AND prompt_tokens IS NOT NULL \
GROUP BY session_id \ AND session_id NOT LIKE 'sub:%' \
) latest ON m.session_id = latest.session_id AND m.seq = latest.max_seq" GROUP BY topic_id \
) latest ON m.topic_id = latest.topic_id AND m.seq = latest.max_seq \
WHERE m.session_id NOT LIKE 'sub:%'"
); );
let mut stmt2 = conn.prepare(&last_sql)?; let mut stmt2 = conn.prepare(&last_sql)?;
let params2: Vec<&dyn rusqlite::ToSql> = session_ids let params2: Vec<&dyn rusqlite::ToSql> = topic_ids
.iter() .iter()
.map(|s| s as &dyn rusqlite::ToSql) .map(|s| s as &dyn rusqlite::ToSql)
.collect(); .collect();
@ -1705,8 +1725,8 @@ impl SessionStore {
)) ))
})?; })?;
for row in last_rows { for row in last_rows {
let (sid, last_prompt, last_ctx_window) = row?; let (tid, last_prompt, last_ctx_window) = row?;
let entry = stats.entry(sid).or_insert(SessionTokenStats { let entry = stats.entry(tid).or_insert(SessionTokenStats {
prompt_tokens: 0, prompt_tokens: 0,
completion_tokens: 0, completion_tokens: 0,
total_tokens: 0, total_tokens: 0,