feat(ai-orch): Coordinator (Orchestrator-Worker) 패턴 구현
Build and Push Images / build-and-push (push) Has been cancelled

진짜 멀티 에이전트 오케스트레이션 — 라우터 + 워커 + 합성 3단계

Engine (multiAgentExecutionEngine.ts)
- execution_mode 'coordinator' 신규 추가
- executeCoordinator() 3 라운드 구현:
  Round 1: coordinator 가 사용자 요청 분해 → JSON delegations 출력
           system prompt 에 sub-agent 카탈로그(role/desc/connectors) 자동 inject
  Round 2: delegated sub-agent 들 각자 짧은 task 만 받아 병렬 실행
  Round 3: coordinator 가 모든 sub-agent 응답을 받아 사용자 친화적 최종 답변 합성
- coordinator 식별: member.config.is_coordinator === true 우선,
                    없으면 execution_order 가 가장 작은 멤버
- JSON 파싱 실패 시 모든 worker 에 원문 fallback

Service (aiAgentGroupService.ts)
- updateMember 에 config / is_coordinator 옵션 추가
  is_coordinator 단축키는 기존 config jsonb 와 || 머지

UI (workspace/page.tsx)
- EXEC_MODES 에 'coordinator' (Compass 아이콘) 추가
- MemberCardContent 에 코디네이터 토글 버튼 (왕관 아이콘) — 클릭 즉시
  updateGroupMember(is_coordinator) 호출
- 활성 시 분홍 배지 "코디네이터" 표시

검증 결과 (PLM 그룹 / 환율전문가=coordinator)
- 사용자: "환율과 화성시 날씨 알려줘"
  Round 1: 기상전문가 ← "화성시의 날씨 정보를 알려줘"
  Round 2: 기상전문가 → API 호출 → HTML 응답 → 정직하게 "데이터 못받음"
  Round 3: coordinator 가 환율/날씨 모두 정리한 단일 답변 합성

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
chpark
2026-04-28 20:19:01 +09:00
parent 9891c06038
commit e2845508fa
3 changed files with 197 additions and 4 deletions
@@ -126,6 +126,8 @@ export class AiAgentGroupService {
role_name?: string;
connectors?: ConnectorRef[];
execution_order?: number;
config?: Record<string, any>;
is_coordinator?: boolean;
}): Promise<GroupMember | null> {
const sets: string[] = [];
const params: any[] = [];
@@ -134,6 +136,12 @@ export class AiAgentGroupService {
if (data.role_name !== undefined) { sets.push(`role_name = $${idx++}`); params.push(data.role_name); }
if (data.connectors !== undefined) { sets.push(`connectors = $${idx++}::jsonb`); params.push(JSON.stringify(data.connectors)); }
if (data.execution_order !== undefined) { sets.push(`execution_order = $${idx++}`); params.push(data.execution_order); }
if (data.config !== undefined) { sets.push(`config = $${idx++}::jsonb`); params.push(JSON.stringify(data.config)); }
// is_coordinator 단축키 — 기존 config 와 머지
if (data.is_coordinator !== undefined && data.config === undefined) {
sets.push(`config = COALESCE(config, '{}'::jsonb) || $${idx++}::jsonb`);
params.push(JSON.stringify({ is_coordinator: data.is_coordinator }));
}
if (sets.length === 0) return null;
params.push(memberId);
@@ -78,6 +78,8 @@ export class MultiAgentExecutionEngine {
allResults = await this.executeParallel(group.members, enrichedMessage, "");
} else if (executionMode === "sequential") {
allResults = await this.executeSequential(group.members, enrichedMessage);
} else if (executionMode === "coordinator") {
allResults = await this.executeCoordinator(group.members, enrichedMessage, userMessage);
} else {
allResults = await this.executeMixed(group.members, enrichedMessage);
}
@@ -187,6 +189,157 @@ export class MultiAgentExecutionEngine {
return Promise.all(promises);
}
/**
* Orchestrator-Worker 패턴
* Round 1: coordinator 가 사용자 요청 분해 → JSON 라우팅 결정
* Round 2: 결정된 sub-agent 들 병렬 실행 (각자 자신의 task 만)
* Round 3: coordinator 가 결과 합성 → 사용자 친화적 최종 답변
*
* Coordinator 식별 우선순위:
* - member.config.is_coordinator === true
* - 없으면 execution_order 가 가장 작은 멤버
*/
private static async executeCoordinator(
members: GroupMember[],
userMessage: string,
originalUserMessage: string
): Promise<ExecutionResult[]> {
if (!members || members.length === 0) return [];
// 1) coordinator 식별
const coord =
members.find((m: any) => m.config?.is_coordinator === true) ||
members.slice().sort((a: any, b: any) => (a.execution_order ?? 999) - (b.execution_order ?? 999))[0];
const workers = members.filter((m: any) => m !== coord);
if (workers.length === 0) {
// worker 없으면 coordinator 단독 실행
return [await this.executeSingleAgent(coord, userMessage, "")];
}
// 2) Round 1 — 라우팅 계획 생성
// coordinator 에게 "사용 가능 에이전트 목록" + 원문 → JSON 출력 요구
const workerCatalog = workers
.map((m: GroupMember) => {
const conns = (m.connectors || []).map((c: any) => `${c.name}(${c.type})`).join(", ") || "없음";
const desc = (m as any).description || m.agent_name || m.role_name || "";
return `- role: "${m.role_name}"\n 설명: ${desc}\n 데이터 소스: ${conns}`;
})
.join("\n");
const planSystemPrompt =
`당신은 멀티에이전트 코디네이터입니다. 사용자 요청을 분석하고 적합한 sub-agent 에게 작업을 분배합니다.\n\n` +
`[사용 가능한 sub-agent 들]\n${workerCatalog}\n\n` +
`[작업 분배 규칙]\n` +
`1. 사용자 요청을 의도별로 분해하라. 예: "환율과 날씨" → 환율 부분 + 날씨 부분.\n` +
`2. 각 sub-agent 의 역할/데이터소스에 맞는 부분만 정확히 그 에이전트에게 분배한다.\n` +
`3. 어떤 sub-agent 도 처리할 수 없는 부분은 분배하지 않고 빈 배열로 답한다.\n` +
`4. 한 sub-agent 에게는 자기 역할에 해당하는 부분만 짧고 명확하게 전달한다 (사용자 원문을 그대로 넘기지 말 것).\n` +
`5. 출력은 반드시 다음 JSON 스키마 한 가지로만, 다른 설명 없이 출력한다:\n` +
`{\n "delegations": [\n { "role": "<role 정확히 일치>", "task": "<해당 에이전트가 처리할 짧은 지시문>" }\n ]\n}\n` +
`6. delegations 가 비어 있으면 코디네이터 본인이 직접 답한다.\n`;
const planResp = await LlmClient.chatCompletion({
model: coord.agent_model || (coord as any).agent?.model || (coord as any).model || "claude-sonnet-4-20250514",
messages: [
{ role: "system", content: planSystemPrompt },
{ role: "user", content: `사용자 원문 메시지:\n${originalUserMessage}` },
],
max_tokens: 2048,
temperature: 0.2,
} as any);
const planText = planResp.choices?.[0]?.message?.content || "";
const planTokens = planResp.usage?.total_tokens || 0;
// JSON 파싱 (코드블록/잡설 제거)
let delegations: Array<{ role: string; task: string }> = [];
try {
const cleaned = planText
.replace(/```json\s*/gi, "")
.replace(/```\s*$/g, "")
.replace(/^[^{]*({[\s\S]*})[^}]*$/, "$1");
const parsed = JSON.parse(cleaned);
delegations = Array.isArray(parsed?.delegations) ? parsed.delegations : [];
} catch (err) {
logger.warn(`[Coordinator] 라우팅 JSON 파싱 실패. 모든 worker 에 원문 전달로 fallback. raw=${planText.substring(0, 200)}`);
delegations = workers.map((m: any) => ({ role: m.role_name, task: originalUserMessage }));
}
const planResult: ExecutionResult = {
memberId: coord.id,
roleName: coord.role_name,
agentName: coord.agent_name || coord.role_name,
modelName: coord.agent_model || "",
executionOrder: 0,
response:
`[코디네이터 - 작업 분배 계획]\n` +
(delegations.length === 0
? "(분배할 sub-agent 없음 — 코디네이터 단독 응답)"
: delegations.map((d, i) => ` ${i + 1}. ${d.role} ← "${d.task}"`).join("\n")),
tokensUsed: planTokens,
durationMs: 0,
};
// 3) Round 2 — sub-agent 병렬 실행 (역할명 일치하는 것만)
const workerResults: ExecutionResult[] = [];
if (delegations.length > 0) {
const tasks = delegations
.map((d) => {
const target = workers.find((w: any) => w.role_name === d.role);
if (!target) return null;
return this.executeSingleAgent(target, d.task, "");
})
.filter((p) => p !== null) as Promise<ExecutionResult>[];
const settled = await Promise.all(tasks);
workerResults.push(...settled);
}
// 4) Round 3 — coordinator 가 결과 합성
const synthesisSystemPrompt =
`당신은 멀티에이전트 코디네이터입니다. sub-agent 들이 분담해서 만든 답변을 모아 사용자에게 일관된 최종 답변을 작성합니다.\n` +
`[작성 규칙]\n` +
`1. sub-agent 답변에 있는 사실/수치는 그대로 인용하고 추측을 더하지 않는다.\n` +
`2. 사용자가 물어본 모든 부분에 대해 답을 정리한다 (못 받은 부분은 "데이터 소스 미연결" 등으로 명시).\n` +
`3. 마크다운 사용 가능, 핵심 수치는 굵게 또는 표로 정리.\n` +
`4. sub-agent 의 신원(역할명) 을 굳이 노출할 필요는 없으나 자료 출처(예: rates.JPY) 는 유지한다.\n` +
`5. 인사말/잡설 없이 곧바로 답변 본문부터 시작한다.`;
const collected = workerResults.length > 0
? workerResults
.map((r) => `--- [${r.roleName}] ---\n${r.response}`)
.join("\n\n")
: "(sub-agent 응답 없음)";
const synthResp = await LlmClient.chatCompletion({
model: coord.agent_model || (coord as any).agent?.model || (coord as any).model || "claude-sonnet-4-20250514",
messages: [
{ role: "system", content: synthesisSystemPrompt },
{
role: "user",
content:
`사용자 원문 질문:\n${originalUserMessage}\n\n` +
`sub-agent 들의 분담 답변:\n${collected}\n\n` +
`위를 종합해 사용자 친화적 최종 답변을 작성하라.`,
},
],
temperature: 0.5,
} as any);
const synthText = synthResp.choices?.[0]?.message?.content || "";
const synthTokens = synthResp.usage?.total_tokens || 0;
const finalResult: ExecutionResult = {
memberId: coord.id,
roleName: `${coord.role_name} (최종)`,
agentName: coord.agent_name || coord.role_name,
modelName: coord.agent_model || "",
executionOrder: 999,
response: synthText,
tokensUsed: synthTokens,
durationMs: 0,
};
return [planResult, ...workerResults, finalResult];
}
/**
* 혼합 실행: execution_order 같으면 병렬, 다르면 순차
*/
@@ -8,7 +8,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/u
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Network, Plus, Pencil, Trash2, Loader2, Bot, Database, Globe, FileText, Cpu, Bug, X, Zap, ListOrdered, Shuffle, GripVertical, ArrowDown, Check } from "lucide-react";
import { Network, Plus, Pencil, Trash2, Loader2, Bot, Database, Globe, FileText, Cpu, Bug, X, Zap, ListOrdered, Shuffle, GripVertical, ArrowDown, Check, Crown, Compass } from "lucide-react";
import { toast } from "sonner";
import { DndContext, closestCenter, PointerSensor, useSensor, useSensors, type DragEndEvent } from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy, useSortable } from "@dnd-kit/sortable";
@@ -20,6 +20,7 @@ const EXEC_MODES = [
{ value: "parallel", label: "병렬", desc: "모두 동시 실행", icon: Zap, color: "text-amber-500" },
{ value: "sequential", label: "순차", desc: "1→2→3 순서대로", icon: ListOrdered, color: "text-blue-500" },
{ value: "mixed", label: "혼합", desc: "같은 순서=병렬", icon: Shuffle, color: "text-violet-500" },
{ value: "coordinator", label: "코디네이터", desc: "리더가 분배·합성", icon: Compass, color: "text-rose-500" },
];
export default function WorkspacePage() {
@@ -614,12 +615,25 @@ const CONNECTOR_TYPE_OPTIONS = [
function MemberCardContent({ member, connectors, onRemove, onAddConnector, onRemoveConnector }: MemberCardProps) {
const [pickerOpen, setPickerOpen] = useState(false);
const [selectedType, setSelectedType] = useState<string | null>(null);
const [isCoord, setIsCoord] = useState(!!member.config?.is_coordinator);
const memberConnectors = member.connectors || [];
const filteredConnectors = selectedType
? connectors.filter((c) => c.type === selectedType)
: [];
const toggleCoordinator = async () => {
const next = !isCoord;
setIsCoord(next);
try {
await aiAgentApi.updateGroupMember(member.id, { is_coordinator: next });
toast.success(next ? "코디네이터로 지정" : "코디네이터 해제");
} catch (e: any) {
setIsCoord(!next);
toast.error(e?.response?.data?.message || "코디네이터 토글 실패");
}
};
return (
<div>
<div className="flex items-start justify-between">
@@ -628,12 +642,30 @@ function MemberCardContent({ member, connectors, onRemove, onAddConnector, onRem
<Bot className="h-3.5 w-3.5 text-primary" />
<span className="text-xs font-semibold">{member.role_name}</span>
<Badge variant="outline" className="h-4 text-[9px]">{member.agent_name || "에이전트"}</Badge>
{isCoord && (
<Badge className="h-4 gap-0.5 bg-rose-500/15 px-1 text-[9px] text-rose-700 hover:bg-rose-500/20">
<Crown className="h-2.5 w-2.5" />
</Badge>
)}
</div>
<p className="mt-0.5 ml-5 text-[10px] text-muted-foreground font-mono">{member.agent_model}</p>
</div>
<button className="flex h-5 w-5 shrink-0 items-center justify-center rounded text-destructive hover:bg-destructive/10" onClick={onRemove}>
<Trash2 className="h-3 w-3" />
</button>
<div className="flex items-center gap-1">
<button
onClick={toggleCoordinator}
className={`flex h-5 px-1.5 items-center justify-center rounded text-[9px] gap-0.5 transition-colors ${
isCoord
? "bg-rose-500/20 text-rose-700 hover:bg-rose-500/30"
: "border border-dashed text-muted-foreground hover:border-rose-400 hover:text-rose-500"
}`}
title="이 에이전트를 그룹 코디네이터로 지정 (요청 분배·결과 합성 담당)"
>
<Crown className="h-2.5 w-2.5" />
</button>
<button className="flex h-5 w-5 shrink-0 items-center justify-center rounded text-destructive hover:bg-destructive/10" onClick={onRemove}>
<Trash2 className="h-3 w-3" />
</button>
</div>
</div>
{/* 연결된 커넥터 */}