优化: token分项显示(in/cache/out/reasoning)+ 详情面板 + base前置

token分项(各计费不同,不显 total):df-ai 解析 provider cache/reasoning(openai_compat prompt_cache_hit/miss/reasoning_tokens + anthropic cache_read/creation)+ TokenUsage 加字段(全构造点)+ AiMessage/AiCompleted/DB V39(ai_messages 加 cache_hit/miss/reasoning 列)+ message_repo 映射(持久化)+ 前端 MessageList 显 in·cache·out·reason(in=cache_miss 全价,reasoning 有才显)+ 点击 token 弹详情面板(完整 usage+缓存命中率+model)+ df-miniapp 同步

base前置(提升 prompt cache 命中率):chat.rs aug 拼 base 后(4处)+ knowledge_inject 知识拼 base 后(固定 base 前缀,cache 命中)

附修:replace_conversation 原 13 列 INSERT 丢消息级 token → 改 18 列
This commit is contained in:
lxy
2026-08-03 01:22:30 +08:00
parent 864c696b70
commit a031521776
25 changed files with 563 additions and 45 deletions
@@ -61,6 +61,7 @@ pub async fn try_continue_agent_loop(
};
let ev = AiChatEvent::AiCompleted {
total_tokens: 0, prompt_tokens: 0, completion_tokens: 0,
prompt_cache_hit_tokens: 0, prompt_cache_miss_tokens: 0, reasoning_tokens: 0,
incomplete: None,
conversation_id: Some(emit_conv_id),
pinned_goals: snap.pinned_goals_snapshot.clone(),
@@ -111,6 +112,7 @@ pub async fn try_continue_agent_loop(
if !still_generating {
let ev = AiChatEvent::AiCompleted {
total_tokens: 0, prompt_tokens: 0, completion_tokens: 0,
prompt_cache_hit_tokens: 0, prompt_cache_miss_tokens: 0, reasoning_tokens: 0,
incomplete: None,
conversation_id: Some(conv_id_owned.clone()),
pinned_goals: snap.pinned_goals_snapshot.clone(),
+78 -3
View File
@@ -1116,6 +1116,9 @@ pub(crate) async fn run_agentic_loop(
));
merge_msg.prompt_tokens = Some(0);
merge_msg.completion_tokens = Some(0);
merge_msg.prompt_cache_hit_tokens = Some(0);
merge_msg.prompt_cache_miss_tokens = Some(0);
merge_msg.reasoning_tokens = Some(0);
session.conv(&conv_id).messages.push(merge_msg);
if !merge_result.conflicts.is_empty() {
let mut conflict_msg = ChatMessage::assistant(&format!(
@@ -1128,6 +1131,9 @@ pub(crate) async fn run_agentic_loop(
));
conflict_msg.prompt_tokens = Some(0);
conflict_msg.completion_tokens = Some(0);
conflict_msg.prompt_cache_hit_tokens = Some(0);
conflict_msg.prompt_cache_miss_tokens = Some(0);
conflict_msg.reasoning_tokens = Some(0);
session.conv(&conv_id).messages.push(conflict_msg);
}
}
@@ -1139,6 +1145,9 @@ pub(crate) async fn run_agentic_loop(
prompt_tokens: tokens.prompt(),
completion_tokens: tokens.completion(),
total_tokens: tokens.total(),
prompt_cache_hit_tokens: tokens.cache_hit(),
prompt_cache_miss_tokens: tokens.cache_miss(),
reasoning_tokens: tokens.reasoning(),
};
finish_round_exit(
&session_arc, &db, &conv_id,
@@ -1176,6 +1185,9 @@ pub(crate) async fn run_agentic_loop(
prompt_tokens: tokens.prompt(),
completion_tokens: tokens.completion(),
total_tokens: tokens.total(),
prompt_cache_hit_tokens: tokens.cache_hit(),
prompt_cache_miss_tokens: tokens.cache_miss(),
reasoning_tokens: tokens.reasoning(),
};
// 入口 stop:本轮可能尚未 stream(首轮即停),不记 model——避免把未实际生成的 model 写入 models 数组
// 统一走 finish_round_exit:save(Some usage, None model) + spawn_title + emit(None,None,publish=true)
@@ -1548,8 +1560,13 @@ pub(crate) async fn run_agentic_loop(
prompt_tokens: if round_usage.prompt_tokens == 0 { estimated_prompt } else { round_usage.prompt_tokens },
completion_tokens: round_usage.completion_tokens,
total_tokens: if round_usage.prompt_tokens == 0 { estimated_prompt + round_usage.completion_tokens } else { round_usage.total_tokens },
// 分项 token(2026-08-02):cache/reasoning 透传自 round_usage,落库 + 累加器都需
prompt_cache_hit_tokens: round_usage.prompt_cache_hit_tokens,
prompt_cache_miss_tokens: round_usage.prompt_cache_miss_tokens,
reasoning_tokens: round_usage.reasoning_tokens,
};
tokens.add(usage.prompt_tokens, usage.completion_tokens);
// 累加本轮全量 usage(含 cache/reasoning 分项)到 tokens 累加器
tokens.add_usage(&usage);
// 追加 partial assistant 消息(若无 tool_calls 且有文本)
// 退出校验改 conv 存在性 + push 改 per_conv.messages。
@@ -1571,8 +1588,12 @@ pub(crate) async fn run_agentic_loop(
msg.reasoning_content = round_reasoning_content.clone();
// 消息级 token(对齐 push_assistant_message 双轨持久化):本轮 partial usage
// (prompt=round 或 estimated 兜底,completion=round)。系统提示消息无 token,不设。
// 分项 token(2026-08-02):cache/reasoning 透传自 round_usage。
msg.prompt_tokens = Some(usage.prompt_tokens);
msg.completion_tokens = Some(usage.completion_tokens);
msg.prompt_cache_hit_tokens = Some(usage.prompt_cache_hit_tokens);
msg.prompt_cache_miss_tokens = Some(usage.prompt_cache_miss_tokens);
msg.reasoning_tokens = Some(usage.reasoning_tokens);
conv.messages.push(msg);
// 追加系统提示消息:响应因网络中断不完整(对齐决策 a1 系统提示机制)
let mut notice = ChatMessage::system("⚠ 响应因网络中断不完整,以上为已接收的部分内容。可重新发送以获取完整回复。");
@@ -1590,6 +1611,9 @@ pub(crate) async fn run_agentic_loop(
prompt_tokens: tokens.prompt(),
completion_tokens: tokens.completion(),
total_tokens: usage.total_tokens,
prompt_cache_hit_tokens: tokens.cache_hit(),
prompt_cache_miss_tokens: tokens.cache_miss(),
reasoning_tokens: tokens.reasoning(),
};
finish_round_exit(
&session_arc, &db, &conv_id,
@@ -1608,9 +1632,23 @@ pub(crate) async fn run_agentic_loop(
// stream 后不再立即释放(会话级并发语义:工具执行期间也占槽)。per-provider permit
// (_provider_permit)仍在 candidate 循环内随作用域 Drop 自动释放。
// 累加本轮 token:provider 流式 usage 的 prompt_tokens 为 0 时(GLM 等),用预估输入兜底
// 累加本轮 token:provider 真实 usage。GLM 等流式不报 completion_tokens 为 0,如实反映(不预估)。
// 分项 token(2026-08-02):构造本轮完整 usage(prompt 用 estimated 兜底,cache/reasoning 透传 round),
// add_usage 一次性累加 prompt/completion/cache/reasoning 到 tokens 累加器。
let round_prompt = if round_usage.prompt_tokens == 0 { estimated_prompt } else { round_usage.prompt_tokens };
tokens.add(round_prompt, round_usage.completion_tokens);
let round_usage_full = df_ai::provider::TokenUsage {
prompt_tokens: round_prompt,
completion_tokens: round_usage.completion_tokens,
total_tokens: if round_usage.prompt_tokens == 0 {
estimated_prompt.saturating_add(round_usage.completion_tokens)
} else {
round_usage.total_tokens
},
prompt_cache_hit_tokens: round_usage.prompt_cache_hit_tokens,
prompt_cache_miss_tokens: round_usage.prompt_cache_miss_tokens,
reasoning_tokens: round_usage.reasoning_tokens,
};
tokens.add_usage(&round_usage_full);
// 追加 assistant 消息到历史 + G1 目标提取(扁平重构,原嵌套 8 层 → 3 层)
let has_tool_calls = !tool_calls_acc.is_empty();
@@ -1627,6 +1665,8 @@ pub(crate) async fn run_agentic_loop(
&mut session, &conv_id, has_tool_calls, &tool_calls_acc,
&full_text, &resolved_model, &last_reasoning_content,
round_prompt, round_usage.completion_tokens,
round_usage.prompt_cache_hit_tokens, round_usage.prompt_cache_miss_tokens,
round_usage.reasoning_tokens,
);
if GOAL_PIN_ENABLED {
update_pinned_goals(&mut session, &conv_id, &tool_calls_acc);
@@ -1642,6 +1682,9 @@ pub(crate) async fn run_agentic_loop(
prompt_tokens: tokens.prompt(),
completion_tokens: tokens.completion(),
total_tokens: tokens.total(),
prompt_cache_hit_tokens: tokens.cache_hit(),
prompt_cache_miss_tokens: tokens.cache_miss(),
reasoning_tokens: tokens.reasoning(),
};
save_conversation(&session_arc, &db, &conv_id, Some(&usage), Some(&resolved_model), true).await;
}
@@ -1652,6 +1695,9 @@ pub(crate) async fn run_agentic_loop(
prompt_tokens: tokens.prompt(),
completion_tokens: tokens.completion(),
total_tokens: tokens.total(),
prompt_cache_hit_tokens: tokens.cache_hit(),
prompt_cache_miss_tokens: tokens.cache_miss(),
reasoning_tokens: tokens.reasoning(),
};
// 统一走 finish_round_exit:save(Some usage, Some model) + spawn_title + emit(None,None,publish=true)
finish_round_exit(
@@ -1722,6 +1768,9 @@ pub(crate) async fn run_agentic_loop(
prompt_tokens: tokens.prompt(),
completion_tokens: tokens.completion(),
total_tokens: tokens.total(),
prompt_cache_hit_tokens: tokens.cache_hit(),
prompt_cache_miss_tokens: tokens.cache_miss(),
reasoning_tokens: tokens.reasoning(),
};
save_conversation(&session_arc, &db, &conv_id, Some(&usage), Some(&resolved_model), true).await;
// 审批等待 return 前 disarm guard——保持 generating=true 留 try_continue 续生成,
@@ -1750,6 +1799,9 @@ pub(crate) async fn run_agentic_loop(
prompt_tokens: tokens.prompt(),
completion_tokens: tokens.completion(),
total_tokens: tokens.total(),
prompt_cache_hit_tokens: tokens.cache_hit(),
prompt_cache_miss_tokens: tokens.cache_miss(),
reasoning_tokens: tokens.reasoning(),
};
// 统一走 finish_round_exit:save(Some usage, Some model) + 不 spawn_title(对齐原无 title) +
// emit(Some(true), publish=false)。**do_publish=false 保留原 max_iterations 不 publish 行为**
@@ -1773,6 +1825,9 @@ pub(crate) async fn run_agentic_loop(
prompt_tokens: tokens.prompt(),
completion_tokens: tokens.completion(),
total_tokens: tokens.total(),
prompt_cache_hit_tokens: tokens.cache_hit(),
prompt_cache_miss_tokens: tokens.cache_miss(),
reasoning_tokens: tokens.reasoning(),
};
// 落库 + 标题 + 知识提炼打包后台化:不阻塞 generating 复位与 Completed 事件
// save 先行(extract/title 都读已落库消息);extract 内部 fire-and-forget,与 title 可能并发
@@ -1806,6 +1861,9 @@ pub(crate) async fn run_agentic_loop(
prompt_tokens: tokens.prompt(),
completion_tokens: tokens.completion(),
total_tokens: usage_total,
prompt_cache_hit_tokens: tokens.cache_hit(),
prompt_cache_miss_tokens: tokens.cache_miss(),
reasoning_tokens: tokens.reasoning(),
};
emit_ai_completed_once(
&app_handle, &conv_id, &normal_usage,
@@ -2248,12 +2306,16 @@ async fn emit_ai_completed_once(
pinned_goals: &[super::GoalEntry],
) {
// emit 端(前端通道):incomplete 用 emit_incomplete(MidStream 传 Some(true))。
// token 分项(2026-08-02):cache_hit/cache_miss/reasoning 透传前端分计费展示。
let _ = app_handle.emit(
"ai-chat-event",
AiChatEvent::AiCompleted {
total_tokens: usage.total_tokens,
prompt_tokens: usage.prompt_tokens,
completion_tokens: usage.completion_tokens,
prompt_cache_hit_tokens: usage.prompt_cache_hit_tokens,
prompt_cache_miss_tokens: usage.prompt_cache_miss_tokens,
reasoning_tokens: usage.reasoning_tokens,
incomplete: emit_incomplete,
conversation_id: Some(conv_id.to_string()),
pinned_goals: pinned_goals.to_vec(),
@@ -2267,6 +2329,9 @@ async fn emit_ai_completed_once(
total_tokens: usage.total_tokens,
prompt_tokens: usage.prompt_tokens,
completion_tokens: usage.completion_tokens,
prompt_cache_hit_tokens: usage.prompt_cache_hit_tokens,
prompt_cache_miss_tokens: usage.prompt_cache_miss_tokens,
reasoning_tokens: usage.reasoning_tokens,
incomplete: publish_incomplete,
conversation_id: Some(conv_id.to_string()),
pinned_goals: pinned_goals.to_vec(),
@@ -2335,6 +2400,7 @@ async fn finish_round_exit(
// 入参 session 需外层调用方持锁;本函数只做纯内存 mutate,无 await/emit,不会死锁。
// prompt_tokens/completion_tokens: 本轮 LLM 调用 token 用量(消息级持久化,解 reload/压缩/切会话后
// 历史 assistant 消息 token 不显)。两构造分支都设。
// 分项 token(2026-08-02):cache_hit/cache_miss/reasoning 透传自 round_usage,前端分计费展示。
fn push_assistant_message(
session: &mut AiSession,
conv_id: &str,
@@ -2345,6 +2411,9 @@ fn push_assistant_message(
last_reasoning_content: &Option<String>,
prompt_tokens: u32,
completion_tokens: u32,
cache_hit: u32,
cache_miss: u32,
reasoning: u32,
) {
if has_tool_calls {
let mut order: Vec<u32> = tool_calls_acc.keys().copied().collect();
@@ -2368,6 +2437,9 @@ fn push_assistant_message(
msg.reasoning_content = last_reasoning_content.clone();
msg.prompt_tokens = Some(prompt_tokens);
msg.completion_tokens = Some(completion_tokens);
msg.prompt_cache_hit_tokens = Some(cache_hit);
msg.prompt_cache_miss_tokens = Some(cache_miss);
msg.reasoning_tokens = Some(reasoning);
session.conv(conv_id).messages.push(msg);
} else if !full_text.is_empty() {
let mut msg = ChatMessage::assistant(full_text);
@@ -2375,6 +2447,9 @@ fn push_assistant_message(
msg.reasoning_content = last_reasoning_content.clone();
msg.prompt_tokens = Some(prompt_tokens);
msg.completion_tokens = Some(completion_tokens);
msg.prompt_cache_hit_tokens = Some(cache_hit);
msg.prompt_cache_miss_tokens = Some(cache_miss);
msg.reasoning_tokens = Some(reasoning);
session.conv(conv_id).messages.push(msg);
}
}
+16 -5
View File
@@ -303,7 +303,7 @@ pub async fn ai_regenerate(
// 若未来需从末条 user 消息的 mentionSpans resolve(验证 #8),改此处传入即可。
let aug_seg = resolve_and_inject(&state, &provider_config, &None, &None, &lang).await;
if !aug_seg.is_empty() {
system_prompt = format!("{}\n\n---\n{}", aug_seg, system_prompt);
system_prompt = format!("{}\n\n---\n{}", system_prompt, aug_seg);
}
// 落库:弹出后的历史先持久化(前端立即反映已删旧回复;loop 内再 save 覆盖)
@@ -469,7 +469,7 @@ pub async fn ai_chat_send(
// 隔离标注(build_augmentation_segment 头尾包裹,FR-S4 风格)防 prompt injection 与用户指令/行为准则混淆。
let aug_seg = resolve_and_inject(&state, &provider_config, &skill, &mention_spans, &lang).await;
if !aug_seg.is_empty() {
system_prompt = format!("{}\n\n---\n{}", aug_seg, system_prompt);
system_prompt = format!("{}\n\n---\n{}", system_prompt, aug_seg);
}
// conv_id 已在上方状态占用块得出(入参/active/懒创建),供知识注入溯源 + spawn 后台 loop。
@@ -1524,7 +1524,7 @@ pub async fn ai_chat_edit(
// Augmentation 注入:edit 路径无新 @ mention / 技能,传 None 走空路径(不污染 prompt)。
let aug_seg = resolve_and_inject(&state, &provider_config, &None, &None, &lang).await;
if !aug_seg.is_empty() {
system_prompt = format!("{}\n\n---\n{}", aug_seg, system_prompt);
system_prompt = format!("{}\n\n---\n{}", system_prompt, aug_seg);
}
// 落库:编辑+截断后的历史先持久化(前端立即反映已截断旧回复)
@@ -1683,6 +1683,9 @@ pub async fn ai_chat_force_send(
total_tokens: 0,
prompt_tokens: 0,
completion_tokens: 0,
prompt_cache_hit_tokens: 0,
prompt_cache_miss_tokens: 0,
reasoning_tokens: 0,
incomplete: None,
conversation_id: Some(cid.clone()),
pinned_goals: old_pinned_goals.clone(),
@@ -1701,7 +1704,7 @@ pub async fn ai_chat_force_send(
// Augmentation 注入:/ 技能 + @ mention 经统一 Resolver 投影(语义同 ai_chat_send)。
let aug_seg = resolve_and_inject(&state, &provider_config, &skill, &mention_spans, &lang).await;
if !aug_seg.is_empty() {
system_prompt = format!("{}\n\n---\n{}", aug_seg, system_prompt);
system_prompt = format!("{}\n\n---\n{}", system_prompt, aug_seg);
}
// conv_id 已在上方状态占用块得出。
// 知识注入:DRY(B):收敛至 inject_knowledge_into_prompt 单一入口(同消息取 text+id,②口径修复)。
@@ -1790,7 +1793,9 @@ pub async fn ai_chat_stop(
}
drop(session);
let ev = AiChatEvent::AiCompleted {
total_tokens: 0, prompt_tokens: 0, completion_tokens: 0, incomplete: None,
total_tokens: 0, prompt_tokens: 0, completion_tokens: 0,
prompt_cache_hit_tokens: 0, prompt_cache_miss_tokens: 0, reasoning_tokens: 0,
incomplete: None,
conversation_id: Some(target),
pinned_goals,
};
@@ -1855,6 +1860,9 @@ pub async fn ai_chat_stop(
total_tokens: 0,
prompt_tokens: 0,
completion_tokens: 0,
prompt_cache_hit_tokens: 0,
prompt_cache_miss_tokens: 0,
reasoning_tokens: 0,
incomplete: None,
conversation_id: conv_id,
pinned_goals,
@@ -1970,6 +1978,9 @@ pub async fn ai_stop_loop(
total_tokens: 0,
prompt_tokens: 0,
completion_tokens: 0,
prompt_cache_hit_tokens: 0,
prompt_cache_miss_tokens: 0,
reasoning_tokens: 0,
incomplete: None,
conversation_id: Some(conversation_id),
pinned_goals,
@@ -103,6 +103,9 @@ pub fn record_to_message(rec: &AiMessageRecord) -> ChatMessage {
timestamp: rec.timestamp,
prompt_tokens: rec.prompt_tokens,
completion_tokens: rec.completion_tokens,
prompt_cache_hit_tokens: rec.prompt_cache_hit_tokens,
prompt_cache_miss_tokens: rec.prompt_cache_miss_tokens,
reasoning_tokens: rec.reasoning_tokens,
}
}
@@ -149,6 +152,9 @@ pub fn message_to_record(
created_at: created_at.to_string(),
prompt_tokens: msg.prompt_tokens,
completion_tokens: msg.completion_tokens,
prompt_cache_hit_tokens: msg.prompt_cache_hit_tokens,
prompt_cache_miss_tokens: msg.prompt_cache_miss_tokens,
reasoning_tokens: msg.reasoning_tokens,
}
}
@@ -780,6 +786,11 @@ mod tests {
status: None,
reasoning_content: None,
timestamp: Some(1700000000000),
prompt_tokens: None,
completion_tokens: None,
prompt_cache_hit_tokens: None,
prompt_cache_miss_tokens: None,
reasoning_tokens: None,
}
}
+32
View File
@@ -25,10 +25,16 @@ const SAVE_CONVERSATION_TIMEOUT_SECS: u64 = 5;
///
/// 纯结构 + 方法:抽自 run_agentic_loop 的 `total_prompt`/`total_completion` 双计数器,
/// 保证多轮累加、None 起始、跨 loop 实例叠加语义一致且可单测。
///
/// token 分项显示(2026-08-02):新增 cache_hit/cache_miss/reasoning 三计数器,
/// 各轮 provider usage 累加,emit AiCompleted 时透传前端做分计费展示。
#[derive(Debug, Clone, Default)]
pub(crate) struct TokenAccumulator {
prompt: u32,
completion: u32,
cache_hit: u32,
cache_miss: u32,
reasoning: u32,
}
impl TokenAccumulator {
@@ -40,6 +46,15 @@ impl TokenAccumulator {
self.completion = self.completion.saturating_add(completion);
}
/// 叠加一轮全量用量(含 cache/reasoning 分项)。
/// 调用方传 round TokenUsage,本方法拆分 prompt/completion/cache/reasoning 累加。
pub(crate) fn add_usage(&mut self, u: &df_ai::provider::TokenUsage) {
self.add(u.prompt_tokens, u.completion_tokens);
self.cache_hit = self.cache_hit.saturating_add(u.prompt_cache_hit_tokens);
self.cache_miss = self.cache_miss.saturating_add(u.prompt_cache_miss_tokens);
self.reasoning = self.reasoning.saturating_add(u.reasoning_tokens);
}
pub(crate) fn prompt(&self) -> u32 {
self.prompt
}
@@ -51,6 +66,18 @@ impl TokenAccumulator {
pub(crate) fn total(&self) -> u32 {
self.prompt.saturating_add(self.completion)
}
pub(crate) fn cache_hit(&self) -> u32 {
self.cache_hit
}
pub(crate) fn cache_miss(&self) -> u32 {
self.cache_miss
}
pub(crate) fn reasoning(&self) -> u32 {
self.reasoning
}
}
/// 把单轮增量叠加到 DB 的 Option<i64> 字段(读旧值+增量,跨 loop 实例防覆盖)
@@ -611,6 +638,11 @@ mod tests {
model: None,
status: None,
reasoning_content: None,
prompt_tokens: None,
completion_tokens: None,
prompt_cache_hit_tokens: None,
prompt_cache_miss_tokens: None,
reasoning_tokens: None,
timestamp: None,
}
}
@@ -405,7 +405,8 @@ pub(crate) async fn inject_knowledge_into_prompt(
if knowledge_context.is_empty() {
system_prompt
} else {
format!("{}\n\n---\n{}", knowledge_context, system_prompt)
// 知识拼 system_prompt 后(固定 base 前置,提升 provider prompt cache 命中率)
format!("{}\n\n---\n{}", system_prompt, knowledge_context)
}
}
+7
View File
@@ -158,6 +158,13 @@ pub enum AiChatEvent {
total_tokens: u32,
prompt_tokens: u32,
completion_tokens: u32,
/// 缓存命中 token(低价,deepseek prompt_cache_hit / anthropic cache_read)。
/// token 分项显示(2026-08-02):前端 in=miss+cache=hit 分计费展示。0=非 cache provider。
prompt_cache_hit_tokens: u32,
/// 未命中 token(全价真实输入)。前端 in 显示用此字段(非 prompt_tokens 总)。
prompt_cache_miss_tokens: u32,
/// 思考 token(deepseek-reasoner/o1 reasoning_tokens,隐藏输出)。0=非 reasoning 模型。
reasoning_tokens: u32,
/// 不完整标记(可选):Some(true)=网络中断保文,None 或 Some(false)=完整回复
incomplete: Option<bool>,
conversation_id: Option<String>,
+3
View File
@@ -68,6 +68,9 @@ pub(crate) async fn ensure_conversation_title(
reasoning_content: m.reasoning_content.clone(),
prompt_tokens: None,
completion_tokens: None,
prompt_cache_hit_tokens: None,
prompt_cache_miss_tokens: None,
reasoning_tokens: None,
timestamp: m.timestamp,
})
.collect();