Skip to main content

자가 발전하는 유저 인터뷰 에이전트 개발기

· 53 min read
하승오
백엔드팀

똑닥 AI 모더레이터는 사람 대신 유저 인터뷰를 진행하는 AI 에이전트입니다. 이 글에서는 AI 모더레이터가 어떤 배경에서 시작되었고, 검수자의 피드백이 다음 인터뷰의 전략을 자동으로 다시 만들어내는 "자가 발전" 구조를 어떻게 설계하였는지 공유합니다.

들어가며

유저 인터뷰는 제품 팀이 가장 하고 싶어 하면서도 가장 자주 미루게 되는 일 중 하나입니다. 참가자를 모집하고, 질문지를 준비하고, 40분 가까이 대화를 듣고, 그 내용을 다시 정리하는 과정은 담당자 한 명이 한 주에 두세 건을 소화하기에도 부담이 큽니다. 그런데 정작 좋은 인사이트는 미리 준비한 질문지보다 "그건 왜 그러셨어요?"와 같은 꼬리 질문에서 나오는 경우가 많습니다. 저희는 이 꼬리 질문을 사람 대신 AI가 이어갈 수 있다면 어떨까 하는 생각에서 똑닥 AI 모더레이터 프로젝트를 시작하였습니다.

프로젝트를 시작하면서 저희는 "묻고 답하는 실력은 약 70%의 기본 스킬과 30%의 도메인 지식으로 이루어진다"는 전제를 세웠습니다. 기본 스킬은 주제가 바뀌어도 그대로 재사용할 수 있고, 도메인 지식은 주제별 데이터로 보강한다는 의미입니다. 이 전제 위에서 로드맵을 다음과 같이 세 단계로 나누었습니다:

  1. 사람이 먼저 인터뷰를 진행하여 데이터를 축적한다.
  2. AI가 그 경험을 학습한다. 학습은 사람의 피드백으로 구조화된다.
  3. 사람 대신 AI가 인터뷰를 수행한다.

이 글은 두 번째 단계, 즉 "학습한다"는 말이 실제 코드에서 어떻게 동작하는지에 대한 기록입니다. 사람이 프롬프트를 직접 고쳐 쓰는 대신 검수자가 남긴 피드백이 다음 인터뷰의 스킬 세트를 자동으로 다시 만들어내는 구조를 어떻게 설계하였는지, Strands Agents SDK 위에서 스킬과 steering을 어떻게 구성하였는지, 그리고 아직 완성되지 않은 부분은 무엇인지까지 순서대로 정리해보았습니다.

1. 인터뷰어를 프롬프트 하나로 만들기 어려웠던 이유

첫 프로토타입은 시스템 프롬프트 하나로 시작하였습니다. 정체성, 금지 규칙, 인터뷰 가이드, 예시 대화를 모두 한 프롬프트에 담아 동작시켰는데, 며칠 지나지 않아 서로 성격이 다른 두 종류의 요구사항이 충돌한다는 점을 알게 되었습니다.

먼저 절대 흔들리면 안 되는 요구사항이 있습니다. 참가자의 개인정보를 되풀이하지 않는 것, 의료 상담에 응하지 않는 것, 답을 유도하지 않는 것, 존댓말을 유지하는 것 등이 여기에 해당합니다. 이런 규칙은 주제가 병원 탐색이든 결제 경험이든 동일하게 적용되어야 하고, 백 번 중 한 번이라도 깨지면 안 됩니다.

반대로 계속 바뀌어야 하는 요구사항도 있습니다. 어떤 신호가 보이면 더 깊이 파고들어야 하는지, 단답형으로 답하는 참가자에게는 어떻게 되물어야 하는지, 도입 질문은 어떻게 시작할지와 같은 것들입니다. 이는 주제마다 다르고, 인터뷰를 거듭할수록 점점 나아져야 합니다.

두 요구사항을 하나의 프롬프트에 함께 두면 양쪽 모두 관리하기 어려워집니다. 전략을 고치기 위해 프롬프트를 수정하면 가드레일 문구가 밀려나고, 가드레일을 강화하면 전략의 자유도가 줄어듭니다. 무엇보다 AI가 스스로 나아지는 구조를 만들려면 전략 부분은 LLM이 다시 쓸 수 있어야 하는데, 가드레일까지 LLM이 다시 쓰도록 둘 수는 없었습니다.

그래서 저희는 다음과 같은 설계 원칙을 먼저 정하였습니다.

안전은 결정형(코드)으로, 전략은 생성형(데이터)으로. 가드레일은 코드 상수와 응답 후 검증기에 두고, 인터뷰 전략만 LLM이 생성하고 재생성하는 스킬로 분리한다. 스킬은 신뢰할 수 없는 LLM 산출물이므로 "강화"일 뿐, 가드레일을 대체하지 않는다.

이후의 모든 구조는 이 원칙에서 출발합니다.


2. 대화 지능의 3계층과 steering

위 원칙을 구조로 옮기면 세 개의 계층과, 이와 병행하는 하나의 검증 축으로 나뉩니다.

각 계층의 성격을 표로 정리하면 다음과 같습니다:

계층성격누가 바꾸나70/30 대응
Layer 1 정적 정체성 + 가드레일코드 상수, 불변개발자(배포)기본 스킬 70%
Layer 2 주제별 동적 프롬프트DB 컬럼AI 자동 생성 + 관리자 편집도메인 30%
Layer 3 개방형 다중 스킬DB 레코드 N개, 세대 관리AI 생성 에이전트도메인 30%
steering응답 생성 검증·재작성 유도코드 + 주제별 정책기본 스킬 70%

2.1 Layer 1: 코드에 고정된 정체성

Layer 1은 TypeScript 상수로 정의되어 있습니다. 별도의 .md 파일이나 DB가 아닌, 코드 자체에 포함된 값입니다. (이 글에 실린 코드는 설명을 위해 실제 구현을 단순화한 것입니다.)

// interview.constants.ts — Layer 1은 코드 상수
const PROMPT_SECTION = {
// 정체성 + 하드 가드레일
STATIC_IDENTITY: `
당신은 '똑닥' 서비스의 AI 인터뷰어입니다. ...
다음 규칙을 반드시 지키세요(예외 없음):
- 참가자의 개인정보를 되풀이하거나 새로 캐묻지 마세요.
- 의료 상담에는 응하지 마세요.
- 답을 유도하거나 예시를 먼저 제시하지 마세요.
- 답변을 요약·정리하며 인터뷰를 임의로 닫지 마세요.
- 항상 정중한 존댓말을 사용하세요.
등록된 스킬이 있으면 상황에 맞는 스킬을 활성화해 그 지침을 따르세요.
`,
OUTPUT_DISCIPLINE: `사고 과정을 응답에 쓰지 말고, 역할 라벨·예시 대화 형식을 흉내 내지 마세요.`,
SENTINEL_GUIDE: `인터뷰를 끝낼 때는 응답 맨 끝에 [[END:사유]] 마커를 정확히 한 번 출력하세요.`,
};

시스템 프롬프트를 조립하는 코드는 아래와 같이 순수 함수 하나로 구성되어 있습니다.

// prompt-assembler.ts
function assembleSystemPrompt(topicPrompt?: string): string {
const layer1 = STATIC_IDENTITY + OUTPUT_DISCIPLINE + SENTINEL_GUIDE;

// Layer 2가 있으면 Layer 1 "뒤에" 이어붙인다. 앞에 오거나 대체할 수는 없다.
return topicPrompt ? `${layer1}\n\n## 주제 맥락\n${topicPrompt}` : layer1;
}

Layer 1을 코드 상수로 둔 이유는 설계 문서에 다음과 같이 적어두었습니다. "Layer 1은 신뢰 불가능한 입력(가이드·스킬 텍스트)으로 대체·무력화될 수 없어야 한다. 안전 규칙을 데이터가 아닌 코드에 고정해 프롬프트 인젝션·품질 변동에 견고하게 만든다." 가이드는 운영자가 작성하고 스킬은 LLM이 생성하기 때문에, 둘 다 배포 없이 언제든 바뀔 수 있는 데이터입니다. 그 데이터에 어떤 내용이 들어오더라도 Layer 1 뒤에 이어붙을 뿐이고, Layer 1 자체를 대체할 수는 없도록 하였습니다.

2.2 Layer 2: 프롬프트를 작성하는 프롬프트

Layer 2는 주제별 "주제 맥락"에 해당합니다. 운영자가 인터뷰 가이드를 저장하면 이벤트가 발행되고, 백그라운드에서 LLM이 가이드를 읽어 3~6문장 분량의 프레이밍을 작성한 뒤 Topic.systemPrompt에 저장합니다. 덕분에 사람이 시스템 프롬프트를 직접 작성하지 않아도 됩니다.

이 메타 프롬프트에서 눈여겨볼 부분은 무엇을 쓰지 말라고 지시하는가입니다.

// system-prompt-prompt.ts — Layer 2를 생성하는 메타 프롬프트
const system = `
당신은 AI 인터뷰어를 위한 "주제 맥락" 시스템 프롬프트를 작성하는 전문가입니다.
- 이 주제의 목표, 깊이 다룰 영역, 대화 톤, 주의점에 집중하세요.
- 개인정보 보호·의료 상담 거절·존댓말 같은 "공통 가드레일"은 반복하지 마세요.
(별도 상위 계층이 항상 적용합니다)
- 답을 유도하라는 지시는 넣지 마세요.
- 3~6문장 평문으로 쓰세요.
`;

"공통 가드레일은 반복하지 마세요"라는 지시는 단순히 중복을 피하기 위한 것만은 아닙니다. Layer 2가 가드레일을 다시 쓰기 시작하면 어느 쪽이 기준인지 모호해지고, Layer 1의 권위가 흐려지게 됩니다. 즉, 계층 분리를 프롬프트 수준에서도 강제한 것입니다.

2.3 Layer 3: 개방형 다중 스킬

Layer 3은 이 글에서 가장 비중 있게 다룰 계층입니다. 스킬은 skillName, description, instructions 세 개의 필드로 이루어진 레코드이며, 한 주제에 여러 개가 하나의 세대(generation)로 묶여 활성화됩니다. 런타임에는 이 레코드들이 Strands의 AgentSkills 플러그인으로 변환되어 모델에게 도구처럼 전달됩니다.

// prompt-assembler.ts — DB 스킬 레코드 → Strands AgentSkills 입력
function buildInterviewSkills(skills: Skill[]): LlmSkill[] {
return skills.map((skill) => ({
name: skill.skillName, // 예: "deepen-high-signal"
description: skill.description, // 언제 발동하는가 — 모델이 이걸 보고 고른다
instructions: skill.markdown, // 발동 후 따를 지침
}));
}

여기서 description은 스킬의 발동 조건 역할을 합니다. 모델은 대화 맥락을 보고 "지금은 deepen-high-signal을 사용할 때"라고 판단하면 해당 스킬의 지침을 읽습니다. 스킬의 개수와 이름은 고정된 enum이 아니라 생성 에이전트가 주제마다 결정합니다. 이렇게 스킬 목록을 열어둔 결정이 나중에 자가 발전을 가능하게 하는 핵심이 되었습니다. 스킬 목록이 코드에 고정되어 있었다면 LLM이 새로운 전략을 만들어낼 여지가 없었을 것입니다.

2.4 steering: 응답이 생성된 뒤 검사하기

프롬프트가 "이렇게 하라"는 지시라면, steering은 "생성된 결과를 검사하겠다"는 장치입니다. Strands SDK의 SteeringHandler는 모델 호출 직후 afterModelCall 훅으로 최종 텍스트를 전달받고, proceed(통과) 또는 guide(폐기 후 피드백을 붙여 재시도) 중 하나를 결정합니다.

저희는 두 개의 핸들러를 항상 함께 적용하고 있습니다. 하나는 LLM을 사용하지 않는 결정형 규칙입니다.

// one-question.handler.ts — 결정형 규칙, LLM 없음
class OneQuestionHandler {
afterModelCall(text: string): SteeringDecision {
if (countQuestions(text) > 1) {
return { type: 'guide', feedback: '한 번에 하나의 질문만 하세요.' }; // 폐기 후 재생성
}
return { type: 'proceed' };
}
}

"한 번에 질문은 하나만"은 인터뷰의 가장 기본적인 규칙인데, 프롬프트만으로는 계속 지켜지지 않았습니다. 물음표 개수를 세는 코드 한 줄이 프롬프트 여러 줄보다 확실하게 동작하였고, 유닛 테스트로 검증할 수 있다는 점도 장점이었습니다.

다른 하나는 LLM을 judge로 사용하는 핸들러입니다.

// conduct-judge.handler.ts — LLM judge 규칙
class ConductJudgeHandler {
async afterModelCall(text: string): Promise<SteeringDecision> {
let verdict: string;
try {
verdict = await this.judge(`다음 응답이 규칙을 위반했는가?\n${BASELINE_RULES}\n${text}`);
} catch {
return { type: 'proceed' }; // judge 실패는 안전측 통과 — 인터뷰를 멈추지 않는다
}

if (verdict.startsWith('GUIDE:')) {
return { type: 'guide', feedback: verdict.replace('GUIDE:', '') };
}
return { type: 'proceed' }; // PASS
}
}

judge는 BASELINE_CONDUCT_RULES(요약 종료 금지, 유도 금지, 존댓말, 개인정보 누설 금지, 의료 상담 되받기 등 7개 규칙)를 번호 목록으로 붙여 별도의 LLM에게 "이 응답이 규칙을 위반했는가"를 묻고, PASS 또는 GUIDE: 교정 지시 형태의 답을 받습니다. 이 과정에는 두 가지 설계 판단이 들어 있습니다:

  • judge 호출에는 steering도 스킬도 붙이지 않습니다. judge가 다시 judge를 호출하는 재귀를 막기 위해서입니다.
  • judge가 실패하면 응답을 통과시킵니다. Bedrock 장애로 인터뷰가 조용히 멈추거나 재시도 루프에 빠지는 것보다, 한 턴의 검증을 건너뛰는 편이 낫다고 판단하였습니다. 반대로 뒤에서 설명할 목표 달성 judge는 실패 시 "미달성"으로 처리합니다. 그쪽은 판정 실패가 인터뷰를 조기 종료시켜서는 안 되기 때문입니다. 같은 "안전한 쪽"이지만 방향은 서로 반대입니다.

또한 baseline 규칙을 비활성화하는 스위치는 존재하지 않습니다. SteeringPolicyResolver.resolve()는 두 핸들러를 항상 반환하고, 주제별 policy는 judge 규칙을 추가만 할 수 있습니다. 어떤 주제 설정으로도 baseline을 제거하는 경로는 코드에 없습니다.


3. Strands Agents SDK 위에 구성하기

앞서 설명한 세 계층과 steering을 실제로 하나의 LLM 호출로 합치는 곳이 Strands Agents TS SDK입니다. 저희는 SDK를 직접 사용하지 않고 LlmAdapter라는 단일 인터페이스 뒤에 두었습니다. 인터뷰 응답, 스킬 합성, judge, 채점까지 모든 LLM 호출이 이 인터페이스 하나를 거칩니다.

// strands-runtime.ts — 모든 LLM 호출이 지나는 단일 접점
async function createAgent(options: LlmInput): Promise<Agent> {
const model = new BedrockModel({
modelId: options.modelId,
maxTokens: options.maxTokens,
// Sonnet 5는 구형 budget_tokens 방식을 거부한다 → adaptive thinking 사용
thinking: { type: 'adaptive', effort: 'medium' },
stopSequences: options.stopSequences, // role-leak 조기 차단
});

return new Agent({
model,
systemPrompt: options.system, // Layer 1 + Layer 2
messages: options.messages, // 대화록
plugins: [new AgentSkills(options.skills)], // Layer 3
interventions: toSteeringHandlers(options.rules), // steering
});
}

BedrockModel, AgentSkills 플러그인, SteeringHandler 목록이 하나의 Agent로 조립됩니다. SDK가 ESM 전용이어서 CommonJS 기반의 NestJS에서는 동적 import()로만 불러올 수 있는데, 그 경계를 이 파일 하나에 두고 나머지 코드는 SDK 타입을 전혀 알지 못하도록 구성하였습니다.

3.1 한 턴의 흐름

참가자가 메시지를 보내면 엔진은 다음과 같은 순서로 동작합니다.

이 흐름을 코드로 옮기면 아래 함수 하나에 해당합니다.

// interview-engine.service.ts — 참가자 발화 한 번에 대한 응답 생성
async function streamResponse(session: Session) {
const topic = await topics.find(session.topicId);
const messages = await buildMessages(session); // 대화록 → user/assistant
const skills = await getSessionSkills(session); // 세션 스냅샷의 스킬 세트

// 1) 종료 백스톱 — 스킬의 [[END:]] 판단과 별개로 매 턴 검사
if (isOver40Minutes(session)) return close(session, 'time_limit');
if (await goalJudge.allGoalsMet(messages)) return close(session, 'goals_met');

// 2) 4개 소스를 하나의 LlmInput으로 조립
const system = assembleSystemPrompt(topic.systemPrompt);
const interventions = steering.resolve(policyFor(session.topicId));

// 3) 스트리밍이 아니라 runOnce — steering이 응답을 폐기할 수 있으므로
const text = await llm.runOnce({ system, messages, skills, interventions });

await finalize(session, text); // [[END:]] 파싱 · role-leak 제거 · SSE 전송
}

함수 이름은 streamResponse이지만 실제 호출은 runOnce, 즉 스트리밍이 아닌 방식입니다. 처음에는 토큰을 실시간으로 스트리밍하였습니다. 그런데 steering이 guide를 반환하면 이미 전송된 응답을 폐기하고 다시 생성해야 하는데, 참가자 화면에는 절반쯤 출력된 문장이 남아 있고 그 토큰은 회수할 수 없었습니다. 결국 검증이 끝난 최종 텍스트를 받아 한 번에 전달하는 방식으로 변경하였습니다. 실시간성은 조금 잃었지만, "참가자가 보는 모든 문장은 검증을 통과한 문장이다"라는 불변식을 확보할 수 있었습니다.

인터뷰 종료 장치는 세 겹으로 두었습니다. 스킬이 스스로 판단하여 [[END:사유]] 마커를 출력하는 자율 종료, 매 턴 가이드 원문의 목표를 기준으로 판정하는 목표 달성 judge, 그리고 40분 시간 제한입니다. 스킬의 판단을 신뢰하되, 그것에만 의존하지는 않도록 하였습니다.


4. 자가 발전 루프

이제 이 글의 핵심인 자가 발전 루프를 살펴보겠습니다. 여기서 "AI가 학습한다"는 말은 모델의 가중치를 바꾸는 것이 아니라, 사람의 피드백을 재료로 인터뷰 전략(Layer 3 스킬 세트)을 다시 합성하여 새 세대를 만드는 것을 의미합니다. 루프는 두 가지로 나뉩니다.

4.1 루프 A: 사람의 인터뷰를 쌓아서 시작하기

로드맵의 1단계에 해당합니다. 진행자가 시스템 안에서 참가자와 1:1로 채팅을 진행하며, 이때 LLM은 호출되지 않습니다. 완료된 사람 세션이 쌓이면 별도의 업로드 없이 그 대화록으로 첫 스킬 세대를 만듭니다.

// accumulation.service.ts — 사람 인터뷰 대화록을 스킬 합성 입력으로 직렬화
async function assembleAccumulationContext(topicId: string): Promise<string> {
const sessions = await completedHumanSessions(topicId);

const blocks = sessions.map((session) => {
const lines = session.turns.map((turn) => `[${turn.role}] ${renderTurn(turn)}`);
return `### 세션 ${session.id}\n${lines.join('\n')}\n${session.feedbacks}`;
});

return `# 축적된 사람 주도 인터뷰(${sessions.length}건)\n\n${blocks.join('\n\n')}`;
}

// 참가자 발화만 마스킹한다. 진행자의 질문 패턴이 학습 신호이기 때문.
function renderTurn(turn: Turn): string {
return turn.role === 'participant' ? pii.mask(turn.content) : turn.content;
}

renderTurn에서 참가자 턴과 진행자 턴을 다르게 처리하는 부분은 이 루프에서 가장 논의가 길었던 결정입니다. 처음에는 모든 턴을 마스킹하였습니다. 그런데 학습해야 할 대상은 참가자가 무슨 말을 했는가가 아니라 진행자가 어떻게 되물었는가입니다. 진행자의 꼬리 질문 패턴이 핵심 신호인데, 이를 마스킹하면 "[이름]님은 왜 [병원]을 선택하셨어요?"와 같은 형태만 남게 됩니다. 진행자의 발화는 개인정보의 주체가 아니라는 점을 확인한 뒤, 참가자 턴만 마스킹하고 진행자 턴은 원문을 그대로 사용하기로 결정하였습니다.

4.2 루프 B: 검수자의 피드백을 트리거로 사용하기

AI가 인터뷰를 마치면 검수자가 대화록을 읽고 피드백을 남깁니다. 피드백은 세션 전체에 대한 overall과 특정 턴에 대한 turn, 두 종류의 자유 서술로 구성됩니다. Y/N 체크박스는 두지 않았습니다. "이 턴에서 참가자가 이미 답한 내용을 다시 물었다"와 같이 구체적인 문장이 다음 세대를 만드는 데 훨씬 좋은 재료가 되기 때문입니다.

피드백이 저장되는 시점이 진화의 트리거가 됩니다.

// feedback.service.ts — 피드백 저장이 곧 진화 트리거
function triggerEvolve(topicId: string, sessionId: string, saved: Feedback[]) {
const overall = saved.find((f) => f.scope === 'overall');
const turns = saved.filter((f) => f.scope === 'turn');

// fire-and-forget: 검수자는 LLM을 기다리지 않는다. 실패는 로깅만.
skillService
.evolveSkill(topicId, { sessionId, overall, turns })
.catch((err) => logger.error(`evolve failed (non-blocking): ${err.message}`));
}

피드백은 완료된 세션에만 남길 수 있고, 저장은 단일 트랜잭션의 upsert로 처리되며, 하나의 저장 배치에 대해 진화 작업은 한 번만 큐에 등록됩니다. 따라서 검수자가 저장 버튼을 누른 뒤 LLM 응답을 기다릴 필요가 없습니다.

4.3 컨텍스트 조립: 증분 학습이 아닌 전체 재합성

트리거된 작업은 먼저 "무엇을 참고하여 다시 만들 것인가"에 해당하는 컨텍스트를 조립합니다.

// skill-evolve-context.service.ts — "무엇을 보고 다시 만들 것인가"
async function assemble(topicId: string, trigger: FeedbackContext): Promise<string> {
// 트리거 세션 하나가 아니라 이 주제의 이력 전체를 읽는다
const feedbacks = await allFeedbacksOfTopic(topicId);
const transcripts = await transcriptsOfSessionsWithFeedback(topicId);

return [
`## 트리거 세션\n${trigger.sessionId}`,
`## 누적 피드백(${feedbacks.length})\n${feedbacks.map((f) => `- [${f.scope}] ${f.text}`).join('\n')}`,
`## 관련 대화록(${transcripts.length}턴)\n${transcripts.map((t) => `[${t.role}] ${t.content}`).join('\n')}`,
].join('\n\n');
}

주목할 점은 이 작업이 트리거가 된 세션 하나만 보지 않는다는 것입니다. 해당 주제에 지금까지 쌓인 피드백 전체와, 피드백이 달린 모든 세션의 대화록 전체를 매번 다시 읽습니다. 즉, 직전 세대에 이번 피드백을 덧붙이는 증분 학습이 아니라 **매번 전체 이력을 바탕으로 스킬 세트를 처음부터 다시 합성(full re-synthesis)**하는 구조입니다.

이렇게 설계한 이유는 다음과 같습니다. 증분 방식으로 규칙을 덧붙이면 세대가 쌓일수록 스킬의 일관성이 떨어집니다. 예를 들어 3세대에서 추가한 규칙과 7세대에서 추가한 규칙이 서로 충돌하더라도 이를 알아차리기 어렵습니다. 반면 전체를 다시 합성하면 LLM이 매번 모순을 정리할 기회를 갖게 됩니다. 대신 컨텍스트가 세션 수에 비례하여 계속 커진다는 비용이 있는데, 이 부분은 6장에서 다시 다루겠습니다.

4.4 재합성: build와 evolve는 같은 프롬프트를 사용

조립된 컨텍스트는 현재 활성 스킬 세트의 전문과 함께 합성 프롬프트에 포함됩니다.

// skill-orchestrator.service.ts — 자가 발전 한 사이클
async function runEvolve(topicId: string, trigger: FeedbackContext) {
const active = await lifecycle.getActiveSkills(topicId); // 현재 세대 (gen N)
const { guide, fewShot } = await loadBuildInputs(topicId);

const evolveContext =
`## 현재 스킬 세트(gen ${active[0].generation})\n` +
active.map((s) => `### ${s.skillName}\n${s.markdown}`).join('\n\n') +
(await evolveContextService.assemble(topicId, trigger));

// build와 같은 프롬프트. evolveContext가 붙는 것만 다르다.
const prompt = buildSkillPrompt({ guide, fewShot, evolveContext });
const drafts = await synthesis.synthesizeSkillSet(prompt); // LLM → JSON → 검증

return lifecycle.createGeneration({ topicId, drafts, trigger: 'feedback' }); // gen N+1
}

정리하면 진화에 사용되는 입력은 **가이드 + few-shot 예시 + (현재 스킬 세트 전문 + 누적 피드백 + 대화록)**입니다. 첫 생성(runBuild)과 진화(runEvolve)는 동일한 buildSkillPrompt를 사용하며, evolveContext가 포함되는지 여부만 다릅니다.

// skill-prompt.ts — 스킬 세트를 합성하는 프롬프트
const system = `
당신은 인터뷰 스킬 세트를 설계하는 전문가입니다.
가이드와 예시 대화를 바탕으로, 대화 맥락에 따라 발동되는 "생성 전략" 스킬 여러 개를 설계하세요.
권장 3~7개. 예: 도입 및 첫 질문 / 고신호 포착 후 심화 / 저신호는 짧게 팔로업 후 전환.

주의: 개인정보 보호·의료 상담 되받기 같은 "가드레일"은 스킬로 만들지 마세요.
(시스템이 별도로 강제합니다)

출력: JSON 배열만. 각 원소는
{ skillName: "소문자-하이픈-slug", description: "언제 발동하는가", instructions: "따를 지침" }
`;

여기서도 1장에서 세운 원칙이 프롬프트 문장으로 다시 등장합니다. "가드레일은 스킬로 만들지 마세요(시스템이 별도로 강제합니다)." 생성 에이전트가 성실하게 개인정보 보호 스킬을 만들어 넣으면 Layer 1과 중복될 뿐 아니라, "스킬이 있으니 안전하다"는 잘못된 인식을 만들 수 있기 때문입니다. 스킬은 전략만 담도록 하였습니다.

권장 스킬 개수는 3~7개이며 최대 10개까지 허용합니다. 어떤 스킬을 몇 개 만들지는 매번 LLM이 결정하며, 실제로 세대가 바뀌면 스킬 이름이 전부 달라지는 경우도 있습니다. 이전 세대에만 있던 스킬은 새 세대에서 자연스럽게 제외됩니다.

4.5 검증과 재시도

LLM의 출력은 JSON 배열이어야 하고, 각 스킬은 slug 패턴 ^[a-z0-9][a-z0-9-]{1,63}$을 따르는 고유한 이름과 비어 있지 않은 description·instructions를 가져야 합니다. 파싱 또는 검증에 실패하면 한 번 재시도하고, 그래도 실패하면 예외를 발생시킵니다. 이 경우 호출자는 기존 세대를 그대로 유지하므로, 진화가 실패하더라도 인터뷰가 중단되는 일은 없습니다.

초기에는 스킬 하나에 7개 섹션을 강제하는 모놀리식 검증을 사용하였습니다. 다중 스킬 구조로 전환하면서 "각 스킬은 하나의 목적만 가지고 짧아야 한다"는 방향과 맞지 않아, 스킬 단위의 최소 검증으로 교체하였습니다. 검증이 느슨해진 것이 아니라 검증의 단위가 바뀐 것입니다.

4.6 세대 관리: 의미 없는 세대는 만들지 않기

합성이 끝나면 새로운 세대가 저장됩니다.

// skill-lifecycle.service.ts — 새 세대 저장
async function createGeneration({ topicId, drafts, trigger }): Promise<Result> {
const active = await getActiveSkills(topicId);

// 내용이 같으면 세대를 만들지 않는다 (skillName + sha256(instructions) 비교)
if (isSameSet(active, drafts)) {
return { generation: active[0].generation, skipped: true };
}

const generation = (await maxGeneration(topicId)) + 1; // 주제 단위 배치 번호
for (const draft of drafts) {
const version = (await maxVersion(topicId, draft.skillName)) + 1; // 스킬 이름 단위 개정 번호
await skills.save({ ...draft, topicId, generation, version, trigger });
}

// build / evolve / accumulation 세 경로가 모두 여기로 수렴 → 이벤트는 이 한 곳에서만 발행
events.emit('SKILL_GENERATION_CHANGED', { topicId, generation });
return { generation, skipped: false };
}

세대 관리에는 두 종류의 번호가 사용됩니다:

번호단위의미
generation주제스킬 집합의 배치 번호. 활성 세트 = 최신 generation
version(주제, skillName)같은 이름의 스킬이 몇 번째 개정인지. 단조 증가

isSameSet은 스킬 이름과 instructions의 SHA-256 해시를 비교합니다. 검수자가 피드백을 남겼더라도 LLM이 "현재 세트가 이미 그 피드백을 반영하고 있다"고 판단하여 동일한 내용을 반환하면 새 세대를 만들지 않습니다. 이 처리가 없으면 피드백 한 줄마다 세대가 하나씩 늘어나, 나중에 세대 간 비교가 어려워집니다.

롤백은 최신 세대를 soft delete하는 방식으로 이루어지며, 삭제 후 활성 세트는 자동으로 직전 세대가 됩니다. 마지막으로 남은 세대는 삭제할 수 없습니다.

SKILL_GENERATION_CHANGED 이벤트를 이 메서드 한 곳에서만 발행하는 것도 의도된 설계입니다. 첫 생성, 피드백 진화, 축적 시작이라는 세 경로가 모두 이 메서드로 수렴하기 때문에, "세대가 바뀌면 도입 질문을 미리 만들어둔다"와 같은 후속 처리를 호출부마다 반복해서 넣지 않아도 됩니다.

4.7 세션 스냅샷: 진행 중인 인터뷰를 보호하기

세션이 시작될 때 활성 스킬 집합은 InterviewSession.skillSet{ skillName: version } 형태로 고정됩니다. 이후 매 턴 스킬을 읽을 때 엔진은 현재의 활성 세대가 아니라 이 스냅샷에 기록된 (skillName, version) 쌍으로 정확한 레코드를 조회합니다.

따라서 인터뷰 도중 다른 세션의 피드백으로 세대가 바뀌더라도, 진행 중인 세션은 시작 시점의 스킬 세트로 끝까지 진행됩니다. 나중에 "이 세션은 어떤 스킬로 진행되었는가"를 정확히 재현할 수 있다는 장점도 있습니다. 반면 Layer 2의 systemPrompt는 매 호출 시 현재 값을 읽기 때문에, 운영자가 주제 맥락을 수정하면 다음 턴부터 바로 반영됩니다. 스킬은 재현성을, 주제 맥락은 즉시성을 우선하도록 각각 다르게 설계하였습니다.

4.8 동시성 처리

진화 작업은 비동기로 수행됩니다. 작업 상태는 Redis에 7일간 보존되어 재시작이나 다중 레플리카 환경에서도 유지됩니다. 같은 주제에 대해 두 작업이 동시에 실행되면 version의 단조 증가가 깨질 수 있으므로, 파드 안에서는 Promise 체이닝으로 순차 실행을 보장하고 파드 사이에서는 주제 단위의 Redlock으로 상호 배제를 적용하였습니다. 락 리스는 30초이며 자동으로 연장되고, 락 획득 재시도는 약 11분까지 이어집니다. 이는 가장 긴 스킬 합성 타임아웃(10분)을 충분히 덮는 값입니다. 프로세스가 비정상 종료되어 pending 상태로 남은 작업은 "락을 보유하지 않은 채 60초 경과"를 기준으로 orphan으로 판정하고 failed로 정리합니다.


5. 사후 채점과 오프라인 평가

진화가 실제로 좋은 방향으로 이루어지고 있는지는 측정해보아야 알 수 있습니다. 이를 위해 두 가지 장치를 두었습니다.

(1) 사후 채점

세션이 완료되면 LLM judge가 대화록, 가이드, 검수자 피드백을 읽고 두 축을 독립적으로 채점합니다. 이 두 축은 PRD의 성공 지표를 그대로 옮긴 것으로, 첫째는 목표 부합 정보 확보(goalInfo), 둘째는 유익하거나 새로운 인사이트(insight)입니다. 각 축마다 Y/N, 0~100 점수, 한두 문장의 근거를 산출합니다. 점수 임계값으로 Y/N을 자동 도출하지 않도록 명시하여 두 판정을 독립시켰습니다.

채점 결과에는 채점 모델, 프롬프트 버전(u4b-scoring-v1), 입력 참조가 함께 저장됩니다. 나중에 프롬프트를 변경하였을 때 어느 채점이 어느 기준으로 이루어진 것인지 구분하기 위함입니다. 버전별 평균 점수와 Y 비율을 집계하고 두 버전 간 차이를 비교하는 API도 함께 제공합니다.

(2) 오프라인 평가

npm run eval 명령으로 Bedrock을 judge로 사용하는 rubric 기반 평가를 실행할 수 있습니다. rubric은 다음과 같이 다섯 종류로 구성되어 있습니다:

evaluator평가 대상
interview_rules개별 발화가 인터뷰 규칙을 지키는가. 심각 위반(개인정보·유도·반말)은 0점
response_relevance꼬리 질문이 직전 답변의 고신호를 포착해 심화하는가
goal_coverage세션 전체가 가이드의 탐색 요소를 덮었는가
skill_quality합성된 스킬이 재사용 가능한 전략을 담고 예시를 복붙하지 않았는가
skill_faithfulness스킬이 가이드·예시 근거에 충실한가. 환각·모순·PII 유출이면 0점

평가 케이스는 협조적인 참가자, 아이 이야기로 주제를 벗어나는 참가자, 단답형으로 답하는 참가자 세 가지를 준비하였습니다. --dry-run --replay 옵션으로 저장된 응답을 재생하면 Bedrock 호출 없이도 채점기를 검증할 수 있고, --min-score 게이트를 통해 CI에서 회귀를 막고 있습니다.


6. 아직 닫히지 않은 루프

여기까지 읽으면 "피드백 → 진화 → 채점 → 다시 피드백"의 루프가 완전히 닫힌 것처럼 보일 수 있습니다. 하지만 코드를 있는 그대로 보면 아직 그렇지 않습니다. 남은 과제를 정리하면 다음과 같습니다.

(1) 학습 신호는 오직 사람의 피드백입니다. 4.3에서 살펴본 SkillEvolveContextService.assembleFeedbackConversationTurn만 읽으며, 5장의 InterviewScore는 읽지 않습니다. 즉, LLM 채점은 측정 수단이지 학습 입력이 아닙니다. 점수가 낮은 세션이 있더라도 검수자가 피드백을 남기지 않으면 진화는 일어나지 않습니다. 이는 초기에 의도한 결정이었습니다. AI가 AI를 평가한 결과로 AI를 수정하는 루프는 검증 없이 운영하기에 위험하다고 판단하였기 때문입니다. 다만 사람의 검수 용량이 병목이 되는 시점이 오면, 채점 결과를 최소한 "이 세션을 우선 검수해 달라"는 우선순위 신호로는 활용해야 할 것입니다.

(2) 다중 스킬 세션은 자동 채점 대상에서 제외됩니다. 채점 서비스는 세션의 skillVersion이 null이면 채점을 건너뜁니다. 그런데 skillVersion은 스킬이 정확히 하나이고 그 이름이 legacy 단일 스킬일 때만 채워지며, 다중 스킬 세션에서는 null입니다. 결과적으로 현재의 세대 구조로 진행되는 세션은 자동 채점과 버전 비교의 대상이 아닙니다. 채점 파이프라인을 단일 스킬 시절에 만들었고, 다중 스킬로 전환할 때 "이번 범위는 런타임까지, 평가 연동은 후속 작업"으로 범위를 나누었기 때문입니다. skillSet 스냅샷을 키로 삼는 세대 단위 집계로 변경하는 작업이 남아 있습니다.

(3) few-shot 예시가 생각보다 적습니다. 첫 생성 경로에서 사용하는 예시 대화는 주제의 모든 예시 로그를 합쳐 전체 5턴만 가져옵니다. 로그별 5턴이 아니라 전체 5턴입니다. 반면 4.1의 축적 경로는 사람 세션의 대화록 전체를 evolveContext에 포함합니다. 두 경로의 입력량이 크게 다른데, 이것이 의도한 차이인지는 다시 검토해볼 필요가 있습니다.

(4) 주제별 steering 정책이 실제로는 매칭되지 않습니다. steering 정책 레지스트리는 'hospital-search'와 같은 목적 ID를 키로 사용하는데, 런타임은 주제의 UUID로 조회합니다. 결과적으로 항상 undefined가 반환되어 전역 baseline만 적용되고 있습니다. 안전 규칙은 전역이기 때문에 인터뷰가 위험해지는 것은 아니지만, "아이 진료 이야기로 벗어나면 본인 진료로 되돌린다"와 같은 주제별 확장 규칙은 현재 동작하지 않습니다.

(5) 전체 재합성의 비용 문제가 있습니다. 4.3에서 선택한 full re-synthesis 방식은 세션이 늘어날수록 컨텍스트가 선형으로 커집니다. 지금은 한 주제에 수십 세션 규모라 문제가 없지만, 수백 세션 규모가 되면 오래된 세션을 요약하여 포함하거나 최근 N개로 범위를 제한하는 등의 결정이 필요해집니다. 그 시점에는 "증분 방식은 일관성이 떨어진다"는 처음의 우려를 다시 마주하게 될 것입니다.


마치며

"자가 발전하는 에이전트"라는 표현은 다소 거창하게 들리지만, 코드에서 그 실체는 다음 세 가지 구조로 요약할 수 있습니다:

  1. 안전은 코드에 고정하였습니다. 정체성과 가드레일은 TypeScript 상수와 응답 후 검증기에 있으며, 어떤 데이터도 이를 대체할 수 없습니다.
  2. 전략은 데이터로 분리하였습니다. 인터뷰를 어떻게 이끌어갈지는 LLM이 합성한 스킬 세트에 담기며, 그 세트는 이름도 개수도 고정되어 있지 않습니다.
  3. 사람의 피드백을 재합성의 트리거로 연결하였습니다. 검수자가 문장 하나를 남기면 그 주제의 전체 이력이 다시 읽히고, 내용이 실제로 달라졌을 때만 새 세대가 만들어집니다.

첫 번째가 없으면 두 번째는 위험해지고, 두 번째가 없으면 세 번째는 불가능합니다. 이 순서를 지키는 것이 중요했습니다.

마지막으로 이 프로젝트를 진행하며 느낀 점을 간단히 남겨봅니다.

사람의 피드백에 따라 스스로 전략을 다시 만드는, 동적인 하네스를 가진 에이전트를 직접 설계하고 운영해본 것은 분명 의미 있는 경험이었습니다. 프롬프트를 사람이 계속 고쳐 쓰는 방식과는 다른 종류의 문제들을 만나게 되었고, 그 과정에서 얻은 구조적인 교훈은 다른 에이전트를 만들 때에도 그대로 적용할 수 있을 것이라 생각합니다.

다만 아쉬운 점도 있었습니다. AI 인터뷰어라는 아이디어가 실제 상황에서는 기대했던 만큼 매력적으로 동작하지 않았습니다. 이는 AI 답변의 품질 문제는 아니었습니다. 참가자들이 인터뷰어가 AI라는 사실을 미리 알고 참여하다 보니, 답변이 매우 짧거나 성의 없는 경우가 많았습니다. 사람 인터뷰어 앞에서라면 자연스럽게 이어졌을 이야기가 AI 앞에서는 한두 문장으로 끝나버리는 일이 반복되었고, 그 결과 AI 유저 인터뷰를 통해 유의미한 인사이트를 발굴하는 데에는 어려움이 있었습니다.

돌아보면 "AI가 얼마나 잘 묻는가"보다 "사람이 AI에게 얼마나 성실하게 답하는가"가 더 큰 변수였던 셈입니다. 이 부분은 기술이 아니라 참여 경험을 설계하는 문제에 가깝기 때문에, 다음에 비슷한 시도를 하게 된다면 인터뷰 구조보다 참가자가 답할 동기를 어떻게 만들 것인지부터 고민해야 할 것 같습니다.

참고 자료