Files
homeclaw/tests/system-prompt.test.ts
kimandClaude Opus 5 46a5c3f59f perf: 검색 결과 읽기 지침을 시스템 프롬프트에서 빼 결과가 생긴 뒤에만 붙인다
효율 감사 실측(사용자 턴 1,011건 / LLM 호출 3,613건 / prompt 누적 29.1M tok):
  · 고정비 2,494tok/턴 = 전체 prompt의 31%
  · 출력 60토큰 미만 호출이 47% — 도구 라운드·가드 재프롬프트라 고정비가 라운드 수만큼 곱해진다
  · **59%의 턴이 grounding 도구를 한 번도 부르지 않는다**

searchReadingRule(481tok)에는 성격이 다른 두 가지가 한 덩어리로 섞여 있었다:
  ① 검색어를 어떻게 쓸 것인가(세대 추측 금지, 154tok) — 검색 **전에** 필요
  ② 돌아온 결과를 어떻게 읽을 것인가(스테일 타임스탬프·표 행 정렬, 333tok) — 결과가 **생긴 뒤**
     에만 쓸모가 있다
②를 groundingResultReadingRule()로 분리해, handle-chat이 첫 grounding 결과가 들어온 시점에
대화 뒤에 한 번 덧붙인다. 읽을 결과가 없는 59%의 턴은 이제 이 값을 아예 내지 않고, 부르는
턴도 검색어를 쓰는 1라운드에는 내지 않는다. 인사 턴 시스템 프롬프트 1,273 → 904tok(-29%).

**시스템 메시지를 갈아끼우지 않고 뒤에 덧붙인 이유는 KV 캐시다.** 시스템 프롬프트는 라운드 루프
앞에서 한 번 만들어지므로, 라운드마다 다시 만들면 프리픽스가 달라져 캐시가 통째로 무효화된다 —
라운드마다 7K 토큰을 다시 계산하게 되어 아낀 것보다 잃는 게 크다. 뒤에 붙이면 프리픽스는 그대로다.

부수 효과로 지침이 대상(검색 결과) 바로 옆에 놓인다. 제약이 겹치면 먼 것부터 버리는 모델에게는
이쪽이 유리하다([[feedback_local_model_needs_code_backstop]]).

2026-09-02 감사가 만든 hasGroundingTools 게이트는 사실상 죽어 있었다는 점도 이번에 드러났다 —
tool-scope에 web_search를 거르는 분기가 없어 그 조건은 항상 참이다. 이번 분리는 그 게이트에
의존하지 않는다.

기존 회귀 테스트가 이동을 정확히 잡아냈다(2026-08-10 캐시된 기상표 오독 사고의 예시 문자열).
테스트를 새 위치로 옮겨 내용이 사라지지 않았음을 계속 고정한다. 546개 통과.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rfme1WVEPkpNwnf5oVNXXc
2026-09-04 20:59:35 +09:00

219 lines
13 KiB
TypeScript

/**
* system-prompt.test.ts
*
* The system message is the single biggest lever on model behaviour, and until 2026-07-29 it
* was assembled inline inside handleChat() where the only way to see the result was to send a
* real chat message and read tool_overhead off an SSE event.
*
* The property these tests protect is narrow but important: a rule that suppresses a tool must
* be present whenever that tool is callable. Dropping one is silent — nothing errors, the model
* just loses a guardrail on exactly the turn that needed it.
*/
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { buildChatSystemPrompt, groundingResultReadingRule, type SystemPromptInput } from '../src/gateway/chat/system-prompt';
const build = (over: Partial<SystemPromptInput> = {}) => buildChatSystemPrompt({
toolNames: [],
dateStr: 'Tuesday, July 29, 2026',
timeStr: '03:00 PM',
...over,
});
describe('무조건 들어가는 블록', () => {
test('도구가 하나도 없어도 핵심 규칙은 남는다', () => {
const p = build();
for (const block of ['ANTI-HALLUCINATION', 'TEMPORAL CONSISTENCY', 'OUTPUT FORMAT']) {
assert.ok(p.includes(block), `${block} 누락`);
}
});
// [2026-08-10] 470토큰까지 커진 반면 실제 사용은 로그 83일 중 3일(~4%)뿐이라 메시지 게이팅으로
// 전환. 출력 형식 제약이라 키로 삼을 도구가 없어서, 이 파일의 "도구로만 게이팅" 원칙에 대한
// 유일한 의도적 예외다 — 키워드를 놓쳐도 대가가 깨진 다이어그램 하나뿐이라 허용된다.
test('CHEMISTRY NOTATION은 화학 맥락일 때만 붙는다', () => {
assert.ok(!build({ message: '안녕' }).includes('CHEMISTRY NOTATION'));
assert.ok(!build({ message: '이 코드 리팩터링해줘', toolNames: ['coder_read_file'] }).includes('CHEMISTRY NOTATION'));
assert.ok(build({ message: '벤젠 화학식이 뭐야' }).includes('CHEMISTRY NOTATION'));
assert.ok(build({ message: '아스피린 분자구조 그려줘' }).includes('CHEMISTRY NOTATION'));
});
// 게이팅을 무조건 유지에서 조건부로 바꾼 이유가 치과/의료 어휘였으므로, 그쪽이 빠지면
// 애초에 게이팅한 의미가 없다 — 이 사용자의 실제 도메인이다.
test('치과/의료 어휘도 CHEMISTRY NOTATION을 켠다', () => {
for (const m of ['치과 레진 성분이 뭐지', '국소마취제 리도카인 어떻게 작용해', '불소도포 효과 있나']) {
assert.ok(build({ message: m }).includes('CHEMISTRY NOTATION'), `"${m}"에서 누락`);
}
});
// MAPS 블록도 CHEMISTRY와 같은 메시지 게이팅 예외 — 출력 형식 제약(구글맵 URL을 한 줄로),
// 키로 삼을 도구 없음, 놓쳐도 대가가 지도 하나 안 뜨는 것뿐.
test('MAPS는 위치/장소 질문일 때만 붙는다', () => {
assert.ok(!build({ message: '안녕' }).includes('MAPS:'));
assert.ok(!build({ message: '이 코드 리팩터링해줘', toolNames: ['coder_read_file'] }).includes('MAPS:'));
for (const m of ['경복궁 어디에 있어', '스타필드 하남 주소 알려줘', '남산타워 지도 보여줘', 'where is the Louvre', '판교역 근처 뭐 있어']) {
assert.ok(build({ message: m }).includes('MAPS:'), `"${m}"에서 누락`);
}
});
test('현재 날짜/시각이 주입된다 — TEMPORAL CONSISTENCY의 근거값', () => {
const p = build({ dateStr: 'Monday, January 5, 2026', timeStr: '09:30 AM' });
assert.ok(p.includes('Monday, January 5, 2026'));
assert.ok(p.includes('09:30 AM'));
});
test('불확실성 표현은 길이 제한에서 면제된다는 조항이 살아있다', () => {
// 이 조항이 빠지면 1-2문장 압박이 "모른다"를 밀어내고 환각을 확신형 한 줄로 포장한다.
const p = build();
assert.ok(/Stating uncertainty NEVER counts against the length/i.test(p));
});
// [2026-08-10] 실제 사고(세션 papa@2d4f3f6e): "지금 비오는 곳이 있나?"에 web_search를 부르긴
// 했으나, 결과가 URL에 "tm=2024.12.13.20:00"이 박힌 2024년 캐시 페이지였는데도 "현재"라며
// 소개했다. 게다가 그 표를 옮겨 적으면서 열도 잘못 읽어 동두천의 강수량(2.9mm)을 파주 것으로,
// 양평의 풍속(0.6m/s)을 강수량으로 뒤바꿨다. 도구를 불렀다는 사실만으로는(hasGroundingToolCall)
// 결과를 정확히 옮겨 썼는지 전혀 검증되지 않는다 — 이 조항은 그 결과 자체를 의심하라는 지침이다.
// [2026-09-02 효율 감사] 이 지침은 **검색 결과가 존재할 때만** 할 일이 있다("스니펫이 자체
// 타임스탬프를 갖는지 보라", "결과가 표면 행 라벨로 읽어라"). 그런데 무조건 붙어 있어서
// "안녕하세요"에도 ~700토큰이 실렸다. 그라운딩 도구 보유 여부로 게이팅한다 — 도구가 없으면
// 판단할 결과 자체가 없다. 날짜 자체가 ground truth라는 핵심 문장은 계속 무조건이다.
// [2026-09-04 효율 감사] 이 블록을 다시 둘로 쪼갰다. 실측해 보니 사용자 턴 1,011건 중 **59%가
// grounding 도구를 한 번도 부르지 않는다** — 그 턴들은 읽을 결과가 영영 없는데도 매 라운드
// 333토큰을 냈고, 부르는 턴에서도 검색어를 쓰는 1라운드에는 아직 읽을 게 없었다.
// · 검색어 작성 규칙(세대 추측 금지)은 검색 **전에** 필요하므로 시스템 프롬프트에 남는다.
// · 결과 읽기 규칙은 groundingResultReadingRule()로 빠져, handle-chat이 첫 grounding
// 결과가 들어온 뒤 대화 **뒤에 덧붙인다**(시스템 프리픽스를 안 건드려야 KV 캐시가 산다).
test('검색어 작성 규칙은 그라운딩 도구가 있을 때 시스템 프롬프트에 남는다', () => {
const withSearch = build({ toolNames: ['web_search'] });
assert.ok(/newest.*latest.*신형/is.test(withSearch), '세대 추측 금지 규칙이 있어야 함');
assert.ok(!/newest/i.test(build()), '도구 없는 턴엔 빠져야 함');
assert.ok(/ground truth/i.test(build()), '날짜=ground truth 핵심 문장은 남아야 함');
});
test('결과 읽기 지침은 시스템 프롬프트를 떠났다 — 매 턴 내던 고정비였다', () => {
const withSearch = build({ toolNames: ['web_search'] });
assert.ok(!/tm=2024\.12\.13\.20:00/.test(withSearch), '결과 읽기 예시가 시스템 프롬프트에 남아 있으면 안 됨');
assert.ok(!/misaligning one row/i.test(withSearch), '표 오독 경고도 옮겨갔어야 함');
});
test('결과 읽기 지침의 내용은 groundingResultReadingRule에 그대로 살아 있다', () => {
// 2026-08-10 실제 사고(세션 papa@2d4f3f6e): "지금 비오는 곳이 있나?"에 web_search를 부르긴
// 했으나 결과가 URL에 "tm=2024.12.13.20:00"이 박힌 2024년 캐시 페이지였는데도 "현재"라며
// 소개했고, 표를 옮기며 동두천의 강수량을 파주 것으로 뒤바꿨다. 그 지침이 사라지면 안 된다.
const r = groundingResultReadingRule('Friday, September 4, 2026');
assert.ok(/tm=2024\.12\.13\.20:00/.test(r), '실제 사고 사례의 URL 패턴이 예시로 남아있어야 함');
assert.ok(/misaligning one row/i.test(r), '표 오독(행 뒤바뀜) 경고가 있어야 함');
assert.ok(/Friday, September 4, 2026/.test(r), '현재 날짜가 주입돼야 함');
});
test('뉴스·날씨 출력형식은 해당 도구가 있을 때만 붙는다', () => {
assert.ok(!/제목\|요약\|출처/.test(build()), '도구 없이 뉴스 표 규격이 실리면 안 됨');
assert.ok(/제목\|요약\|출처/.test(build({ toolNames: ['news_search'] })));
assert.ok(!/우산|precipitation chance/i.test(build({ toolNames: ['news_search'] })), '뉴스 턴에 날씨 규칙이 실리면 안 됨');
assert.ok(/precipitation chance/i.test(build({ toolNames: ['weather_kma'] })));
});
});
describe('도구 게이팅 — 도구가 있을 때만 규칙이 붙는다', () => {
const cases: Array<[string, string[], string]> = [
['IMAGE EDITING RULE', ['image_edit'], 'image_edit'],
['IMAGE/VIDEO GENERATION', ['image_generate'], 'image_generate'],
['IMAGE/VIDEO GENERATION', ['video_generate'], 'video_generate'],
['CODE OUTPUT', ['coder_read_file'], 'coder_*'],
['PACKAGE INSTALL', ['shell'], 'shell'],
['PACKAGE INSTALL', ['run_command'], 'run_command'],
['BROWSER RULE', ['browser_open'], 'browser_*'],
];
// [2026-08-10] VERIFY CONCRETE FACTS는 무조건 블록 중 단일 최대(245토큰)였는데, 내용이
// 전부 "web_search를 먼저 불러라"다 — 그 도구가 없는 턴에는 실행 불가능한 지시였고, 게다가
// executeTool()이 이름으로만 디스패치하므로 스키마에 없는 도구를 프롬프트가 언급하는 것 자체가
// 위험하다([[project_tool_schema_leak]]). 조건이 규칙의 실행 가능성과 정확히 일치하므로
// 이건 메시지 게이팅이 아니라 정상적인 도구 게이팅이다.
test('VERIFY CONCRETE FACTS — web_search 있을 때만', () => {
const p = build({ toolNames: ['web_search'] });
assert.ok(p.includes('VERIFY CONCRETE FACTS'));
assert.ok(p.includes('ANTI-HALLUCINATION'), '앞부분(도구 출력 충실)은 무조건 남아야 한다');
const noSearch = build({ toolNames: ['coder_read_file'] });
assert.ok(!noSearch.includes('VERIFY CONCRETE FACTS'));
assert.ok(!/web_search/.test(noSearch), '스키마에 없는 도구 이름이 프롬프트에 새면 안 된다');
assert.ok(noSearch.includes('ANTI-HALLUCINATION'));
});
for (const [block, toolNames, label] of cases) {
test(`${block} — ${label} 있을 때만`, () => {
assert.ok(build({ toolNames }).includes(block), `${label} 있는데 ${block} 없음`);
assert.ok(!build({ toolNames: ['web_search'] }).includes(block), `${label} 없는데 ${block} 붙음`);
});
}
test('억제 규칙은 도구가 호출 가능한 한 반드시 함께 온다', () => {
// 핵심 불변식: image_edit이 스키마에 있으면 "함부로 편집하지 말라"가 없을 수 없다.
// 사용자 문구로 게이팅했다면 정규식이 놓친 표현에서 이 규칙이 사라졌을 것.
const p = build({ toolNames: ['image_edit'] });
assert.ok(p.includes('IMAGE EDITING RULE'));
assert.ok(/NEVER call image_edit/i.test(p));
});
});
describe('전용 세션 — 엄격한 출력 계약', () => {
test('번역 세션은 번역 지시만 받는다', () => {
const p = build({ isTranslateSession: true, toolNames: ['image_edit', 'shell'] });
assert.ok(/medical translator/i.test(p));
// 행동 규칙 블록이 섞이면 출력 형식이 오염된다.
assert.ok(!p.includes('ANTI-HALLUCINATION'));
assert.ok(!p.includes('IMAGE EDITING RULE'));
});
test('프로젝트 파일 세션도 마찬가지', () => {
const p = build({ isProjSession: true });
assert.ok(/project file designer/i.test(p));
assert.ok(!p.includes('OUTPUT FORMAT'));
});
});
describe('실행 모드 프리앰블', () => {
test('배경 작업/하트비트/크론은 자율 실행 지시가 앞에 붙는다', () => {
assert.ok(/Autonomous background task/i.test(build({ executionMode: 'background_task' })));
assert.ok(/Heartbeat check/i.test(build({ executionMode: 'heartbeat' })));
assert.ok(/Scheduled cron task/i.test(build({ executionMode: 'cron' })));
});
test('일반 대화에는 붙지 않는다', () => {
assert.ok(!/EXECUTION MODE/i.test(build({ executionMode: 'interactive' })));
assert.ok(!/EXECUTION MODE/i.test(build()));
});
});
describe('주입되는 컨텍스트', () => {
test('모델 프로필/호출자/성격/스킬 컨텍스트가 그대로 실린다', () => {
const p = build({
modelProfileBlock: '\nMODEL-SPECIFIC NOTE: 테스트 노트',
callerContext: 'CALLER_MARK',
personalityCtx: 'PERSONA_MARK',
skillsCtx: 'SKILL_MARK',
browserStateCtx: 'BROWSER_MARK',
});
for (const m of ['테스트 노트', 'CALLER_MARK', 'PERSONA_MARK', 'SKILL_MARK', 'BROWSER_MARK']) {
assert.ok(p.includes(m), `${m} 누락`);
}
});
test('선택 입력이 없어도 undefined 문자열이 새지 않는다', () => {
const p = build();
assert.ok(!p.includes('undefined'), '프롬프트에 "undefined" 유출');
});
});
describe('비용', () => {
test('도구 없는 최소 프롬프트가 조건부 블록 전부 켠 것보다 확실히 작다', () => {
const bare = build().length;
const full = build({ toolNames: ['image_edit', 'image_generate', 'coder_read_file', 'shell', 'browser_open'] }).length;
assert.ok(full > bare, '조건부 블록이 전혀 늘지 않음 — 게이팅이 깨졌을 가능성');
// 게이팅이 통째로 무력화되면(항상 켜짐) 이 차이가 0이 된다.
assert.ok(full - bare > 1500, `조건부 블록 총량이 예상보다 작음 (${full - bare}자)`);
});
});