9a3a816c80
把散落各服务的硬编码 system prompt 收口为受管注册表,不重编译即可改/回滚/对比: - shared/prompts:内置默认(随代码) + 运行期覆盖(PROMPTS_FILE) + Get/Keys,并发安全,含单测 - 接入 9 处:mcp-go(graph.extract);dispatcher(eval.quality/eval.refine/guard.jailbreak/ coordinator.lead/memory.extract,按引用登记默认、无文本重复) - main 启动调 LoadFile 加载 PROMPTS_FILE 覆盖 - live A/B:覆盖 graph.extract → 图谱抽取 2 条→0 条、向量仍正常(覆盖生效、管道未坏) - v2(DB 控制面热切换 + 灰度)留后续 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
200 lines
6.4 KiB
Go
200 lines
6.4 KiB
Go
package eino
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"log"
|
||
"strings"
|
||
|
||
"github.com/cloudwego/eino/schema"
|
||
|
||
"github.com/sundynix/sundynix-dispatcher/internal/llm"
|
||
"github.com/sundynix/sundynix-shared/contract"
|
||
"github.com/sundynix/sundynix-shared/prompts"
|
||
)
|
||
|
||
// memOp 是 consolidate 的一条记忆操作(Mem0 式对账结果)。
|
||
type memOp struct {
|
||
Op string `json:"op"` // ADD / UPDATE / DELETE / NOOP
|
||
Key string `json:"key"`
|
||
Value string `json:"value"`
|
||
Importance float64 `json:"importance"` // 1~10,poignancy(读路径权重)
|
||
Reason string `json:"reason"`
|
||
}
|
||
|
||
// consolidateMemory 写回阶段(异步、攒批):把近期对话与已有画像交给 LLM 一次对账,
|
||
// 输出 ADD/UPDATE/DELETE/NOOP 操作并执行。ADD/UPDATE→upsert(带 importance)、DELETE→软删。
|
||
// 一次 LLM 调用同时做"抽取+对账",从根上解决 exact-key 盲写导致的记忆腐烂。
|
||
func (o *Orchestrator) consolidateMemory(ctx context.Context, uid string, hist []*schema.Message) {
|
||
if uid == "" || o.tools == nil || o.pool == nil || !o.pool.Ready() {
|
||
return
|
||
}
|
||
dialogue := renderDialogue(hist)
|
||
if strings.TrimSpace(dialogue) == "" {
|
||
return
|
||
}
|
||
existing := parseProfile(o.fetchMemory(ctx, uid, ""))
|
||
|
||
cctx, cancel := llmCtx(ctx)
|
||
defer cancel()
|
||
sys := prompts.Get(prompts.MemoryExtract)
|
||
txt, err := o.pool.Chat(cctx, []llm.ChatMessage{
|
||
{Role: "system", Content: sys},
|
||
{Role: "user", Content: consolidatePrompt(dialogue, existing)},
|
||
})
|
||
if err != nil {
|
||
log.Printf("[eino] (writeback) 记忆对账失败 user=%s: %v", uid, err)
|
||
return
|
||
}
|
||
ops := sanitizeOps(parseOps(txt), existing)
|
||
var add, del int
|
||
for _, op := range ops {
|
||
switch op.Op {
|
||
case "DELETE":
|
||
o.deleteMemory(ctx, uid, op.Key)
|
||
del++
|
||
default: // ADD / UPDATE
|
||
o.upsertMemory(ctx, uid, op.Key, op.Value, op.Importance)
|
||
add++
|
||
}
|
||
}
|
||
if add+del > 0 {
|
||
log.Printf("[eino] (writeback) 记忆对账 user=%s:写入/更新 %d,删除 %d", uid, add, del)
|
||
}
|
||
}
|
||
|
||
// consolidatePrompt 拼对账提示词:给出已有记忆 + 近期对话,要求输出操作列表。
|
||
func consolidatePrompt(dialogue string, existing map[string]string) string {
|
||
var ex strings.Builder
|
||
if len(existing) == 0 {
|
||
ex.WriteString("(暂无)")
|
||
} else {
|
||
for k, v := range existing {
|
||
fmt.Fprintf(&ex, "- %s:%s\n", k, v)
|
||
}
|
||
}
|
||
return fmt.Sprintf(`【已有记忆】
|
||
%s
|
||
【近期对话】
|
||
%s
|
||
|
||
请对账并只输出 JSON 数组,每项 {"op":"ADD|UPDATE|DELETE|NOOP","key":"维度","value":"值","importance":1到10的整数,"reason":"简述"}:
|
||
- ADD:对话中出现、已有记忆里没有的新长期偏好;
|
||
- UPDATE:与已有某条同维度但值变了(**key 必须复用已有的那个**);
|
||
- DELETE:已有记忆被对话明确推翻/过时(key 用已有的);
|
||
- NOOP:无需变更的不要输出。
|
||
importance 表示该偏好的重要程度(随口一提=低,强烈/反复强调=高)。只输出 JSON,无多余文字。`, strings.TrimRight(ex.String(), "\n"), dialogue)
|
||
}
|
||
|
||
// renderDialogue 把近期消息渲染为"用户/助手"对话文本(取末尾若干条,限长)。
|
||
func renderDialogue(hist []*schema.Message) string {
|
||
const maxMsgs = 8
|
||
start := 0
|
||
if len(hist) > maxMsgs {
|
||
start = len(hist) - maxMsgs
|
||
}
|
||
var b strings.Builder
|
||
for _, m := range hist[start:] {
|
||
role := "用户"
|
||
if m.Role == schema.Assistant {
|
||
role = "助手"
|
||
} else if m.Role == schema.System {
|
||
continue
|
||
}
|
||
fmt.Fprintf(&b, "%s:%s\n", role, truncate(m.Content, 500))
|
||
}
|
||
return strings.TrimRight(b.String(), "\n")
|
||
}
|
||
|
||
// parseOps 解析 LLM 对账结果(容忍 ```json 围栏)为 []memOp。
|
||
func parseOps(txt string) []memOp {
|
||
var ops []memOp
|
||
if json.Unmarshal([]byte(stripFence(txt)), &ops) != nil {
|
||
return nil
|
||
}
|
||
return ops
|
||
}
|
||
|
||
// sanitizeOps 校验/规整操作:规范 op 名;ADD/UPDATE 需 key+value;DELETE 需 key 且必须命中已有;
|
||
// importance 夹到 [1,10];同 key 去重(保留末个);过滤 NOOP。
|
||
func sanitizeOps(ops []memOp, existing map[string]string) []memOp {
|
||
byKey := map[string]memOp{}
|
||
order := []string{}
|
||
for _, op := range ops {
|
||
op.Op = strings.ToUpper(strings.TrimSpace(op.Op))
|
||
op.Key = strings.TrimSpace(op.Key)
|
||
op.Value = strings.TrimSpace(op.Value)
|
||
if op.Key == "" {
|
||
continue
|
||
}
|
||
switch op.Op {
|
||
case "ADD", "UPDATE":
|
||
if op.Value == "" {
|
||
continue
|
||
}
|
||
if op.Importance < 1 {
|
||
op.Importance = 1
|
||
} else if op.Importance > 10 {
|
||
op.Importance = 10
|
||
}
|
||
case "DELETE":
|
||
if _, ok := existing[op.Key]; !ok { // 只删确实存在的,防误删/幻删
|
||
continue
|
||
}
|
||
default: // NOOP 或未知 → 丢弃
|
||
continue
|
||
}
|
||
if _, seen := byKey[op.Key]; !seen {
|
||
order = append(order, op.Key)
|
||
}
|
||
byKey[op.Key] = op
|
||
}
|
||
out := make([]memOp, 0, len(order))
|
||
for _, k := range order {
|
||
out = append(out, byKey[k])
|
||
}
|
||
return out
|
||
}
|
||
|
||
// parseProfile 把 memory_get 渲染的画像("- 维度:值" 多行)解析回 map,供对账。
|
||
func parseProfile(s string) map[string]string {
|
||
m := map[string]string{}
|
||
for _, line := range strings.Split(s, "\n") {
|
||
line = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "-"))
|
||
if line == "" {
|
||
continue
|
||
}
|
||
for _, sep := range []string{":", ":"} { // 兼容全角/半角冒号
|
||
if i := strings.Index(line, sep); i > 0 {
|
||
k := strings.TrimSpace(line[:i])
|
||
if k != "" {
|
||
m[k] = strings.TrimSpace(line[i+len(sep):])
|
||
}
|
||
break
|
||
}
|
||
}
|
||
}
|
||
return m
|
||
}
|
||
|
||
// upsertMemory 经 mcp-go memory_upsert 登记/更新一条偏好(带 importance)。
|
||
func (o *Orchestrator) upsertMemory(ctx context.Context, uid, key, value string, importance float64) {
|
||
cctx, cancel := context.WithTimeout(ctx, toolCallTimeout)
|
||
defer cancel()
|
||
if _, err := o.tools.CallTool(cctx, contract.ToolSubjectGo("memory_upsert"),
|
||
&contract.ToolCall{Tool: "memory_upsert", Args: map[string]any{"user_id": uid, "key": key, "value": value, "importance": importance}}); err != nil {
|
||
log.Printf("[eino] memory_upsert 失败 %s=%s: %v", key, value, err)
|
||
}
|
||
}
|
||
|
||
// deleteMemory 经 mcp-go memory_delete 软删一条偏好。
|
||
func (o *Orchestrator) deleteMemory(ctx context.Context, uid, key string) {
|
||
cctx, cancel := context.WithTimeout(ctx, toolCallTimeout)
|
||
defer cancel()
|
||
if _, err := o.tools.CallTool(cctx, contract.ToolSubjectGo("memory_delete"),
|
||
&contract.ToolCall{Tool: "memory_delete", Args: map[string]any{"user_id": uid, "key": key}}); err != nil {
|
||
log.Printf("[eino] memory_delete 失败 %s: %v", key, err)
|
||
}
|
||
}
|