SSOT는 로컬 0_VALUE/. GitHub URL은 복사본 참조로 SSOT 원칙 위반. 02_Governance는 존재하지 않는 구 경로로 전부 깨진 링크. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
13 KiB
tags
| tags | ||||||
|---|---|---|---|---|---|---|
|
rb8001 응답 품질 — 톤 과장·문맥 유실·과잉 제안 전수 조사 리서치
관련 문서
트러블슈팅 (이 리서치가 닫아야 할 문제)
계획 (구현 방향)
상위 원칙
- 0_VALUE Coding Principles — #4 폴백 절제, #9 질문별 특례 하드코딩 금지
리서치 목적
트러블슈팅 문서의 재현 질문셋을 실제로 실행하고, 코드 경로를 추적하여 다음을 확정한다.
- 톤 과장의 정확한 원인 (프롬프트 어느 부분이 과잉 서비스를 유발하는지)
- 문맥 유실의 정확한 원인 (어디서 직전 대화가 끊기는지)
- 과잉 제안의 정확한 원인 (왜 묻지 않은 것까지 나열하는지)
- Pydantic JSON 강제 방식의 적용 가능성
- 각 원인에 대한 최소 수정 범위
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.py의 process_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 알고리즘
- 메시지를 토큰 기준으로 청크 분할 (SAFETY_MARGIN 1.2, 적응적 청크 비율)
- 청크별 LLM 요약 생성
- 재귀적 병합 (부분 요약 → 통합 요약)
- 식별자(UUID, URL, 파일명) 보존 강제
- 실패 시 점진적 fallback: 전체 요약 → 부분 요약 → 개수만 기록
7-5. 로빙에 적용할 것 (범위 분리)
| 적용 대상 | 이번 계획 범위 | 향후 (별도 문서) |
|---|---|---|
| provider별 형식 변환 | ✅ recent_conversations → previous_messages 변환 |
Context Engine 중간 계층 도입 |
| 토큰 예산 관리 | ❌ | assemble() 패턴 도입 |
| Compaction | ❌ | OpenClaw compaction 아이디어에서 추적 |
| 세션 메모리 | ❌ | daily log + MEMORY.md 도입 |
이번 수정의 위치: OpenClaw의 assemble() 중 "provider별 형식 변환" 부분만 가져옴. llm_service.py의 process_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_conversations → previous_messages 변환 |
문맥 유실 해소 |
| 3 | neutral 감정 constraints 생략, length 기본값 "짧게" | 불필요한 응답 늘림 방지 |
| 4 | 재현 질문셋 E2E 검증 | 닫힘 조건 충족 확인 |
| 5 | (선택) Pydantic JSON 강제 | 구조적 톤 제어 |
1~4 완료 시 트러블슈팅 종결 가능.
10. 결론
- 톤 과장의 원인: 시스템 프롬프트의 "도움이 되는 방향으로 조언", "액션 아이템 제시" 문구 + 감정 constraints의 무조건 주입
- 문맥 유실의 원인: OpenAI handler가
previous_messages키를 읽는데, 이 키를 아무도 채우지 않음 - 과잉 제안의 원인: "묻지 않은 것은 제안하지 마라" 지시 부재 + 응답 길이 제한 없음
- 해결: 프롬프트 v2 교체(DB, 배포 불필요) + 대화 이력 전달 수정(1곳) + 감정 constraints 조정(1곳)