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:
parent
4d1a46e587
commit
9e24611db0
@ -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[] = [
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)", () => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user