feat: 短期多轮历史接入 Eino 图 MessagesPlaceholder (⑨)
会话历史(Redis,易失,与长期画像分开)经 MCP 工具进出 Eino 图:
recall 召回历史填 MessagesPlaceholder,写回把本轮 user/assistant 落历史。
- mcp-go: internal/history(go-redis, sundynix:history:<session>, LPUSH+LTRIM 保留近20条,
24h TTL) + 工具 history_get(返回JSON turns)/history_append; main 开 Redis(降级)
- dispatcher Eino: 模板加 MessagesPlaceholder('history'); recall 调 history_get→转 schema.Message;
Handle 累积 answer; memorize 异步 history_append(user+assistant)
- shared: contract.MetaSessionID; gateway: SubmitTask 注入 Meta[session_id](X-Session-ID 头,缺省 default)
- demo.sh: 同会话两轮提交,验证第2轮召回第1轮历史
- 验证: 4 模块 build✓ + 3 e2e PASS; live 跑通——轮1=0轮历史→落库, 轮2 history_get 命中→注入
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -3,9 +3,11 @@ package eino
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cloudwego/eino/compose"
|
||||
@@ -41,7 +43,7 @@ type Orchestrator struct {
|
||||
// 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)
|
||||
run, err := buildGraph(context.Background(), pool, o.fetchMemory, o.fetchHistory)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -67,6 +69,7 @@ func (o *Orchestrator) Handle(ctx context.Context, t *contract.Task) error {
|
||||
defer stream.Close()
|
||||
|
||||
n := 0
|
||||
var answer strings.Builder
|
||||
for {
|
||||
chunk, rerr := stream.Recv()
|
||||
if errors.Is(rerr, io.EOF) {
|
||||
@@ -83,6 +86,7 @@ func (o *Orchestrator) Handle(ctx context.Context, t *contract.Task) error {
|
||||
log.Printf("[eino] publish token failed: %v", perr)
|
||||
break
|
||||
}
|
||||
answer.WriteString(chunk.Content)
|
||||
n++
|
||||
}
|
||||
|
||||
@@ -92,9 +96,9 @@ func (o *Orchestrator) Handle(ctx context.Context, t *contract.Task) error {
|
||||
log.Printf("[eino] task %s done, %d tokens streamed", t.ID, n)
|
||||
o.breaker.Report(true)
|
||||
|
||||
// 写回阶段:流已排空(= 模型生成结束),此处离开热路径、异步抽取记忆。
|
||||
// 写回阶段:流已排空(= 模型生成结束),此处离开热路径、异步落历史 + 抽取记忆。
|
||||
// 注:流式节点用 OnEndWithStreamOutput 而非 OnEndFn,故不走回调而在此触发。
|
||||
go o.memorize(t)
|
||||
go o.memorize(t, answer.String())
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -122,13 +126,65 @@ func (o *Orchestrator) fetchMemory(ctx context.Context, userID, _ string) string
|
||||
return res.Content
|
||||
}
|
||||
|
||||
// memorize 写回阶段:从本轮对话抽取并更新偏好记忆。
|
||||
// 目前发占位日志;真实实现应跑抽取 LLM → 去重/更新 → memory_upsert(异步,离开热路径)。
|
||||
func (o *Orchestrator) memorize(t *contract.Task) {
|
||||
uid, _ := t.Meta[contract.MetaUserID].(string)
|
||||
if uid == "" {
|
||||
return
|
||||
// fetchHistory 经 MCP history_get 工具召回会话短期多轮历史,转为 Eino 消息。
|
||||
// 工具不可用/无 session 时返回空,降级为无历史(不阻断主流程)。
|
||||
func (o *Orchestrator) fetchHistory(ctx context.Context, sessionID string) []*schema.Message {
|
||||
if o.tools == nil || sessionID == "" {
|
||||
return nil
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(ctx, toolCallTimeout)
|
||||
defer cancel()
|
||||
res, err := o.tools.CallTool(cctx, contract.ToolSubjectGo("history_get"), &contract.ToolCall{
|
||||
Tool: "history_get",
|
||||
Args: map[string]any{"session_id": sessionID},
|
||||
})
|
||||
if err != nil || res == nil || !res.OK || res.Content == "" {
|
||||
return nil
|
||||
}
|
||||
var turns []struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if json.Unmarshal([]byte(res.Content), &turns) != nil {
|
||||
return nil
|
||||
}
|
||||
msgs := make([]*schema.Message, 0, len(turns))
|
||||
for _, tn := range turns {
|
||||
if tn.Role == "assistant" {
|
||||
msgs = append(msgs, schema.AssistantMessage(tn.Content, nil))
|
||||
} else {
|
||||
msgs = append(msgs, schema.UserMessage(tn.Content))
|
||||
}
|
||||
}
|
||||
if len(msgs) > 0 {
|
||||
log.Printf("[eino] history_get ok for %s: %d 条历史", sessionID, len(msgs))
|
||||
}
|
||||
return msgs
|
||||
}
|
||||
|
||||
// memorize 写回阶段:把本轮对话落进短期历史,并(TODO)抽取长期偏好记忆。
|
||||
// 异步执行,离开热路径。
|
||||
func (o *Orchestrator) memorize(t *contract.Task, answer string) {
|
||||
uid, _ := t.Meta[contract.MetaUserID].(string)
|
||||
sid, _ := t.Meta[contract.MetaSessionID].(string)
|
||||
if sid != "" && o.tools != nil {
|
||||
o.appendHistory(sid, "user", string(t.Graph))
|
||||
o.appendHistory(sid, "assistant", answer)
|
||||
log.Printf("[eino] (writeback) task %s 已落会话历史 session=%s", t.ID, sid)
|
||||
}
|
||||
if uid != "" {
|
||||
log.Printf("[eino] (writeback) task %s 待抽取 user=%s 的新偏好记忆", t.ID, uid)
|
||||
// TODO: 抽取 LLM → 去重/更新 → memory_upsert
|
||||
}
|
||||
}
|
||||
|
||||
func (o *Orchestrator) appendHistory(sessionID, role, content string) {
|
||||
cctx, cancel := context.WithTimeout(context.Background(), toolCallTimeout)
|
||||
defer cancel()
|
||||
if _, err := o.tools.CallTool(cctx, contract.ToolSubjectGo("history_append"), &contract.ToolCall{
|
||||
Tool: "history_append",
|
||||
Args: map[string]any{"session_id": sessionID, "role": role, "content": content},
|
||||
}); err != nil {
|
||||
log.Printf("[eino] history_append failed: %v", err)
|
||||
}
|
||||
log.Printf("[eino] (writeback) task %s 完成,待抽取 user=%s 的新偏好记忆", t.ID, uid)
|
||||
// TODO: 发 sundynix.memory.extract 事件 → memory worker 抽取 → memory_upsert
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user