feat(dispatcher): Eino 采纳 Phase C —— 对话主流程跑 compose.Graph + callbacks 归一

按"并存 + 等价回归"策略,对话主流程可跑在 Eino compose.Graph 上:
- compose_graph.go:runConversation 按 EINO_COMPOSE 灰度开关分流;
  runComposeConversation 建图 START→ChatModel→END,Compile→Stream 回流 token;
  模型未就绪/编译失败降级回 runAgent。默认关,graph.go 仍是默认且权威。
- compose_callbacks.go:composeTracer 用 utils/callbacks 把 ChatModel/Tool 的
  start/end/error 桥到 ExecEvent(可观测归一,不再各处手写 emit)。
- LLM 接口 + Pool 增 ChatModel();fakeLLM 加 cm 字段 + stub Eino 模型。
- 测试:compose 图编译运行 / compose 对话流式 / 开关关→走 runAgent。

顺带修真 bug:SubjectTaskStatus 原 sundynix.tasks.status 落在任务流通配
sundynix.tasks.> 内 → 状态事件被当成"幽灵任务"自我放大(实测污染 2300+ 条)。
挪到 sundynix.status.task + dispatcher 加空任务护栏。

验收:make test-go 全绿;live compose 路径 54 字答复 eval 1.00、默认路径
eval 1.00、幽灵任务 0 复发。剩余 branch/map/render 等节点逐步迁移后 graph.go 退役。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-06-23 10:42:50 +08:00
parent 71102d2424
commit 2ee16d1f99
9 changed files with 281 additions and 6 deletions
+12 -2
View File
@@ -9,7 +9,7 @@
- [x] **Phase A · 地基**`llm.Pool` 换 Eino ChatModel 组件(commit d84b1ec,验收通过)
- [x] **Phase B · 质变**MCP 工具→`InvokableTool` + ReAct agent(模型自主调工具,验收 7/7 命中)
- [ ] **Phase C · 编排归一**`Flow→compose.Graph` + callbacks 桥接
- [~] **Phase C · 编排归一**✅ 对话主流程跑 `compose.Graph`EINO_COMPOSE 灰度开关,默认关)+ callbacks→ExecEvent 归一 / ⬜ branch·map·render 等节点逐步迁移(并存+等价回归)
- [~] **Phase D · 状态化执行**:✅ 任务生命周期 FSM(已完成)/ ⬜ HITL 中断恢复 / ⬜ 多智能体(按场景)
---
@@ -117,7 +117,17 @@ github.com/cloudwego/eino-ext/... # ⚠️ 官方组件实现(open
---
## Phase C · 编排归一:迁到 compose.Graph P2
## Phase C · 编排归一:迁到 compose.Graph P2🟡 进行中(核心已落地,并存灰度)
> 落地(并存+等价回归策略):对话主流程已可跑在 `compose.Graph` 上——
> - `compose_graph.go``runConversation` 按 `EINO_COMPOSE` 开关分流;`runComposeConversation` 建图 `START→ChatModel→END`、`Compile`→`Stream`token 回流;模型未就绪/编译失败降级回 `runAgent`。**默认关,graph.go 仍是默认且权威。**
> - `compose_callbacks.go``composeTracer` 用 `utils/callbacks` 把 ChatModel/Tool 的 start/end/error 桥到 ExecEvent(可观测归一)。
> - 测试:compose 图编译+运行、compose 对话流式回流、开关关→走 runAgent,三个单测;live 实测 compose 路径出 54 字答复 + eval 1.00,默认路径 eval 1.00。
> - **顺带修真 bug**`SubjectTaskStatus` 原为 `sundynix.tasks.status`,落在任务流捕获通配 `sundynix.tasks.>` 内 → 状态事件被当成"幽灵任务"自我放大(实测污染 2300+ 条)。已挪到 `sundynix.status.task` + dispatcher 加空任务护栏。
>
> 剩余:branch / map / render / retriever / prompt 等节点逐步迁 compose(同并存+等价回归),对齐后 graph.go 退役。
**目标**:自研解释器退役,DSL 图编译为 `compose.Graph`,吃到流式 reduce/branch、类型化边、自动并发、callbacks。
@@ -0,0 +1,68 @@
package eino
import (
"context"
"fmt"
"github.com/cloudwego/eino/callbacks"
"github.com/cloudwego/eino/components/model"
"github.com/cloudwego/eino/components/tool"
"github.com/cloudwego/eino/schema"
ucallbacks "github.com/cloudwego/eino/utils/callbacks"
)
// composeTracer 把 Eino compose 运行时的回调(ChatModel / Tool 的 start/end/error
// 翻译成我们现有的 ExecEvent 轨迹——可观测"归一":用框架原生回调,而非各处手写 emit。
// node 为该次运行在"运行·观测"里的归属节点 id(如 agent:xxx)。
func composeTracer(tr *execTracer, node string) callbacks.Handler {
return ucallbacks.NewHandlerHelper().
ChatModel(&ucallbacks.ModelCallbackHandler{
OnStart: func(ctx context.Context, _ *callbacks.RunInfo, in *model.CallbackInput) context.Context {
tr.emit(node, "model", "start", "compose·ChatModel", inputMsgsPreview(in), 0)
return ctx
},
OnEnd: func(ctx context.Context, _ *callbacks.RunInfo, out *model.CallbackOutput) context.Context {
detail := ""
if out != nil && out.Message != nil {
detail = truncate(out.Message.Content, 120)
}
tr.emit(node, "model", "end", "compose·ChatModel", detail, 0)
return ctx
},
OnEndWithStreamOutput: func(ctx context.Context, _ *callbacks.RunInfo, out *schema.StreamReader[*model.CallbackOutput]) context.Context {
out.Close() // 仅观测:正文经主输出流消费,这里只标记结束
tr.emit(node, "model", "end", "compose·ChatModel", "流式完成", 0)
return ctx
},
OnError: func(ctx context.Context, _ *callbacks.RunInfo, err error) context.Context {
tr.emit(node, "model", "error", "compose·ChatModel", err.Error(), 0)
return ctx
},
}).
Tool(&ucallbacks.ToolCallbackHandler{
OnStart: func(ctx context.Context, _ *callbacks.RunInfo, in *tool.CallbackInput) context.Context {
if in != nil {
tr.emit(node, "tool", "start", "compose·工具", truncate(in.ArgumentsInJSON, 120), 0)
}
return ctx
},
OnEnd: func(ctx context.Context, _ *callbacks.RunInfo, out *tool.CallbackOutput) context.Context {
if out != nil {
tr.emit(node, "tool", "end", "compose·工具", truncate(out.Response, 160), 0)
}
return ctx
},
OnError: func(ctx context.Context, _ *callbacks.RunInfo, err error) context.Context {
tr.emit(node, "tool", "error", "compose·工具", err.Error(), 0)
return ctx
},
}).
Handler()
}
func inputMsgsPreview(in *model.CallbackInput) string {
if in == nil {
return ""
}
return fmt.Sprintf("%d 条消息", len(in.Messages))
}
@@ -0,0 +1,93 @@
package eino
import (
"context"
"fmt"
"io"
"os"
"time"
"github.com/cloudwego/eino/compose"
"github.com/cloudwego/eino/schema"
"github.com/sundynix/sundynix-dispatcher/internal/harness"
)
// composeEnabled 报告是否启用 compose.Graph 编排路径(Phase C 灰度开关,默认关 → 走自研 graph.go)。
// 并存策略:EINO_COMPOSE=1 时对话主流程改走 Eino compose 运行时,行为对齐后再逐步退役 graph.go。
func composeEnabled() bool { return os.Getenv("EINO_COMPOSE") == "1" }
// runConversation 是对话/模型节点的统一入口:按灰度开关选 compose.Graph 或自研 runAgent。
// 二者对外行为一致(据黑板拼消息 → 流式回流 token → 累计成稿),便于等价回归。
func (o *Orchestrator) runConversation(ctx context.Context, taskID string, b *board, system string, tr *execTracer, node string) {
if composeEnabled() {
o.runComposeConversation(ctx, taskID, b, system, tr, node)
return
}
o.runAgent(ctx, taskID, b, system, tr, node)
}
// runComposeConversation 用 Eino compose.Graph 跑对话主流程:
// START → ChatModel 节点 → END,编译为 Runnable 后流式执行;可观测经 callbacks 桥到 ExecEvent。
// 模型未就绪 / 编译失败时降级回自研 runAgent,保证不回归。
func (o *Orchestrator) runComposeConversation(ctx context.Context, taskID string, b *board, system string, tr *execTracer, node string) {
cm := o.pool.ChatModel()
if cm == nil {
o.runAgent(ctx, taskID, b, system, tr, node) // 无模型 → 走自研路径的降级桩
return
}
g := compose.NewGraph[[]*schema.Message, *schema.Message]()
if err := g.AddChatModelNode("model", cm); err != nil {
tr.info(node, "system", "compose 降级", "建图失败,退回自研路径:"+err.Error())
o.runAgent(ctx, taskID, b, system, tr, node)
return
}
_ = g.AddEdge(compose.START, "model")
_ = g.AddEdge("model", compose.END)
r, err := g.Compile(ctx)
if err != nil {
tr.info(node, "system", "compose 降级", "编译失败,退回自研路径:"+err.Error())
o.runAgent(ctx, taskID, b, system, tr, node)
return
}
rc := &RunCtx{
UserID: b.uid, SessionID: b.sid,
System: firstNonEmpty(system, defaultAgentSystem),
Query: b.query,
Profile: b.profile,
History: b.history,
ToolOut: append(append([]string{}, b.toolOut...), b.refs...),
}
msgs, _ := buildMessages(ctx, rc)
t0 := time.Now()
// ChatModel 的 start/end 由 composeTracercallbacks)落轨迹,这里不再手写 emit(归一)。
sr, err := r.Stream(ctx, msgs, compose.WithCallbacks(composeTracer(tr, node)))
if err != nil {
tr.emit(node, "model", "error", "compose 图执行", err.Error(), time.Since(t0).Milliseconds())
return
}
defer sr.Close()
chunks := 0
for {
chunk, rerr := sr.Recv()
if rerr == io.EOF {
break
}
if rerr != nil {
tr.emit(node, "model", "error", "compose 图执行", rerr.Error(), time.Since(t0).Milliseconds())
return
}
if chunk.Content == "" {
continue
}
safe, _ := harness.RedactSecrets(chunk.Content) // 输出护栏:逐片脱敏
_ = o.sink.PublishToken(taskID, []byte(safe))
b.answer += safe
chunks++
}
tr.info(node, "system", "compose 图", fmt.Sprintf("%d 段输出 / %d 字(Eino compose 运行时)", chunks, len([]rune(b.answer))))
}
@@ -0,0 +1,89 @@
package eino
import (
"context"
"strings"
"testing"
"github.com/cloudwego/eino/components/model"
"github.com/cloudwego/eino/compose"
"github.com/cloudwego/eino/schema"
"github.com/sundynix/sundynix-dispatcher/internal/harness"
"github.com/sundynix/sundynix-dispatcher/internal/llm"
)
// stubModel 是实现 Eino model.BaseChatModel 的测试桩,固定回一段文本(确定性)。
type stubModel struct{ reply string }
func (s *stubModel) Generate(_ context.Context, _ []*schema.Message, _ ...model.Option) (*schema.Message, error) {
return schema.AssistantMessage(s.reply, nil), nil
}
func (s *stubModel) Stream(_ context.Context, _ []*schema.Message, _ ...model.Option) (*schema.StreamReader[*schema.Message], error) {
sr, sw := schema.Pipe[*schema.Message](1)
go func() {
sw.Send(schema.AssistantMessage(s.reply, nil), nil)
sw.Close()
}()
return sr, nil
}
// TestComposeGraphRuns 直接验证 compose.GraphSTART→ChatModel→END)能编译并流式产出。
func TestComposeGraphRuns(t *testing.T) {
g := compose.NewGraph[[]*schema.Message, *schema.Message]()
if err := g.AddChatModelNode("model", &stubModel{reply: "你好世界"}); err != nil {
t.Fatal(err)
}
_ = g.AddEdge(compose.START, "model")
_ = g.AddEdge("model", compose.END)
r, err := g.Compile(context.Background())
if err != nil {
t.Fatalf("compile: %v", err)
}
out, err := r.Invoke(context.Background(), []*schema.Message{schema.UserMessage("hi")})
if err != nil {
t.Fatalf("invoke: %v", err)
}
if out.Content != "你好世界" {
t.Fatalf("got %q", out.Content)
}
}
// TestComposeConversationEquivalent 验证 EINO_COMPOSE=1 时对话主流程走 compose 路径,
// 把模型输出流式回流到 sink 并累计成稿——与自研 runAgent 行为等价(都逐片转发模型输出)。
func TestComposeConversationEquivalent(t *testing.T) {
t.Setenv("EINO_COMPOSE", "1")
fs := &fakeSink{}
o := &Orchestrator{
pool: &fakeLLM{ready: true, cm: &stubModel{reply: "我是 compose 路径的回答"}},
breaker: harness.NewCircuitBreaker(),
sink: fs,
}
b := &board{query: "你好"}
tr := &execTracer{} // sink 为 nil → 轨迹发射空操作
o.runConversation(context.Background(), "task_compose", b, "", tr, "agent")
if !strings.Contains(b.answer, "compose 路径") {
t.Fatalf("成稿未含模型输出: %q", b.answer)
}
if !strings.Contains(fs.text(), "compose 路径") {
t.Fatalf("sink 未收到流式 token: %q", fs.text())
}
}
// TestComposeDisabledUsesRunAgent 验证开关关闭时仍走自研 runAgent(默认行为不变)。
func TestComposeDisabledUsesRunAgent(t *testing.T) {
t.Setenv("EINO_COMPOSE", "0")
fs := &fakeSink{}
o := &Orchestrator{
pool: &fakeLLM{ready: true, stream: func([]llm.ChatMessage) string { return "自研路径回答" }},
breaker: harness.NewCircuitBreaker(),
sink: fs,
}
b := &board{query: "你好"}
o.runConversation(context.Background(), "task_legacy", b, "", &execTracer{}, "agent")
if !strings.Contains(b.answer, "自研路径") {
t.Fatalf("未走自研路径: %q", b.answer)
}
}
+3 -3
View File
@@ -50,7 +50,7 @@ func (o *Orchestrator) runGraph(ctx context.Context, t *contract.Task, tr *execT
tr.info("task", "system", "无结构化图", "按单轮对话执行")
b.profile = o.fetchMemory(ctx, b.uid, b.query)
b.history = o.fetchHistory(ctx, b.sid)
o.runAgent(ctx, t.ID, b, plan.System, tr, "agent")
o.runConversation(ctx, t.ID, b, plan.System, tr, "agent")
return b.answer, nil
}
@@ -123,7 +123,7 @@ func (o *Orchestrator) runGraph(ctx context.Context, t *contract.Task, tr *execT
if cbool(n.Config, "autonomous") { // 开启自主工具 → ReAct(模型自己选工具)
o.runReactAgent(ctx, t.ID, b, sys, n, tr, "agent:"+n.ID)
} else {
o.runAgent(ctx, t.ID, b, sys, tr, "agent:"+n.ID)
o.runConversation(ctx, t.ID, b, sys, tr, "agent:"+n.ID)
}
case "aggregate":
merged := aggregate(cstr(n.Config, "strategy"), append(append([]string{}, b.refs...), b.toolOut...))
@@ -147,7 +147,7 @@ func (o *Orchestrator) runGraph(ctx context.Context, t *contract.Task, tr *execT
// 图里无 agent 节点(纯工具/检索图)也要出一段模型答复,否则没有输出。
if b.answer == "" {
o.runAgent(ctx, t.ID, b, plan.System, tr, "agent")
o.runConversation(ctx, t.ID, b, plan.System, tr, "agent")
}
return b.answer, nil
}
@@ -20,6 +20,7 @@ type fakeLLM struct {
ready bool
stream func(msgs []llm.ChatMessage) string // ChatStream 要回流的整段文本
chat func(msgs []llm.ChatMessage) (string, error) // Chat 返回
cm model.BaseChatModel // compose 路径用的 Eino 模型(可为 nil)
}
func (f *fakeLLM) Ready() bool { return f.ready }
@@ -43,6 +44,9 @@ func (f *fakeLLM) Chat(_ context.Context, msgs []llm.ChatMessage) (string, error
// ToolCallingModel:假模型不支持函数调用 → ReAct 路径会降级回普通对话。
func (f *fakeLLM) ToolCallingModel() model.ToolCallingChatModel { return nil }
// ChatModelcompose 路径用;nil 时 runComposeConversation 降级回 runAgent。
func (f *fakeLLM) ChatModel() model.BaseChatModel { return f.cm }
type fakeSink struct {
mu sync.Mutex
tokens []string
@@ -43,6 +43,8 @@ type LLM interface {
Chat(ctx context.Context, msgs []llm.ChatMessage) (string, error)
// ToolCallingModel 返回支持函数调用的模型(ReAct agent 用);不支持则返回 nil。
ToolCallingModel() model.ToolCallingChatModel
// ChatModel 返回 Eino ChatModel 组件(compose.Graph 编排用);未就绪则 nil。
ChatModel() model.BaseChatModel
}
// 工具调用超时;超时即降级(不带工具上下文继续推理)。
@@ -96,6 +98,11 @@ func (o *Orchestrator) finishStatus(taskID string, err error) {
// Handle 消费一个任务:按 DSL 编译 Eino 图并执行,把 Token 流回流到 sundynix.streams.<id>。
func (o *Orchestrator) Handle(ctx context.Context, t *contract.Task) error {
// 护栏:丢弃空任务(无 id),避免误投/历史脏数据被当真任务处理并触发状态回写放大。
if t.ID == "" {
log.Printf("[eino] 跳过空任务(无 id")
return nil
}
tr := o.tracer(t.ID)
defer tr.done()
+3
View File
@@ -76,6 +76,9 @@ func (p *Pool) model() model.BaseChatModel {
// Ready 报告是否已配置可用后端(且 ChatModel 构建成功)。
func (p *Pool) Ready() bool { return p.model() != nil }
// ChatModel 返回当前 Eino ChatModel 组件(用于 compose.Graph 编排);未就绪则 nil。
func (p *Pool) ChatModel() model.BaseChatModel { return p.model() }
// ToolCallingModel 返回支持函数调用的模型(用于 ReAct agent);未就绪 / 不支持则 nil。
func (p *Pool) ToolCallingModel() model.ToolCallingChatModel {
if tcm, ok := p.model().(model.ToolCallingChatModel); ok {
+2 -1
View File
@@ -31,7 +31,8 @@ const (
// 任务生命周期状态回写:dispatcher 开跑/跑完/出错经此主题广播,网关订阅落 PG 并推 UI。
// core NATS pub-sub(状态是幂等覆盖,丢一条由下一条纠正,无需持久化)。
SubjectTaskStatus = "sundynix.tasks.status"
// 注意:必须在 sundynix.tasks.> 之外,否则会被任务流捕获成"幽灵任务"自我放大。
SubjectTaskStatus = "sundynix.status.task"
)
// 任务生命周期状态机:submitted(网关建任务)→ runningdispatcher 开跑)