MyStory/Consensus_Verifiers

MeshProof: 두 모델이 합의할 때까지 수렴하는 QA 시스템(2)

LupyLaon 2025. 8. 24. 16:25

MeshProof: 두 모델이 합의할 때까지 수렴하는 QA 시스템

consensus-verifiers로 만드는 신뢰형 LLM 파이프라인

(2/3) 검증기와 오케스트레이션 엔진 deep dive — 핵심 구현 해부하기


🎯 이번 편에서 다룰 내용

1편에서 MeshProof의 전체 개념을 살펴봤다면, 이번에는 실제 코드 레벨에서 어떻게 구현되는지 깊이 파헤쳐보겠습니다. 특히 각 검증기의 내부 알고리즘과 오케스트레이션 엔진의 핵심 로직을 중심으로 설명합니다.


🧮 MathSumVerifier: 수학 문제의 정교한 검증

수학 문제는 명확한 정답이 있기 때문에 가장 확실한 합의가 가능한 영역입니다. MathSumVerifier는 다양한 표현 방식을 정규화하여 동치성을 판단합니다.

문제 감지와 타겟 추출

export function MathSumVerifier(): Verifier {
    // 수학 문제 패턴 정의
    const PATTERNS = [
        /더해서\s*(-?\d+)\s*가 나오/i,      // "더해서 5가 나오는"
        /합이\s*(-?\d+)\s*(?:이|가)?/i,     // "합이 10이 되는"  
        /sum(?:s)?\s*(?:to|=)\s*(-?\d+)/i   // "sum to 15"
    ];

    function getTarget(question: string): number | null {
        for (const regex of PATTERNS) {
            const match = question.match(regex);
            if (match) return Number(match[1]);
        }
        return null;
    }

    return {
        name: "math.sum",
        supports: (question: string): boolean => getTarget(question) !== null,
        verify: (question: string, candidate: Answer): VerifyResult => {
            // 구현 상세...
        }
    };
}

정수쌍 추출의 복잡성

실제로 AI가 정수쌍을 표현하는 방식은 매우 다양합니다:

function extractPairs(text: string): Array<[number, number]> {
    const pairs: Array<[number, number]> = [];

    // 패턴 1: (x,y) 튜플 형식
    for (const m of text.matchAll(/\((-?\d+)\s*[,;]\s*(-?\d+)\)/g)) {
        pairs.push([Number(m[1]), Number(m[2])]);
    }

    // 패턴 2: "x + y" 덧셈 표현
    for (const m of text.matchAll(/(-?\d+)\s*\+\s*(-?\d+)/g)) {
        pairs.push([Number(m[1]), Number(m[2])]);
    }

    // 패턴 3: "x와 y", "x, y" (단, 괄호 안은 제외)
    for (const m of text.matchAll(/(?<!\()(-?\d+)\s*(?:와|,)\s*(-?\d+)(?!\))/g)) {
        pairs.push([Number(m[1]), Number(m[2])]);
    }

    return pairs;
}

정규화와 동치 판단

가장 핵심적인 부분은 서로 다른 표현을 동일한 형태로 정규화하는 것입니다:

// orchestrator.run.ts에서 사용되는 정규화 함수
function canonicalizePairs(text: string): string {
    const pairs = extractPairs(text);
    
    // 각 쌍을 (min, max) 형태로 정규화
    const normalized = pairs.map(([a, b]) => [Math.min(a, b), Math.max(a, b)] as [number, number]);
    
    // 중복 제거
    const unique = Array.from(new Set(normalized.map(([a,b]) => `${a},${b}`)))
        .map(s => s.split(",").map(Number) as [number, number]);
    
    // 정렬하여 순서 무관하게 만들기
    unique.sort((p, q) => (p[0] - q[0]) || (p[1] - q[1]));
    
    return JSON.stringify(unique);
}

// 실제 사용 예시
const gptAnswer = "(1,4), (2,3)";
const claudeAnswer = "(2,3), (4,1)";

console.log(canonicalizePairs(gptAnswer));   // "[[1,4],[2,3]]"  
console.log(canonicalizePairs(claudeAnswer)); // "[[1,4],[2,3]]"
// → 동일! 합의 달성

검증 로직 완전 구현

verify: (question: string, candidate: Answer): VerifyResult => {
    const target = getTarget(question);
    if (target === null) return { ok: true, score: 1 };

    const fullText = `${candidate.answer} ${candidate.reasoning ?? ""}`;
    const pairs = extractPairs(fullText);

    if (pairs.length === 0) {
        return { 
            ok: false, 
            score: 0.2, 
            notes: ["정수 쌍을 추출하지 못함"] 
        };
    }

    // 모든 쌍이 올바른 합을 갖는지 검증
    const incorrect = pairs.filter(([a, b]) => a + b !== target);
    
    if (incorrect.length > 0) {
        return {
            ok: false,
            score: 0.2,
            notes: incorrect.map(([a, b]) => `합 불일치: ${a}+${b} ≠ ${target}`)
        };
    }

    return { ok: true, score: 1 };
}

🔗 FactCitationVerifier: 출처 검증의 정교함

사실 확인이 중요한 질문에서는 단순히 링크만 있으면 되는 것이 아닙니다. 링크의 품질, 접근성, 날짜 표기 등을 종합적으로 평가해야 합니다.

설정 가능한 검증 옵션

export type FactCitationOptions = {
    minLinks?: number;                 // 최소 링크 수 (기본 1)
    requireDeepLinks?: boolean;        // 루트 도메인만 링크면 감점
    minDeepLinks?: number;             // 깊은 링크 최소 개수
    requireDatesInReasoning?: boolean; // YYYY-MM-DD 날짜 필수
    weights?: DomainWeights;           // 도메인별 가중치
};

export type DomainWeights = {
    whitelistBoost?: Record<string, number>;    // 신뢰할 수 있는 도메인 가산점
    blacklistPenalty?: Record<string, number>;  // 의심스러운 도메인 감점
};

URL 추출과 정제

function extractUrls(text: string): string[] {
    const urlRegex = /\bhttps?:\/\/[^\s)>\]"'`]+/gi;
    const evidence = new Set<string>();

    // URL 추출 후 후행 구두점 제거
    for (const match of text.matchAll(urlRegex)) {
        const cleanUrl = match[0].replace(/[),.]+$/g, "");
        evidence.add(cleanUrl);
    }

    return Array.from(evidence);
}

// 깊은 링크 판단
function isDeepLink(url: string): boolean {
    try {
        const { pathname } = new URL(url);
        return pathname !== "/" && pathname.length > 1;
    } catch {
        return false;
    }
}

도메인 가중치 시스템

실제 운영에서는 도메인의 신뢰도에 따라 점수를 차등 부여합니다:

// orchestrator.run.ts에서 실제 사용 중인 가중치
const registry = new VerifierRegistry()
    .use(FactCitationVerifier({
        minLinks: 2,
        requireDeepLinks: true,
        minDeepLinks: 1,
        requireDatesInReasoning: true,
        weights: {
            whitelistBoost: {
                "stat.kita.net": 0.25,        // 무역통계 사이트
                "unipass.customs.go.kr": 0.25, // 관세청 통합포털
                "motie.go.kr": 0.25,          // 산업통상자원부
                "kostat.go.kr": 0.25,         // 통계청
                "bok.or.kr": 0.2,             // 한국은행
                "kita.net": 0.15,             // 무역협회
            },
            blacklistPenalty: {
                "medium.com": 0.2,            // 개인 블로그
                "blogspot.com": 0.2,          // 개인 블로그  
                "tistory.com": 0.15,          // 개인 블로그
                "reddit.com": 0.15,           // 커뮤니티
            }
        }
    }));

복합적 점수 계산

FactCitationVerifier의 점수는 여러 요소를 종합합니다:

function calculateScore(urls: string[], options: FactCitationOptions): number {
    // 1. 기본 통과 여부 (0.6 가중치)
    const basePass = urls.length >= (options.minLinks || 1) ? 1 : 0;
    
    // 2. 깊은 링크 비율 (0.2 가중치)  
    const deepCount = urls.filter(isDeepLink).length;
    const deepRatio = urls.length ? (deepCount / urls.length) : 0;
    
    // 3. 도메인 가중치 (0.2 가중치)
    const domainWeight = calculateDomainScore(urls, options.weights);
    
    const finalScore = 0.6 * basePass + 0.2 * deepRatio + 0.2 * domainWeight;
    
    return Math.max(0, Math.min(1, finalScore));
}

function calculateDomainScore(urls: string[], weights?: DomainWeights): number {
    if (!weights) return 0.5; // 중립
    
    const hostnames = urls.map(url => {
        try { return new URL(url).hostname.toLowerCase(); }
        catch { return ""; }
    }).filter(Boolean);
    
    let boost = 0;
    for (const hostname of new Set(hostnames)) {
        if (weights.whitelistBoost?.[hostname]) {
            boost += weights.whitelistBoost[hostname];
        }
        if (weights.blacklistPenalty?.[hostname]) {
            boost -= weights.blacklistPenalty[hostname];
        }
    }
    
    // -0.5 ~ +0.5로 클램프 후 0~1로 변환
    boost = Math.max(-0.5, Math.min(0.5, boost));
    return 0.5 + boost;
}

날짜 표기 검증

const dateRegex = /\b(20\d{2}|19\d{2})[-/.](0[1-9]|1[0-2])([-/.](0[1-9]|[12]\d|3[01]))\b/g;

function hasDateInReasoning(text: string): boolean {
    return dateRegex.test(text);
}

// 실제 검증에서의 사용
if (requireDatesInReasoning && !hasDateInReasoning(reasoning)) {
    return {
        ok: false,
        score: Math.min(score, 0.6), // 날짜 없으면 최대 0.6점
        notes: [...notes, "reasoning에 자료 기준일(YYYY-MM-DD) 표기 필요"]
    };
}

🤖 Judge System: AI 기반 동치성 판정

수학 문제가 아닌 일반적인 질문에서는 두 답변이 본질적으로 같은 내용인지, 아니면 어느 쪽이 더 나은지 판단하기 위해 별도의 Judge AI를 활용합니다.

Judge 시스템 아키텍처

type Verdict = {
    relation: "equivalent" | "different";  // 동치 여부
    winner: "A" | "B" | "tie";             // 승자 (different일 때)
    rationale: string;                     // 판단 근거
};

export async function judgeOpenAI(
    question: string,
    answerA: string,
    answerB: string,
    opts?: { apiKey?: string; model?: string }
): Promise<Verdict> {
    const apiKey = opts?.apiKey ?? process.env.OPENAI_API_KEY!;
    const model = opts?.model ?? process.env.OPENAI_JUDGE_MODEL ?? "gpt-5";
    
    const client = new OpenAI({ apiKey });
    // 구현 상세...
}

엄격한 프롬프트 엔지니어링

Judge의 신뢰성을 높이기 위해서는 명확하고 일관된 지시가 중요합니다:

const systemPrompt = `You are a strict judge. Output a JSON object with keys:
relation: "equivalent"|"different"
winner: "A"|"B"|"tie"  
rationale: short string`;

const userPrompt = `Question:
${question}

Answer A:
${answerA}

Answer B:
${answerB}

Decide semantic relation and pick a better answer if different. Return ONLY JSON.`;

안전한 JSON 파싱

AI의 응답이 항상 완벽한 JSON은 아니므로 fallback 로직이 필요합니다:

// GPT-5는 temperature 미지원 → 조건부 파라미터 설정
const isGpt5 = /gpt-5/i.test(model);
const completionParams: any = {
    model,
    messages: [
        { role: "system", content: systemPrompt },
        { role: "user", content: userPrompt }
    ],
    response_format: { type: "json_object" }
};

if (!isGpt5) {
    completionParams.temperature = 0.2; // 일관성을 위해 낮은 temperature
}

const response = await client.chat.completions.create(completionParams);
const rawContent = response.choices?.[0]?.message?.content ?? "{}";

// 안전한 파싱과 fallback
try {
    const verdict = JSON.parse(rawContent) as Verdict;
    if (verdict && verdict.relation && verdict.winner) {
        return verdict;
    }
} catch (error) {
    console.warn("Judge JSON parsing failed:", error);
}

// fallback: 보수적 판정
return { 
    relation: "different", 
    winner: "tie", 
    rationale: "fallback due to parsing error" 
};

🔄 Orchestration Engine: 복잡한 조율의 핵심

오케스트레이션 엔진은 MeshProof의 뇌 역할을 합니다. 병렬 호출, 검증, 재시도, 합의 판단까지의 전체 과정을 관리합니다.

병렬 모델 호출과 에러 처리

export async function orchestrate(
    question: string,
    debug = false,
    keys?: { openai?: string; anthropic?: string },
    opts?: { useOpenAI?: boolean; useClaude?: boolean }
): Promise<FinalOut> {
    let gpt: Answer | undefined;
    let claude: Answer | undefined;
    let gptError: any, claudeError: any;

    // 병렬로 두 모델 호출 (실패해도 중단되지 않음)
    const [gptResult, claudeResult] = await Promise.allSettled([
        opts?.useOpenAI ? askOpenAI(question, { apiKey: keys?.openai }) : null,
        opts?.useClaude ? askAnthropic(question, { apiKey: keys?.anthropic }) : null
    ]);

    // 결과 처리
    if (gptResult?.status === 'fulfilled' && gptResult.value) {
        gpt = gptResult.value;
    } else if (gptResult?.status === 'rejected') {
        gptError = gptResult.reason;
    }

    if (claudeResult?.status === 'fulfilled' && claudeResult.value) {
        claude = claudeResult.value;
    } else if (claudeResult?.status === 'rejected') {
        claudeError = claudeResult.reason;
    }

    // 둘 다 실패하면 에러
    if (!gpt && !claude) {
        const gptMsg = gptError?.message || "OpenAI unavailable";
        const claudeMsg = claudeError?.message || "Anthropic unavailable";
        throw new Error(`Both providers failed. openai=${gptMsg} | anthropic=${claudeMsg}`);
    }
}

검증과 재시도 메커니즘

출처가 부족한 답변에 대해서는 자동으로 재시도를 수행합니다:

// 재시도 필요성 판단
function needsCitationsRetry(verification?: any, answer?: Answer): boolean {
    if (!verification || !answer) return false;
    
    const citationDetail = verification.details?.find((d: any) => d.category === "fact.citation");
    if (!citationDetail) return false;

    const urls: string[] = citationDetail.evidence ?? [];
    const deepLinks = urls.filter(url => {
        try {
            const pathname = new URL(url).pathname;
            return pathname && pathname !== "/";
        } catch {
            return false;
        }
    });

    const hasDate = /\b(20\d{2}|19\d{2})[-/.](0[1-9]|1[0-2])([-/.](0[1-9]|[12]\d|3[01]))\b/
        .test(`${answer.reasoning ?? ""} ${answer.answer ?? ""}`);

    return (!citationDetail.ok) || 
           (urls.length < 2) || 
           (deepLinks.length < 1) || 
           !hasDate;
}

// 재시도용 프롬프트 생성
function buildRetrySuffix(): string {
    return "\n\n[중요 지시]\n" +
        "- reasoning에 최신 출처를 **최소 2개** 포함하세요.\n" +
        "- **홈페이지 루트 링크 금지**, 통계표/보도자료 등 **상세 페이지(Deep link)** URL을 사용하세요.\n" +
        "- 각 출처 옆에 **발표/게시 날짜(YYYY-MM-DD)**를 표기하세요.\n" +
        "- 최종 출력은 JSON 형식 유지: { answer, reasoning, confidence }";
}

// 실제 재시도 실행
if (gpt && needsCitationsRetry(gptVerification, gpt)) {
    const retryQuestion = question + buildRetrySuffix();
    try {
        gpt = await askOpenAI(retryQuestion, { apiKey: keys?.openai });
        gptVerification = await registry.verifyAndAggregate(question, gpt);
        trace.push({ round: "retry-gpt", gpt, verifiers: { gpt: gptVerification } });
    } catch (error) {
        if (debug) console.error("[gpt:retry:error]", error);
    }
}

합의 판단과 최종 선택

// 1) 한쪽만 성공한 경우
if (gpt && !claude) {
    return {
        content: gpt.answer,
        sources: extractSources(gpt.reasoning, gpt.answer),
        meta: { picked: "gpt", confidence: gpt.confidence },
        ...(debug ? { debug: { trace } } : {})
    };
}

// 2) 수학 문제: 세트 동치 비교
if (/(더해서|합이|sum)/i.test(question)) {
    const gptCanonical = canonicalizePairs(`${gpt.answer} ${gpt.reasoning ?? ""}`);
    const claudeCanonical = canonicalizePairs(`${claude.answer} ${claude.reasoning ?? ""}`);
    
    if (gptCanonical !== "[]" && gptCanonical === claudeCanonical) {
        const pick = (gptVerification.score >= claudeVerification.score) ? "gpt" : "claude";
        const chosen = pick === "gpt" ? gpt : claude;
        
        return {
            content: chosen.answer,
            sources: extractSources(gpt.reasoning, gpt.answer, claude.reasoning, claude.answer),
            meta: { picked: pick, agreement: "equivalent", confidence: chosen.confidence },
            ...(debug ? { debug: { trace } } : {})
        };
    }
}

// 3) 일반 문제: Judge AI 판정
const verdict = await judgeOpenAI(
    question,
    `${gpt.answer}\n\n${gpt.reasoning ?? ""}`,
    `${claude.answer}\n\n${claude.reasoning ?? ""}`,
    { apiKey: keys?.openai }
);

if (verdict.relation === "equivalent") {
    const pick = (gptVerification.score >= claudeVerification.score) ? "gpt" : "claude";
    const chosen = pick === "gpt" ? gpt : claude;
    
    return {
        content: chosen.answer,
        sources: extractSources(chosen.reasoning, chosen.answer),
        meta: { picked: pick, agreement: "equivalent", confidence: chosen.confidence }
    };
}

// 4) 서로 다른 경우: 점수와 Judge 판정을 종합
const gptScore = (gptVerification.score ?? 0) + (gpt.confidence ?? 0);
const claudeScore = (claudeVerification.score ?? 0) + (claude.confidence ?? 0);

let final: Answer, picked: "gpt" | "claude";

if (verdict.winner === "A") {
    final = gpt; picked = "gpt";
} else if (verdict.winner === "B") {
    final = claude; picked = "claude";
} else {
    // tie인 경우 점수로 결정
    final = gptScore >= claudeScore ? gpt : claude;
    picked = gptScore >= claudeScore ? "gpt" : "claude";
}

return {
    content: final.answer,
    sources: extractSources(final.reasoning, final.answer),
    meta: { picked, agreement: "different", confidence: final.confidence }
};

🛡️ Security Layer: 생산 환경을 위한 보안

JWT 기반 인증 시스템

// JWT 토큰 생성과 검증
class AuthManager {
    private static readonly JWT_SECRET = process.env.JWT_SECRET!;
    private static readonly JWT_EXPIRES_IN = '24h';

    static generateToken(user: { id: number; email: string }): string {
        return jwt.sign(
            { 
                id: user.id, 
                email: user.email,
                iat: Math.floor(Date.now() / 1000)
            },
            AuthManager.JWT_SECRET,
            { expiresIn: AuthManager.JWT_EXPIRES_IN }
        );
    }

    static verifyToken(token: string): { id: number; email: string } | null {
        try {
            return jwt.verify(token, AuthManager.JWT_SECRET) as { id: number; email: string };
        } catch {
            return null;
        }
    }
}

// 인증 미들웨어
export const authRequired = (req: Request, res: Response, next: NextFunction) => {
    const token = req.cookies.cv_token;
    
    if (!token) {
        return res.status(401).json({ error: 'Authentication required' });
    }

    const user = AuthManager.verifyToken(token);
    if (!user) {
        res.clearCookie('cv_token');
        return res.status(401).json({ error: 'Invalid token' });
    }

    req.user = user;
    next();
};

CSRF Double-Submit Cookie 보호

// server.ts에서 CSRF 보호 구현
app.use((req, res, next) => {
    const existingToken = req.cookies["XSRF-TOKEN"];
    const token = existingToken || crypto.randomBytes(16).toString("hex");
    
    // 토큰이 없으면 쿠키에 설정
    if (!existingToken) {
        res.cookie("XSRF-TOKEN", token, {
            httpOnly: false,      // 클라이언트에서 읽을 수 있어야 함
            sameSite: "lax",
            secure: process.env.NODE_ENV === "production",
            path: "/",
        });
    }
    
    // POST/PUT/DELETE 요청에서는 헤더 검증
    if (["POST", "PUT", "PATCH", "DELETE"].includes(req.method)) {
        const headerToken = req.get("X-CSRF-TOKEN");
        if (!headerToken || headerToken !== token) {
            return res.status(403).json({ error: "CSRF validation failed" });
        }
    }
    
    next();
});

AES-256-GCM 키 암호화

// 사용자 API 키를 안전하게 저장
class KeyVault {
    private static readonly ALGORITHM = 'aes-256-gcm';
    private readonly masterKey: Buffer;

    constructor() {
        this.masterKey = crypto.scryptSync(
            process.env.ENCRYPTION_PASSWORD!,
            process.env.ENCRYPTION_SALT!,
            32
        );
    }

    encrypt(plaintext: string): EncryptedData {
        const iv = crypto.randomBytes(16);
        const cipher = crypto.createCipher(KeyVault.ALGORITHM, this.masterKey);
        cipher.setAutoPadding(true);
        
        let encrypted = cipher.update(plaintext, 'utf8', 'hex');
        encrypted += cipher.final('hex');
        
        const authTag = cipher.getAuthTag();

        return {
            encrypted,
            iv: iv.toString('hex'),
            authTag: authTag.toString('hex')
        };
    }

    decrypt(encryptedData: EncryptedData): string {
        const decipher = crypto.createDecipher(KeyVault.ALGORITHM, this.masterKey);
        decipher.setAuthTag(Buffer.from(encryptedData.authTag, 'hex'));
        
        let decrypted = decipher.update(encryptedData.encrypted, 'hex', 'utf8');
        decrypted += decipher.final('utf8');
        
        return decrypted;
    }
}

Rate Limiting

import rateLimit from "express-rate-limit";

// API 키 관련 엔드포인트에 더 엄격한 제한
const keysLimiter = rateLimit({ 
    windowMs: 60_000,  // 1분
    max: 10,           // 최대 10회
    message: { error: "Too many key management requests" }
});

app.use("/api/auth/me/keys", keysLimiter);

// 일반 API에는 더 관대한 제한
const generalLimiter = rateLimit({
    windowMs: 60_000,  // 1분
    max: 100,          // 최대 100회
});

app.use("/api/", generalLimiter);

🔧 Provider System: AI 모델 호출의 정교함

OpenAI Provider: Responses API vs Chat Completions

export async function askOpenAI(
    question: string,
    opts?: {
        apiKey?: string;
        model?: string;
        useResponses?: boolean;           // Responses API 사용 여부
        reasoningEffort?: "minimal" | "medium" | "high";
        verbosity?: "low" | "medium" | "high";
    }
): Promise<Answer> {
    const model = opts?.model ?? process.env.OPENAI_MODEL ?? "gpt-5";
    const useResponses = opts?.useResponses ?? (process.env.OPENAI_USE_RESPONSES === "1");
    
    // Responses API 사용 (권장)
    if (useResponses) {
        const payload: any = {
            model,
            input: [
                { role: "system", content: SYSTEM_PROMPT },
                { role: "user", content: buildUserPrompt(question) },
            ],
        };
        
        // 추론 설정 (Extended Thinking 등)
        if (opts?.reasoningEffort) {
            payload.reasoning = { effort: opts.reasoningEffort };
        }
        if (opts?.verbosity) {
            payload.text = { verbosity: opts.verbosity };
        }
        
        const response = await client.responses.create(payload);
        return parseResponseOutput(response);
    }
    
    // Chat Completions API (호환성)
    const messages = [
        { role: "system", content: SYSTEM_PROMPT },
        { role: "user", content: buildUserPrompt(question) },
    ];
    
    const response = await client.chat.completions.create({
        model,
        messages,
        response_format: { type: "json_object" },
        // GPT-5는 temperature 미지원 → 조건부 설정
        ...(!/gpt-5/i.test(model) && { temperature: 0.7 })
    });
    
    return parseChatOutput(response);
}

Anthropic Provider: Extended Thinking 지원

export async function askAnthropic(
    question: string,
    opts?: {
        apiKey?: string,
        model?: string,
        thinkingBudget?: number;    // Extended Thinking 예산
        maxTokens?: number;
    }
): Promise<Answer> {
    const model = opts?.model ?? "claude-sonnet-4-20250514";
    const thinkingBudget = Math.max(0, Number(opts?.thinkingBudget ?? 0));
    let maxTokens = Math.max(1, Number(opts?.maxTokens ?? 800));
    
    // thinking budget보다 max_tokens가 작으면 자동 보정
    if (thinkingBudget > 0 && maxTokens <= thinkingBudget) {
        const headroom = Number(process.env.ANTHROPIC_OUTPUT_HEADROOM || 512);
        maxTokens = thinkingBudget + headroom;
        
        if (process.env.NODE_ENV !== "production") {
            console.warn(`max_tokens auto-bumped to ${maxTokens} (budget=${thinkingBudget})`);
        }
    }

    const client = new Anthropic({ apiKey: opts?.apiKey ?? process.env.ANTHROPIC_API_KEY! });

    const params: any = {
        model,
        system: SYSTEM_PROMPT,
        max_tokens: maxTokens,
        messages: [{ role: "user", content: buildUserPrompt(question) }]
    };

    // Extended Thinking 설정
    if (thinkingBudget > 0) {
        params.thinking = { 
            type: "enabled", 
            budget_tokens: thinkingBudget 
        };
    }
    
    // Claude Sonnet 4는 temperature 미지원
    
    const response = await client.messages.create(params);
    return parseAnthropicOutput(response);
}

🧪 디버깅과 관찰성

상세한 추적 로그

type TraceEntry = {
    round: number | string;
    gpt?: Answer;
    claude?: Answer;
    verifiers?: {
        gpt?: VerificationResult;
        claude?: VerificationResult;
    };
    consensus?: ConsensusResult;
    verdict?: Verdict;
};

// orchestrate 함수에서 각 단계별로 추적
const trace: TraceEntry[] = [];

// 1차 호출 결과
trace.push({ 
    round: 1, 
    gpt, 
    claude, 
    verifiers: { gpt: gptVerification, claude: claudeVerification } 
});

// 재시도가 있었다면
if (gptRetried) {
    trace.push({ 
        round: "retry-gpt", 
        gpt: retriedGptAnswer, 
        verifiers: { gpt: retriedGptVerification } 
    });
}

// Judge 판정 결과
if (verdict) {
    trace.push({ verdict });
}

// 최종 결과에 디버그 정보 포함
return {
    content: finalAnswer.answer,
    sources: extractSources(...),
    meta: { picked, agreement, confidence },
    ...(debug ? { debug: { trace } } : {})
};

성능 메트릭

// 각 단계별 소요 시간 측정
const startTime = Date.now();
const timings: Record<string, number> = {};

timings.modelCall = Date.now() - startTime;

const verifyStart = Date.now();
// 검증 로직...
timings.verification = Date.now() - verifyStart;

const judgeStart = Date.now();  
// 판정 로직...
timings.judgment = Date.now() - judgeStart;

if (debug) {
    console.log("Performance metrics:", timings);
}

🔮 다음 편 예고

3편에서는 React 기반 프론트엔드와 전체 사용자 경험을 다룹니다:

  • 🎨 UI/UX 설계: 복잡한 검증 과정을 직관적으로 표현하기
  • 🔄 실시간 피드백: 로딩 상태와 프로그레스 표시
  • 📊 디버그 뷰어: 전체 검증 과정의 시각화
  • 🔐 프론트엔드 보안: CSRF 토큰 자동 처리
  • 🚀 최적화와 성능: 코드 스플리팅과 캐싱 전략

핵심 질문: "복잡한 백엔드 프로세스를 사용자가 쉽고 직관적으로 사용할 수 있게 만들려면?"


2편에서 MeshProof의 핵심 엔진을 해부해봤습니다.
다음 편에서는 이 모든 기능을 사용자가 실제로 어떻게 경험하게 되는지 살펴보겠습니다.