feat: 第一张真实 Eino 图 + 偏好记忆(让模型知道是我)

dispatcher 不再手搓 pool.Stream,改用编译好的 Eino 图驱动;接入用户常驻画像,
推理前召回并注入 system prompt,实现个性化(架构'心脏'首次真跳)。

Eino 图(dispatcher/internal/eino): START→recall→prompt→model→END + 全局 State
- recall(Lambda): 取 Meta[user_id] → 调 MCP memory_get → ProcessState 写画像
- prompt(ChatTemplate): {profile} 注入 system,{query} 作 user
- model: poolModel 适配 LLM Pool 为 model.BaseChatModel(Generate+Stream, schema.Pipe)
- 写回: 流排空后异步 memorize(流式节点走 OnEndWithStreamOutput 非 OnEndFn)

记忆存储(mcp-go owns): GORM Profile→sundynix_user_profile(复合主键, AutoMigrate,
遵守前缀约定), 新工具 memory_get/memory_upsert, 连不上降级
Gateway: SubmitTask 注入 Meta[user_id](X-User-ID 头), PUT /api/v1/memory→memory_upsert
shared: contract.MetaUserID; llm.Pool 拆出 StreamText

验证: 4 模块 build✓ + 3 e2e PASS; live 跑通——PUT 偏好落 sundynix_user_profile,
带 X-User-ID 提交→Eino recall 召回→注入→SSE 流出含画像的个性化回答, writeback 触发

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-06-10 14:06:18 +08:00
parent a67604f4b7
commit cbd130ecae
19 changed files with 638 additions and 57 deletions
@@ -0,0 +1,72 @@
package eino
import (
"context"
"github.com/cloudwego/eino/components/prompt"
"github.com/cloudwego/eino/compose"
"github.com/cloudwego/eino/schema"
"github.com/sundynix/sundynix-dispatcher/internal/llm"
"github.com/sundynix/sundynix-shared/contract"
)
// memoryFetcher 召回某用户与本次输入相关的偏好记忆(经 MCP memory_get 工具)。
type memoryFetcher func(ctx context.Context, userID, query string) string
// buildGraph 编译这套"记忆增强"图:
//
// START → recall(召回画像→写State) → prompt(注入system) → model(流式) → END
//
// 返回可流式执行的 Runnable。
func buildGraph(ctx context.Context, pool *llm.Pool, fetch memoryFetcher) (compose.Runnable[*contract.Task, *schema.Message], error) {
g := compose.NewGraph[*contract.Task, *schema.Message](
compose.WithGenLocalState(func(context.Context) *AgentState { return &AgentState{} }),
)
// 1) recall:取 user_id → memory_get 召回画像 → 写入 State,并输出模板变量。
if err := g.AddLambdaNode("recall", compose.InvokableLambda(
func(ctx context.Context, t *contract.Task) (map[string]any, error) {
uid, _ := t.Meta[contract.MetaUserID].(string)
profile := fetch(ctx, uid, string(t.Graph))
_ = compose.ProcessState(ctx, func(_ context.Context, s *AgentState) error {
s.UserID, s.Profile, s.Input = uid, profile, string(t.Graph)
return nil
})
if profile == "" {
profile = "(暂无该用户的偏好记忆)"
}
return map[string]any{"profile": profile, "query": string(t.Graph)}, nil
})); err != nil {
return nil, err
}
// 2) prompt:把画像注入 system message,用户输入作为 user message。
tpl := prompt.FromMessages(schema.FString,
schema.SystemMessage("你在与特定用户对话。关于该用户的已知信息:\n{profile}\n请据此个性化作答并保持其偏好。"),
schema.UserMessage("{query}"),
)
if err := g.AddChatTemplateNode("prompt", tpl); err != nil {
return nil, err
}
// 3) modelLLM Pool 适配为 ChatModel 节点,流式产出。
if err := g.AddChatModelNode("model", newPoolModel(pool)); err != nil {
return nil, err
}
if err := g.AddEdge(compose.START, "recall"); err != nil {
return nil, err
}
if err := g.AddEdge("recall", "prompt"); err != nil {
return nil, err
}
if err := g.AddEdge("prompt", "model"); err != nil {
return nil, err
}
if err := g.AddEdge("model", compose.END); err != nil {
return nil, err
}
return g.Compile(ctx)
}