feat(dispatcher): 编排引擎按图执行(拓扑+连线+分支剪枝),弃用线性拍平

旧 compileFlow 把 DSL 图拍平成线性 init→tool…→prompt→model,连线/分支/
memory/aggregate/render 节点全被忽略——"画得出、跑不全"。改为纯 Go 图解释器
(graph.go),按真实拓扑与连线执行,每种节点 kind 有真实行为:

- input     注入用户输入
- memory    按勾选注入画像/历史(无 memory 节点则沿用默认注入,不回归)
- retriever kb 按 owner 作用域 → kb_search 累计参考资料
- tool      调 MCP 工具,产出进黑板,失败降级不阻断
- agent     据黑板拼消息 → pool 流式回流 token,累计成稿
- aggregate 按策略合并参考资料(拼接/去重合并/摘要)
- render    把成稿经 report_render 渲染 docx
- branch    求值条件 + active-set 剪枝下游(边序约定 [true,false])
- map       占位(fan-out 暂串行,路线图 Phase 2)
- output    终端

全程逐节点点亮"运行·观测",token 流与记忆写回保持不变;报告 intent 走原专用
编排不动。compile.go 精简为只留 RunCtx/buildMessages/previewArgs。

实测(gateway+dispatcher+DeepSeek 实跑):
- input→agent→output 真实流式答复 ✓
- branch 条件 2>1 走分支A、1>2 走分支B(下游真被剪枝)✓
- memory 节点按勾选注入;exec 事件按新节点名(agent:a 等)回流 ✓
- 桌面端 Studio 载示例→运行:4节点3连线校验通过,检索节点 mcp-go 不在时
  优雅降级,agent 据空资料如实作答,输出/轨迹面板正常 ✓

路线图 Phase 2:map 真并行 fan-out + aggregate reduce 接上 report 那套;
前端给 branch 的边打 true/false 标签,使条件分支完全精确(当前靠出边顺序约定)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-06-15 11:42:29 +08:00
parent 5d76652bff
commit fd145b5852
4 changed files with 421 additions and 196 deletions
+12 -146
View File
@@ -3,18 +3,13 @@ package eino
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/cloudwego/eino/compose"
"github.com/cloudwego/eino/schema"
"github.com/sundynix/sundynix-dispatcher/internal/dsl"
"github.com/sundynix/sundynix-shared/contract"
)
// RunCtx 是图中流转的"黑板":init 填充,工具节点逐个增补,prompt 节点据此组装消息。
// 用统一类型在节点间流转,规避 Eino 严格类型对齐的麻烦
// RunCtx 是组装模型消息用的上下文:图解释器(graph.go)把黑板汇总到它,
// buildMessages 据此拼出发给模型的消息序列。用统一结构避免散落多处拼装
type RunCtx struct {
UserID string
SessionID string
@@ -22,147 +17,10 @@ type RunCtx struct {
Query string // 用户输入
Profile string // 召回的画像
History []*schema.Message // 短期历史
ToolOut []string // 工具节点产出(按执行序
ToolOut []string // 工具/检索节点产出(含参考资料
}
// compileFlow 把一个任务的 DSL 图动态编译为可执行的 Eino 图:
//
// START → init(编译+记忆召回) → tool_0 → tool_1 → … → prompt(组装消息) → model(流式) → END
//
// 工具/检索节点按拓扑序真实调用 MCPsundynix.tools.go.*),结果注入模型上下文。
// 分支/并行节点暂未编译(TODOcompose.Branch / fan-out)。
func (o *Orchestrator) compileFlow(ctx context.Context, t *contract.Task, tr *execTracer) (compose.Runnable[*contract.Task, *schema.Message], error) {
plan := dsl.Compile(t.Graph) // 系统提示词 / 用户输入 / 默认兜底
flow, _ := dsl.Parse(t.Graph)
g := compose.NewGraph[*contract.Task, *schema.Message]()
// init:取身份 → 召回画像+历史 → 初始化黑板。
if err := g.AddLambdaNode("init", compose.InvokableLambda(
func(ctx context.Context, task *contract.Task) (*RunCtx, error) {
uid, _ := task.Meta[contract.MetaUserID].(string)
sid, _ := task.Meta[contract.MetaSessionID].(string)
end := tr.span("init", "memory", "召回画像与历史")
profile := o.fetchMemory(ctx, uid, plan.Query)
history := o.fetchHistory(ctx, sid)
end(fmt.Sprintf("画像 %d 字 · 历史 %d 条", len([]rune(profile)), len(history)), nil)
return &RunCtx{
UserID: uid,
SessionID: sid,
System: plan.System,
Query: plan.Query,
Profile: profile,
History: history,
}, nil
})); err != nil {
return nil, err
}
// 按拓扑序为每个工具/检索节点加一个真实执行节点。
prev := "init"
idx := 0
if flow != nil {
for _, n := range flow.Topo() {
tool, args := dsl.ToolBinding(n)
if tool == "" {
continue
}
key := fmt.Sprintf("tool_%d", idx)
idx++
uid, _ := t.Meta[contract.MetaUserID].(string)
if err := g.AddLambdaNode(key, compose.InvokableLambda(o.makeToolNode(t.ID, tool, args, tr, uid))); err != nil {
return nil, err
}
if err := g.AddEdge(prev, key); err != nil {
return nil, err
}
prev = key
}
}
// prompt:黑板 → []*schema.Message(系统提示词 + 画像 + 工具产出 + 历史 + 用户输入)。
if err := g.AddLambdaNode("prompt", compose.InvokableLambda(
func(ctx context.Context, rc *RunCtx) ([]*schema.Message, error) {
msgs, err := buildMessages(ctx, rc)
tr.info("prompt", "prompt", "组装提示词", fmt.Sprintf("%d 条消息 · 工具产出 %d 段", len(msgs), len(rc.ToolOut)))
return msgs, err
})); err != nil {
return nil, err
}
if err := g.AddEdge(prev, "prompt"); err != nil {
return nil, err
}
// modelLLM Pool 流式(已配置在线模型则真实推理)。
if err := g.AddChatModelNode("model", newPoolModel(o.pool)); err != nil {
return nil, err
}
if err := g.AddEdge(compose.START, "init"); 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)
}
// makeToolNode 返回一个真实调用 MCP 工具的图节点:把结果增补进黑板,失败降级不阻断。
// uid 非空时把检索类工具的 kb 锁进 owner 作用域("uid/kb"),使编排检索命中本人知识库。
func (o *Orchestrator) makeToolNode(taskID, tool string, args map[string]any, tr *execTracer, uid string) func(context.Context, *RunCtx) (*RunCtx, error) {
node := "tool:" + tool
return func(ctx context.Context, rc *RunCtx) (*RunCtx, error) {
if o.tools == nil {
tr.info(node, "tool", "工具 "+tool, "工具总线未接入,跳过")
return rc, nil
}
// 未显式带查询词则注入当前用户输入,便于检索类工具。
call := map[string]any{}
for k, v := range args {
call[k] = v
}
if call["q"] == nil && call["query"] == nil {
call["q"] = rc.Query
}
// 检索类工具的 kb 按 owner 作用域,对齐知识库隔离(前端只发库名)。
if uid != "" {
if kbv, ok := call["kb"].(string); ok && kbv != "" && !strings.Contains(kbv, "/") {
call["kb"] = uid + "/" + kbv
}
}
end := tr.span(node, "tool", "调用工具 "+tool)
cctx, cancel := context.WithTimeout(ctx, toolCallTimeout)
defer cancel()
res, err := o.tools.CallTool(cctx, contract.ToolSubjectGo(tool), &contract.ToolCall{
Tool: tool, TaskID: taskID, Args: call,
})
if err != nil {
end("调用失败,降级跳过", err)
return rc, nil
}
if res == nil || !res.OK || res.Content == "" {
end("无结果,降级跳过", nil)
return rc, nil // 工具不可用/无结果 → 降级跳过
}
end("入参 "+previewArgs(call)+" → 产出 "+truncate(res.Content, 160), nil)
rc.ToolOut = append(rc.ToolOut, "["+tool+"] "+res.Content)
return rc, nil
}
}
// previewArgs 把工具入参压成一行短预览。
func previewArgs(args map[string]any) string {
if data, err := json.Marshal(args); err == nil {
return truncate(string(data), 120)
}
return ""
}
// buildMessages 把黑板组装为发给模型的消息序列。
// buildMessages 把上下文组装为发给模型的消息序列(系统提示词 + 画像 + 工具产出 + 历史 + 用户输入)。
func buildMessages(_ context.Context, rc *RunCtx) ([]*schema.Message, error) {
var sys strings.Builder
sys.WriteString(rc.System)
@@ -181,3 +39,11 @@ func buildMessages(_ context.Context, rc *RunCtx) ([]*schema.Message, error) {
msgs = append(msgs, schema.UserMessage(rc.Query))
return msgs, nil
}
// previewArgs 把工具入参压成一行短预览。
func previewArgs(args map[string]any) string {
if data, err := json.Marshal(args); err == nil {
return truncate(string(data), 120)
}
return ""
}