diff --git a/src/constants/chatPrompts.ts b/src/constants/chatPrompts.ts index 1964487..c8f3347 100644 --- a/src/constants/chatPrompts.ts +++ b/src/constants/chatPrompts.ts @@ -17,10 +17,14 @@ export const CRISIS_MARKER = "[[CRISIS]]"; /** * [블록 1] 공통 시스템 프롬프트 (페르소나 문서 2.3 전문). - * `{{displayName}}`, `{{knowledgeLevel}}`은 주입 변수(2.2). + * + * 캐시 친화성: 개인화 값(닉네임·지식수준)은 이 블록에 넣지 않고 [사용자 컨텍스트] + * 블록(USER_CONTEXT_TEMPLATE)에만 둔다. 이 블록을 모든 유저 공통의 정적 prefix로 + * 만들어 Vertex implicit caching의 교차-유저 히트율을 높이기 위함이다(값을 끼워넣으면 + * 유저마다 prefix가 달라져 공유 캐시가 깨진다). */ export const COMMON_SYSTEM_PROMPT = `너는 "짹"이다. KBO 승부예측 앱 "팬잇(Panit)"에 사는 참새 캐릭터이자 -사용자 {{displayName}}의 야구 친구다. 어느 팀의 짹인지는 뒤에 붙는 +사용자의 야구 친구다(사용자의 닉네임은 [사용자 컨텍스트]에 있다). 어느 팀의 짹인지는 뒤에 붙는 [팀 페르소나]가 정한다. [팀 페르소나]와 [사용자 컨텍스트]를 함께 사용해 대화한다. @@ -56,8 +60,8 @@ export const COMMON_SYSTEM_PROMPT = `너는 "짹"이다. KBO 승부예측 앱 " 무례하게 굴지 않는다. 짹은 자기 팀을 사랑하지만 야구 자체를 더 사랑한다. ## 2. 지식수준 분기 — 설명 난이도만 바꾼다 -사용자의 지식수준은 {{knowledgeLevel}}이다. 말투와 캐릭터는 절대 바꾸지 -말고, "설명하는 방식"만 다음과 같이 조절한다. +사용자의 지식수준은 [사용자 컨텍스트]에 명시된다(beginner/casual/expert). +말투와 캐릭터는 절대 바꾸지 말고, "설명하는 방식"만 다음과 같이 조절한다. - beginner(야린이): 야구 용어를 쓰면 반드시 한 줄로 풀어서 같이 말한다. (예: "병살, 그러니까 공 하나로 아웃 두 개 잡히는 거") 규칙 설명은 비유와 예시 중심으로. 숫자·기록은 최소한만. diff --git a/src/services/chatConfigService.ts b/src/services/chatConfigService.ts index f318941..de042cb 100644 --- a/src/services/chatConfigService.ts +++ b/src/services/chatConfigService.ts @@ -30,9 +30,15 @@ export const DEFAULT_PROVIDER_CONFIG: ChatProviderConfig = { name: "vertex", model: "gemini-3.1-flash-lite", temperature: 0.7, - maxOutputTokens: 512, - timeoutMs: 12_000, - maxRetries: 1, + // Gemini 3는 thinking 토큰도 출력 예산에 포함된다 — 512는 thinking이 예산을 + // 소진해 빈 응답(finishReason=length)을 유발했다. 짧은 캐릭터 응답이라도 + // thinking + 함수호출 + 답변 여유분으로 넉넉히 잡는다. + maxOutputTokens: 2048, + // 모든 모델 공평 타임아웃 — 25초 총예산(클라 receiveTimeout 30초 - 여유)에서 쓸 수 + // 있는 최대 단일 시도. 모델마다 속도가 달라도 동일 예산이라 프로필이 불필요하다 + // (느린 모델도 24초까지 기다림). 재시도는 예산상 0(24s×1 ≤ 25s). + timeoutMs: 24_000, + maxRetries: 0, }; export const DEFAULT_CHAT_CONFIG: ChatConfig = { diff --git a/tests/services/chatContextService.test.ts b/tests/services/chatContextService.test.ts index b72f223..1d8f90d 100644 --- a/tests/services/chatContextService.test.ts +++ b/tests/services/chatContextService.test.ts @@ -123,13 +123,18 @@ describe("chatContextService", () => { }); describe("assembleSystemPrompt(§5 조립 순서)", () => { - it("공통(변수 치환) + 서버지시 + 팀 페르소나 + 컨텍스트 순으로 조립한다", () => { + it("공통(정적) + 서버지시 + 팀 페르소나 + 컨텍스트 순으로 조립한다", () => { const prompt = assembleSystemPrompt(DEFAULT_CHAT_CONFIG, ctx({})); - expect(prompt).toContain("사용자 솔방울의 야구 친구다"); // {{displayName}} 치환 - expect(prompt).toContain("사용자의 지식수준은 casual이다"); // {{knowledgeLevel}} 치환 + // 캐시 친화: 공통 블록은 비개인화(정적), 개인화 값은 컨텍스트 블록에만 + expect(prompt).toContain("사용자의 야구 친구다"); // 비개인화 공통(닉네임 미포함) + expect(prompt).not.toContain("사용자 솔방울의 야구 친구다"); // 닉네임은 공통에 없음 + expect(prompt).toContain("- 사용자: 솔방울 / 지식수준: casual"); // 개인화는 컨텍스트 블록 expect(prompt).toContain(CHAT_CANARY_TOKEN); // 카나리 포함 expect(prompt).toContain("[팀 페르소나 — 한화 이글스 짹 (HH)]"); // HH placeholder - expect(prompt.indexOf("[팀 페르소나")).toBeLessThan(prompt.indexOf("[사용자 컨텍스트")); + // 실제 블록 헤더("— " 포함)로 순서 검증 — 본문의 참조 문구와 구분 + expect(prompt.indexOf("[팀 페르소나 — ")).toBeLessThan(prompt.indexOf("[사용자 컨텍스트 — ")); + // 정적 공통 prefix가 개인화 컨텍스트 블록보다 앞에 와야 캐시 prefix 공유가 성립 + expect(prompt.indexOf("사용자의 야구 친구다")).toBeLessThan(prompt.indexOf("[사용자 컨텍스트 — ")); }); it("응원팀 미설정이면 기본 짹 블록을 쓴다(2.1)", () => {