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:
Blizzard
2026-06-10 14:18:45 +08:00
parent cbd130ecae
commit 4928ffc0f7
12 changed files with 288 additions and 47 deletions
+12 -6
View File
@@ -14,36 +14,42 @@ import (
// memoryFetcher 召回某用户与本次输入相关的偏好记忆(经 MCP memory_get 工具)。
type memoryFetcher func(ctx context.Context, userID, query string) string
// historyFetcher 召回某会话的短期多轮历史(经 MCP history_get 工具)。
type historyFetcher func(ctx context.Context, sessionID string) []*schema.Message
// buildGraph 编译这套"记忆增强"图:
//
// START → recall(召回画像→写State) → prompt(注入system) → model(流式) → END
// START → recall(召回画像+历史→写State) → prompt(注入system+history) → model(流式) → END
//
// 返回可流式执行的 Runnable。
func buildGraph(ctx context.Context, pool *llm.Pool, fetch memoryFetcher) (compose.Runnable[*contract.Task, *schema.Message], error) {
func buildGraph(ctx context.Context, pool *llm.Pool, fetch memoryFetcher, fetchHist historyFetcher) (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输出模板变量。
// 1) recall:取 user_id/session_id → 召回画像(memory_get)+历史(history_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)
sid, _ := t.Meta[contract.MetaSessionID].(string)
profile := fetch(ctx, uid, string(t.Graph))
hist := fetchHist(ctx, sid)
_ = compose.ProcessState(ctx, func(_ context.Context, s *AgentState) error {
s.UserID, s.Profile, s.Input = uid, profile, string(t.Graph)
s.UserID, s.SessionID, s.Profile, s.Input = uid, sid, profile, string(t.Graph)
return nil
})
if profile == "" {
profile = "(暂无该用户的偏好记忆)"
}
return map[string]any{"profile": profile, "query": string(t.Graph)}, nil
return map[string]any{"profile": profile, "query": string(t.Graph), "history": hist}, nil
})); err != nil {
return nil, err
}
// 2) prompt画像注入 system message,用户输入作为 user message。
// 2) prompt:画像注入 system,历史用占位符插入,用户输入作为 user message。
tpl := prompt.FromMessages(schema.FString,
schema.SystemMessage("你在与特定用户对话。关于该用户的已知信息:\n{profile}\n请据此个性化作答并保持其偏好。"),
schema.MessagesPlaceholder("history", true),
schema.UserMessage("{query}"),
)
if err := g.AddChatTemplateNode("prompt", tpl); err != nil {
+20 -2
View File
@@ -48,18 +48,36 @@ func (pm *poolModel) Stream(ctx context.Context, input []*schema.Message, _ ...m
// 真实模型不需要本函数。
func replyFor(msgs []*schema.Message) string {
var profile, user string
for _, m := range msgs {
priorTurns := 0
for i, m := range msgs {
switch m.Role {
case schema.System:
profile = m.Content
case schema.Assistant:
priorTurns++ // 历史里的助手消息 = 过往轮次
case schema.User:
user = m.Content
if i == len(msgs)-1 {
user = m.Content // 最后一条 user 才是本轮输入
}
}
}
return "【已注入用户画像】" + condense(profile, 80) +
"(本会话已有 " + itoa(priorTurns) + " 轮历史)" +
" | 据此为你个性化作答:已编排执行该 Agent 图(输入「" + condense(user, 30) + "」)。"
}
func itoa(n int) string {
if n == 0 {
return "0"
}
var b []byte
for n > 0 {
b = append([]byte{byte('0' + n%10)}, b...)
n /= 10
}
return string(b)
}
func condense(s string, max int) string {
s = strings.TrimSpace(strings.ReplaceAll(s, "\n", " "))
r := []rune(s)
@@ -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
}
+5 -4
View File
@@ -3,8 +3,9 @@ package eino
// AgentState 是 Eino 图的全局状态,贯穿 recall→prompt→model 各节点。
// 偏好记忆经 recall 节点写入,供模板注入与写回抽取使用。
type AgentState struct {
UserID string // 来自 Task.Meta["user_id"]
Profile string // 召回到的常驻画像(always-on 偏好记忆)
Input string // 本次输入(DSL 原文
Answer string // 累积输出,供写回阶段抽取新记忆
UserID string // 来自 Task.Meta["user_id"]
SessionID string // 来自 Task.Meta["session_id"]
Profile string // 召回到的常驻画像(always-on 偏好记忆
Input string // 本次输入(DSL 原文)
Answer string // 累积输出,供写回阶段抽取新记忆
}