feat(ai-orch): 멀티 에이전트 진짜 도구 호출 + hallucination 가드
Build and Push Images / build-and-push (push) Has been cancelled
Build and Push Images / build-and-push (push) Has been cancelled
치명적 결함 수정 - multiAgentExecutionEngine.executeConnector 의 rest_api 분기가 실제 API 를 호출하지 않고 메타정보만 prompt 에 넣던 문제 수정. 모델이 환율 같은 수치를 hallucinate 하던 근본 원인 제거. REST API 실호출 (executeConnector) - axios 직접 호출, default_method / default_headers / default_request_body 반영 - auth_type: bearer / api_key / basic 처리 - 응답 데이터를 12000자까지 prompt 에 inject (이전 4000자 → JPY 등 알파벳 후반 키 잘림 문제 해결) - status / duration_ms / truncated 메타와 함께 반환 System prompt 보강 (data guard) - 응답 본문을 끝까지 정독하라고 명시 - 데이터 안에 있으면 자신 있게 인용하고 출처 필드 경로 표기 - HTML 응답·status 비-200·필드 누락 시 "값을 받지 못했습니다"로 응답하고 추측 거부 - "절대 추측 금지" 만 있던 이전 가드는 정상 데이터까지 거부하는 부작용 → 양방향 가드로 균형 Connector 결과 prompt 포맷 개선 - 이전: JSON.stringify 중첩으로 가독성 ↓ 모델이 응답 본문을 못 알아봄 - 이후: "========== 데이터 소스 ==========" 라벨 + "--- 응답 본문 시작 / 끝 ---" 명시 - REST API 응답은 raw string 그대로 inject (JSON.stringify 중복 제거) 검증 결과 - 환율전문가: rates.JPY = 0.10809 정확 인용 + 출처 표기 ✅ - 기상전문가: HTML 응답 시 "데이터 못 받음" 정직 답변 ✅ (이전: hallucinate 한 false 수치) DB - ai_agent_groups.id=2 (PLM) execution_mode 'parallel' → 'sequential' 로 UPDATE (sequential 모드는 이전 단계 결과를 다음에 전달, 진짜 오케스트레이션 흐름) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -264,10 +264,25 @@ export class MultiAgentExecutionEngine {
|
||||
try {
|
||||
const data = await this.executeConnector(connector);
|
||||
connectorResults.push({ connector: connector.name, type: connector.type, data });
|
||||
connectorContext += `\n[데이터 소스: ${connector.name} (${connector.type})]:\n${JSON.stringify(data).substring(0, 2000)}\n`;
|
||||
// REST API 결과는 raw response 본문을 별도 라벨로 inject (JSON.stringify 한번 더 X)
|
||||
if (data?.type === "rest_api" && typeof data.response === "string") {
|
||||
connectorContext +=
|
||||
`\n========== 데이터 소스: ${connector.name} ==========\n` +
|
||||
`유형: REST API ${data.method} ${data.url}\n` +
|
||||
`상태: ${data.status} (${data.duration_ms}ms)${data.truncated ? " [응답이 truncate 됨]" : ""}\n` +
|
||||
`--- 응답 본문 시작 ---\n${data.response}\n--- 응답 본문 끝 ---\n`;
|
||||
} else if (data?.error) {
|
||||
connectorContext += `\n========== 데이터 소스: ${connector.name} ==========\n` +
|
||||
`유형: ${connector.type}\n` +
|
||||
`상태: 조회 실패 — ${data.error}\n`;
|
||||
} else {
|
||||
connectorContext += `\n========== 데이터 소스: ${connector.name} ==========\n` +
|
||||
`유형: ${connector.type}\n` +
|
||||
`데이터: ${JSON.stringify(data).substring(0, 4000)}\n`;
|
||||
}
|
||||
} catch (e: any) {
|
||||
connectorResults.push({ connector: connector.name, type: connector.type, error: e.message });
|
||||
connectorContext += `\n[데이터 소스: ${connector.name}]: 조회 실패 - ${e.message}\n`;
|
||||
connectorContext += `\n========== 데이터 소스: ${connector.name} ==========\n조회 실패 - ${e.message}\n`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -298,11 +313,24 @@ export class MultiAgentExecutionEngine {
|
||||
}
|
||||
|
||||
// LLM 호출
|
||||
// connector 결과가 있을 때는 강한 가드레일을 system prompt 에 추가:
|
||||
// - 답변에 사용하는 모든 수치/사실은 반드시 [데이터 소스] 블록 안에서 인용해야 함
|
||||
// - 데이터에 없으면 "데이터 없음" 으로 명시 (hallucination 금지)
|
||||
const dataGuard = connectorContext
|
||||
? `\n[데이터 사용 규칙]\n` +
|
||||
`1. 아래 "데이터 소스" 블록의 응답 본문(--- 응답 본문 시작 --- ~ 끝 ---)을 끝까지 정독한 뒤 답한다. JSON 안의 모든 키를 빠짐없이 확인할 것.\n` +
|
||||
`2. 응답 본문에 사용자가 요청한 값이 있으면 그 값을 자신 있게 인용해 답한다 (예: rates.JPY = 0.10809).\n` +
|
||||
`3. 응답 본문에 그 값이 없거나, 상태가 200이 아니거나, 응답이 HTML 페이지(<!DOCTYPE 등) 이면 "현재 데이터 소스에서 값을 받지 못했습니다" 라고 답한다.\n` +
|
||||
`4. 데이터에 없는 수치를 추측·합성하지 않는다. 한편 데이터에 있는 수치는 의심하지 말고 정확히 인용한다.\n` +
|
||||
`5. 답변에는 응답 JSON 의 출처 필드 경로(예: rates.JPY)를 함께 표기한다.\n`
|
||||
: "";
|
||||
|
||||
const systemPrompt = [
|
||||
agent.system_prompt || "당신은 도움이 되는 AI 어시스턴트입니다.",
|
||||
`\n당신의 역할: ${member.role_name}`,
|
||||
knowledgeContext,
|
||||
connectorContext ? `\n사용 가능한 데이터:\n${connectorContext}` : "",
|
||||
dataGuard,
|
||||
connectorContext ? `\n[데이터 소스 - 실제 호출 결과]:\n${connectorContext}` : "",
|
||||
previousContext ? `\n이전 에이전트들의 분석 결과:\n${previousContext}` : "",
|
||||
].join("");
|
||||
|
||||
@@ -381,13 +409,70 @@ export class MultiAgentExecutionEngine {
|
||||
);
|
||||
if (!conn) return { error: "커넥션을 찾을 수 없습니다." };
|
||||
|
||||
return {
|
||||
type: "rest_api",
|
||||
name: conn.connection_name,
|
||||
base_url: conn.base_url,
|
||||
method: conn.method,
|
||||
info: `REST API (${conn.method} ${conn.base_url}) 호출 가능`,
|
||||
};
|
||||
// 실제 REST API 호출 (이전엔 메타정보만 반환했지만, 모델 hallucination 방지를 위해 실호출)
|
||||
try {
|
||||
const axios = require("axios");
|
||||
const method = (conn.default_method || "GET").toUpperCase();
|
||||
const fullUrl = (conn.base_url || "") + (conn.endpoint_path || "");
|
||||
const headers: Record<string, string> = {
|
||||
Accept: "application/json, text/plain, */*",
|
||||
...(conn.default_headers && typeof conn.default_headers === "object" ? conn.default_headers : {}),
|
||||
};
|
||||
// auth_type / auth_config 처리 (basic / bearer / api_key)
|
||||
if (conn.auth_type === "bearer" && conn.auth_config?.token) {
|
||||
headers["Authorization"] = `Bearer ${conn.auth_config.token}`;
|
||||
} else if (conn.auth_type === "api_key" && conn.auth_config?.key) {
|
||||
const headerName = conn.auth_config.header_name || "X-API-Key";
|
||||
headers[headerName] = conn.auth_config.key;
|
||||
} else if (conn.auth_type === "basic" && conn.auth_config?.username) {
|
||||
const cred = Buffer.from(`${conn.auth_config.username}:${conn.auth_config.password || ""}`).toString("base64");
|
||||
headers["Authorization"] = `Basic ${cred}`;
|
||||
}
|
||||
|
||||
const reqOpts: any = {
|
||||
method,
|
||||
url: fullUrl,
|
||||
headers,
|
||||
timeout: (conn.timeout || 15) * 1000,
|
||||
validateStatus: () => true,
|
||||
};
|
||||
if ((method === "POST" || method === "PUT" || method === "PATCH") && conn.default_request_body) {
|
||||
reqOpts.data = conn.default_request_body;
|
||||
}
|
||||
|
||||
const t0 = Date.now();
|
||||
const res = await axios(reqOpts);
|
||||
const dur = Date.now() - t0;
|
||||
|
||||
// 응답 데이터를 prompt 안전 길이로 잘라서 반환
|
||||
let body: any = res.data;
|
||||
let bodyStr = "";
|
||||
if (typeof body === "string") {
|
||||
bodyStr = body.substring(0, 12000);
|
||||
} else {
|
||||
try { bodyStr = JSON.stringify(body, null, 2).substring(0, 12000); }
|
||||
catch { bodyStr = String(body).substring(0, 12000); }
|
||||
}
|
||||
|
||||
return {
|
||||
type: "rest_api",
|
||||
name: conn.connection_name,
|
||||
url: fullUrl,
|
||||
method,
|
||||
status: res.status,
|
||||
duration_ms: dur,
|
||||
response: bodyStr,
|
||||
truncated: bodyStr.length >= 12000,
|
||||
};
|
||||
} catch (err: any) {
|
||||
return {
|
||||
type: "rest_api",
|
||||
name: conn.connection_name,
|
||||
url: (conn.base_url || "") + (conn.endpoint_path || ""),
|
||||
error: err.message,
|
||||
status: err.response?.status,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (connector.type === "file" && connector.path) {
|
||||
|
||||
Reference in New Issue
Block a user