feat(ai-orch): 멀티 에이전트 진짜 도구 호출 + hallucination 가드
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:
chpark
2026-04-28 18:55:25 +09:00
parent b4dc9b1927
commit 9891c06038
@@ -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) {