diff --git a/content/posts/en/spring-ai-cs-automation.md b/content/posts/en/spring-ai-cs-automation.md index f5fbebf..2596f3d 100644 --- a/content/posts/en/spring-ai-cs-automation.md +++ b/content/posts/en/spring-ai-cs-automation.md @@ -1,174 +1,266 @@ --- -title: "Building a CS Automation System with Spring AI - Function Calling and FAQ Instructions" +title: "Operating a CS Support System with Spring AI - From Function Calling to an Operator Suggestion Workflow" date: "2026-07-06" -updated: "2026-07-06" -description: "How we automated repetitive customer inquiries with Spring AI - using function calling to fetch user context and registering FAQs as instructions to combine auto-replies with draft recommendations." +updated: "2026-07-28" +description: "How a Spring AI customer-support draft system moved from function calling to parallel lookups, QA, error diagnosis, and an operator-review workflow in production." tags: ["spring-ai", "spring-boot", "ai", "llm", "cs"] draft: false --- -## Background +## Background and operating boundaries -As the service grew, so did customer inquiries. But when I looked at what came in, a large share were **repeats of the same question**. +This work started in June 2026 as a migration of customer-support draft and tag-suggestion features spread across PHP and n8n into a Spring backend. The first implementation centered on FAQ instructions and Spring AI function calling: the model read the inquiry, fetched the customer data it needed, and wrote a reply draft. -- "I think I was charged twice" -- "How many days are left on my pass?" -- "When will my refund arrive?" -- "My coupon won't apply" +The system changed as we prepared it for production. We drew a firm boundary around it as a **support assistant that prepares a draft and its evidence for an operator**, not an autonomous system that sends replies. Model-driven function calling was also replaced in the first-draft path by parallel, read-only prefetching controlled by the backend. -For these, the answer is nearly fixed, and the information needed is mostly already in our DB. Yet agents kept opening the admin page every time to check a user's status and rewriting similar answers by hand. Repetitive inquiries ate up support capacity, which slowed responses even for the sensitive inquiries that actually need a human. +This article covers that evolution rather than only the initial proof of concept. It includes the asynchronous pipeline, deduplication, QA, confidence evaluation, error diagnosis, schema rollout, and performance work that were added before production use. -So we decided to build a CS automation system on top of Java/Spring that **handles repetitive inquiries automatically and drafts answers for the ones a human needs to review**. +**The operating goal was not automatic sending.** -## The goal: not "automate everything" but "split well" +Some support questions are predictable lookups, while refunds, payments, and account changes require judgment. Automatic sending was considered in the early design, but it was deliberately excluded from the final production scope. -CS automation often conjures up "the AI answers every inquiry on its own," but doing that causes incidents. If the AI auto-sends a wrong answer to a money-related inquiry like a payment or refund, that itself becomes a second support ticket. +The current flow is: -So from the start we set the goal like this: +1. A new inquiry or a customer follow-up triggers a reply draft and tag suggestions. +2. For an error inquiry, the system also prepares an operator-only diagnosis from logs, traces, and customer activity. +3. An operator sends the draft as-is or edits it first. +4. The AI never sends a reply itself or executes state-changing operations such as refunds, holds, or account deletion. -1. **Simple, certain inquiries** → the AI fetches user context and **auto-sends the reply** -2. **Ambiguous or sensitive inquiries** → the AI **recommends a draft reply**, and an operator reviews it before sending - -In other words, the heart of automation isn't "how much you auto-send" but **"how well you separate what can be auto-sent from what a human must see."** +This boundary let us improve generation quality without turning a bad answer into an immediate customer-facing incident. ```mermaid flowchart TB - A["Inquiry received"] --> B["Classify intent/type"] - B --> C["Fetch user context via
Function Calling
(orders, payments, enrollment)"] - C --> D["Generate answer from
FAQ instructions"] - D --> E{"Auto-sendable?"} - E -->|"Simple, certain"| F["Auto-send reply"] - E -->|"Ambiguous, sensitive"| G["Recommend draft"] - G --> H["Operator reviews, then sends"] + A["New inquiry or customer follow-up"] --> B["Transaction committed"] + B --> C["Publish Pub/Sub event"] + C --> D["Per-ticket Redis lock
Reject duplicates and stale events"] + D --> E["Full conversation snapshot"] + E --> F["Six customer-data lookups
Parallel, three-second limit"] + E --> G["FAQ + File Search"] + E --> H["Error-inquiry diagnosis"] + E --> I["Tag suggestion"] + F --> J["Generate and save fast draft"] + G --> K["QA correction and citations"] + J --> K + K --> L["Confidence evaluation"] + H --> M["Operator-only diagnosis"] + I --> N["Suggested tags"] + L --> O["Operator UI"] + M --> O + N --> O + O --> P{"Operator decision"} + P -->|"Send as-is or after editing"| Q["APPLIED"] + P -->|"Dismiss"| R["REJECTED"] ``` -## Why Spring AI +## How the draft-generation structure changed -Our team already worked on Spring Boot, and the data needed for CS automation (orders, payments, enrollment status) all lived inside existing Spring services. So there was no reason to stand up a separate Python server just to call our own APIs again. +**From one FAQ prompt to mixed knowledge retrieval** -Two reasons drove the choice of Spring AI. +The first version loaded every public FAQ into the prompt. If the FAQ set is only a few hundred entries, this is a practical way to start without building a separate retrieval service. The backend reads up to 200 active FAQs and formats their titles and content as answer context. -- **Function Calling (Tool)** — when the LLM decides "I should look up this user's payment history," it can call a Java method we've registered directly. We don't have to cram all the user context into the prompt up front. -- **`ChatClient` abstraction** — the same code works even if the provider changes, and system instructions and structured output attach cleanly in a Spring-native way. +Once operator-managed documents and inquiry images also had to be used as evidence, we added Google File Search. The two knowledge paths now have different jobs: -## Design 1 - Registering the FAQ as "instructions" +- Public FAQs are included fresh at generation time. +- Operator-managed documents are retrieved from a File Search Store. +- Search citations are resolved to internal document names and saved with the draft. +- A separate QA stage compares the fast draft with the retrieved evidence. -The first thing we did was define the answer criteria. We consolidated scattered FAQs and support guides and registered them as a **system instruction (system prompt)**. Its job is to keep the model from making things up freely and to answer only within the rules and tone we defined. +RAG in this system does not mean that every document goes through one vector search. Small, frequently used FAQs stay in the prompt; material that needs retrieval and source tracking goes through File Search. -```java -String faqInstruction = """ - You are an assistant that helps with %s customer support. - Answer only within the FAQ and policy scope below. - - [Answer rules] - - If something is not in policy, do not guess; reply that it needs to be checked. - - Payment and refund amounts must be based only on actual queried data. - - Answer politely, in three sentences or fewer. - - [FAQ] - %s - """.formatted(serviceName, faqDocument); -``` +**The first problem with function calling** -Our FAQ isn't large, so instead of going all the way to vector-store-based RAG, **injecting the organized FAQ document straight into the system prompt** was enough. RAG is the card you play when the knowledge base grows too big to fit into context or changes often — retrieving only the relevant pieces to cut token cost and hallucination. If the documents fit within a manageable size, there's no reason to bolt on a search pipeline. +Adding Spring AI's `@Tool` annotation did not make the model use tools well. Shadow results showed that the legacy path frequently called ticket, hold, and withdrawal tools, while the new path often stopped after giving a generic FAQ answer. -## Design 2 - Fetching user context with Function Calling +The issue was the tool descriptions and the prompt. A description such as "looks up tickets" did not tell the model when to call it. A rule that said to request manual confirmation when an answer was absent from the FAQ also gave the model an easy way to avoid tools. -FAQs alone can't answer **personalized inquiries** like "How many days are left on my pass?" That's where Function Calling comes in. If you annotate the methods that look up user state with `@Tool`, the LLM calls them on its own when it decides it needs to. +We added explicit usage conditions to each description and instructed the model to prefer a real lookup whenever an answer depended on customer-specific facts. ```java -@Component -class CustomerSupportTools { - - @Tool(description = "Look up the user's recent payments and orders") - List getRecentOrders(String userId) { - return orderQueryService.findRecentSummaries(userId); - } - - @Tool(description = "Look up the user's current pass status and expiration date") - EnrollmentStatus getEnrollmentStatus(String userId) { - return enrollmentQueryService.getStatus(userId); - } - - @Tool(description = "Look up the coupons the user holds and their eligibility") - List getAvailableCoupons(String userId) { - return couponQueryService.findUsable(userId); - } +@Tool( + description = """ + Returns the customer's passes, remaining sessions, start date, and expiry. + Use it for questions about passes, remaining lessons, expiry, or refund eligibility. + """ +) +List getTicketList() { + invocationLog.add("getTicketList"); + return ticketService.findAll(userId).stream() + .map(TicketView::from) + .toList(); } ``` -When handling an inquiry, we pass the FAQ instruction and the user tools together. +The tool implementation also needed safeguards that no prompt could provide: -```java -CsAnswer answer = chatClient.prompt() - .system(faqInstruction) // FAQ instructions - .user(inquiry.content()) // raw customer inquiry - .tools(customerSupportTools) // user-context tools - .call() - .entity(CsAnswer.class); // structured output -``` +- `userId` and `ticketId` are fixed in a per-request tool instance, so the model cannot supply another user's identifier. +- Domain DTOs are converted to AI-specific DTOs, keeping passwords, tokens, and full card data out of model context. +- Detailed lesson lookup verifies that the lesson belongs to the inquiry's customer. +- Every invoked tool name is recorded so operators can see whether a reply used customer data. -Now the LLM decides on its own, "They asked about the expiration date, so I should call `getEnrollmentStatus`," and builds the answer grounded in the actual DB value. Since we don't preload user info into the prompt, the context stays light. +An early branch also contained a state-changing account-deletion tool. The final suggestion workflow removed it. Customer-data tools used for reply generation are read-only. -## Design 3 - Auto-send vs. draft recommendation +**Why model-driven calls became parallel prefetching** -Rather than taking the answer as plain text, we had the model **also judge "whether it's OK to auto-send"** through structured output. +After tool-use quality improved, latency became the problem. A model that chooses a tool, waits for its result, and reasons again adds LLM round trips. At one point, when File Search, QA, error diagnosis, and confidence evaluation were also serial, one replay took about 36.8 seconds to persist the draft and 41.1 seconds to finish the pipeline. -```java -record CsAnswer( - String reply, // generated answer - String category, // inquiry type (payment/refund/enrollment/usage ...) - boolean autoSendable, // whether it can be auto-sent - double confidence // confidence 0.0 - 1.0 -) {} -``` +The first improvement separated draft visibility from enrichment. We then ran reply generation, error diagnosis, and tag suggestion in parallel, while starting File Search and internal data lookup together. In one comparison window from development logs, first-draft visibility fell from 72.7 to 23.2 seconds. QA completion fell from 72.7 to 40.8 seconds, and full completion through confidence evaluation fell from 81.1 to 52.1 seconds. -Then the application makes the final branch. It doesn't blindly trust the model's judgment; it adds a safeguard that **hands sensitive categories to a human regardless of confidence**. +We changed the structure once more. Read-only data that the fast draft commonly needs is now fetched directly by the backend rather than selected by the model. ```java -if (answer.autoSendable() - && answer.confidence() >= AUTO_SEND_THRESHOLD - && !SENSITIVE_CATEGORIES.contains(answer.category())) { - csSender.sendToUser(inquiry, answer.reply()); // auto-send -} else { - draftInbox.recommend(inquiry, answer.reply()); // recommend draft to operator -} +var contextTasks = List.of( + async("getUserInfo", tools::getUserInfo), + async("getClassHistory", tools::getClassHistory), + async("getTicketList", tools::getTicketList), + async("getPaymentHistory", tools::getPaymentHistory), + async("getHoldingHistory", tools::getHoldingHistory), + async("getCardInfo", tools::getCardInfo) +); + +FastContext context = awaitWithin(contextTasks, Duration.ofSeconds(3)); +String fastDraft = generateDraft(conversation, faq, context); ``` -We set the bar conservatively. We auto-send only when confidence is above the threshold (e.g., `0.9`) and the category isn't a sensitive one — like payment or refund, where money or the account is at stake. Payment, refund, personal information, and account changes are classified as sensitive categories, so no matter how confident the model is, a human must review. New categories start with auto-send off, accumulating drafts only until we've checked their quality, before they enter the automation scope. +Internal lookup gets at most three seconds. List data is limited to the ten most recent items, and the draft continues with successful results when one lookup fails. Phone numbers and email addresses, which are unnecessary for the fast draft, are excluded from its model input. + +This removed Spring AI tool round trips from the first-draft path. It did not eliminate function calling from the system: the read tools remain useful in other paths and for traceability, and the error-diagnosis model still invokes a read-only diagnostic tool. The practical lesson was not to delegate every lookup to the model when latency and selection uncertainty matter. + +In one production ticket observed after recovery, the draft became visible in roughly 2.2 seconds and QA completed in about six seconds. That single trace is not a general latency benchmark, but it confirmed the effect of prefetching and staged draft visibility. + +## The production workflow + +**Asynchronous events and freshness guarantees** + +If the inquiry request waits for an AI call, a model failure can turn into a customer-facing inquiry-submission failure. The backend therefore publishes an event after the transaction commits, and a Pub/Sub subscriber performs the AI work. + +Asynchrony introduces duplicates and reordering. Pub/Sub may redeliver a message, and an older event may arrive after a customer has already posted another follow-up. + +We use three layers of protection: + +1. A Redis lock keyed by ticket ID serializes generation for a ticket. +2. The handler verifies that the event still points to the latest customer input before and after AI work. +3. Every draft stores a `source_input_id`, so only redelivery of the same input is treated as a duplicate. + +The third layer fixed a real design defect. A rule that merely asked whether a ticket already had a recent draft could misclassify a new customer follow-up as a duplicate. With `source_input_id`, redelivery of the same input is skipped while a new input generates a fresh draft. The previous `GENERATED` draft becomes `SUPERSEDED`. + +**Show the draft first, attach quality signals afterward** + +The original pipeline waited for File Search, QA, error diagnosis, and confidence evaluation before saving anything. Operators got a complete result, but they waited too long to see it. + +The current pipeline stores results in stages: + +1. Build a fast draft from customer context and FAQs. +2. Save it as `GENERATED` so the operator UI can display it. +3. Have a QA model compare File Search evidence with the fast draft. +4. Update the same draft with the corrected reply, pre-QA text, citations, and actual model used. +5. Attach error-diagnosis and confidence results afterward. + +The fast draft records that File Search review is pending. Confidence and diagnosis use `PENDING` states. If processing stops or Pub/Sub redelivers the event, the handler can resume only the incomplete enrichment. + +Freshness is checked again before each write. If the customer posts another message while the AI is working, the stale result is discarded and the new event regenerates from the full conversation. + +**Separate the customer reply from error diagnosis** + +An inquiry such as "booking does not work" cannot be answered safely from FAQs alone. Even when logs exist, the system must distinguish a correlated event from the direct cause of the current inquiry. Mixing customer copy and root-cause analysis in one prompt made observations and guesses too easy to blur. + +Error inquiries now have a separate operator-only diagnostic path: + +- The model must first call the read-only `getErrorDiagnosticContext` tool. +- The tool gathers logs, traces, customer activity, and business data around the reported time. +- Credentials, personal information, internal URLs, and raw stack traces are excluded from model output. +- The model must return `FAILURE_POINT_IDENTIFIED`, `PARTIAL_EVIDENCE`, or `INSUFFICIENT`. +- It cannot identify a failure point without direct evidence. + +Customer activity shows a sequence of actions, but it is supporting evidence rather than proof of a backend failure. If activity events exist without request-flow or error evidence, the highest allowed verdict is `PARTIAL_EVIDENCE`. A similar historical error also cannot be presented as the cause of the current inquiry. + +The diagnosis is never sent to the customer. It gives the operator observed evidence, an AI interpretation, and suggested follow-up checks alongside the reply draft. + +**Confidence is not an auto-send score** + +The earlier version of this post described a model returning `autoSendable` and a single confidence value, with the application sending a reply above a threshold. That is not how the production workflow works. Confidence evaluation does not decide whether to send. + +The current evaluator stores four values between zero and one: + +- `groundedness`: whether the reply is supported by FAQs, documents, or queried data +- `completeness`: whether it addresses the necessary parts of the inquiry +- `safety`: whether it avoids personal-data exposure and risky instructions +- `uncertaintyHandling`: whether it states limitations instead of guessing + +It also stores reasons and `hardBlockers`. A reply is penalized if it asserts a cause despite insufficient diagnosis or recommends a destructive, platform-inappropriate step such as reinstalling an app. The evaluation prompt explicitly forbids outputting a total score, an auto-send decision, or a threshold. + +Confidence is a quality signal for the operator. A person still decides what the customer receives. + +**Record operator decisions as quality data** + +Generating drafts alone does not show whether the system is useful. We need to know whether an operator used a draft, edited it heavily, or discarded it. + +`ticket_ai_draft` records these outcomes: + +| Situation | State and record | +| --- | --- | +| New draft | `GENERATED` | +| Replaced after new customer input | `SUPERSEDED` | +| Dismissed by an operator | `REJECTED` | +| Sent unchanged | `APPLIED` + `UNCHANGED` | +| Sent after editing | `APPLIED` + `EDITED` + edit ratio | + +The final sent text, operator, and application time are also recorded. Suggested tags are not applied automatically; only the tags selected by an operator are added. + +This is more useful than counting successful generations. Unchanged acceptance rate, edited acceptance rate, average edit ratio, and rejection rate by inquiry type show which prompts or documents need work. The same data can feed a knowledge-gap analysis that compares AI drafts with actual operator replies. + +## Rollout and results + +**A safe transition depended on deployment order** + +The first migration used a `shadow` value for `spring_ai_enabled` so the old and new paths could be compared on the same inquiry. Shadow drafts and tags were isolated, and state-changing tools were not injected. + +The final operator-suggestion workflow excluded long-term shadow operation and automatic sending. When the Spring path is enabled it creates drafts, tags, and diagnoses; when disabled it stops new CS AI work. It does not silently fall back to the old PHP inference path, which would split data contracts and quality standards again. + +The strongest production lesson was the ordering of schema and code. Code that read `source_input_id` was deployed before the production table had the column, causing an `Unknown column` error before any model call began. The problem had nothing to do with the model or prompt. + +After the column was applied, the error disappeared and the reply draft, QA, error diagnosis, and confidence evaluation all completed. We now verify the rollout in distinct stages: + +1. Apply database schema and setting keys first. +2. Deploy the backend with the feature disabled. +3. Deploy the operator UI. +4. Verify Pub/Sub subscriptions, DLQ, model settings, and the File Search Store. +5. Enable the feature and follow one real inquiry through draft, QA, diagnosis, confidence, and operator application. + +A successful code deployment is not the same as a working feature. The schema, settings, messaging infrastructure, and operator UI must agree. + +**What went wrong along the way** + +**Registered tools were not necessarily called.** We added when-to-use guidance, then removed model selection from the fast path by prefetching read-only data in parallel. -As a result, agents no longer write answers from a blank screen; they work by **reviewing and editing an already-filled draft and sending it right away**. Certain inquiries need no touch at all, and even ambiguous ones have a starting point, so response time drops. +**Waiting for one complete result made operators wait too long.** We saved a fast draft first and attached File Search, QA, diagnosis, and confidence to the same record later. -## Trial and error +**"A recent draft exists" was an unsafe deduplication rule.** Storing `source_input_id` distinguished redelivery from a genuinely new customer follow-up. -**1. The model confidently made up things that weren't in policy.** Early on, it would answer with confident numbers that weren't in our policy, like "refunds take three business days." So we spelled out in the instructions, "if there's no basis, don't guess — say it needs to be checked," and forced fact-bound items like amounts and durations to rely only on actual values fetched via Function Calling. Answers received as structured output were also validated for format and required fields before sending, to filter out broken responses. +**A failing LLM proxy became fixed latency on every call.** Drafting, File Search, QA, confidence, summaries, and tag suggestion now call a fast Gemini model directly and retry once with a stronger model. Error diagnosis keeps the more capable model. -**2. We opened auto-send too aggressively, then narrowed it.** At first we set the threshold low, and even ambiguous answers tried to go out automatically. A misfire is itself a second support ticket, so we raised the threshold conservatively and excluded sensitive categories from auto-send entirely. We firmly chose "don't send the wrong thing" over "automate a lot." +**Structured output still needed defensive handling.** JSON Schema, normalization, required-field validation, retries, and explicit failure states were all necessary. A successful model call is not the same as a valid business result. -## Expected impact +**CS volume fell by roughly 43% in the observed operating data** -How much automation pays off is ultimately decided by **the composition of incoming inquiries**. The CS automation pipeline classifies and tags incoming inquiries by type, so we quantified that type data over the last 12 months (about 22,000 tickets) and converted each type by the criterion of "is it OK to handle automatically." +The analysis covered about 22,000 inquiries from the preceding 12 months. The roughly 43% figure was not an estimate of the automation candidate pool; it was an observed reduction in CS volume in the operating data. It should not be interpreted as only the size of a candidate pool. -| Category | Share | Automatable | Representative types | -| --- | --- | --- | --- | -| Simple, lookup inquiries | ~22% | Automated | pass, curriculum, events, how-to, documents | -| Procedural, intake inquiries | ~20% | Automated | hold requests, improvement intake | -| Inquiries needing a human | ~57% | Human | refunds, unpaid payments, cancellation, errors, tutor issues | +The current system does not auto-send. Operators review every draft and edit it when necessary. Even with that boundary, the operating data after adoption showed a reduction of roughly 43% in CS volume. This figure alone cannot isolate how much the model, FAQ maintenance, reply drafts, operator review, or other workflow changes contributed. -- **Simple, lookup inquiries (~22%)** — the answers are standardized and the needed information is mostly in the DB, so auto-replies or drafts can absorb almost all of them. -- **Procedural, intake inquiries (~20%)** — the flow is fixed, as with hold requests and intake, so they're handled by auto-reply and intake automation. -- **Inquiries needing a human (~57%)** — money, investigation, and sensitive issues, so we assist with a draft but a person sends it. +The 43% reduction is an observed result. The following metrics should be tracked alongside it to identify which stages contributed to the change: -Adding the two types converted to automated handling gives about **43%** of all inquiries. Absorbing this share with auto-replies and draft automation reduces the CS handling load that agents carried directly by roughly 43% as well. The remaining ~57% — money, investigation, and sensitive issues — stays with a human for the final judgment and send. +- time from inquiry receipt to first visible draft +- time through QA, diagnosis, and confidence completion +- unchanged and edited acceptance rates +- average edit ratio +- rejection rate by inquiry category +- generation failures, retries, and DLQ volume -The point isn't "automate every inquiry," but to strip away the data-confirmed automatable zone (~43%) first, so agents focus on the inquiries that genuinely need judgment. Agents write fewer answers from scratch, and certain inquiries get handled without them touching them at all. +This keeps the measured result and the system's operating model in the same account. The reduction should continue to be measured, while draft-generation and operator-decision data should be separated to identify the next improvement. -## Wrap-up +## Closing -The core of this system wasn't flashy AI but **setting boundaries**. +The work started with function calling that fetched customer data for an FAQ-based reply. Production raised harder questions: how to process only the latest conversation, show a useful draft quickly, separate evidence from inference, and capture the operator's final decision. -- Register the FAQ as instructions so the model doesn't stray outside our policy -- Use Function Calling so it answers only based on real data -- Split auto-send and draft recommendation so inquiries that must not be wrong always pass through a human +Spring AI provided implementation tools such as `ChatClient`, Tool, and structured output. The quality of the production system came from the boundaries around them: read versus write, fast output versus verified output, and AI suggestions versus human decisions. -Even with the same Spring AI, reading this alongside the [Spring AI in Practice](/posts/spring-ai-pipeline-real-world) post — which focused on "what was built" with a seven-step diagnostic pipeline — lets you compare a pipeline-style design with a support-assist-style design. +For another production use of Spring AI, see [Spring AI in Practice](/posts/spring-ai-pipeline-real-world), which covers a multi-stage diagnostic pipeline. diff --git a/content/posts/ko/spring-ai-cs-automation.md b/content/posts/ko/spring-ai-cs-automation.md index f37d9c1..27b1656 100644 --- a/content/posts/ko/spring-ai-cs-automation.md +++ b/content/posts/ko/spring-ai-cs-automation.md @@ -1,174 +1,266 @@ --- -title: "Spring AI로 CS 자동화 시스템 구축하기 - Function Calling과 FAQ 지침서" +title: "Spring AI CS 상담 보조 시스템 운영기 - Function Calling에서 운영자 제안 워크플로까지" date: "2026-07-06" -updated: "2026-07-06" -description: "반복되는 고객 문의를 Spring AI 기반으로 자동화한 경험. Function Calling으로 사용자 맥락을 조회하고 FAQ를 지침서로 등록해 자동 답변과 상담 초안 추천을 함께 구성한 이야기." +updated: "2026-07-28" +description: "Spring AI 기반 CS 답변 초안을 운영에 적용하며 Function Calling 중심 설계를 병렬 조회, QA, 오류 진단, 운영자 검수 워크플로로 바꾼 과정과 성과를 정리합니다." tags: ["spring-ai", "spring-boot", "ai", "llm", "cs"] draft: false --- -## 배경 +## 배경과 운영 경계 -서비스가 커지면서 CS(고객 문의)도 함께 늘었다. 그런데 들어오는 문의를 들여다보면 상당수가 **비슷한 질문의 반복**이었다. +이 작업은 2026년 6월, 기존 PHP와 n8n에 흩어져 있던 CS 답변 초안·태그 추천 기능을 Spring 백엔드로 옮기면서 시작했다. 초기 구현의 중심에는 FAQ 프롬프트와 Spring AI Function Calling이 있었다. 모델이 문의를 읽고 필요한 고객 데이터를 조회한 뒤 답변 초안을 만드는 구조였다. -- "결제가 두 번 된 것 같아요" -- "지금 제 수강권이 며칠 남았나요?" -- "환불하면 언제 돌아오나요?" -- "쿠폰이 적용이 안 돼요" +운영 준비를 거치며 시스템의 성격은 달라졌다. AI가 답변을 자동 발송하는 시스템이 아니라, **운영자가 검토할 답변과 근거를 빠르게 준비하는 상담 보조 시스템**으로 경계를 분명히 했다. 모델 주도 Function Calling도 첫 초안 경로에서는 읽기 전용 데이터를 백엔드가 병렬 조회하는 방식으로 바뀌었다. -이런 문의는 답변이 거의 정해져 있고 필요한 정보도 대부분 우리 DB 안에 있다. 그런데도 상담사가 매번 관리자 페이지를 열어 사용자 상태를 확인하고 비슷한 답변을 손으로 다시 작성하고 있었다. 반복 문의가 상담 리소스를 잡아먹으니 정작 사람이 판단해야 하는 민감한 문의의 응대 속도까지 느려졌다. +이 글은 초기 PoC의 모양만 설명하지 않는다. 실제 운영 전환 과정에서 추가된 비동기 처리, 중복 방지, QA, 신뢰도 평가, 오류 진단, 스키마 배포와 성능 개선까지 함께 정리한다. -그래서 **반복 문의를 자동으로 처리하고 사람이 봐야 하는 문의는 초안까지 만들어 주는** CS 자동화 시스템을 Java/Spring 위에 새로 구축하기로 했다. +**운영 목표는 자동 발송이 아니었다.** -## 목표는 다 자동화가 아니라 잘 나누기 +CS 문의에는 수강권 조회처럼 답이 정형화된 질문도 있고, 환불·결제·계정처럼 사람이 판단해야 하는 질문도 있다. 처음에는 단순 문의의 자동 발송까지 고려했지만 최종 운영 범위에서는 제외했다. -CS 자동화라고 하면 흔히 "AI가 모든 문의에 알아서 답한다"를 떠올리지만 실제로 그렇게 하면 사고가 난다. 결제·환불처럼 돈이 걸린 문의에 AI가 틀린 답을 자동 발송하면 그게 곧 2차 CS다. +현재 흐름은 다음과 같다. -그래서 처음부터 목표를 이렇게 잡았다. +1. 새 문의나 고객의 추가 문의가 들어오면 AI가 답변 초안과 추천 태그를 만든다. +2. 오류 문의라면 로그·트레이스·고객 이용 흐름을 읽어 운영자 전용 진단도 만든다. +3. 운영자가 초안을 그대로 쓰거나 수정해 발송한다. +4. AI가 고객에게 직접 답변하거나 환불·홀딩·탈퇴 같은 변경 작업을 실행하지는 않는다. -1. **단순하고 확실한 문의** → AI가 사용자 맥락까지 조회해서 **자동 답변 발송** -2. **애매하거나 민감한 문의** → AI가 **답변 초안을 추천**하고 운영자가 검토 후 발송 - -즉 자동화의 핵심은 "얼마나 많이 자동으로 보내느냐"가 아니라 **자동으로 보내도 되는 것과 사람이 봐야 하는 것을 얼마나 잘 가르느냐**에 있었다. +이 경계 덕분에 생성 품질을 개선하면서도 잘못된 답변이 곧바로 고객에게 전달되는 위험을 피할 수 있었다. ```mermaid flowchart TB - A["고객 문의 수신"] --> B["의도·유형 분류"] - B --> C["Function Calling으로
사용자 맥락 조회
(주문·결제·수강 상태)"] - C --> D["FAQ 지침서 기반
답변 생성"] - D --> E{"자동 발송 가능?"} - E -->|"단순·확실"| F["자동 답변 발송"] - E -->|"애매·민감"| G["답변 초안 추천"] - G --> H["운영자 검토 후 발송"] + A["새 문의 또는 고객 추가 문의"] --> B["트랜잭션 커밋"] + B --> C["Pub/Sub 이벤트 발행"] + C --> D["티켓별 Redis 락
중복·오래된 이벤트 차단"] + D --> E["전체 상담 대화 스냅샷"] + E --> F["고객 정보 6종
3초 제한 병렬 조회"] + E --> G["FAQ + File Search"] + E --> H["오류 문의 진단"] + E --> I["태그 추천"] + F --> J["빠른 초안 생성·저장"] + G --> K["QA 보정·출처 연결"] + J --> K + K --> L["신뢰도 평가"] + H --> M["운영자 전용 진단"] + I --> N["추천 태그"] + L --> O["운영자 화면"] + M --> O + N --> O + O --> P{"운영자 결정"} + P -->|"그대로 또는 수정 후 발송"| Q["APPLIED"] + P -->|"무시"| R["REJECTED"] ``` -## 왜 Spring AI인가 +## 초안 생성 구조를 바꾼 과정 -우리 팀은 이미 Spring Boot 위에서 일하고 있었고 CS 자동화에 필요한 데이터(주문·결제·수강 상태)도 전부 기존 Spring 서비스 안에 있었다. 그렇다면 굳이 별도 파이썬 서버를 띄워 우리 API를 다시 호출하게 만들 이유가 없었다. +**FAQ 프롬프트에서 혼합형 지식 조회로** -Spring AI를 선택한 이유는 두 가지였다. +초기 구현은 공개 FAQ를 모두 읽어 시스템 프롬프트에 넣었다. FAQ가 수백 건을 넘지 않는 규모라면 별도 검색 인프라 없이도 빠르게 시작할 수 있는 방법이다. 실제 코드에서도 활성 FAQ를 최대 200건까지 읽고, 제목과 본문을 답변 컨텍스트로 구성했다. -- **Function Calling(Tool)** - LLM이 "이 사용자의 결제 내역을 조회해야겠다"고 판단하면, 우리가 등록해 둔 Java 메서드를 직접 호출하게 할 수 있다. 사용자 맥락을 프롬프트에 미리 다 욱여넣지 않아도 된다. -- **`ChatClient` 추상화** - 프로바이더가 바뀌어도 동일한 코드로 호출할 수 있고 시스템 지침·구조화 출력 같은 걸 Spring 스타일로 깔끔하게 붙일 수 있다. +운영자가 올린 문서와 첨부 이미지까지 근거로 써야 하면서 Google File Search를 함께 사용하게 됐다. 현재는 두 지식 경로를 나눈다. -## 설계 1 - FAQ를 "지침서"로 등록 +- 공개 FAQ는 답변을 만들 때 최신 내용을 프롬프트에 포함한다. +- 운영자가 관리하는 문서는 File Search Store에서 검색한다. +- 검색 결과의 citation은 내부 파일명과 연결해 초안에 저장한다. +- 검색 결과와 빠른 초안은 별도 QA 단계에서 다시 대조한다. -가장 먼저 한 일은 답변의 기준을 만드는 것이었다. 흩어져 있던 FAQ와 상담 가이드를 정리해 **시스템 지침(system prompt)** 형태로 등록했다. 모델이 자유롭게 지어내지 않고 우리가 정한 답변 규칙과 톤 안에서만 답하도록 가두는 역할이다. +이 구조에서 RAG는 벡터 검색 하나를 뜻하지 않는다. 크기가 작고 자주 읽는 FAQ는 프롬프트에 넣고, 운영 문서처럼 검색과 출처가 필요한 자료만 File Search로 보낸다. 자료의 성격에 따라 경로를 나눈 셈이다. -```java -String faqInstruction = """ - 너는 %s의 고객 상담을 돕는 어시스턴트다. - 아래 FAQ와 정책 범위 안에서만 답변한다. - - [답변 규칙] - - 정책에 없는 내용은 추측하지 말고 "확인이 필요하다"고 답한다. - - 결제·환불 금액은 반드시 조회된 실제 데이터를 근거로만 말한다. - - 사용자에게 존댓말로, 3문장 이내로 간결하게 답한다. - - [FAQ] - %s - """.formatted(serviceName, faqDocument); -``` +**Function Calling에서 가장 먼저 막힌 문제** -우리 FAQ는 규모가 크지 않아서 벡터 스토어 기반 RAG까지 갈 필요 없이 **정리된 FAQ 문서를 시스템 프롬프트에 통째로 주입**하는 방식으로 충분했다. RAG는 지식베이스가 컨텍스트에 다 넣기 부담스러울 만큼 커지거나 자주 바뀔 때, 관련 조각만 검색해 넣어 토큰 비용과 환각을 줄이는 카드다. 문서가 감당 가능한 크기면 굳이 검색 파이프라인을 얹을 이유가 없다. +Spring AI의 `@Tool`을 붙이는 것만으로 모델이 도구를 잘 쓰지는 않았다. 초기 shadow 결과를 확인해 보니 레거시 경로는 수강권·홀딩·탈퇴 도구를 자주 호출했지만 신규 경로는 일반 FAQ만 보고 답을 끝내는 경우가 많았다. -## 설계 2 - Function Calling으로 사용자 맥락 조회 +원인은 도구 설명과 프롬프트였다. "`수강권을 조회한다`" 정도의 설명만으로는 언제 호출해야 하는지가 부족했다. FAQ에 없는 내용은 확인이 필요하다고 답하라는 규칙도 모델 입장에서는 도구를 건너뛸 수 있는 쉬운 출구가 됐다. -FAQ만으로는 "제 수강권 며칠 남았어요?" 같은 **개인화된 문의**에 답할 수 없다. 이때 필요한 게 Function Calling이다. 사용자 상태를 조회하는 메서드에 `@Tool`을 붙여 등록해 두면, LLM이 필요하다고 판단할 때 알아서 호출한다. +그래서 도구마다 호출 조건을 구체적으로 적고, 고객별 사실이 필요한 문의에서는 일반 안내보다 실제 조회를 우선하라는 규칙을 추가했다. ```java -@Component -class CustomerSupportTools { - - @Tool(description = "사용자의 최근 결제·주문 내역을 조회한다") - List getRecentOrders(String userId) { - return orderQueryService.findRecentSummaries(userId); - } - - @Tool(description = "사용자의 현재 수강권 상태와 만료일을 조회한다") - EnrollmentStatus getEnrollmentStatus(String userId) { - return enrollmentQueryService.getStatus(userId); - } - - @Tool(description = "사용자가 보유한 쿠폰과 적용 가능 조건을 조회한다") - List getAvailableCoupons(String userId) { - return couponQueryService.findUsable(userId); - } +@Tool( + description = """ + 고객의 수강권 목록과 잔여 횟수, 시작일, 만료일을 조회한다. + 수강권, 남은 수업, 만료, 환불 가능 여부를 묻는 문의에 사용한다. + """ +) +List getTicketList() { + invocationLog.add("getTicketList"); + return ticketService.findAll(userId).stream() + .map(TicketView::from) + .toList(); } ``` -문의를 처리할 때는 FAQ 지침서와 사용자 툴을 함께 물려서 호출한다. +도구 구현에는 프롬프트보다 중요한 안전장치도 넣었다. -```java -CsAnswer answer = chatClient.prompt() - .system(faqInstruction) // FAQ 지침서 - .user(inquiry.content()) // 고객 문의 원문 - .tools(customerSupportTools) // 사용자 맥락 조회 툴 - .call() - .entity(CsAnswer.class); // 구조화 출력 -``` +- `userId`와 `ticketId`는 요청마다 생성하는 도구 인스턴스에 고정했다. 모델이 다른 사용자 ID를 인자로 넣을 수 없다. +- 도메인 DTO를 그대로 반환하지 않고 AI 전용 DTO로 변환했다. 비밀번호·토큰·전체 카드번호 같은 필드가 모델 컨텍스트에 들어가지 않는다. +- 특정 수업 상세 조회에는 해당 수업이 문의 고객의 것인지 소유권 검사를 추가했다. +- 실제 호출한 도구 이름을 기록해 답변의 데이터 조회 여부를 확인할 수 있게 했다. -이제 LLM은 "만료일을 물었으니 `getEnrollmentStatus`를 불러야겠다"고 스스로 판단하고 실제 DB 값을 근거로 답변을 만든다. 프롬프트에 사용자 정보를 미리 다 넣어둘 필요가 없으니 컨텍스트도 가벼워진다. +초기 브랜치에는 회원탈퇴 같은 쓰기 도구도 있었지만 최종 제안 워크플로에서는 제외했다. 현재 답변 생성에 쓰는 고객 도구는 읽기 전용이다. -## 설계 3 - 자동 발송 vs 초안 추천 +**모델 주도 호출을 병렬 선조회로 바꾼 이유** -답변을 그냥 텍스트로 받지 않고 **"자동으로 보내도 되는지"까지 모델이 함께 판단**하도록 구조화 출력으로 받았다. +도구 호출 품질을 높인 뒤에는 속도가 문제였다. 모델이 도구를 하나씩 선택하고 결과를 받은 다음 다시 추론하면 LLM 왕복이 늘어난다. File Search, QA, 오류 진단, 신뢰도 평가까지 직렬로 기다리던 시점에는 한 재생성 건의 초안 DB 저장까지 약 36.8초가 걸렸고, 전체 파이프라인은 약 41.1초였다. -```java -record CsAnswer( - String reply, // 생성된 답변 - String category, // 문의 유형 (결제/환불/수강/사용법 ...) - boolean autoSendable, // 자동 발송 가능 여부 - double confidence // 확신도 0.0 ~ 1.0 -) {} -``` +첫 개선은 초안 저장과 후처리를 분리하는 일이었다. 이후 답변 초안, 오류 진단, 태그 추천을 병렬로 돌리고 File Search와 내부 정보 조회도 동시에 시작했다. dev 로그의 한 비교 구간에서는 첫 초안 표시가 72.7초에서 23.2초로 줄었다. QA 완료는 72.7초에서 40.8초, 신뢰도 평가까지는 81.1초에서 52.1초로 단축됐다. -그리고 애플리케이션에서 최종 분기를 건다. 모델의 판단을 그대로 믿지 않고 **민감 카테고리는 확신도와 무관하게 사람에게 넘기는** 안전장치를 둔다. +여기서 한 번 더 구조를 바꿨다. 첫 초안에 자주 필요한 읽기 전용 정보는 모델이 선택하게 두지 않고 백엔드가 직접 가져왔다. ```java -if (answer.autoSendable() - && answer.confidence() >= AUTO_SEND_THRESHOLD - && !SENSITIVE_CATEGORIES.contains(answer.category())) { - csSender.sendToUser(inquiry, answer.reply()); // 자동 발송 -} else { - draftInbox.recommend(inquiry, answer.reply()); // 운영자에게 초안 추천 -} +var contextTasks = List.of( + async("getUserInfo", tools::getUserInfo), + async("getClassHistory", tools::getClassHistory), + async("getTicketList", tools::getTicketList), + async("getPaymentHistory", tools::getPaymentHistory), + async("getHoldingHistory", tools::getHoldingHistory), + async("getCardInfo", tools::getCardInfo) +); + +FastContext context = awaitWithin(contextTasks, Duration.ofSeconds(3)); +String fastDraft = generateDraft(conversation, faq, context); ``` -기준은 보수적으로 잡았다. 확신도가 임계값(예: `0.9`) 이상이면서 결제·환불처럼 **돈이나 계정이 걸린 민감 카테고리가 아닌** 경우에만 자동 발송한다. 결제·환불·개인정보·계정 변경은 카테고리 자체를 민감으로 분류해, 모델이 아무리 확신해도 반드시 사람이 검토하게 했다. 새로 생기는 카테고리는 한동안 자동 발송을 끄고 초안만 쌓아 보며 품질을 확인한 뒤 자동화 범위에 넣었다. +내부 조회는 최대 3초까지만 기다리고, 목록 데이터는 최근 10건으로 제한한다. 일부 조회가 실패해도 성공한 정보만으로 초안을 만든다. 전화번호와 이메일처럼 초안에 불필요한 개인정보는 빠른 모델 입력에서 제외했다. + +이 변경으로 첫 초안 경로의 Spring AI 도구 왕복은 사라졌다. 그렇다고 Function Calling 자체를 버린 것은 아니다. 읽기 도구는 다른 경로와 추적에 남아 있고, 오류 진단에서는 모델이 읽기 전용 진단 도구를 호출한다. 중요한 건 모든 조회를 무조건 모델에게 맡기지 않고, 지연과 선택 불확실성이 큰 구간은 애플리케이션이 제어하게 했다는 점이다. + +운영 복구 후 확인한 단일 티켓에서는 초안 표시까지 약 2.2초, QA 완료까지 약 6초가 관찰됐다. 표본 한 건의 결과이므로 일반적인 지연 시간으로 단정할 수는 없지만, 병렬 선조회와 빠른 초안 분리의 효과를 확인하기에는 충분했다. + +## 운영 워크플로 + +**비동기 이벤트와 최신성 보장** + +문의 등록 요청 안에서 AI 호출까지 기다리면 모델 장애가 고객의 문의 등록 실패로 이어진다. 그래서 트랜잭션이 커밋된 뒤 이벤트를 발행하고 Pub/Sub 구독자가 처리하게 했다. + +비동기로 바꾸면 중복과 순서 역전 문제가 생긴다. Pub/Sub은 같은 메시지를 다시 전달할 수 있고, 고객이 짧은 시간에 댓글을 연속으로 남기면 이전 이벤트가 늦게 도착할 수도 있다. + +이 문제는 세 겹으로 막았다. + +1. 티켓 ID 기준 Redis 락으로 같은 티켓의 생성을 직렬화한다. +2. 이벤트가 현재 최신 고객 입력을 가리키는지 AI 호출 전후로 확인한다. +3. 초안마다 `source_input_id`를 저장해 동일 입력의 중복 생성만 막는다. + +마지막 항목은 운영에서 실제로 필요성이 드러났다. 처음에는 "이 티켓에 최근 초안이 있는가"만 확인해 고객의 추가 문의까지 중복으로 오판할 수 있었다. `source_input_id`를 넣은 뒤에는 같은 입력의 재전달은 건너뛰고, 새 고객 입력에는 다시 초안을 만든다. 새 초안이 저장되면 이전 `GENERATED` 초안은 `SUPERSEDED`로 바뀐다. + +**초안을 먼저 보여주고 품질 정보는 이어 붙인다** + +초기 파이프라인은 File Search, QA, 오류 진단, 신뢰도 평가가 모두 끝나야 초안을 저장했다. 운영자는 완성된 결과를 받았지만 기다리는 시간이 길었다. + +현재는 결과를 단계적으로 저장한다. + +1. 고객 정보와 FAQ로 빠른 초안을 만든다. +2. `GENERATED` 상태로 먼저 저장해 운영 화면에 노출한다. +3. File Search 결과와 빠른 초안을 QA 모델이 검증한다. +4. 보정한 답변, 수정 전 답변, citation, 실제 사용 모델을 같은 초안에 갱신한다. +5. 오류 진단과 신뢰도 평가 결과를 후속으로 기록한다. + +빠른 초안에는 File Search 검토 중임을 나타내는 상태를 남기고, 신뢰도와 오류 진단에는 `PENDING`을 사용한다. 중간에 처리가 끊기거나 메시지가 재전달되면 미완료 상태만 다시 처리할 수 있다. + +저장 직전에는 대화 스냅샷이 여전히 최신인지 다시 검사한다. AI가 답을 만드는 사이 고객이 추가 문의를 남겼다면 오래된 결과는 버리고 새 이벤트가 전체 대화로 다시 생성한다. + +**오류 문의에는 답변과 진단을 분리했다** + +"예약이 안 돼요" 같은 문의는 FAQ만으로 답하기 어렵다. 로그가 있어도 그 기록이 현재 문의의 직접 원인인지 구분해야 한다. 고객에게 보낼 답변과 운영자가 볼 원인 분석을 같은 프롬프트에서 만들면 관측 사실과 추정이 섞이기 쉽다. + +오류 문의에는 별도의 운영자 전용 진단 경로를 두었다. + +- 모델은 `getErrorDiagnosticContext` 읽기 도구를 먼저 호출한다. +- 도구는 문의 시각 주변의 로그, 트레이스, 고객 이용 이벤트와 업무 데이터를 모은다. +- 인증정보, 개인정보, 내부 URL, 원문 스택은 모델 출력에서 제외한다. +- 모델은 `FAILURE_POINT_IDENTIFIED`, `PARTIAL_EVIDENCE`, `INSUFFICIENT` 중 하나로 판정한다. +- 직접 근거가 없으면 원인을 확정하지 못하게 한다. + +고객 이용 이벤트는 행동 순서를 보여주는 보조 자료일 뿐이다. 이벤트만 있고 요청 처리 로그가 없다면 최대 `PARTIAL_EVIDENCE`까지만 허용한다. 과거의 비슷한 오류를 현재 문의의 원인처럼 쓰지 않는 규칙도 넣었다. + +진단 결과는 고객에게 전송되지 않는다. 운영자는 답변 초안과 함께 관측 근거, AI 해석, 추가 확인 항목을 보고 판단한다. + +**신뢰도 점수는 자동 발송 점수가 아니다** + +기존 글에서는 모델이 `autoSendable`과 하나의 확신도를 반환하고 임계값을 넘으면 자동 발송한다고 설명했다. 실제 운영 구현은 다르다. 신뢰도 평가는 발송 여부를 결정하지 않는다. + +현재 평가는 다음 네 항목을 각각 0에서 1 사이로 기록한다. + +- `groundedness`: FAQ·검색 문서·조회 데이터에 근거했는가 +- `completeness`: 문의에 필요한 답을 빠뜨리지 않았는가 +- `safety`: 개인정보나 위험한 조치를 포함하지 않았는가 +- `uncertaintyHandling`: 근거가 부족할 때 추측 대신 한계를 밝혔는가 + +평가 결과에는 이유와 `hardBlockers`도 함께 저장한다. 오류 진단이 불충분한데 답변이 원인을 확정하거나, 고객 환경과 맞지 않는 삭제·재설치를 권하면 감점과 차단 사유가 생긴다. 프롬프트에는 total 점수, 자동발송 여부, 임계값을 출력하지 말라고 명시했다. + +신뢰도는 운영자가 초안을 검토할 때 참고하는 품질 신호다. 고객에게 보낼지 결정하는 주체는 여전히 사람이다. + +**운영자의 실제 결정을 학습 가능한 기록으로 남기기** + +초안을 생성하는 것만으로는 품질이 좋아졌는지 알 수 없다. 운영자가 초안을 썼는지, 얼마나 고쳤는지, 아예 버렸는지를 남겨야 다음 개선 근거가 생긴다. + +`ticket_ai_draft`에는 다음 상태와 결과를 기록한다. + +| 상황 | 상태·기록 | +| --- | --- | +| 새 초안 생성 | `GENERATED` | +| 새 고객 입력으로 교체 | `SUPERSEDED` | +| 운영자가 무시 | `REJECTED` | +| 그대로 발송 | `APPLIED` + `UNCHANGED` | +| 수정 후 발송 | `APPLIED` + `EDITED` + 편집 비율 | + +발송한 최종 내용, 운영자, 적용 시각도 함께 남긴다. 추천 태그 역시 AI가 자동으로 붙이지 않고 운영자가 선택한 태그만 적용한다. + +이 데이터는 단순 성공 건수보다 유용하다. 그대로 채택된 초안 비율, 수정 후 채택률, 평균 편집 비율, 문의 유형별 거절률을 보면 어떤 프롬프트와 지식이 부족한지 찾을 수 있다. 초안과 실제 답변의 차이를 분석해 지식 보완 후보를 만드는 흐름도 여기에 연결된다. + +## 전환 과정과 성과 + +**안전한 전환은 코드보다 배포 순서가 중요했다** + +첫 이관 때는 `spring_ai_enabled`에 `shadow` 값을 두고 기존 경로와 신규 경로를 같은 문의로 비교했다. shadow 초안과 추천 태그는 별도 상태로 저장하고, 회원탈퇴 같은 쓰기 도구는 주입하지 않았다. + +최종 운영자 제안 워크플로에서는 장기 shadow와 자동 발송을 제외했다. Spring 경로를 켜면 신규 답변 초안·태그·진단을 만들고, 끄면 신규 CS AI 처리를 중단한다. 이전 PHP 추론으로 자동 복귀시키지 않는 이유는 두 경로의 데이터 계약과 품질 기준이 다시 갈라지는 것을 막기 위해서다. + +운영 전환 과정에서 가장 크게 배운 것은 DDL과 코드의 순서였다. `source_input_id`를 읽는 코드가 먼저 배포되고 운영 테이블에 컬럼이 없자, AI 모델 호출 전에 `Unknown column` 오류가 발생했다. 모델이나 프롬프트 문제가 아니었다. + +컬럼을 반영한 뒤 같은 오류가 사라졌고 답변 초안, QA, 오류 진단, 신뢰도 평가가 모두 완료됐다. 이 경험 뒤로 배포 단계를 다음처럼 분리해 확인하게 됐다. + +1. DB 스키마와 설정 키를 먼저 반영한다. +2. Backend를 배포하되 기능은 끈 상태로 둔다. +3. 운영자 화면을 배포한다. +4. Pub/Sub 구독, DLQ, 모델 설정, File Search Store를 확인한다. +5. 기능을 켠 뒤 실제 문의 한 건으로 초안·QA·진단·신뢰도·운영자 적용까지 확인한다. + +코드 배포 성공과 기능 정상 동작은 같은 말이 아니다. 스키마, 설정, 메시지 인프라, 운영 화면이 모두 맞아야 한 흐름이 완성된다. + +**운영에서 드러난 문제** + +**도구가 등록돼 있어도 모델이 호출하지 않았다.** 도구 설명에 사용 시점을 넣고 프롬프트를 고쳤다. 첫 초안 경로에서는 모델 선택을 없애고 백엔드 병렬 선조회로 바꿨다. -결과적으로 상담사는 빈 화면에서 답변을 처음부터 쓰는 게 아니라, **이미 채워진 초안을 검토·수정해 바로 보내는** 방식으로 일하게 됐다. 확실한 문의는 아예 손을 안 대도 되고 애매한 문의도 시작점이 있으니 응대 속도가 빨라진다. +**한 번에 완성하려다 운영자가 너무 오래 기다렸다.** 빠른 초안을 먼저 저장하고 File Search·QA·진단·신뢰도 결과를 같은 초안에 이어 붙였다. -## 겪은 시행착오 +**"최근 초안 있음"을 중복 기준으로 삼아 추가 문의를 놓칠 수 있었다.** 생성 기준 입력을 `source_input_id`로 남겨 같은 입력과 새 입력을 구분했다. -**1. 모델이 정책에 없는 걸 그럴듯하게 지어냈다.** 초기엔 "환불은 영업일 기준 3일" 같은, 우리 정책에 없는 숫자를 자신 있게 답하는 경우가 있었다. 그래서 지침서에 "근거가 없으면 추측하지 말고 확인이 필요하다고 답하라"를 명시하고 금액·기간처럼 사실이 걸린 항목은 반드시 Function Calling으로 조회한 실제 값에만 근거하도록 강제했다. 구조화 출력으로 받은 답변도 발송 전에 형식과 필수값을 검증해 깨진 응답을 걸러냈다. +**LLM Proxy 실패가 매 호출의 고정 지연이 됐다.** 답변 초안, File Search, QA, 신뢰도, 요약, 태그 추천은 빠른 Gemini 모델을 직접 호출하고 실패 시 상위 모델로 한 번 재시도하도록 바꿨다. 오류 진단은 더 정밀한 모델을 유지했다. -**2. 자동 발송을 너무 공격적으로 열었다가 좁혔다.** 처음엔 임계값을 낮게 잡았더니 애매한 답변까지 자동으로 나가려 했다. 오발송은 그 자체로 2차 CS이므로, 임계값을 보수적으로 올리고 민감 카테고리는 아예 자동 발송에서 제외했다. "많이 자동화"보다 "틀린 걸 안 보내는" 쪽으로 방향을 확실히 잡았다. +**구조화 출력도 그대로 믿을 수 없었다.** JSON Schema, 정규화, 필수 필드 검증, 재시도와 실패 상태를 따로 두었다. 모델 호출 성공과 유효한 업무 결과는 다른 문제였다. -## 기대 효과 +**실제 운영 데이터에서 CS 처리량이 약 43% 줄었다** -자동화가 얼마나 먹힐지는 결국 **들어오는 문의의 구성**이 결정한다. CS 자동화 파이프라인은 들어온 문의를 유형으로 분류·태깅하는데, 이 유형 데이터를 최근 12개월(약 2.2만 건)로 수치화하고 각 유형을 "자동으로 처리해도 되는가" 기준으로 환산해 봤다. +기존 분석 대상은 최근 12개월 약 2.2만 건의 문의였다. 여기서 제시한 약 43%는 자동화 후보 규모를 추정한 값이 아니라, 실제 운영 데이터에서 확인한 CS 처리량 감소 수치다. 따라서 후보군으로만 해석하면 안 된다. -| 구분 | 비중 | 자동 여부 | 대표 유형 | -| --- | --- | --- | --- | -| 단순·조회성 문의 | 약 22% | 자동 처리 | 수강권·커리큘럼·이벤트·이용 방법·증빙서류 | -| 절차·접수성 문의 | 약 20% | 자동 처리 | 홀딩 신청, 개선사항 접수 | -| 사람이 판단해야 하는 문의 | 약 57% | 사람 필요 | 환불·미납 결제·탈퇴·각종 오류·튜터 관련 | +현재 시스템은 자동 발송하지 않으며 운영자가 초안을 검토하고 필요하면 수정한다. 그럼에도 시스템 적용 이후의 운영 데이터에서는 CS 처리량이 약 43% 감소했다. 다만 이 수치만으로 AI 모델, FAQ 정비, 답변 초안, 운영자 검수처럼 함께 바뀐 요소의 기여도를 각각 분리할 수는 없다. -- **단순·조회성 문의(약 22%)** - 답변이 정형화돼 있고 필요한 정보도 대부분 DB 안에 있어, 자동 응답이나 초안으로 거의 흡수할 수 있다. -- **절차·접수성 문의(약 20%)** - 홀딩 신청·접수처럼 흐름이 정해져 있어 자동 응답·접수 자동화로 처리한다. -- **사람이 판단해야 하는 문의(약 57%)** - 금전·조사·민감 이슈라 초안으로 돕되 발송은 사람이 한다. +43%는 이미 관측한 결과이고, 이후에는 다음 지표를 함께 추적해 어느 단계가 처리량 감소에 기여했는지 확인해야 한다. -자동 처리로 환산된 두 유형을 합치면 전체 문의의 **약 43%** 규모다. 이만큼을 자동 응답·초안 자동화로 흡수하면, 사람이 직접 응대하던 CS 처리량도 약 43% 줄어드는 셈이다. 나머지 약 57%는 금전·조사·민감 이슈라 사람이 최종 판단과 발송을 맡는다. +- 문의 접수부터 첫 초안 표시까지 걸린 시간 +- QA와 진단·신뢰도 평가까지의 전체 시간 +- 초안 채택률과 수정 후 채택률 +- 평균 편집 비율 +- 문의 유형별 거절률 +- 생성 실패, 재시도, DLQ 건수 -핵심은 "모든 문의를 자동화"가 아니라, 데이터로 확인한 자동화 가능 영역(약 43%)을 먼저 걷어내 상담사가 정말 판단이 필요한 문의에 집중하게 만드는 데 있다. 상담사가 빈 화면에서 답을 새로 쓰는 일이 줄고 확실한 문의는 손대지 않아도 처리되는 방향으로 간다. +이렇게 보면 43%라는 결과와 시스템의 작동 방식을 함께 설명할 수 있다. 실제 감소폭은 유지해서 측정하되, 초안 생성부터 운영자 결정까지의 데이터를 나눠 봐야 다음 개선 지점을 찾을 수 있다. ## 마치며 -이 시스템의 핵심은 화려한 AI가 아니라 **경계 설정**이었다. +처음에는 Function Calling으로 고객 데이터를 조회해 FAQ 답변을 만드는 기능이 중심이었다. 운영까지 가져가는 과정에서 더 중요한 문제들이 드러났다. 최신 대화만 처리하는 방법, 첫 초안을 빨리 보여주는 방법, 모델의 근거와 추정을 나누는 방법, 운영자의 최종 결정을 기록하는 방법이다. -- FAQ를 지침서로 등록해 모델이 우리 정책 밖으로 나가지 않게 가두고 -- Function Calling으로 실제 데이터에 근거해서만 답하게 하고 -- 자동 발송과 초안 추천을 나눠서, 틀리면 안 되는 문의는 반드시 사람을 거치게 했다 +Spring AI는 `ChatClient`, Tool, 구조화 출력 같은 구현 수단을 제공했다. 하지만 운영 품질을 만든 것은 프레임워크 자체보다 그 주변의 경계였다. 읽기와 쓰기를 나누고, 빠른 결과와 검증 결과를 나누고, AI의 제안과 사람의 결정을 나눴다. -같은 Spring AI를 쓰더라도 "무엇을 만들었는가"에 초점을 맞춰 7단계 진단 파이프라인을 다룬 [Spring AI 실전 적용기](/posts/spring-ai-pipeline-real-world)와 함께 보면, 파이프라인형 설계와 상담 보조형 설계의 차이를 비교해볼 수 있다. +같은 Spring AI를 진단 파이프라인에 적용한 사례는 [Spring AI 실전 적용기](/posts/spring-ai-pipeline-real-world)에서 이어서 볼 수 있다.