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
@@ -3,9 +3,14 @@ package eino
import (
"context"
"errors"
"io"
"log"
"time"
"github.com/cloudwego/eino/compose"
"github.com/cloudwego/eino/schema"
"github.com/sundynix/sundynix-dispatcher/internal/harness"
"github.com/sundynix/sundynix-dispatcher/internal/llm"
"github.com/sundynix/sundynix-shared/contract"
@@ -25,77 +30,105 @@ type ToolCaller interface {
// 工具调用超时;超时即降级(不带工具上下文继续推理)。
const toolCallTimeout = 3 * time.Second
// Orchestrator DSL 图编译为 Eino Graph 并驱动执行
// Orchestrator DSL 任务交给编译好的 Eino 图执行(记忆召回 → 注入 → 流式)
type Orchestrator struct {
pool *llm.Pool
breaker *harness.CircuitBreaker
sink TokenSink
tools ToolCaller
run compose.Runnable[*contract.Task, *schema.Message]
}
func NewOrchestrator(pool *llm.Pool, breaker *harness.CircuitBreaker, sink TokenSink, tools ToolCaller) *Orchestrator {
return &Orchestrator{pool: pool, breaker: breaker, sink: sink, tools: tools}
// NewOrchestrator 构建并编译记忆增强图。
func NewOrchestrator(pool *llm.Pool, breaker *harness.CircuitBreaker, sink TokenSink, tools ToolCaller) (*Orchestrator, error) {
o := &Orchestrator{breaker: breaker, sink: sink, tools: tools}
run, err := buildGraph(context.Background(), pool, o.fetchMemory)
if err != nil {
return nil, err
}
o.run = run
return o, nil
}
// Handle 消费一个任务:编译图 → 流式推理 → 经 sink 把 Token 回流到 sundynix.streams.<id>。
// Handle 消费一个任务:执行 Eino 图,把 Token 回流到 sundynix.streams.<id>。
func (o *Orchestrator) Handle(ctx context.Context, t *contract.Task) error {
if !o.breaker.Allow() {
log.Printf("[eino] circuit open, drop task %s", t.ID)
return nil
}
log.Printf("[eino] task %s received (graph=%d bytes), streaming tokens...", t.ID, len(t.Graph))
log.Printf("[eino] task %s received (graph=%d bytes), running graph...", t.ID, len(t.Graph))
// TODO: compose.NewGraph(...) 编译 DSL;此处 prompt 占位为图原文。
prompt := string(t.Graph)
// 工具节点:经 NATS 调用第 5 层 MCPsundynix.tools.go.*)。
// 这里以 wiki_search 演示完整调用链路;真实 Eino 图会按 DSL 节点择机调用。
if ctxNote := o.retrieveContext(ctx, t); ctxNote != "" {
prompt = ctxNote + "\n" + prompt
stream, err := o.run.Stream(ctx, t)
if err != nil {
log.Printf("[eino] task %s graph error: %v", t.ID, err)
_ = o.sink.CompleteStream(t.ID)
o.breaker.Report(false)
return err
}
defer stream.Close()
n := 0
err := o.pool.Stream(ctx, prompt, func(tok []byte) {
if perr := o.sink.PublishToken(t.ID, tok); perr != nil {
for {
chunk, rerr := stream.Recv()
if errors.Is(rerr, io.EOF) {
break
}
if rerr != nil {
log.Printf("[eino] task %s stream recv error: %v", t.ID, rerr)
break
}
if chunk == nil || chunk.Content == "" {
continue
}
if perr := o.sink.PublishToken(t.ID, []byte(chunk.Content)); perr != nil {
log.Printf("[eino] publish token failed: %v", perr)
return
break
}
n++
})
if err != nil {
log.Printf("[eino] task %s stream error: %v", t.ID, err)
}
if cerr := o.sink.CompleteStream(t.ID); cerr != nil {
log.Printf("[eino] complete stream failed: %v", cerr)
}
log.Printf("[eino] task %s done, %d tokens streamed", t.ID, n)
o.breaker.Report(err == nil)
return err
o.breaker.Report(true)
// 写回阶段:流已排空(= 模型生成结束),此处离开热路径、异步抽取记忆。
// 注:流式节点用 OnEndWithStreamOutput 而非 OnEndFn,故不走回调而在此触发。
go o.memorize(t)
return nil
}
// retrieveContext 经 MCP wiki_search 工具拉取检索上下文
// 工具不可用/超时时返回空串,降级为无工具上下文推理(不阻断主流程)。
func (o *Orchestrator) retrieveContext(ctx context.Context, t *contract.Task) string {
if o.tools == nil {
// fetchMemory 经 MCP memory_get 工具召回用户常驻画像
// 工具不可用/超时/无 user_id 时返回空串,降级为无记忆推理(不阻断主流程)。
func (o *Orchestrator) fetchMemory(ctx context.Context, userID, _ string) string {
if o.tools == nil || userID == "" {
return ""
}
cctx, cancel := context.WithTimeout(ctx, toolCallTimeout)
defer cancel()
res, err := o.tools.CallTool(cctx, contract.ToolSubjectGo("wiki_search"), &contract.ToolCall{
Tool: "wiki_search",
TaskID: t.ID,
Args: map[string]any{"q": string(t.Graph)},
res, err := o.tools.CallTool(cctx, contract.ToolSubjectGo("memory_get"), &contract.ToolCall{
Tool: "memory_get",
Args: map[string]any{"user_id": userID},
})
if err != nil {
log.Printf("[eino] task %s wiki_search unavailable, degrade: %v", t.ID, err)
log.Printf("[eino] memory_get unavailable for %s, degrade: %v", userID, err)
return ""
}
if !res.OK {
log.Printf("[eino] task %s wiki_search error: %s", t.ID, res.Error)
log.Printf("[eino] memory_get error for %s: %s", userID, res.Error)
return ""
}
log.Printf("[eino] task %s wiki_search ok: %s", t.ID, res.Content)
log.Printf("[eino] memory_get ok for %s: %s", userID, res.Content)
return res.Content
}
// memorize 写回阶段:从本轮对话抽取并更新偏好记忆。
// 目前发占位日志;真实实现应跑抽取 LLM → 去重/更新 → memory_upsert(异步,离开热路径)。
func (o *Orchestrator) memorize(t *contract.Task) {
uid, _ := t.Meta[contract.MetaUserID].(string)
if uid == "" {
return
}
log.Printf("[eino] (writeback) task %s 完成,待抽取 user=%s 的新偏好记忆", t.ID, uid)
// TODO: 发 sundynix.memory.extract 事件 → memory worker 抽取 → memory_upsert
}