feat(agent): 多智能体协同 v1 —— coordinator 节点(Eino×Anthropic 融合)
LLM 自主在 agent 间路由/委派:orchestrator=ReAct(Eino 出机器),编排认知按 Anthropic orchestrator-worker 配方(出脑子)。专家=包成工具的子 agent(agent-as-tool),lead 给 每个专家写定制简报(brief)后并行派发、综合。方案见 MULTI_AGENT.md。 为什么 agent-as-tool 而非 Eino host:host 的 specialist 拿原始输入(preHandler return state.msgs),传不了 lead 写的定制简报,而定制简报正是 Anthropic 多智能体的 精髓。agent-as-tool 让 orchestrator 自己 emit 工具调用、参数 brief 即简报。 = OpenAI agent.as_tool() / Anthropic 研究系统的 orchestrator-worker。 - coordinator.go: specialistTool(react.Agent/ChatModel 包成 InvokableTool,入参 brief, 精炼返回) + parseSpecialists/buildSpecialists(带工具→react,不带→ChatModel,MCP 工具 按 spec.tools 过滤) + runCoordinator(lead 提示词=Anthropic 配方) + leadOrchestratorPrompt。 - 双路接入 execDSLNode(compose)+ runGraph(graph.go)的 case coordinator。 - 护栏:禁套娃(专家是内联叶子)/ MaxStep / 专家 I/O 计入共享 Budget / 降级(无 ToolCallingModel 或 0 专家 → runAgent)。 - streamAgentReply:抽出 runReactAgent 与 runCoordinator 共用的流式回流尾段。 - 复用即得:evaluator-optimizer=harness 低分纠偏;成本天花板=预算护栏;上下文隔离= 专家独立 react.Agent;观测=每次派发落 agent 轨迹。 测试:parseSpecialists / agent-as-tool 包装(brief 透传+精炼返回+失败作观察) / 降级。 live 验证(真 deepseek):两专家**并行派发**、lead 给各自写**不同定制简报**、最终 **综合**(非拼接)成稿,评测 1.00。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -292,6 +292,8 @@ func (o *Orchestrator) execDSLNode(ctx context.Context, t *contract.Task, n dsl.
|
||||
} else {
|
||||
o.runAgent(ctx, t.ID, b, sys, tr, "agent:"+n.ID)
|
||||
}
|
||||
case "coordinator": // 多智能体协调:orchestrator 自主把子任务派给专家(agent-as-tool)再综合
|
||||
o.runCoordinator(ctx, t.ID, b, firstNonEmpty(cstr(n.Config, "system"), plan.System), n, tr, "coordinator:"+n.ID)
|
||||
case "aggregate":
|
||||
merged := aggregate(cstr(n.Config, "strategy"), append(append([]string{}, b.refs...), b.toolOut...))
|
||||
b.refs, b.toolOut = merged, nil
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
package eino
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cloudwego/eino/components/tool"
|
||||
"github.com/cloudwego/eino/compose"
|
||||
"github.com/cloudwego/eino/flow/agent/react"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
|
||||
"github.com/sundynix/sundynix-dispatcher/internal/dsl"
|
||||
"github.com/sundynix/sundynix-dispatcher/internal/harness"
|
||||
)
|
||||
|
||||
// 多智能体协同(Eino × Anthropic 融合,详见仓库 MULTI_AGENT.md):
|
||||
// orchestrator = react.Agent(机器=Eino),编排认知按 Anthropic orchestrator-worker 配方;
|
||||
// 专家 = 包成工具的子 agent(agent-as-tool),lead 给每个专家写定制简报(brief)后并行派发、综合。
|
||||
|
||||
// leadOrchestratorPrompt 是协调者(lead)的编排提示词 —— Anthropic 配方:先分解、按复杂度定额(别过度派发)、
|
||||
// 给每个专家写明确简报、并行派发、收齐后综合消解冲突,不直接拼接专家原文。
|
||||
const leadOrchestratorPrompt = `你是多智能体协调者(lead)。你手下有若干专家,每个专家是你可以调用的一个工具。
|
||||
工作方式:
|
||||
1. 先想清楚任务能否拆分、需要哪几个专家、各花多少功夫——简单任务用 1 个专家甚至自己直接答,复杂任务才并行派多个,不要过度派发。
|
||||
2. 调用专家时,在 brief 参数里给它写一份明确的简报:要它完成的子任务、期望的输出形式、边界与约束;不要把原问题原样丢给它。
|
||||
3. 可以在一轮里并行调用多个专家。收齐各专家的结论后,由你综合成一段连贯、完整的最终答复,消解专家之间的冲突,必要时点明哪个结论来自哪个专家。
|
||||
不要把专家的原始返回直接拼接给用户——要消化、综合。`
|
||||
|
||||
// specialistCondensedSuffix 追加到每个专家的系统提示词:要求精炼返回,省 orchestrator 综合时的上下文。
|
||||
const specialistCondensedSuffix = "\n\n返回要求:只给结论与关键依据要点,简洁,不要堆砌原始材料或冗长背景。"
|
||||
|
||||
// specialistSpec 是协调者节点配置里的一个子智能体(专家)定义。
|
||||
type specialistSpec struct {
|
||||
Name string
|
||||
Use string // 用途/擅长,作为工具描述供 lead 判断何时调
|
||||
System string // 专家人设
|
||||
Tools []string // 该专家可用的 MCP 工具名子集(空=纯对话专家)
|
||||
}
|
||||
|
||||
// parseSpecialists 从协调者节点 config 的 agents 列表解析出专家定义。
|
||||
func parseSpecialists(cfg map[string]any) []specialistSpec {
|
||||
raw, _ := cfg["agents"].([]any)
|
||||
var out []specialistSpec
|
||||
for _, item := range raw {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
s := specialistSpec{Name: cstr(m, "name"), Use: cstr(m, "use"), System: cstr(m, "system")}
|
||||
if s.Name == "" {
|
||||
continue
|
||||
}
|
||||
if tools, ok := m["tools"].([]any); ok {
|
||||
for _, t := range tools {
|
||||
if ts, ok := t.(string); ok && strings.TrimSpace(ts) != "" {
|
||||
s.Tools = append(s.Tools, strings.TrimSpace(ts))
|
||||
}
|
||||
}
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// specialistTool 把一个专家(react.Agent 或 ChatModel)包成 Eino InvokableTool(agent-as-tool):
|
||||
// 入参 brief = lead 写给它的定制简报;返回专家的精炼结论。每次派发落一条 agent 轨迹。
|
||||
type specialistTool struct {
|
||||
info *schema.ToolInfo
|
||||
name string
|
||||
run func(ctx context.Context, brief string) (string, error)
|
||||
tr *execTracer
|
||||
}
|
||||
|
||||
func (s *specialistTool) Info(context.Context) (*schema.ToolInfo, error) { return s.info, nil }
|
||||
|
||||
// InvokableRun 跑一次专家派发:取 lead 写的 brief → 执行子 agent → 返回精炼结论给 orchestrator。
|
||||
// 专家失败作为"观察"返回(不中断协调);专家 I/O 计入共享预算(Anthropic 的成本克制由 Budget 兜底)。
|
||||
func (s *specialistTool) InvokableRun(ctx context.Context, argsJSON string, _ ...tool.Option) (string, error) {
|
||||
var args struct {
|
||||
Brief string `json:"brief"`
|
||||
}
|
||||
if argsJSON != "" {
|
||||
_ = json.Unmarshal([]byte(argsJSON), &args)
|
||||
}
|
||||
brief := strings.TrimSpace(args.Brief)
|
||||
if brief == "" {
|
||||
brief = "(协调者未给简报,请按你的职责处理当前任务)"
|
||||
}
|
||||
if bud := harness.BudgetFrom(ctx); bud != nil {
|
||||
bud.AddPrompt(brief)
|
||||
}
|
||||
end := s.tr.span("agent:"+s.name, "agent", "派发专家 "+s.name)
|
||||
out, err := s.run(ctx, brief)
|
||||
if err != nil {
|
||||
end("专家执行失败", err)
|
||||
return "专家 " + s.name + " 执行失败:" + err.Error(), nil
|
||||
}
|
||||
if bud := harness.BudgetFrom(ctx); bud != nil {
|
||||
bud.AddComplete(out)
|
||||
}
|
||||
end("简报 "+truncate(brief, 100)+" → "+truncate(out, 160), nil)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// buildSpecialists 据配置建专家工具集。带工具→react.Agent(过滤后的 MCP 工具子集);不带→纯 ChatModel。
|
||||
// 无可用模型的专家跳过。专家是内联叶子(非图节点),天然不能再是 coordinator → 杜绝套娃递归。
|
||||
func (o *Orchestrator) buildSpecialists(ctx context.Context, specs []specialistSpec, b *board, taskID string, tr *execTracer) []tool.BaseTool {
|
||||
allTools := o.agentTools(b, taskID, tr) // 全量 MCP 工具,下面按 spec.Tools 过滤
|
||||
byName := map[string]tool.BaseTool{}
|
||||
for _, t := range allTools {
|
||||
if info, err := t.Info(ctx); err == nil {
|
||||
byName[info.Name] = t
|
||||
}
|
||||
}
|
||||
var out []tool.BaseTool
|
||||
for _, spec := range specs {
|
||||
sys := firstNonEmpty(spec.System, defaultAgentSystem) + specialistCondensedSuffix
|
||||
run := o.specialistRunner(ctx, spec, sys, byName)
|
||||
if run == nil {
|
||||
tr.info("coordinator", "system", "专家跳过", "无可用模型:"+spec.Name)
|
||||
continue
|
||||
}
|
||||
out = append(out, &specialistTool{
|
||||
name: spec.Name, tr: tr, run: run,
|
||||
info: &schema.ToolInfo{
|
||||
Name: spec.Name,
|
||||
Desc: firstNonEmpty(spec.Use, "专家 "+spec.Name),
|
||||
ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{
|
||||
"brief": {Type: schema.String, Desc: "给该专家的明确简报:子任务、期望输出、边界约束", Required: true},
|
||||
}),
|
||||
},
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// specialistRunner 构造专家执行闭包:带工具且模型支持函数调用→react.Agent.Generate;否则→ChatModel.Generate。
|
||||
// 无可用模型返回 nil(该专家被跳过)。
|
||||
func (o *Orchestrator) specialistRunner(ctx context.Context, spec specialistSpec, sys string, byName map[string]tool.BaseTool) func(context.Context, string) (string, error) {
|
||||
var tools []tool.BaseTool
|
||||
for _, tn := range spec.Tools {
|
||||
if t, ok := byName[tn]; ok {
|
||||
tools = append(tools, t)
|
||||
}
|
||||
}
|
||||
if len(tools) > 0 {
|
||||
if tcm := o.pool.ToolCallingModel(); tcm != nil {
|
||||
ag, err := react.NewAgent(ctx, &react.AgentConfig{
|
||||
ToolCallingModel: tcm,
|
||||
ToolsConfig: compose.ToolsNodeConfig{Tools: tools},
|
||||
MaxStep: reactMaxStep(),
|
||||
StreamToolCallChecker: streamHasToolCall,
|
||||
})
|
||||
if err == nil {
|
||||
return func(c context.Context, brief string) (string, error) {
|
||||
msg, gerr := ag.Generate(c, []*schema.Message{schema.SystemMessage(sys), schema.UserMessage(brief)})
|
||||
if gerr != nil {
|
||||
return "", gerr
|
||||
}
|
||||
return msg.Content, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
// 工具型专家但模型不支持函数调用 → 退纯对话(下方)
|
||||
}
|
||||
cm := o.pool.ChatModel()
|
||||
if cm == nil {
|
||||
return nil
|
||||
}
|
||||
return func(c context.Context, brief string) (string, error) {
|
||||
msg, gerr := cm.Generate(c, []*schema.Message{schema.SystemMessage(sys), schema.UserMessage(brief)})
|
||||
if gerr != nil {
|
||||
return "", gerr
|
||||
}
|
||||
return msg.Content, nil
|
||||
}
|
||||
}
|
||||
|
||||
// runCoordinator 执行多智能体协调节点:orchestrator = ReAct(专家=agent-as-tool),lead 提示词按
|
||||
// Anthropic 配方(分解→定制简报→并行派发→综合)。无 ToolCallingModel / 0 可用专家 → 降级单 agent。
|
||||
func (o *Orchestrator) runCoordinator(ctx context.Context, taskID string, b *board, system string, n dsl.Node, tr *execTracer, node string) {
|
||||
specs := parseSpecialists(n.Config)
|
||||
tcm := o.pool.ToolCallingModel()
|
||||
if tcm == nil || len(specs) == 0 {
|
||||
tr.info(node, "system", "协调者降级", "模型不支持函数调用或未配置专家,退回普通对话")
|
||||
o.runAgent(ctx, taskID, b, system, tr, node)
|
||||
return
|
||||
}
|
||||
specialists := o.buildSpecialists(ctx, specs, b, taskID, tr)
|
||||
if len(specialists) == 0 {
|
||||
tr.info(node, "system", "协调者降级", "无可用专家,退回普通对话")
|
||||
o.runAgent(ctx, taskID, b, system, tr, node)
|
||||
return
|
||||
}
|
||||
|
||||
ag, err := react.NewAgent(ctx, &react.AgentConfig{
|
||||
ToolCallingModel: tcm,
|
||||
ToolsConfig: compose.ToolsNodeConfig{Tools: specialists},
|
||||
MaxStep: reactMaxStep(),
|
||||
StreamToolCallChecker: streamHasToolCall,
|
||||
})
|
||||
if err != nil {
|
||||
tr.emit(node, "model", "error", "构建协调者", err.Error(), 0)
|
||||
o.runAgent(ctx, taskID, b, system, tr, node)
|
||||
return
|
||||
}
|
||||
|
||||
// lead 提示词 = Anthropic 编排配方 + 节点自定义任务背景。
|
||||
leadSys := leadOrchestratorPrompt
|
||||
if s := strings.TrimSpace(system); s != "" && s != defaultAgentSystem {
|
||||
leadSys += "\n\n任务背景:" + s
|
||||
}
|
||||
rc := &RunCtx{
|
||||
UserID: b.uid, SessionID: b.sid,
|
||||
System: leadSys,
|
||||
Query: b.query,
|
||||
History: b.history,
|
||||
ToolOut: append(append([]string{}, b.toolOut...), b.refs...),
|
||||
Upstream: append([]string{}, b.agentOut...), // 前序协作 agent 产出 → 接力
|
||||
}
|
||||
msgs, _ := buildMessages(ctx, rc)
|
||||
if bud := harness.BudgetFrom(ctx); bud != nil {
|
||||
for _, m := range msgs {
|
||||
bud.AddPrompt(m.Content)
|
||||
}
|
||||
if bud.Exceeded() {
|
||||
if b.fatalErr == nil {
|
||||
b.fatalErr = errBudget
|
||||
}
|
||||
tr.emit(node, "system", "error", "token 预算", "已达单任务预算上限,中止", 0)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
tr.emit(node, "model", "start", "多智能体协调", fmt.Sprintf("%d 个专家可派发", len(specialists)), 0)
|
||||
t0 := time.Now()
|
||||
sr, err := ag.Stream(ctx, msgs)
|
||||
if err != nil {
|
||||
tr.emit(node, "model", "error", "多智能体协调", err.Error(), time.Since(t0).Milliseconds())
|
||||
return
|
||||
}
|
||||
defer sr.Close()
|
||||
o.streamAgentReply(ctx, taskID, b, sr, tr, node, "多智能体协调", t0)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package eino
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/cloudwego/eino/schema"
|
||||
|
||||
"github.com/sundynix/sundynix-dispatcher/internal/dsl"
|
||||
"github.com/sundynix/sundynix-dispatcher/internal/harness"
|
||||
"github.com/sundynix/sundynix-dispatcher/internal/llm"
|
||||
)
|
||||
|
||||
// TestParseSpecialists 钉死节点 config 的 agents 列表解析(含 tools 子集、无名跳过)。
|
||||
func TestParseSpecialists(t *testing.T) {
|
||||
cfg := map[string]any{"agents": []any{
|
||||
map[string]any{"name": "legal", "use": "法律条款", "system": "你是律师", "tools": []any{"wiki_search", "kb_search"}},
|
||||
map[string]any{"name": "finance", "use": "财务测算"},
|
||||
map[string]any{"use": "无名字应被跳过"},
|
||||
}}
|
||||
specs := parseSpecialists(cfg)
|
||||
if len(specs) != 2 {
|
||||
t.Fatalf("应解析出 2 个专家(无名跳过),got %d", len(specs))
|
||||
}
|
||||
if specs[0].Name != "legal" || specs[0].Use != "法律条款" || len(specs[0].Tools) != 2 {
|
||||
t.Fatalf("legal 解析有误: %+v", specs[0])
|
||||
}
|
||||
if specs[1].Name != "finance" || len(specs[1].Tools) != 0 {
|
||||
t.Fatalf("finance 解析有误: %+v", specs[1])
|
||||
}
|
||||
}
|
||||
|
||||
// TestSpecialistToolInvoke 钉死 agent-as-tool 包装:lead 写的 brief 透传给专家、结论回传、
|
||||
// 空 brief 兜底、专家失败作为"观察"返回而非中断协调。
|
||||
func TestSpecialistToolInvoke(t *testing.T) {
|
||||
var gotBrief string
|
||||
st := &specialistTool{
|
||||
name: "legal", tr: &execTracer{},
|
||||
info: &schema.ToolInfo{Name: "legal"},
|
||||
run: func(_ context.Context, brief string) (string, error) { gotBrief = brief; return "结论:条款合规", nil },
|
||||
}
|
||||
|
||||
out, err := st.InvokableRun(context.Background(), `{"brief":"审查合同第3条违约金"}`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotBrief != "审查合同第3条违约金" {
|
||||
t.Fatalf("lead 简报应透传给专家,got %q", gotBrief)
|
||||
}
|
||||
if out != "结论:条款合规" {
|
||||
t.Fatalf("专家结论应回传,got %q", out)
|
||||
}
|
||||
|
||||
// 空 brief → 兜底文案(不把空串丢给专家)。
|
||||
st.run = func(_ context.Context, brief string) (string, error) { gotBrief = brief; return "ok", nil }
|
||||
_, _ = st.InvokableRun(context.Background(), `{}`)
|
||||
if !strings.Contains(gotBrief, "未给简报") {
|
||||
t.Fatalf("空 brief 应兜底,got %q", gotBrief)
|
||||
}
|
||||
|
||||
// 专家失败 → 返回失败观察,err 为 nil(不中断 orchestrator 的 ReAct 循环)。
|
||||
st.run = func(_ context.Context, _ string) (string, error) { return "", fmt.Errorf("boom") }
|
||||
out, err = st.InvokableRun(context.Background(), `{"brief":"x"}`)
|
||||
if err != nil {
|
||||
t.Fatalf("专家失败不应上抛 error(应作观察返回): %v", err)
|
||||
}
|
||||
if !strings.Contains(out, "执行失败") {
|
||||
t.Fatalf("应返回失败观察,got %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunCoordinatorDegrade 钉死降级:模型不支持函数调用(ToolCallingModel=nil)→ 退回普通对话出稿,
|
||||
// 不因"多智能体"而卡死或空答。
|
||||
func TestRunCoordinatorDegrade(t *testing.T) {
|
||||
fs := &fakeSink{}
|
||||
o := &Orchestrator{
|
||||
pool: &fakeLLM{ready: true, stream: func([]llm.ChatMessage) string { return "降级直答" }},
|
||||
breaker: harness.NewCircuitBreaker(),
|
||||
sink: fs,
|
||||
}
|
||||
b := &board{query: "你好"}
|
||||
n := dsl.Node{ID: "c", Kind: "coordinator", Config: map[string]any{
|
||||
"agents": []any{map[string]any{"name": "x", "use": "y"}},
|
||||
}}
|
||||
o.runCoordinator(context.Background(), "t_co", b, "", n, &execTracer{}, "coordinator:c")
|
||||
if !strings.Contains(b.answer, "降级直答") {
|
||||
t.Fatalf("ToolCallingModel 缺失应降级 runAgent 出答案,got %q", b.answer)
|
||||
}
|
||||
}
|
||||
@@ -138,6 +138,8 @@ func (o *Orchestrator) runGraph(ctx context.Context, t *contract.Task, tr *execT
|
||||
} else {
|
||||
o.runConversation(nctx, t.ID, b, sys, tr, "agent:"+n.ID)
|
||||
}
|
||||
case "coordinator": // 多智能体协调:orchestrator 自主把子任务派给专家(agent-as-tool)再综合
|
||||
o.runCoordinator(nctx, t.ID, b, firstNonEmpty(cstr(n.Config, "system"), plan.System), n, tr, "coordinator:"+n.ID)
|
||||
case "aggregate":
|
||||
merged := aggregate(cstr(n.Config, "strategy"), append(append([]string{}, b.refs...), b.toolOut...))
|
||||
b.refs, b.toolOut = merged, nil
|
||||
|
||||
@@ -225,7 +225,12 @@ func (o *Orchestrator) runReactAgent(ctx context.Context, taskID string, b *boar
|
||||
return
|
||||
}
|
||||
defer sr.Close()
|
||||
o.streamAgentReply(ctx, taskID, b, sr, tr, node, "ReAct 智能体", t0)
|
||||
}
|
||||
|
||||
// streamAgentReply 把一个 agent / 协调者的流式输出回流到 sink:跨分片脱敏 + 计输出 token +
|
||||
// 落产出(供下游接力) + 轨迹收尾。runReactAgent 与 runCoordinator 共用,杜绝两路尾段漂移。
|
||||
func (o *Orchestrator) streamAgentReply(ctx context.Context, taskID string, b *board, sr *schema.StreamReader[*schema.Message], tr *execTracer, node, label string, t0 time.Time) {
|
||||
chunks := 0
|
||||
var produced strings.Builder // 本节点产出(供下游 agent 接力)
|
||||
red := harness.NewStreamRedactor() // 输出护栏:跨分片脱敏,杜绝密钥被切断而漏检
|
||||
@@ -243,7 +248,7 @@ func (o *Orchestrator) runReactAgent(ctx context.Context, taskID string, b *boar
|
||||
break
|
||||
}
|
||||
if rerr != nil {
|
||||
tr.emit(node, "model", "error", "ReAct 智能体", rerr.Error(), time.Since(t0).Milliseconds())
|
||||
tr.emit(node, "model", "error", label, rerr.Error(), time.Since(t0).Milliseconds())
|
||||
return
|
||||
}
|
||||
if chunk.Content == "" {
|
||||
@@ -256,6 +261,6 @@ func (o *Orchestrator) runReactAgent(ctx context.Context, taskID string, b *boar
|
||||
bud.AddComplete(produced.String()) // 成本护栏:计入输出 token
|
||||
}
|
||||
o.recordAgentOutput(b, produced.String())
|
||||
tr.emit(node, "model", "end", "ReAct 智能体",
|
||||
tr.emit(node, "model", "end", label,
|
||||
fmt.Sprintf("%d 段输出 / %d 字", chunks, len([]rune(produced.String()))), time.Since(t0).Milliseconds())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user