feat(ai-orch): Coordinator (Orchestrator-Worker) 패턴 구현
Build and Push Images / build-and-push (push) Has been cancelled
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:
@@ -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,13 +642,31 @@ 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>
|
||||
<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>
|
||||
|
||||
{/* 연결된 커넥터 */}
|
||||
<div className="mt-2 ml-5 flex flex-wrap items-center gap-1">
|
||||
|
||||
Reference in New Issue
Block a user