Files
smartmate/backend/pkg/token_budget.go
Losita d8280cc647 Version: 0.9.26.dev.260417
后端:
1. Prompt 层从 execute 专属骨架重构为全节点统一四段式 buildUnifiedStageMessages
  - 新增 unified_context.go:定义 StageMessagesConfig + buildUnifiedStageMessages 统一骨架,所有节点(Chat/Plan/Execute/Deliver/DeepAnswer)共用同一套 msg0~msg3 拼装逻辑
  - 新增 conversation_view.go:通用对话历史渲染 buildConversationHistoryMessage,各节点复用,不再各自维护提取逻辑
  - 新增 chat_context.go / plan_context.go / deliver_context.go:各节点自行渲染 msg1(对话视图)和 msg2(工作区),统一层只负责"怎么拼",不再替节点决定"放什么"
  - Chat/Plan/Deliver/Execute 的 BuildXXXMessages 全部从 buildStageMessages 切到 buildUnifiedStageMessages,移除旧路径
  - 删除 execute_pinned.go:execute 记忆渲染合并到统一层 renderUnifiedMemoryContext
  - Plan prompt 不再在 user prompt 中拼装任务类 ID 列表和 renderStateSummary,改为依赖 msg2 规划工作区;Chat 粗排判断从"上下文有任务类 ID"改为"批量调度需求"
  - Deliver prompt 新增 IsAborted/IsExhaustedTerminal 区分,支持粗排收口和主动终止场景
2. Execute ReAct 上下文简化——移除归档搬运、窗口裁剪和重复工具压缩
  - 移除 splitExecuteLoopRecordsByBoundary、findLatestExecuteBoundaryMarker、tailExecuteLoops、compressExecuteLoopObservationsByTool、buildEarlyExecuteReactSummary、trimExecuteMessage1ByBudget 等六个函数
  - 移除 executeLoopWindowLimit / executeConversationTurnLimit / executeMessage1MaxRunes 等预算常量
  - msg1 不再从历史中归档上一轮 ReAct 结果,只保留真实对话流(user + assistant speak),全量注入
  - msg2 不再按 loop_closed / step_advanced 边界切分"归档/活跃",直接全量注入全部 ReAct Loop 记录
  - token 预算由统一压缩层兜底,prompt 层不再做提前裁剪
3. 压缩层从 Execute 专属提升为全节点通用 UnifiedCompact
  - 删除 execute_compact.go(Execute 专属压缩文件)
  - 新增 unified_compact.go:UnifiedCompactInput 参数化,各节点(Plan/Chat/Deliver/Execute)构造时从自己的 NodeInput 提取公共字段,消除对 Execute 的直接依赖
  - CompactionStore 接口扩展 LoadStageCompaction / SaveStageCompaction,各节点按 stageKey 独立维护压缩状态互不覆盖
  - 非 4 段式消息时退化成按角色汇总统计,确保 context_token_stats 仍然刷新
4. Retry 重试机制全面下线
  - dao/agent.go:saveChatHistoryCore / SaveChatHistory / SaveChatHistoryInTx 移除 retry_group_id / retry_index /
  retry_from_user_message_id / retry_from_assistant_message_id 四个参数,修复乱码注释
  - dao/agent-cache.go:移除 ApplyRetrySeed 和 extractMessageHistoryID 两个方法
  - conv/agent.go:ToEinoMessages 不再回灌 retry_* 字段到运行期上下文
  - service/agentsvc/agent.go:移除 chatRetryMeta 及 resolveRetryGroupID / buildRetrySeed 等全部重试逻辑
  - service/agentsvc/agent_quick_note.go:整个文件删除(retry 快速补写路径已无用)
  - service/events/chat_history_persist.go:移除 retry 参数传递
5. 节点层瘦身 + 可见消息逐条持久化
  - agent_nodes.go 大幅简化:Chat/Plan/Execute/Deliver 节点方法移除 ToolSchema 注入、状态摘要渲染等逻辑,只做参数转发和状态落盘
  - 新增 visible_message.go:persistVisibleAssistantMessage 统一处理可见 assistant speak 的实时持久化,失败仅记日志不中断主流程
  - 新增 llm_debug.go:logNodeLLMContext 统一打印 LLM 上下文调试日志
  - graph_run_state.go 新增 PersistVisibleMessageFunc 类型 + AgentGraphDeps.PersistVisibleMessage 字段
  - service/agentsvc/agent_newagent.go 精简主循环,注入 PersistVisibleMessage 回调;agent_history.go 精简历史构建
  - token_budget.go 移除 Execute 专属预算检查,统一到通用预算

前端:
1. 移除 retry 相关 UI 和类型
  - agent.ts 移除 retry_group_id / retry_index / retry_total 字段及 normalize 逻辑
  - AssistantPanel.vue 移除 retry 相关 UI 和交互代码(约 700 行精简)
  - dashboard.ts 移除 retry 相关类型定义
  - AssistantView.vue 微调
2. ContextWindowMeter 压缩次数展示和数值格式优化
  - 新增 formatCompactCount 工具函数,千位以上用 k 单位压缩(如 80k)
  - 新增压缩次数显示
3.修复了新对话发消息时,user和assistant消息被自动调换的bug

仓库:无
2026-04-17 22:19:38 +08:00

210 lines
6.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package pkg
import (
"math"
"strings"
"unicode"
"github.com/cloudwego/eino/schema"
)
const (
// Worker 模型最大输入上下文(用户提供)
WorkerMaxInputTokens = 224000
// 给模型输出和协议开销预留的冗余 token
ContextReserveTokens = 28000
// 缓存未命中时,从数据库拉取的历史消息上限
DefaultHistoryFetchLimit = 1200
// Redis 会话窗口上下限与缓冲
SessionWindowMin = 32
SessionWindowMax = 4096
SessionWindowBuffer = 2
// ---- Execute Context Compaction 预算 ----
// Execute 阶段 prompt 总 token 上限
ExecuteTokenBudget = 80000
// msg0 + msg3 固定开销 + 安全余量
ExecuteReserveTokens = 8000
StageTokenBudget = ExecuteTokenBudget
StageReserveTokens = ExecuteReserveTokens
)
// MaxContextTokensByModel 返回指定模型的最大上下文 token。
func MaxContextTokensByModel(modelName string) int {
switch strings.ToLower(strings.TrimSpace(modelName)) {
case "worker", "strategist":
return WorkerMaxInputTokens
default:
return WorkerMaxInputTokens
}
}
// HistoryFetchLimitByModel 返回缓存未命中时的历史拉取条数。
func HistoryFetchLimitByModel(_ string) int {
return DefaultHistoryFetchLimit
}
// HistoryTokenBudgetByModel 计算“历史上下文”可使用的 token 预算。
func HistoryTokenBudgetByModel(modelName, systemPrompt, userInput string) int {
maxTokens := MaxContextTokensByModel(modelName)
baseTokens := EstimateTextTokens(systemPrompt) + EstimateTextTokens(userInput) + 64
budget := maxTokens - ContextReserveTokens - baseTokens
if budget < 0 {
return 0
}
return budget
}
// EstimateTextTokens 粗略估算文本 token
// - CJK 字符约 1:1
// - ASCII 字符约 4:1
// - 其他字符约 2:1
func EstimateTextTokens(text string) int {
if strings.TrimSpace(text) == "" {
return 0
}
var cjkCount, asciiCount, otherCount int
for _, r := range text {
switch {
case unicode.IsSpace(r):
continue
case r <= unicode.MaxASCII:
asciiCount++
case isCJK(r):
cjkCount++
default:
otherCount++
}
}
tokens := cjkCount + int(math.Ceil(float64(asciiCount)/4.0)) + int(math.Ceil(float64(otherCount)/2.0))
if tokens <= 0 {
return 1
}
return tokens
}
// EstimateMessageTokens 估算单条消息 token包含固定协议开销
func EstimateMessageTokens(msg *schema.Message) int {
if msg == nil {
return 0
}
const messageOverhead = 6
return messageOverhead + EstimateTextTokens(msg.Content) + EstimateTextTokens(msg.ReasoningContent)
}
// EstimateHistoryTokens 估算历史消息总 token。
func EstimateHistoryTokens(history []*schema.Message) int {
total := 0
for _, msg := range history {
total += EstimateMessageTokens(msg)
}
return total
}
// TrimHistoryByTokenBudget 从最旧消息开始裁剪,直到历史 token 不超过预算。
// 返回值:裁剪后历史、裁剪前 token、裁剪后 token、裁掉条数。
func TrimHistoryByTokenBudget(history []*schema.Message, historyBudget int) ([]*schema.Message, int, int, int) {
if len(history) == 0 {
return history, 0, 0, 0
}
totalBefore := EstimateHistoryTokens(history)
if historyBudget <= 0 {
return []*schema.Message{}, totalBefore, 0, len(history)
}
if totalBefore <= historyBudget {
return history, totalBefore, totalBefore, 0
}
tokenPerMsg := make([]int, len(history))
total := 0
for i, msg := range history {
t := EstimateMessageTokens(msg)
tokenPerMsg[i] = t
total += t
}
drop := 0
for total > historyBudget && drop < len(history) {
total -= tokenPerMsg[drop]
drop++
}
return history[drop:], totalBefore, total, drop
}
// CalcSessionWindowSize 根据裁剪后消息条数计算 Redis 队列窗口大小。
func CalcSessionWindowSize(trimmedHistoryLen int) int {
size := trimmedHistoryLen + SessionWindowBuffer
if size < SessionWindowMin {
size = SessionWindowMin
}
if size > SessionWindowMax {
size = SessionWindowMax
}
return size
}
func isCJK(r rune) bool {
return unicode.Is(unicode.Han, r) || unicode.Is(unicode.Hiragana, r) || unicode.Is(unicode.Katakana, r) || unicode.Is(unicode.Hangul, r)
}
// StageTokenBreakdown 记录四条阶段消息的 token 分布。
type StageTokenBreakdown struct {
Msg0 int `json:"msg0"`
Msg1 int `json:"msg1"`
Msg2 int `json:"msg2"`
Msg3 int `json:"msg3"`
Total int `json:"total"`
Budget int `json:"budget"`
}
// ExecuteTokenBreakdown 保留为历史兼容别名,避免旧调用点改动。
type ExecuteTokenBreakdown = StageTokenBreakdown
// EstimateStageMessagesTokens 估算四条阶段消息的 token 分布。
func EstimateStageMessagesTokens(msg0, msg1, msg2, msg3 string) StageTokenBreakdown {
b := StageTokenBreakdown{
Msg0: EstimateTextTokens(msg0),
Msg1: EstimateTextTokens(msg1),
Msg2: EstimateTextTokens(msg2),
Msg3: EstimateTextTokens(msg3),
Budget: StageTokenBudget,
}
b.Total = b.Msg0 + b.Msg1 + b.Msg2 + b.Msg3
return b
}
// CheckStageTokenBudget 检查是否超出阶段预算,并给出需要压缩的消息标记。
//
// 1. 先计算四条消息的 token 分布,便于后续日志和统计。
// 2. 如果总量没有超预算,直接返回。
// 3. 如果超预算,则按 msg1 / msg2 的相对占比判断是否需要分别压缩。
func CheckStageTokenBudget(msg0, msg1, msg2, msg3 string) (breakdown StageTokenBreakdown, overBudget bool, needCompactMsg1 bool, needCompactMsg2 bool) {
breakdown = EstimateStageMessagesTokens(msg0, msg1, msg2, msg3)
overBudget = breakdown.Total > StageTokenBudget
if !overBudget {
return
}
// msg1 过大时,优先压缩历史对话。
available := StageTokenBudget - StageReserveTokens
needCompactMsg1 = breakdown.Msg1 > available/2
// 若压缩 msg1 后仍然超限,再压缩执行记录区。
needCompactMsg2 = (breakdown.Total - breakdown.Msg1 + available/4) > StageTokenBudget
return
}
// EstimateExecuteMessagesTokens 保留旧名称,内部复用阶段预算实现。
func EstimateExecuteMessagesTokens(msg0, msg1, msg2, msg3 string) StageTokenBreakdown {
return EstimateStageMessagesTokens(msg0, msg1, msg2, msg3)
}
// CheckExecuteTokenBudget 保留旧名称,内部复用阶段预算实现。
func CheckExecuteTokenBudget(msg0, msg1, msg2, msg3 string) (breakdown StageTokenBreakdown, overBudget bool, needCompactMsg1 bool, needCompactMsg2 bool) {
return CheckStageTokenBudget(msg0, msg1, msg2, msg3)
}