DOCS/journey/research/260319_rb8001_응답품질_톤과장_문맥유실_전수조사_리서치.md
happybell80 60a892e5ab fix: DOCS 내 0_VALUE 참조를 GitHub URL → 로컬 상대경로로 전환, 02_Governance → 20_Governance 수정 #33 #34
SSOT는 로컬 0_VALUE/. GitHub URL은 복사본 참조로 SSOT 원칙 위반.
02_Governance는 존재하지 않는 구 경로로 전부 깨진 링크.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 14:00:21 +09:00

13 KiB

tags
tags
rb8001
prompt
tone
context
quality
research

rb8001 응답 품질 — 톤 과장·문맥 유실·과잉 제안 전수 조사 리서치

관련 문서

트러블슈팅 (이 리서치가 닫아야 할 문제)

계획 (구현 방향)

상위 원칙

리서치 목적

트러블슈팅 문서의 재현 질문셋을 실제로 실행하고, 코드 경로를 추적하여 다음을 확정한다.

  1. 톤 과장의 정확한 원인 (프롬프트 어느 부분이 과잉 서비스를 유발하는지)
  2. 문맥 유실의 정확한 원인 (어디서 직전 대화가 끊기는지)
  3. 과잉 제안의 정확한 원인 (왜 묻지 않은 것까지 나열하는지)
  4. Pydantic JSON 강제 방식의 적용 가능성
  5. 각 원인에 대한 최소 수정 범위

1. 현재 상태 (2026-03-19 E2E 실행 결과)

모델: gpt-5-mini (OpenAI handler 경유)

질문 현재 응답 문제
로빙? 사용자님, 로빙입니다. 어떻게 도와드릴까요? 양호
오늘 날씨는 어때? 5줄 — 위치 없다며 장황한 안내, 준비물 언급 과잉 안내
난 지금 서울이야 7줄 — 투자자 추천, 동선 최적화, PDF 분석 등 나열 과잉 제안, 맥락 무시
내가 지금 어디라고? 6줄 — 위치 접근 불가, 안전 문제 운운 문맥 유실, 과장

2. 원인 분석

2-1. 톤 과장 — 시스템 프롬프트가 과잉 서비스를 유도

OpenAI handler _get_system_prompt() (openai_handler.py:206~226)

문제 문구:

- 항상 도움이 되는 방향으로 조언
- 필요시 구체적인 액션 아이템 제시

이 두 줄이 LLM에게 "매 응답마다 무언가를 제안해야 한다"는 압박을 줌. 서울이야처럼 단순 사실 제공에도 투자자 추천, PDF 분석 등을 나열하는 원인.

감정 주입 (llm_service.py:262~268)

- {strategy} {length_instruction} 응답하세요.
- 사용자를 '{preferred_name}'으로 호칭하세요. 호칭은 응답 첫 문장에 1회만...

감정 분석 결과가 neutral이어도 자연스럽게 중간 응답하세요라는 지시가 붙어 짧게 끝내야 할 응답을 늘림.

Gemini handler _get_system_prompt() (gemini_handler.py:606~648)

<examples>
좋은 답변: "사용자님의 정보를 확인하여 정확한 답변을 드리겠습니다."

좋은 답변 예시 자체가 의전형. LLM이 이 톤을 학습함.

2-2. 문맥 유실 — OpenAI handler에 대화 이력이 전달되지 않음

핵심 발견:

  • Gemini handler는 context['recent_conversations']를 읽어 last_3_context로 주입 → 직전 대화 최대 3개 포함
  • OpenAI handler는 context['previous_messages']를 읽음 → 그런데 이 키는 어디에서도 채워지지 않음
  • message_router.py:349에서 context에 recent_conversations를 넣지만, previous_messages는 안 넣음
  • 따라서 OpenAI 경로에서는 대화 이력이 항상 비어있음

이것이 서울이야어디라고?에서 직전 맥락을 유실하는 직접 원인.

CONTEXT_FOLLOWUP 우회 경로 (message_service.py:265~286):

  • 의도가 CONTEXT_FOLLOWUP으로 분류되면 직전 발화를 메시지에 직접 이어붙임
  • 하지만 내가 지금 어디라고?CONTEXT_FOLLOWUP으로 분류되지 않으면 이 경로를 타지 않음
  • 실제로 CONTEXT_FOLLOWUP 판정은 decision_engine의 규칙 기반 패턴 매칭에 의존

2-3. 과잉 제안 — 프롬프트에 제한이 없음

현재 프롬프트에는 다음이 없음:

  • 응답 길이 제한 (최대 문장 수)
  • "묻지 않은 것은 제안하지 마라" 지시
  • "단순 확인이면 짧게 끝내라" 지시
  • 출력 구조 강제 (JSON 등)

LLM은 "도움이 되는 방향으로 조언"이라는 지시를 따라 최대한 많은 제안을 나열함.

3. 해결 방안

3-1. 시스템 프롬프트 개선 (DB에서 v2로 교체)

방금 구현한 프롬프트 DB 폐루프를 활용하여 코드 배포 없이 프롬프트를 교체.

v2 프롬프트 설계 원칙:

  • "도움이 되는 방향으로 조언" → 삭제
  • "필요시 액션 아이템 제시" → "사용자가 요청한 경우에만 제시"로 변경
  • 응답 길이 제한 추가: "2~3문장 이내로 답변. 사용자가 더 원하면 그때 확장"
  • "묻지 않은 것은 제안하지 마라" 명시
  • 의전형 예시 제거

OpenAI v2 프롬프트 (제안):

당신은 '로빙(Robeing)'이라는 이름의 AI 어시스턴트입니다.

응답 원칙:
1. 짧고 직접적으로 답변한다. 2~3문장 이내.
2. 묻지 않은 것은 제안하지 않는다.
3. 단순 확인이나 사실 제공이면 1문장으로 끝낸다.
4. 사용자가 더 원하면 그때 확장한다.
5. 이모지 사용 금지.
6. 한국어로 응답.

3-2. 문맥 유실 수정 — OpenAI handler에 대화 이력 전달

수정 위치: llm_service.pyprocess_request() chat 경로

recent_conversations를 OpenAI의 previous_messages 형식으로 변환하여 context에 추가:

if context.get("recent_conversations"):
    prev_msgs = []
    for conv in context["recent_conversations"][:5]:
        if conv.get("message"):
            prev_msgs.append({"role": "user", "content": conv["message"]})
        if conv.get("response"):
            prev_msgs.append({"role": "assistant", "content": conv["response"]})
    context["previous_messages"] = prev_msgs

수정 파일: app/services/llm/llm_service.py 1곳

3-3. Pydantic JSON 강제 — 선택적 적용

적용 모델:

class ChatResponse(BaseModel):
    answer: str = Field(description="질문에 대한 직접 답변. 2~3문장 이내.")
    follow_up: bool = Field(default=False, description="추가 정보가 필요한지 여부")

적용 방식:

  • response_format={"type": "json_object"}를 chat 경로에도 추가
  • LLM 응답을 ChatResponse.model_validate()로 검증
  • 사용자에게는 answer 필드만 전달

장점:

  • 응답 길이를 구조적으로 제어 (Field description으로 강제)
  • 과잉 제안이 answer 밖으로 나갈 수 없음
  • 테스트 자동화 가능 (answer 길이, follow_up 값 검증)

단점:

  • 자연스러운 대화 흐름에 영향 가능
  • 토큰 소모 약간 증가

권장: 3-1(프롬프트 개선)과 3-2(문맥 수정)를 먼저 적용하고, 그래도 톤이 안 잡히면 3-3(JSON 강제)을 추가.

4. 수정 범위 요약

단계 내용 수정 파일 코드 배포 필요
1단계 시스템 프롬프트 v2 교체 DB만 아니오 (폐루프 활용)
2단계 OpenAI handler 대화 이력 전달 llm_service.py
3단계 (선택) Pydantic JSON 강제 llm_service.py, openai_handler.py

5. 검증 기준 (닫힘 조건)

재현 질문셋 기준:

질문 기대 응답
로빙? 1문장 인사
오늘 날씨는 어때? 실시간 조회 불가 안내 1~2문장, 과잉 안내 없음
난 지금 서울이야 서울이시군요. 또는 알겠습니다. 1문장, 묻지 않은 제안 없음
내가 지금 어디라고? 서울이라고 하셨어요. 1문장, 직전 맥락 유지

6. 감정 주입 constraints 조정

현재 (llm_service.py:262~268):

- 사용자를 '{preferred_name}'으로 호칭하세요. 호칭은 응답 첫 문장에 1회만 자연스럽게 사용하세요.
- {strategy} {length_instruction} 응답하세요.

문제: neutral 감정에서도 "자연스럽게 중간 응답하세요"가 붙어 응답을 늘림.

개선:

  • neutral일 때 감정 constraints 자체를 생략 (빈 문자열)
  • length_instruction 기본값을 "중간" → "짧게"로 변경
  • 호칭 지시를 조건부로 (첫 대화에만 사용, 이후 생략)

7. OpenClaw 레퍼런스 분석

소스: /home/admin/robeing/reference/openclaw (commit 7f86be1037)

7-1. OpenClaw의 컨텍스트 처리 구조

User Message
  → SessionManager.open() — JSONL에서 전체 대화 이력 로드
  → sanitizeSessionHistory() — provider별 메시지 형식 정규화
  → contextEngine.assemble(messages, tokenBudget) — 토큰 예산 내로 조립
  → LLM 호출
  → afterTurn() — 배경에서 auto-compaction

핵심 파일:

  • 컨텍스트 엔진 인터페이스: src/context-engine/types.ts
  • Compaction 알고리즘: src/agents/compaction.ts
  • 세션 이력 정규화: src/agents/pi-embedded-runner/google.ts:520+
  • 컨텍스트 조립: src/agents/pi-embedded-runner/run/attempt.ts:2130-2189

7-2. OpenClaw Context Engine의 핵심 메서드

메서드 역할
assemble(messages, tokenBudget) 메시지를 토큰 예산 내로 조립, provider별 형식 변환 포함
compact(sessionFile, tokenBudget) 오래된 메시지를 요약 압축
ingest(message) 새 메시지 추가
afterTurn() 턴 완료 후 배경 compaction

플러그인 방식: registerContextEngine()으로 엔진 교체 가능.

7-3. OpenClaw의 provider 정규화 (sanitizeSessionHistory)

OpenClaw는 OpenAI/Gemini/Anthropic 각각 다른 메시지 형식을 하나의 정규화 단계에서 통일한 후 handler에 전달. 구체적으로:

  • 이미지 해상도/형식 정규화
  • thinking block 제거 (정책에 따라)
  • tool_use/tool_result 페어링 수리
  • OpenAI용 function call 다운그레이드
  • 세션 간 메시지 경계 주석

로빙과의 차이: 로빙은 이 정규화 계층이 없음. OpenAI handler는 previous_messages, Gemini handler는 recent_conversations를 각자 읽어서 provider별 키 불일치가 발생.

7-4. OpenClaw의 Compaction 알고리즘

  1. 메시지를 토큰 기준으로 청크 분할 (SAFETY_MARGIN 1.2, 적응적 청크 비율)
  2. 청크별 LLM 요약 생성
  3. 재귀적 병합 (부분 요약 → 통합 요약)
  4. 식별자(UUID, URL, 파일명) 보존 강제
  5. 실패 시 점진적 fallback: 전체 요약 → 부분 요약 → 개수만 기록

7-5. 로빙에 적용할 것 (범위 분리)

적용 대상 이번 계획 범위 향후 (별도 문서)
provider별 형식 변환 recent_conversationsprevious_messages 변환 Context Engine 중간 계층 도입
토큰 예산 관리 assemble() 패턴 도입
Compaction OpenClaw compaction 아이디어에서 추적
세션 메모리 daily log + MEMORY.md 도입

이번 수정의 위치: OpenClaw의 assemble() 중 "provider별 형식 변환" 부분만 가져옴. llm_service.pyprocess_request()에서 recent_conversations를 OpenAI messages[] 형식으로 변환.

8. 리스크

리스크 대응
프롬프트를 너무 짧게 바꾸면 필요할 때도 정보 부족 "사용자가 더 원하면 그때 확장" 지시로 대응
JSON 강제 시 자연스러움 저하 1~2단계 선적용 후 필요 시에만 3단계
문맥 이력 5개 추가 시 토큰 증가 이미 Gemini에서 3개 사용 중, OpenAI 5개는 허용 범위
감정 constraints 제거 시 감정 대응 약화 neutral 외 감정에서는 유지
Context Engine 없이 키 변환만 하면 또 다른 handler 추가 시 같은 문제 재발 향후 Context Engine 도입 시 해소. 이번은 최소 수정 원칙 (SSOT #2 Minimal Change)

9. 구현 순서

순서 내용 종결 효과
1 DB에 시스템 프롬프트 v2 적재 + 활성화 톤 과장, 과잉 제안 해소
2 llm_service.py에서 recent_conversationsprevious_messages 변환 문맥 유실 해소
3 neutral 감정 constraints 생략, length 기본값 "짧게" 불필요한 응답 늘림 방지
4 재현 질문셋 E2E 검증 닫힘 조건 충족 확인
5 (선택) Pydantic JSON 강제 구조적 톤 제어

1~4 완료 시 트러블슈팅 종결 가능.

10. 결론

  • 톤 과장의 원인: 시스템 프롬프트의 "도움이 되는 방향으로 조언", "액션 아이템 제시" 문구 + 감정 constraints의 무조건 주입
  • 문맥 유실의 원인: OpenAI handler가 previous_messages 키를 읽는데, 이 키를 아무도 채우지 않음
  • 과잉 제안의 원인: "묻지 않은 것은 제안하지 마라" 지시 부재 + 응답 길이 제한 없음
  • 해결: 프롬프트 v2 교체(DB, 배포 불필요) + 대화 이력 전달 수정(1곳) + 감정 constraints 조정(1곳)