memoryweave/go/internal/distill/engine.go

467 lines
12 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.

// 织忆 MemoryWeave — 蒸馏引擎核心
// 两阶段蒸馏:硬规则过滤 → LLM 5维度评估 → 批量蒸馏
package distill
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strings"
"sync"
"time"
)
// ─── 类型定义 ────────────────────────────────────────────────
// Category 记忆类别
type Category string
const (
CatSystemFact Category = "system_fact"
CatUserPref Category = "user_pref"
CatProjContext Category = "proj_context"
CatToolUsage Category = "tool_usage"
CatCodeSnippet Category = "code_snippet"
CatDecision Category = "decision"
)
// DistillInput 蒸馏输入
type DistillInput struct {
EpisodeID string
Content string
Category Category
Namespace string
AgentID string
}
// DistillResult 蒸馏输出
type DistillResult struct {
Facts []string
Entities []Entity
Score5D FiveDScore
Overall float64
}
// Entity 实体
type Entity struct {
Name string `json:"name"`
Type string `json:"type"` // entity / fact / decision / skill
Properties []string `json:"properties"`
}
// FiveDScore LLM 5维评估
type FiveDScore struct {
IS float64 `json:"is"` // Information Significance
SU float64 `json:"su"` // Strategic Utility
PA float64 `json:"pa"` // Practical Applicability
VD float64 `json:"vd"` // Validation Durability
RU float64 `json:"ru"` // Recall Usability
}
// LLM 5维权重
var Weights = FiveDScore{
IS: 0.20,
SU: 0.20,
PA: 0.15,
VD: 0.25,
RU: 0.20,
}
// ─── 蒸馏引擎 ────────────────────────────────────────────────
// Engine 蒸馏引擎
type Engine struct {
mu sync.Mutex
// LLM 配置
LLMEndpoint string
LLMModel string
APIKey string
// 队列
queue []DistillInput
batchSize int
batchTimeout time.Duration
lastDistill time.Time
// 成本控制
dailyLimit int
dailyUsed int
dailyReset time.Time
// HTTP客户端
client *http.Client
}
func NewEngine(llmEndpoint, llmModel, apiKey string) *Engine {
return &Engine{
LLMEndpoint: llmEndpoint,
LLMModel: llmModel,
APIKey: apiKey,
batchSize: 10,
batchTimeout: 5 * time.Minute,
dailyLimit: 50,
client: &http.Client{Timeout: 30 * time.Second},
}
}
// Enqueue 入队
func (e *Engine) Enqueue(input DistillInput) {
e.mu.Lock()
defer e.mu.Unlock()
if !HardRulesPass(input.Content) {
return
}
e.queue = append(e.queue, input)
shouldFlush := len(e.queue) >= e.batchSize
timeout := time.Since(e.lastDistill) > e.batchTimeout
if shouldFlush || (timeout && len(e.queue) > 0) {
go e.flush()
}
}
// flush 批量蒸馏
func (e *Engine) flush() {
e.mu.Lock()
if len(e.queue) == 0 {
e.mu.Unlock()
return
}
batch := e.queue
e.queue = nil
e.lastDistill = time.Now()
e.mu.Unlock()
log.Printf("[distill] flush START: batch=%d items, dailyUsed=%d/%d, endpoint=%s, model=%s",
len(batch), e.dailyUsed, e.dailyLimit, e.LLMEndpoint, e.LLMModel)
// 成本控制检查
e.checkDailyLimit()
if e.dailyUsed >= e.dailyLimit {
log.Printf("[distill] flush FALLBACK: daily limit reached (%d/%d)", e.dailyUsed, e.dailyLimit)
results := fallbackDistill(batch)
e.emitResults(results)
return
}
// LLM 蒸馏
for _, input := range batch {
result := e.distillOne(input)
log.Printf("[distill] distillOne done: episode=%s facts=%d entities=%d",
input.EpisodeID, len(result.Facts), len(result.Entities))
e.dailyUsed++
e.emitResult(input, result)
}
log.Printf("[distill] flush DONE: processed %d items", len(batch))
}
// distillOne 蒸馏单条
func (e *Engine) distillOne(input DistillInput) DistillResult {
// 如果 LLM 端点不可用,降级
if e.LLMEndpoint == "" {
log.Printf("[distill] LLMEndpoint empty, fallback for %s", input.EpisodeID)
return fallbackSingle(input)
}
score, err := e.callLLM5D(input.Content)
if err != nil {
log.Printf("[distill] callLLM5D err for %s: %v", input.EpisodeID, err)
return fallbackSingle(input)
}
overall := score.IS*Weights.IS +
score.SU*Weights.SU +
score.PA*Weights.PA +
score.VD*Weights.VD +
score.RU*Weights.RU
log.Printf("[distill] score for %s: overall=%.3f is=%.2f su=%.2f pa=%.2f vd=%.2f ru=%.2f",
input.EpisodeID, overall, score.IS, score.SU, score.PA, score.VD, score.RU)
if overall < 0.7 && score.VD < 0.8 {
log.Printf("[distill] score below threshold for %s, skip LLM entity extraction", input.EpisodeID)
// 即使阈值未通过仍然用启发式提取实体§3.1 降级策略)
facts, entities := e.extractFacts(input.Content)
if len(entities) > 0 {
log.Printf("[distill] heuristic entities for %s: %d entities", input.EpisodeID, len(entities))
return DistillResult{Facts: facts, Entities: entities, Score5D: score, Overall: overall}
}
return DistillResult{}
}
// 提取事实和实体
facts, entities := e.extractFacts(input.Content)
return DistillResult{
Facts: facts,
Entities: entities,
Score5D: score,
Overall: overall,
}
}
// callLLM5D 调用 LLM 进行 5维评估
func (e *Engine) callLLM5D(content string) (FiveDScore, error) {
prompt := fmt.Sprintf(`你是一个记忆质量评估器。评估以下内容的5个维度0-1分数
- IS (Information Significance): 信息重要性,对系统运行有多关键
- SU (Strategic Utility): 战略价值,对未来决策有多大帮助
- PA (Practical Applicability): 实用性,可重复使用的价值
- VD (Validation Durability): 验证耐久性,信息在多长时间内保持有效
- RU (Recall Usability): 召回可用性,作为搜索入口的便利性
内容:
%s
只返回 JSON: {"is": 0.X, "su": 0.X, "pa": 0.X, "vd": 0.X, "ru": 0.X}`, truncate(content, 500))
body := map[string]interface{}{
"model": e.LLMModel,
"messages": []map[string]string{
{"role": "user", "content": prompt},
},
"temperature": 0.2,
"max_tokens": 100,
}
jsonBody, err := json.Marshal(body)
if err != nil {
return FiveDScore{}, err
}
req, err := http.NewRequest("POST", e.LLMEndpoint, bytes.NewReader(jsonBody))
if err != nil {
return FiveDScore{}, err
}
req.Header.Set("Content-Type", "application/json")
if e.APIKey != "" {
req.Header.Set("Authorization", "Bearer "+e.APIKey)
}
resp, err := e.client.Do(req)
if err != nil {
return FiveDScore{}, err
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
var result struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
if err := json.Unmarshal(respBody, &result); err != nil {
return FiveDScore{}, err
}
if len(result.Choices) == 0 {
return FiveDScore{}, fmt.Errorf("no choices in LLM response")
}
var score FiveDScore
json.Unmarshal([]byte(result.Choices[0].Message.Content), &score)
return score, nil
}
// extractFacts 从内容中提取事实和实体(关键词 + 命名实体启发式)
func (e *Engine) extractFacts(content string) ([]string, []Entity) {
var facts []string
var entities []Entity
if len(content) > 20 {
facts = append(facts, truncate(content, 200))
}
// 启发式实体提取:提取大写单词、数字组合、中文命名实体
words := strings.Fields(content)
seen := make(map[string]bool)
for _, w := range words {
w = strings.Trim(w, ",.;:!?,。;:!?、\"'()[]【】")
if len(w) < 2 {
continue
}
// 大写字母开头英文命名实体Hermes, ComfyUI, Redis 等)
runes := []rune(w)
if len(runes) >= 2 && runes[0] >= 'A' && runes[0] <= 'Z' {
normalized := strings.ToLower(w)
if !seen[normalized] && !isStopWord(normalized) {
seen[normalized] = true
entities = append(entities, Entity{
Name: w, Type: "entity", Properties: []string{"extracted"},
})
}
}
// 中文实体2-20 个纯中文字符,不含标点)
cleanChinese := stripNonChinese(w)
if len(cleanChinese) >= 2 && len(cleanChinese) <= 20 {
if !seen[cleanChinese] {
seen[cleanChinese] = true
isTech := containsAny(cleanChinese, []string{"端口", "配置", "系统", "内存", "显卡", "服务", "记忆", "织忆", "飞书", "版本", "模型", "工具", "项目", "安装", "部署", "目录", "路径", "开发", "语言"})
etype := "entity"
if isTech { etype = "fact" }
entities = append(entities, Entity{
Name: cleanChinese, Type: etype, Properties: []string{"extracted"},
})
}
}
// 数字/字母组合或纯数字(如 3050, 4GB, 8188, 7821
if isTechToken(w) || isAllDigits(w) {
if !seen[w] {
seen[w] = true
entities = append(entities, Entity{
Name: w, Type: "entity", Properties: []string{"technical"},
})
}
}
}
return facts, entities
}
// isChineseSequence 判断是否为连续的中文字符串(长度在 minLen 到 maxLen 之间)
func isChineseSequence(s string, minLen, maxLen int) bool {
runes := []rune(s)
if len(runes) < minLen || len(runes) > maxLen {
return false
}
for _, r := range runes {
if r < 0x4E00 || r > 0x9FFF {
return false
}
}
return true
}
// isTechToken 判断是否为技术标记(包含数字+字母组合)
func isTechToken(s string) bool {
hasDigit := false
hasLetter := false
for _, r := range s {
if r >= '0' && r <= '9' { hasDigit = true }
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') { hasLetter = true }
}
return hasDigit && hasLetter
}
// containsAny 判断字符串是否包含任意一个子串
func containsAny(s string, subs []string) bool {
for _, sub := range subs {
if strings.Contains(s, sub) {
return true
}
}
return false
}
// isStopWord 判断是否为停用词
func isStopWord(w string) bool {
stops := map[string]bool{
"the": true, "a": true, "an": true, "is": true, "are": true,
"was": true, "were": true, "be": true, "been": true, "being": true,
"have": true, "has": true, "had": true, "do": true, "does": true,
"did": true, "will": true, "would": true, "could": true, "should": true,
"may": true, "might": true, "can": true, "shall": true,
"this": true, "that": true, "these": true, "those": true,
"it": true, "its": true, "they": true, "them": true, "their": true,
"we": true, "our": true, "you": true, "your": true, "he": true, "she": true,
"his": true, "her": true, "and": true, "or": true, "but": true,
"not": true, "no": true, "if": true, "then": true, "else": true,
"for": true, "with": true, "as": true, "at": true, "by": true,
"from": true, "in": true, "of": true, "on": true, "to": true, "up": true,
}
return stops[w]
}
// stripNonChinese 只保留中文字符
func stripNonChinese(s string) string {
var buf []rune
for _, r := range s {
if r >= 0x4E00 && r <= 0x9FFF {
buf = append(buf, r)
}
}
return string(buf)
}
// isAllDigits 判断是否为纯数字
func isAllDigits(s string) bool {
if len(s) == 0 {
return false
}
for _, r := range s {
if r < '0' || r > '9' {
return false
}
}
return true
}
// checkDailyLimit 每日限额检查
func (e *Engine) checkDailyLimit() {
now := time.Now()
if now.Sub(e.dailyReset) > 24*time.Hour {
e.dailyUsed = 0
e.dailyReset = now
}
}
// emitResult 发送蒸馏结果(回调)
func (e *Engine) emitResult(input DistillInput, result DistillResult) {
if len(result.Facts) == 0 {
return
}
// 由注册的回调处理
if OnDistillComplete != nil {
OnDistillComplete(input, result)
}
}
// emitResults 批量发送
func (e *Engine) emitResults(results map[DistillInput]DistillResult) {
for input, result := range results {
e.emitResult(input, result)
}
}
// OnDistillComplete 全局蒸馏完成回调
var OnDistillComplete func(DistillInput, DistillResult)
// ─── 辅助 ────────────────────────────────────────────────
func truncate(s string, maxLen int) string {
runes := []rune(s)
if len(runes) <= maxLen {
return s
}
return string(runes[:maxLen]) + "..."
}
// fallbackSingle 降级蒸馏(无 LLM
func fallbackSingle(input DistillInput) DistillResult {
var facts []string
if len(input.Content) > 20 {
facts = append(facts, truncate(input.Content, 100))
}
return DistillResult{Facts: facts}
}
// fallbackDistill 批量降级蒸馏
func fallbackDistill(inputs []DistillInput) map[DistillInput]DistillResult {
results := make(map[DistillInput]DistillResult)
for _, input := range inputs {
results[input] = fallbackSingle(input)
}
return results
}