Implement a fallback mechanism for empty model responses after tool calls

- 도구 호출 후 모델이 빈 응답을 반환하는 경우, 검열 문구 대신 재시도를 유도하는 `EMPTY_REPLY_NOTICE`를 표시하도록 로직을 개선했습니다.
- `chatProviderService`에 무텍스트 STOP 응답 방어 로직을 추가하여, 함수 호출 모드를 NONE으로 강제한 후 1회 재시도를 수행하도록 처리했습니다.
- 분석 서비스의 스키마 버전을 2로 올리고, `ChatOutcome`에 `empty` 타입을 추가하여 빈 응답 사례를 필터링과 구분하여 통계에 기록합니다.
This commit is contained in:
윤정민 2026-07-03 15:32:50 +09:00
parent 4d1a46e587
commit 9e24611db0
5 changed files with 71 additions and 14 deletions

View File

@ -405,6 +405,14 @@ export const INPUT_BLOCKED_NOTICE =
export const FILTERED_REPLY =
"방금 하려던 말은 그대로 전하기가 어렵겠어 짹. 미안! 대신 다른 야구 얘기 하자.";
/**
* ( STOP) .
* (FILTERED_REPLY) "전하기 어렵다"
* ("기아 성적은?" ) .
*/
export const EMPTY_REPLY_NOTICE =
"어라, 방금 답을 만들다가 꼬였다 짹. 미안! 같은 질문 한 번만 다시 던져줄래?";
// ── 추천 질문 기본 풀(페르소나 문서 6장 — 4-1 클라이언트 검수 대상) ──
export const DEFAULT_SUGGESTIONS: ChatSuggestion[] = [

View File

@ -15,11 +15,12 @@ import type { KnowledgeLevel, TeamCode } from "../types/panit";
/** 싱크 필터가 채팅 이벤트 행을 고르는 마커(jsonPayload.event). 바꾸면 싱크 필터도 갱신. */
export const CHAT_EVENT_MARKER = "chat_exchange";
/** 스키마 버전 — 필드 추가/의미 변경 시 올린다(BQ 쪽 호환 추적용). */
export const CHAT_EVENT_SCHEMA_VERSION = 1;
export const CHAT_EVENT_SCHEMA_VERSION = 2;
export type ChatOutcome =
| "ok" // 모델 응답 정상
| "filtered" // 출력 필터 교체
| "filtered" // 출력 필터 교체(로컬 필터 매치·벤더 안전필터)
| "empty" // 모델 빈 응답·비정상 출력 → 대체 문구(v2에서 "filtered"로부터 분리)
| "crisis_input" // 입력 위기 감지 → 고정 응답(모델 미호출)
| "crisis_output" // 출력 위기 마커/문구 → 고정 응답
| "input_blocked" // 입력 필터/모더레이션 차단(422)

View File

@ -1,5 +1,5 @@
import Anthropic from "@anthropic-ai/sdk";
import { GoogleGenAI, ThinkingLevel, type Content } from "@google/genai";
import { FunctionCallingConfigMode, GoogleGenAI, ThinkingLevel, type Content } from "@google/genai";
import { HttpError } from "../middleware/errors";
import { backoffSumMs, TOTAL_AI_BUDGET_MS } from "./chatConfigService";
import type { ChatProviderConfig, ChatToolCallInfo } from "../types/chat";
@ -263,6 +263,8 @@ class VertexChatProvider implements ChatProvider {
let outputTokens = 0;
let cachedTokens = 0;
const toolCalls: ChatToolCallInfo[] = [];
// 도구 라운드 뒤 무텍스트 STOP 방어용 — true면 함수 호출을 잠그고 텍스트만 강제한다.
let forceText = false;
for (let round = 0; ; round++) {
const remaining = deadline - Date.now();
@ -281,6 +283,11 @@ class VertexChatProvider implements ChatProvider {
maxOutputTokens: cfg.maxOutputTokens,
abortSignal: controller.signal,
...(toolConfig ? { tools: toolConfig } : {}),
// 강제 텍스트 재시도 — 함수 선언은 유지(이력의 functionCall 파트 검증용)하되
// 모드 NONE으로 잠가 이번 응답은 반드시 텍스트로 나오게 한다.
...(forceText ?
{ toolConfig: { functionCallingConfig: { mode: FunctionCallingConfigMode.NONE } } } :
{}),
// Gemini 3.0+ 전용 — thinkingLevel만 사용(thinkingBudget은 2.x 레거시).
// 도구 호출·다단계 검증(CoT)을 살리되 짧은 응답이라 LOW.
thinkingConfig: { thinkingLevel: ThinkingLevel.LOW },
@ -330,6 +337,17 @@ class VertexChatProvider implements ChatProvider {
finishReason = "stop";
}
// flash-lite 퀴크 방어 — functionResponse를 받고도 텍스트 없이 STOP으로 끝나는
// 경우("기아 성적은?" 류), 함수 호출을 잠그고 텍스트 생성을 1회만 강제 재시도한다.
// 잔여 예산이 없으면 재시도 없이 그대로 반환한다(빈 응답 폴백은 서비스 계층 몫).
if (
reply.length === 0 && finishReason === "stop" && toolCalls.length > 0 &&
!forceText && deadline - Date.now() >= 1000
) {
forceText = true;
continue;
}
return {
reply,
finishReason,

View File

@ -32,7 +32,9 @@ import { logChatEvent } from "./chatAnalyticsService";
import { extractNavActions } from "./chatNavService";
import { assemblePrompt, gatherUserContext, resolveTeamCode, type UserContext } from "./chatContextService";
import { buildChatTools, withToolLabels } from "./chatToolService";
import { FALLBACK_SUGGESTION_IDS, FILTERED_REPLY, INPUT_BLOCKED_NOTICE } from "../constants/chatPrompts";
import {
EMPTY_REPLY_NOTICE, FALLBACK_SUGGESTION_IDS, FILTERED_REPLY, INPUT_BLOCKED_NOTICE,
} from "../constants/chatPrompts";
import { CHAT_REPORT_REASONS, type ChatConfig, type ChatMessagesPage, type ChatMessageView,
type ChatQuotaView, type ChatReportReason, type ChatSendResult, type ChatSuggestion,
type ChatSuggestionsView, type ChatToolCallInfo, type NavAction } from "../types/chat";
@ -119,7 +121,8 @@ async function replayDone(
throw new HttpError(503, "stored reply missing", "AI_UNAVAILABLE");
}
return buildSendResult(
uid, date, config, assistantMessageId, stored.content, stored.crisis, stored.createdAt, stored.toolCalls, stored.actions,
uid, date, config, assistantMessageId, stored.content, stored.crisis, stored.createdAt,
stored.toolCalls, stored.actions,
);
}
@ -339,6 +342,7 @@ export async function sendMessage(uid: string, body: SendBody): Promise<ChatSend
// 9) 출력 필터·위기 전환 검사(§7.2, §7.3 ②)
let reply = result.reply;
let filtered = false;
let emptyReply = false; // 필터 아님 — 모델 빈 응답(도구 호출 뒤 무텍스트 등)·비정상 출력
let crisisOut = false;
let actions: NavAction[] = [];
const outputCheck = checkOutput(reply, config.filters, leakBody);
@ -349,22 +353,24 @@ export async function sendMessage(uid: string, body: SendBody): Promise<ChatSend
reply = FILTERED_REPLY;
filtered = true;
} else if (reply.length === 0) {
reply = FILTERED_REPLY;
filtered = true;
// 검열이 아니므로 FILTERED_REPLY("전하기 어렵다")가 아닌 재시도 유도 문구를 쓴다
reply = EMPTY_REPLY_NOTICE;
emptyReply = true;
} else {
// 정상 모델 응답 — 화면 이동 마커([[NAV:route]])를 actions로 분리하고 본문에서 제거
const nav = extractNavActions(reply);
if (nav.clean.length === 0) {
reply = FILTERED_REPLY; // 마커만 있고 본문이 비는 비정상 출력 방어
filtered = true;
reply = EMPTY_REPLY_NOTICE; // 마커만 있고 본문이 비는 비정상 출력 방어(필터 아님)
emptyReply = true;
} else {
reply = nav.clean;
actions = nav.actions;
}
}
// 도구 호출·이동 액션 메타 — 보여줄 응답이 모델 답변일 때만 부착(교체된 crisis/filtered는 제외)
const toolCalls = crisisOut || filtered ? [] : result.toolCalls;
// 도구 호출·이동 액션 메타 — 보여줄 응답이 모델 답변일 때만 부착(교체된 응답은 제외)
const replaced = crisisOut || filtered || emptyReply;
const toolCalls = replaced ? [] : result.toolCalls;
// 10) 단일 배치 저장 — 출력 교체(filtered/crisis)는 비용이 발생했으므로 차감 유지
saved = await finalizeExchange({
@ -376,7 +382,9 @@ export async function sendMessage(uid: string, body: SendBody): Promise<ChatSend
assistantContent: reply,
model: config.provider.model,
promptVersion: config.promptVersion,
filtered,
// 저장 filtered는 "응답이 대체 문구로 교체됨" 표지 — 히스토리 윈도잉(§5.5)이
// 이 플래그로 교체 응답을 문맥에서 제외하므로 빈 응답 폴백도 포함시킨다.
filtered: filtered || emptyReply,
crisis: crisisOut,
toolCalls,
actions,
@ -387,13 +395,14 @@ export async function sendMessage(uid: string, body: SendBody): Promise<ChatSend
finalToolCalls = toolCalls;
finalActions = actions;
logChatEvent({
outcome: crisisOut ? "crisis_output" : filtered ? "filtered" : "ok",
outcome: crisisOut ? "crisis_output" : filtered ? "filtered" : emptyReply ? "empty" : "ok",
uid, teamCode: activeTeamCode, threadId: activeThreadId,
stylePack: config.stylePack, knowledgeLevel: ctx.knowledgeLevel, promptVersion: config.promptVersion,
provider: config.provider.name, model: config.provider.model,
finishReason: result.finishReason,
crisis: crisisOut, crisisType: crisisOut ? (outputCheck.crisisType ?? "selfHarm") : undefined,
filtered, filterReason: outputCheck.action === "filter" ? outputCheck.reason : undefined,
filtered: filtered || emptyReply,
filterReason: outputCheck.action === "filter" ? outputCheck.reason : undefined,
toolNames: result.toolCalls.map((t) => t.name),
navRoutes: actions.map((a) => a.route),
tokensIn: result.usage.inputTokens,

View File

@ -48,6 +48,7 @@ import {
import {
CRISIS_SELF_HARM_MESSAGE,
CRISIS_URGENT_PREFIX,
EMPTY_REPLY_NOTICE,
FILTERED_REPLY,
} from "../../src/constants/chatPrompts";
import { todayKst } from "../../src/types/dateString";
@ -517,6 +518,26 @@ describe("chatService", () => {
const docs = await messagesCol(uid, "HH").where("role", "==", "assistant").get();
expect((docs.docs[0].data() as ChatMessageDoc).filtered).toBe(true);
});
it("빈 모델 응답(도구 호출 뒤 무텍스트)은 검열 문구가 아닌 재시도 유도 문구로 교체한다", async () => {
setTestProvider({
complete: async () => ({
reply: "",
finishReason: "stop",
usage: { inputTokens: 1, outputTokens: 0, cachedTokens: 0 },
toolCalls: [{ name: "get_team_rank_snapshot", args: {} }],
}),
});
const result = await sendMessage(uid, { message: "기아 성적은?", clientMessageId: newUuid() });
expect(result.reply).toBe(EMPTY_REPLY_NOTICE);
expect(result.crisis).toBe(false);
expect(result.toolCalls).toBeUndefined(); // 교체된 응답에는 도구 메타 미첨부
// 저장 filtered=true — 히스토리 윈도잉(§5.5)이 교체 응답을 문맥에서 제외하기 위함
const docs = await messagesCol(uid, "HH").where("role", "==", "assistant").get();
expect((docs.docs[0].data() as ChatMessageDoc).filtered).toBe(true);
expect((await readQuota()).used).toBe(1); // AI 비용 발생 — 차감 유지
});
});
describe("장애·가드(§8)", () => {