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 =
|
export const FILTERED_REPLY =
|
||||||
"방금 하려던 말은 그대로 전하기가 어렵겠어 짹. 미안! 대신 다른 야구 얘기 하자.";
|
"방금 하려던 말은 그대로 전하기가 어렵겠어 짹. 미안! 대신 다른 야구 얘기 하자.";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모델이 빈 응답을 낸 경우(주로 도구 호출 뒤 무텍스트 STOP)의 대체 문구.
|
||||||
|
* 필터 교체(FILTERED_REPLY)와 구분한다 — "전하기 어렵다"는 검열 톤이 정상 질문
|
||||||
|
* ("기아 성적은?" 등)에 나가면 주제를 거부한 것처럼 오해를 부른다.
|
||||||
|
*/
|
||||||
|
export const EMPTY_REPLY_NOTICE =
|
||||||
|
"어라, 방금 답을 만들다가 꼬였다 짹. 미안! 같은 질문 한 번만 다시 던져줄래?";
|
||||||
|
|
||||||
// ── 추천 질문 기본 풀(페르소나 문서 6장 — 4-1 클라이언트 검수 대상) ──
|
// ── 추천 질문 기본 풀(페르소나 문서 6장 — 4-1 클라이언트 검수 대상) ──
|
||||||
|
|
||||||
export const DEFAULT_SUGGESTIONS: ChatSuggestion[] = [
|
export const DEFAULT_SUGGESTIONS: ChatSuggestion[] = [
|
||||||
|
|||||||
@ -15,11 +15,12 @@ import type { KnowledgeLevel, TeamCode } from "../types/panit";
|
|||||||
/** 싱크 필터가 채팅 이벤트 행을 고르는 마커(jsonPayload.event). 바꾸면 싱크 필터도 갱신. */
|
/** 싱크 필터가 채팅 이벤트 행을 고르는 마커(jsonPayload.event). 바꾸면 싱크 필터도 갱신. */
|
||||||
export const CHAT_EVENT_MARKER = "chat_exchange";
|
export const CHAT_EVENT_MARKER = "chat_exchange";
|
||||||
/** 스키마 버전 — 필드 추가/의미 변경 시 올린다(BQ 쪽 호환 추적용). */
|
/** 스키마 버전 — 필드 추가/의미 변경 시 올린다(BQ 쪽 호환 추적용). */
|
||||||
export const CHAT_EVENT_SCHEMA_VERSION = 1;
|
export const CHAT_EVENT_SCHEMA_VERSION = 2;
|
||||||
|
|
||||||
export type ChatOutcome =
|
export type ChatOutcome =
|
||||||
| "ok" // 모델 응답 정상
|
| "ok" // 모델 응답 정상
|
||||||
| "filtered" // 출력 필터 교체
|
| "filtered" // 출력 필터 교체(로컬 필터 매치·벤더 안전필터)
|
||||||
|
| "empty" // 모델 빈 응답·비정상 출력 → 대체 문구(v2에서 "filtered"로부터 분리)
|
||||||
| "crisis_input" // 입력 위기 감지 → 고정 응답(모델 미호출)
|
| "crisis_input" // 입력 위기 감지 → 고정 응답(모델 미호출)
|
||||||
| "crisis_output" // 출력 위기 마커/문구 → 고정 응답
|
| "crisis_output" // 출력 위기 마커/문구 → 고정 응답
|
||||||
| "input_blocked" // 입력 필터/모더레이션 차단(422)
|
| "input_blocked" // 입력 필터/모더레이션 차단(422)
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import Anthropic from "@anthropic-ai/sdk";
|
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 { HttpError } from "../middleware/errors";
|
||||||
import { backoffSumMs, TOTAL_AI_BUDGET_MS } from "./chatConfigService";
|
import { backoffSumMs, TOTAL_AI_BUDGET_MS } from "./chatConfigService";
|
||||||
import type { ChatProviderConfig, ChatToolCallInfo } from "../types/chat";
|
import type { ChatProviderConfig, ChatToolCallInfo } from "../types/chat";
|
||||||
@ -263,6 +263,8 @@ class VertexChatProvider implements ChatProvider {
|
|||||||
let outputTokens = 0;
|
let outputTokens = 0;
|
||||||
let cachedTokens = 0;
|
let cachedTokens = 0;
|
||||||
const toolCalls: ChatToolCallInfo[] = [];
|
const toolCalls: ChatToolCallInfo[] = [];
|
||||||
|
// 도구 라운드 뒤 무텍스트 STOP 방어용 — true면 함수 호출을 잠그고 텍스트만 강제한다.
|
||||||
|
let forceText = false;
|
||||||
|
|
||||||
for (let round = 0; ; round++) {
|
for (let round = 0; ; round++) {
|
||||||
const remaining = deadline - Date.now();
|
const remaining = deadline - Date.now();
|
||||||
@ -281,6 +283,11 @@ class VertexChatProvider implements ChatProvider {
|
|||||||
maxOutputTokens: cfg.maxOutputTokens,
|
maxOutputTokens: cfg.maxOutputTokens,
|
||||||
abortSignal: controller.signal,
|
abortSignal: controller.signal,
|
||||||
...(toolConfig ? { tools: toolConfig } : {}),
|
...(toolConfig ? { tools: toolConfig } : {}),
|
||||||
|
// 강제 텍스트 재시도 — 함수 선언은 유지(이력의 functionCall 파트 검증용)하되
|
||||||
|
// 모드 NONE으로 잠가 이번 응답은 반드시 텍스트로 나오게 한다.
|
||||||
|
...(forceText ?
|
||||||
|
{ toolConfig: { functionCallingConfig: { mode: FunctionCallingConfigMode.NONE } } } :
|
||||||
|
{}),
|
||||||
// Gemini 3.0+ 전용 — thinkingLevel만 사용(thinkingBudget은 2.x 레거시).
|
// Gemini 3.0+ 전용 — thinkingLevel만 사용(thinkingBudget은 2.x 레거시).
|
||||||
// 도구 호출·다단계 검증(CoT)을 살리되 짧은 응답이라 LOW.
|
// 도구 호출·다단계 검증(CoT)을 살리되 짧은 응답이라 LOW.
|
||||||
thinkingConfig: { thinkingLevel: ThinkingLevel.LOW },
|
thinkingConfig: { thinkingLevel: ThinkingLevel.LOW },
|
||||||
@ -330,6 +337,17 @@ class VertexChatProvider implements ChatProvider {
|
|||||||
finishReason = "stop";
|
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 {
|
return {
|
||||||
reply,
|
reply,
|
||||||
finishReason,
|
finishReason,
|
||||||
|
|||||||
@ -32,7 +32,9 @@ import { logChatEvent } from "./chatAnalyticsService";
|
|||||||
import { extractNavActions } from "./chatNavService";
|
import { extractNavActions } from "./chatNavService";
|
||||||
import { assemblePrompt, gatherUserContext, resolveTeamCode, type UserContext } from "./chatContextService";
|
import { assemblePrompt, gatherUserContext, resolveTeamCode, type UserContext } from "./chatContextService";
|
||||||
import { buildChatTools, withToolLabels } from "./chatToolService";
|
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,
|
import { CHAT_REPORT_REASONS, type ChatConfig, type ChatMessagesPage, type ChatMessageView,
|
||||||
type ChatQuotaView, type ChatReportReason, type ChatSendResult, type ChatSuggestion,
|
type ChatQuotaView, type ChatReportReason, type ChatSendResult, type ChatSuggestion,
|
||||||
type ChatSuggestionsView, type ChatToolCallInfo, type NavAction } from "../types/chat";
|
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");
|
throw new HttpError(503, "stored reply missing", "AI_UNAVAILABLE");
|
||||||
}
|
}
|
||||||
return buildSendResult(
|
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 ②)
|
// 9) 출력 필터·위기 전환 검사(§7.2, §7.3 ②)
|
||||||
let reply = result.reply;
|
let reply = result.reply;
|
||||||
let filtered = false;
|
let filtered = false;
|
||||||
|
let emptyReply = false; // 필터 아님 — 모델 빈 응답(도구 호출 뒤 무텍스트 등)·비정상 출력
|
||||||
let crisisOut = false;
|
let crisisOut = false;
|
||||||
let actions: NavAction[] = [];
|
let actions: NavAction[] = [];
|
||||||
const outputCheck = checkOutput(reply, config.filters, leakBody);
|
const outputCheck = checkOutput(reply, config.filters, leakBody);
|
||||||
@ -349,22 +353,24 @@ export async function sendMessage(uid: string, body: SendBody): Promise<ChatSend
|
|||||||
reply = FILTERED_REPLY;
|
reply = FILTERED_REPLY;
|
||||||
filtered = true;
|
filtered = true;
|
||||||
} else if (reply.length === 0) {
|
} else if (reply.length === 0) {
|
||||||
reply = FILTERED_REPLY;
|
// 검열이 아니므로 FILTERED_REPLY("전하기 어렵다")가 아닌 재시도 유도 문구를 쓴다
|
||||||
filtered = true;
|
reply = EMPTY_REPLY_NOTICE;
|
||||||
|
emptyReply = true;
|
||||||
} else {
|
} else {
|
||||||
// 정상 모델 응답 — 화면 이동 마커([[NAV:route]])를 actions로 분리하고 본문에서 제거
|
// 정상 모델 응답 — 화면 이동 마커([[NAV:route]])를 actions로 분리하고 본문에서 제거
|
||||||
const nav = extractNavActions(reply);
|
const nav = extractNavActions(reply);
|
||||||
if (nav.clean.length === 0) {
|
if (nav.clean.length === 0) {
|
||||||
reply = FILTERED_REPLY; // 마커만 있고 본문이 비는 비정상 출력 방어
|
reply = EMPTY_REPLY_NOTICE; // 마커만 있고 본문이 비는 비정상 출력 방어(필터 아님)
|
||||||
filtered = true;
|
emptyReply = true;
|
||||||
} else {
|
} else {
|
||||||
reply = nav.clean;
|
reply = nav.clean;
|
||||||
actions = nav.actions;
|
actions = nav.actions;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 도구 호출·이동 액션 메타 — 보여줄 응답이 모델 답변일 때만 부착(교체된 crisis/filtered는 제외)
|
// 도구 호출·이동 액션 메타 — 보여줄 응답이 모델 답변일 때만 부착(교체된 응답은 제외)
|
||||||
const toolCalls = crisisOut || filtered ? [] : result.toolCalls;
|
const replaced = crisisOut || filtered || emptyReply;
|
||||||
|
const toolCalls = replaced ? [] : result.toolCalls;
|
||||||
|
|
||||||
// 10) 단일 배치 저장 — 출력 교체(filtered/crisis)는 비용이 발생했으므로 차감 유지
|
// 10) 단일 배치 저장 — 출력 교체(filtered/crisis)는 비용이 발생했으므로 차감 유지
|
||||||
saved = await finalizeExchange({
|
saved = await finalizeExchange({
|
||||||
@ -376,7 +382,9 @@ export async function sendMessage(uid: string, body: SendBody): Promise<ChatSend
|
|||||||
assistantContent: reply,
|
assistantContent: reply,
|
||||||
model: config.provider.model,
|
model: config.provider.model,
|
||||||
promptVersion: config.promptVersion,
|
promptVersion: config.promptVersion,
|
||||||
filtered,
|
// 저장 filtered는 "응답이 대체 문구로 교체됨" 표지 — 히스토리 윈도잉(§5.5)이
|
||||||
|
// 이 플래그로 교체 응답을 문맥에서 제외하므로 빈 응답 폴백도 포함시킨다.
|
||||||
|
filtered: filtered || emptyReply,
|
||||||
crisis: crisisOut,
|
crisis: crisisOut,
|
||||||
toolCalls,
|
toolCalls,
|
||||||
actions,
|
actions,
|
||||||
@ -387,13 +395,14 @@ export async function sendMessage(uid: string, body: SendBody): Promise<ChatSend
|
|||||||
finalToolCalls = toolCalls;
|
finalToolCalls = toolCalls;
|
||||||
finalActions = actions;
|
finalActions = actions;
|
||||||
logChatEvent({
|
logChatEvent({
|
||||||
outcome: crisisOut ? "crisis_output" : filtered ? "filtered" : "ok",
|
outcome: crisisOut ? "crisis_output" : filtered ? "filtered" : emptyReply ? "empty" : "ok",
|
||||||
uid, teamCode: activeTeamCode, threadId: activeThreadId,
|
uid, teamCode: activeTeamCode, threadId: activeThreadId,
|
||||||
stylePack: config.stylePack, knowledgeLevel: ctx.knowledgeLevel, promptVersion: config.promptVersion,
|
stylePack: config.stylePack, knowledgeLevel: ctx.knowledgeLevel, promptVersion: config.promptVersion,
|
||||||
provider: config.provider.name, model: config.provider.model,
|
provider: config.provider.name, model: config.provider.model,
|
||||||
finishReason: result.finishReason,
|
finishReason: result.finishReason,
|
||||||
crisis: crisisOut, crisisType: crisisOut ? (outputCheck.crisisType ?? "selfHarm") : undefined,
|
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),
|
toolNames: result.toolCalls.map((t) => t.name),
|
||||||
navRoutes: actions.map((a) => a.route),
|
navRoutes: actions.map((a) => a.route),
|
||||||
tokensIn: result.usage.inputTokens,
|
tokensIn: result.usage.inputTokens,
|
||||||
|
|||||||
@ -48,6 +48,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
CRISIS_SELF_HARM_MESSAGE,
|
CRISIS_SELF_HARM_MESSAGE,
|
||||||
CRISIS_URGENT_PREFIX,
|
CRISIS_URGENT_PREFIX,
|
||||||
|
EMPTY_REPLY_NOTICE,
|
||||||
FILTERED_REPLY,
|
FILTERED_REPLY,
|
||||||
} from "../../src/constants/chatPrompts";
|
} from "../../src/constants/chatPrompts";
|
||||||
import { todayKst } from "../../src/types/dateString";
|
import { todayKst } from "../../src/types/dateString";
|
||||||
@ -517,6 +518,26 @@ describe("chatService", () => {
|
|||||||
const docs = await messagesCol(uid, "HH").where("role", "==", "assistant").get();
|
const docs = await messagesCol(uid, "HH").where("role", "==", "assistant").get();
|
||||||
expect((docs.docs[0].data() as ChatMessageDoc).filtered).toBe(true);
|
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)", () => {
|
describe("장애·가드(§8)", () => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user