니콜이 거짓말 안 하게 만드는 법 — 문맥 판단, 대화 합성, 도구 호출 검증

AI 기억 시스템 시리즈 3편. 1편에서 세션 태그와 토픽 묶음을, 2편에서 벡터 검색과 태그 확장을 다뤘습니다. 이번엔 기억한 내용을 바탕으로 "어떻게 답하느냐"의 문제입니다.
기억을 잘 찾아오는 것과 그걸 가지고 답을 잘 하는 것은 다른 문제입니다. 니콜에게 "비트코인 얼마야?"라고 물었는데 과거 대화에 있던 가격을 그대로 읊으면 곤란하거든요. "저장했어요"라고 말해놓고 실제로는 아무것도 저장하지 않았으면 더 곤란하고요.
이번 글에서 다루는 건 네 가지입니다.
- 태그 확장 노이즈 — 벡터 검색이 엉뚱한 태그를 끌고 오는 문제
- 대화 합성 — "뭔데?"처럼 불완전한 메시지를 완전한 질문으로 만드는 법
- 문맥 판단 — 기억에서 답할지, 도구를 돌릴지 결정하는 법
- 거짓말 감지 — LLM이 안 한 일을 했다고 우기는 걸 잡는 법
1. 태그 확장 노이즈: "비트코인"을 물었는데 "운동"이 딸려온다
2편에서 벡터 검색 후 태그 확장으로 관련 대화를 더 찾는 구조를 소개했는데, 실제로 돌려보니 문제가 생겼습니다.
"비트코인 얼마야?"라고 물었을 때, 벡터로 매칭된 대화 중 하나가 [비트코인, 운동, 요약] 태그를 갖고 있었습니다. 그날 비트코인 이야기하다가 운동 이야기로 넘어간 대화였거든요. 태그 확장 로직이 이 대화의 태그를 전부 가져와서, "운동" 관련 대화까지 주입해버렸습니다.
해결: 2회 이상 등장하는 태그만 확장
벡터 매칭된 대화들의 태그를 모아서 빈도를 셉니다. 1회만 등장하는 태그는 우연히 끼어든 것일 가능성이 높으니 버립니다.
// conversation-tagger.ts const tagCounts = new Map<string, number>(); for (const r of relevant) { for (const t of (r as any).tags || []) { tagCounts.set(t, (tagCounts.get(t) || 0) + 1); } } // 2회 이상 등장 + 범용 태그 제외 let expandTags = [...tagCounts.entries()] .filter(([tag, count]) => count >= 2 && !genericTags.has(tag)) .map(([tag]) => tag);
"비트코인"은 매칭된 5개 대화 중 4개에 나타나고, "운동"은 1개에만 나타납니다. 그래서 "비트코인"만 확장 태그로 선택되고 "운동"은 걸러집니다.
2회 이상인 태그가 하나도 없는 경우도 있습니다. 질문이 너무 구체적이라 매칭 결과 자체가 산발적인 경우인데, 이때는 범용 태그를 제외하고 빈도가 가장 높은 태그 2개만 사용합니다.
if (expandTags.length === 0) { expandTags = [...tagCounts.entries()] .filter(([tag]) => !genericTags.has(tag)) .sort((a, b) => b[1] - a[1]) .slice(0, 2) .map(([tag]) => tag); }
범용 태그는 전체 태그된 대화의 15% 이상에서 사용된 것들입니다. "대화", "질문", "요약" 같은 태그가 여기 해당합니다. tag_registry 테이블에서 usage_count를 추적하고, 매번 동적으로 계산합니다.
2. 대화 합성: "뭔데?" → "비트코인이 요즘 뭔데?"
카카오톡이든 텔레그램이든, 사람은 앞 맥락을 이어서 말합니다. "뭔데?", "그거 지워", "아까 그거". LLM에게 이것만 던지면 무슨 소리인지 알 수가 없죠.
message-resolver: 대명사와 생략을 채워넣는 모듈
최근 대화 히스토리(2쌍, 4메시지)를 보고 현재 메시지가 이전 대화의 연속인지 판단하고, 연속이면 빠진 맥락을 채워서 하나의 완전한 문장으로 합성합니다.
// message-resolver.ts const prompt = `이전 대화와 K님의 새 메시지를 보고 판단하라. [이전 대화] K님: 비트코인 좀 알아봐줘 니콜: 현재 비트코인 가격은 84,500달러입니다... [K님의 새 메시지] 뭔데? 질문: K님의 새 메시지가 이전 대화를 이어서 하는 말인가? - 이어지는 말이면 → 이전 대화의 맥락과 합쳐서 완전한 문장으로 다시 써라 - 새로운 주제면 → 그대로 출력해라 규칙: - 대명사(그거, 이거, 뭔데), 생략된 주어/목적어를 이전 대화에서 채워넣어라 - K님의 의도를 바꾸지 마라. 빠진 맥락만 채워라`;
결과: "뭔데?" → "비트코인 가격이 84,500달러라고 했는데, 그게 뭔데?"
이 합성은 로컬 Ollama(gemma4:26b)로 돌립니다. API 비용 $0, 응답 시간 약 1초. 실패하면 원본 메시지로 그대로 진행하니까 대화가 끊길 걱정은 없습니다.
안전장치 세 가지
메시지가 80자 이상이면 이미 충분히 길다고 보고 합성을 건너뜁니다. 합성 결과가 원본의 5배를 넘기면 LLM이 설명까지 덧붙인 경우라 원본을 유지하고, 3자 미만이면 무시합니다.
if (userMessage.length > 80) { return { resolved: false, message: userMessage, reason: "자체 완결 메시지" }; } if (answer.length > userMessage.length * 5) { return { resolved: false, message: userMessage, reason: "합성 결과 과다" }; }
이 모듈 덕분에 뒤에 나올 벡터 검색과 맥락 판단이 더 정확해집니다. "뭔데?"로 벡터 검색하면 아무것도 안 나오지만, "비트코인 가격이 84,500달러라고 했는데, 그게 뭔데?"로 검색하면 관련 대화가 제대로 잡힙니다.
3. 문맥 판단: "기억에서 꺼내 답할까, 도구를 돌릴까?"
벡터 검색으로 관련 대화를 찾았고, 대화도 합성했습니다. 이제 결정해야 할 게 있는데, 이 맥락만으로 답할 수 있는 질문인지, 아니면 실제로 도구를 실행해야 하는 질문인지입니다.
"어제 비트코인 얼마라고 했지?"는 맥락으로 답할 수 있습니다. "지금 비트코인 얼마야?"는 도구를 돌려야 하고요. 이 구분을 못 하면 둘 중 하나가 깨집니다. 맥락이 있는데 도구를 돌리면 느리고, 도구가 필요한데 맥락으로 답하면 거짓말이 되니까요.
context-sufficiency: 2단계 판단
context-sufficiency.ts가 이 결정을 담당합니다. 사람이 질문을 받았을 때 생각하는 과정을 그대로 따라가는 구조입니다.
첫 번째 단계는 조회 요청인지 확인하는 겁니다. "검색해봐", "알아봐", "브라우저로 확인해" — 이런 말이 들어가면 맥락이 아무리 풍부해도 도구를 써야 합니다.
const prompt = `사용자가 도구를 써서 직접 조회해달라고 요청하는가? 한 단어로 답하라. 사용자: ${userMessage} - 찾아봐/검색해/알아봐/브라우저로/확인해봐/살펴봐/최신 등 직접 조회 요청이면 → YES - 질문만 하는 거면 → NO`;
두 번째 단계는 맥락 충분성 판단입니다. 1단계에서 NO가 나오면, 수집된 맥락(벡터 검색 결과 + 토픽 컨텍스트)과 사용자 질문을 함께 보여주고 "이걸로 답할 수 있느냐"고 물어봅니다.
const suffPrompt = `아래 맥락으로 사용자 질문에 답할 수 있는가? 한 단어로 답하라. 맥락: ${contextSummary} 사용자: ${userMessage} - 맥락에 관련 정보가 있어 답할 수 있으면 → YES - 맥락에 관련 정보가 없으면 → NO`;
이 판단도 로컬 Ollama로 합니다. 온도 0, 최대 30토큰. YES 아니면 NO.
판단 결과에 따른 분기
이 판단 결과에 따라 니콜의 답변 방식이 완전히 달라집니다.
// nicole-core.ts if (result.verdict === "SUFFICIENT") { // 맥락으로 답 가능 → 맥락을 프롬프트에 주입 promptCtx.topicContext = gatheredContext; } else { // 도구 작업 필요 → 스킬 매칭해서 SKILL.md 주입 if (result.skill) { const matched = allEntries.filter(e => e.name === result.skill); promptCtx.skillCatalog = renderMatchedSkillBodies(matched); } // topicContext는 주입하지 않음 — 노이즈 방지 }
SUFFICIENT면 맥락을 주입하고, INSUFFICIENT면 맥락 대신 스킬(SKILL.md)을 주입합니다. 여기서 중요한 건 둘 다 동시에 주입하지 않는다는 겁니다. 도구를 써야 하는 상황에서 맥락까지 같이 넣으면 어떻게 될까요? LLM이 맥락에 있는 어제 가격으로 답해버립니다. 이게 거짓말의 시작이거든요.
스킬 매칭
맥락이 부족하다고 판단되면, 같은 Ollama 호출로 어떤 스킬이 필요한지도 한번에 결정합니다. "비트코인 얼마야?" → stock 스킬, "내일 날씨" → weather 스킬. 스킬이 매칭되면 해당 SKILL.md 본문이 시스템 프롬프트에 들어가고, 니콜은 그 안에 적힌 CLI 명령어를 shell 도구로 실행합니다.
4. 거짓말 감지: "했어요"라고 했는데 정말 했나?
하지 않은 일을 했다고 말하는 것은 LLM의 고질적인 문제입니다. "파일 저장했어요"라고 답변했는데, 실제로 file_write 도구를 호출한 기록이 없는 경우입니다. LLM이 의도적으로 속이려는 건 아닙니다. 이전 턴에서 비슷한 작업을 했던 기억이 섞이거나, "저장해야 한다"는 의도를 "저장했다"고 표현해버리는 거죠.
3중 방어 체계
이 문제를 시스템 레벨에서 잡습니다.
첫 번째 층은 도구 호출 기록을 프롬프트에 넣는 겁니다. 매 턴마다 지금까지 호출한 도구 목록을 시스템 프롬프트에 주입해서, LLM이 자기가 뭘 했는지 직접 볼 수 있게 합니다.
// prompt-verification.ts export function buildToolCallSummarySection( recentCalls: ToolCallRecord[], lastToolResult?: LastToolResult, ): string { // ... for (const call of recentCalls.slice(-10)) { const icon = call.result === "success" ? "✅" : call.result === "error" ? "❌" : "⏱️"; lines.push(`${icon} [${time}] ${call.toolName}(${argsPreview}...)`); if (call.resultSummary) { lines.push(` → ${call.resultSummary}`); } } lines.push("**중요**: '했어요'라고 말하기 전에 위 기록과 일치하는지 확인하라."); lines.push("기록에 없으면 안 한 것이다. 거짓말하지 마라."); // ... }
프롬프트 끝에 "기록에 없으면 안 한 것이다"라고 명시합니다. LLM이 답변을 생성할 때 이 기록을 참조하게 만드는 거죠.
두 번째 층은 응답 후 클레임 검증입니다. LLM이 답변을 생성하고 나면, 그 텍스트에서 "했어요" 패턴을 찾습니다.
const CLAIM_PATTERNS = [ /했어요[.!?,]?$/, /완료했어요[.!?,]?$/, /저장했어요[.!?,]?$/, /전송했어요[.!?,]?$/, /만들었어요[.!?,]?$/, // ... ];
"저장했어요"가 감지되면, 실제 ToolCallRecord 배열에서 file_write가 있는지 대조합니다. 없으면 불일치(mismatch)로 판정합니다.
// api-loop.ts — 메인 루프에서 검증 const verification = verifyClaimAgainstHistory(parsed.content, recentToolCalls); if (verification.warning) { deps.log(`⚠️ 거짓말 감지: ${verification.mismatchedTools.join(", ")}`); }
세 번째 층이 가장 강력한데, 다음 턴에 경고를 주입하는 겁니다. 거짓말이 감지되면 다음 턴 시스템 프롬프트에 경고가 삽입됩니다.
export function buildLyingWarningSection(verification: ClaimVerification): string { return `## ⚠️ 거짓말 경고 (CRITICAL) ${verification.warning} **니콜, 너는 방금 하지 않은 일을 했다고 말했다. 이건 거짓말이다.** 다음 규칙을 따르라: 1. **실행한 것만 "했어요"라고 말해라** 2. **기록에 없으면 안 한 것이다** 3. **실행하지 않았으면 "아직 안 했습니다"라고 솔직하게 말해라**`; }
이 경고가 들어간 다음 턴에서 니콜은 보통 "죄송해요, 아직 안 했어요. 지금 할게요"로 자세를 바로잡습니다. "거짓말하지 마"라고 프롬프트에 써놓는 것만으로는 안 되고, 코드가 실제 기록을 대조해서 잡아야 하는 문제입니다.
전체 흐름: 메시지 하나가 답변이 되기까지
정리하면, 니콜에게 메시지가 들어오면 이런 순서로 처리됩니다.

모든 판단은 로컬 LLM(Ollama gemma4:26b)으로 합니다. YES/NO 한 단어 수준이라 각각 1초 이내. 전체 전처리가 2~3초면 끝나고, 실패해도 원본 메시지로 그대로 대화가 이어집니다.
배운 것
프롬프트 설정만으로는 LLM의 거짓말을 완벽히 방지할 수 없습니다. "거짓말하지 마"라고 아무리 써도 가끔은 합니다. 코드가 도구 호출 기록을 대조해서 잡아야 합니다.
맥락 주입에도 전략이 필요하다는 걸 깨달았습니다. 관련 정보를 무조건 넣으면 오히려 방해가 됩니다. "이 맥락으로 답할 수 있는가?"를 먼저 물어보고, 답할 수 있을 때만 주입해야 합니다. 도구가 필요한 상황에서 맥락을 넣으면 어제 가격으로 답하는 함정에 빠지거든요.
불완전한 입력은 먼저 완성시켜야 합니다. "뭔데?"를 그대로 벡터 검색하면 쓸모없는 결과가 나옵니다. 단 1초짜리 전처리 하나가 뒤의 모든 단계의 정확도를 높여줍니다.
그리고 작은 LLM을 여러 번 쓰는 게 큰 LLM 한 번보다 나았습니다. 대화 합성, 문맥 판단, 스킬 매칭 — 전부 로컬 모델로 YES/NO를 받아서 코드가 분기하는 구조입니다. 하나의 거대한 프롬프트에 모든 판단을 맡기면 어디서 틀렸는지 알 수 없는데, 분리하면 각 단계를 독립적으로 디버깅할 수 있습니다.
다음 글에서는 니콜이 브라우저를 써서 웹사이트에서 직접 정보를 가져올 때, 어떻게 실패를 줄이고 성공 패턴을 재사용하는지를 다루겠습니다.





댓글
댓글을 작성하려면 이 필요합니다.