가장 유용한 지원 에이전트는 메시지를 직접 보낼 수 없는 에이전트이다
요약
AI 에이전트가 사용자에게 메시지를 직접 보내지 못하도록 제한하는 안전한 지원 워크플로우 구축 방법을 다룹니다. 모델의 유창함과 권한을 분리하여, 사람이 승인한 답변만 전달되도록 설계하는 엔지니어링 원칙을 제시합니다.
핵심 포인트
- 모델의 유창함이 곧 실행 권한을 의미하지 않음을 명시
- 제안된 답변과 최종 답변을 분리하여 저장하는 데이터 구조 설계
- 개인정보 및 보안 관련 메시지에 대한 결정론적 정책 적용
- Node.js와 SQLite를 활용한 재현 가능한 워크플로우 구현
AI 지원 데모는 단 한 번의 성공적인 교환만으로도 완벽해 보일 수 있습니다. 메시지가 도착하고, 모델이 도구 (tool)를 선택하며, 세련된 답변이 나타나는 과정 말입니다.
그 직후에 불편한 엔지니어링 질문이 시작됩니다:
당신은 모델이 작성한 그 답변을 실제 사용자에게 보내도록 허용하시겠습니까?
이러한 긴장감은 당신이 뒤처지고 있다는 증거가 아닙니다. 그것은 모델의 능력 (capability)을 보여주는 것과 프로덕션 결정 (production decision)을 내리는 것 사이의 차이입니다.
모델은 텍스트를 분류하고, 구조를 추출하며, 그럴듯한 답변 초안을 작성할 수 있습니다. 하지만 이러한 능력 중 그 어떤 것도 답변이 정확한지, 권한이 있는지, 안전한지, 또는 현재의 제품과 일치하는지를 증명하지는 못합니다. 따라서 가치 있는 엔지니어링 작업은 모델이 더 독립적으로 보이게 만드는 것이 아닙니다. 독립성이 어디에서 멈춰야 하는지를 결정하는 것입니다.
이 튜토리얼에서 우리는 의도적으로 제한된 어시스턴트 (assistant)를 사용하여 Node.js 지원 워크플로우 (workflow)를 구축할 것입니다:
- 사용자가 지원 메시지를 제출합니다.
- 애플리케이션이 원본 메시지를 저장합니다.
- 모델이 카테고리, 긴급도, 그리고 초안을 제안합니다.
- 결정론적 정책 (Deterministic policy) 체크가 민감한 사례를 식별합니다.
- 사람이 제안을 수정, 승인 또는 거부합니다.
- 승인된 텍스트만이 전달 어댑터 (delivery adapter)에 도달합니다.
- 저장된 예시들은 프롬프트 (prompt)나 모델이 변경될 때 다시 재생될 수 있습니다.
어시스턴트는 제안할 수는 있지만, 보낼 수는 없습니다.
프롬프트를 작성하기 전에 경계 정의하기
지원 시스템에는 최소한 세 가지 종류의 결정이 포함됩니다:
| 결정 사항 | AI 지원에 적합한 후보인가? | 최종 권한 |
|---|---|---|
| 긴 메시지 요약 | 대개 그러함 | 모델 출력, 필요에 따라 검토됨 |
| ... |
이러한 구분은 흔히 발생하는 범주 오류 (category error)를 방지합니다: 유창함 (fluency)이 곧 권한 (authority)은 아닙니다.
우리는 모델에게 이를 기억하라고 요청하는 대신, 코드에서 세 가지 불변량 (invariants)을 강제할 것입니다:
- 제안은
sent상태로 직접 전환될 수 없습니다. - 개인정보 보호, 보안, 결제 및 계정 액세스 메시지는 항상 민감한 것으로 표시됩니다.
- 원본 요청, 제안, 프롬프트 버전, 모델 식별자(identifier) 및 최종 답변은 서로 구별 가능한 상태로 유지됩니다.
프로젝트 설정
내장된 fetch API를 사용할 수 있도록 Node.js 20 이상을 사용하세요.
mkdir bounded-support-assistant
cd bounded-support-assistant
npm init -y
...
package.json에 모듈 지원과 스크립트를 추가합니다:
{
"type": "module",
"scripts": {
...
이 예제는 한 대의 머신에서 워크플로를 재현 가능하게 유지하기 위해 SQLite를 사용합니다. 여러 애플리케이션 인스턴스가 동일한 큐(queue)를 검토해야 하는 경우에는 PostgreSQL 또는 다른 트랜잭션 데이터베이스 (transactional database)가 더 적합합니다.
대화뿐만 아니라 결정 사항을 저장하세요
src/db.js를 생성합니다:
import Database from "better-sqlite3";
export const db = new Database("support.db");
...
proposed_reply(제안된 답변)와 final_reply(최종 답변)를 분리하여 유지하면 다음과 같은 몇 가지 운영상의 질문에 답할 수 있습니다:
- 검토자가 모델의 출력값을 실질적으로 변경했는가?
- 어떤 프롬프트 (prompt) 버전이 제안을 생성했는가?
- 잘못된 진술이 생성되었는가, 검토 과정에서 도입되었는가, 아니면 소스 자료에 이미 존재했는가?
- 일시적인 로그 (transient logs)에 의존하지 않고도 사고를 재구성할 수 있는가?
모델이 유용하다고 판단할 수 있다는 이유만으로 비밀 정보 (secrets)를 저장하지 마세요. 애플리케이션의 실제 개인정보 보호 의무에 따라 지원 데이터에 대한 보존 및 액세스 규칙을 설정하세요.
모델이 제안을 반환하도록 만드세요
src/propose.js를 생성합니다:
import { z } from "zod";
const Proposal = z.object({
...
스키마 검증 (Schema validation)은 필요하지만, 그것이 의미론적 검증 (semantic validation)은 아닙니다. Zod는 category가 허용된 문자열을 포함하고 있음을 증명할 수 있습니다. 하지만 draftReply에 인용된 환불 정책이 실제인지 여부는 증명할 수 없습니다.
그것이 바로 다음 계층이 결정론적 정책 (deterministic policy)인 이유입니다.
모델 외부에서 정책을 적용하세요
src/policy.js를 생성합니다:
const SENSITIVE_CATEGORIES = new Set([
"billing",
"account_access",
...
이러한 표현식은 가드레일 (guardrails)이지, 완전한 안전 시스템은 아닙니다. 이는 의역된 표현을 놓치거나 오탐 (false positives)을 발생시킬 수 있습니다. 이들의 유용한 속성은 예측 가능성입니다. 검토자는 모델의 동작에 의존하지 않고도 이를 검사, 테스트 및 변경할 수 있습니다.
이 워크플로우가 모델이 생성한 신뢰도 점수(confidence score)를 전송 권한으로 변환하는 것이 아님에 유의하세요. 모델의 신뢰도는 승인(authorization)이 아닙니다.
인테이크(Intake)를 검토 큐(Review Queue)에 연결하기
src/app.js를 생성합니다:
import express from "express";
import { db } from "./db.js";
import { createProposal, PROMPT_VERSION } from "./propose.js";
...
외부 모델을 호출하지 않고 실행합니다:
AI_MODE=mock npm run dev
메시지를 제출합니다:
curl -X POST http://localhost:3000/support \
-H 'content-type: application/json' \
-d '{
...
큐를 검사합니다:
curl http://localhost:3000/review
수정된 응답을 승인합니다:
curl -X POST http://localhost:3000/review/1/approve \
-H 'content-type: application/json' \
-d '{
...
승인 쿼리의 조건부 업데이트(conditional update)가 중요합니다. 두 명의 검토자가 동일한 케이스를 열 경우, 첫 번째 유효한 상태 전이(transition)만 성공합니다. 두 번째 검토자는 결정을 조용히 덮어쓰는 대신 409 응답을 받게 됩니다.
전달(Delivery)은 별도의 기능이어야 합니다
단순히 에이전트 프레임워크가 도구(tools)를 지원한다는 이유만으로 이메일, 채팅 또는 계정 관리 자격 증명(credentials)을 모델 도구 세트(toolset) 안에 배치하지 마세요.
대신, 전달 워커(delivery worker)가 이미 approved로 표시된 레코드만 선택하도록 합니다:
const next = db.prepare(`
SELECT * FROM support_cases
WHERE status = 'approved'
...
프로덕션 환경의 전달 워커에는 멱등성(idempotency)도 필요합니다. 그렇지 않으면 전달 후 데이터베이스 업데이트가 이루어지기 전에 프로세스가 충돌할 경우 동일한 답장을 두 번 보낼 수 있습니다. support-case-<id>와 같은 멱등성 키(idempotency key)를 허용하는 채널 API를 선호하거나, 안정적인 외부 메시지 키를 가진 전달 시도(delivery-attempt) 테이블을 유지하세요.
리플레이 케이스(Replay Cases)로 프롬프트 변경 사항 테스트하기
그럴듯한 응답이 회귀 테스트(regression test)가 될 수는 없습니다. 프롬프트나 모델을 변경하기 전에, 예상되는 제약 조건이 포함된 어렵고 익명화된 소규모 케이스 세트를 저장하세요.
fixtures/support-cases.json을 생성합니다:
[
{
"name": "account recovery does not request secrets",
...
src/replay.js를 생성합니다:
import fs from "node:fs/promises";
import { createProposal } from "./propose.js";
import { evaluateProposal } from "./policy.js";
...
이전과 제안된 모델 설정(model configuration) 모두에 대해 동일한 피스처(fixtures)를 실행합니다. 이것이 완전한 평가 스위트(evaluation suite)는 아니지만, 프롬프트 변경 사항을 직관이 아닌 검토 가능한 엔지니어링 변경 사항으로 전환해 줍니다.
실제 실패 사례(real failures)에서 추출한 케이스는 개인 정보와 비밀 정보(secrets)를 제거한 후에만 추가하십시오. 유용한 피스처는 비결정론적 모델(nondeterministic model)로부터 정확히 한 문장을 요구하는 것이 아니라, 안정적으로 유지되어야 하는 결정 사항을 명시해야 합니다.
라이브 접점(live contact surface) 추가하기
워크플로에 채팅 인터페이스가 반드시 필요한 것은 아닙니다. /support로 포스트(post)를 보내는 양식(form)만으로도 충분합니다. 사용자가 대화형 인터페이스를 필요로 한다면, 동일한 권한 경계(authority boundary)를 유지하십시오. 즉, 인터페이스는 메시지를 전달할 뿐이며, 무엇을 보낼지는 귀하의 검토 정책(review policy)이 결정합니다.
한 가지 구현 옵션은 Knocket으로, 임베드 가능한 웹 라이브 채팅 위젯과 통합 인박스(unified inbox)를 제공합니다. 웹사이트 설치 시 생성된 스크립트 태그를 사용하며, 커스텀 채팅 백엔드(chat backend)를 직접 구축할 필요가 없습니다. 방문자는 대화를 시작하기 위해 계정이 필요하지 않습니다.
실용적인 구성 방식은 다음과 같습니다:
- 서비스에서 제공하는 스크립트를 사용하여 위젯을 설치합니다.
- 들어오는 메시지를 사람 검토자(human reviewer)를 위해 Telegram으로 라우팅합니다.
- AI가 생성한 모든 초안(draft)은 전송된 응답이 아닌 내부 자료로 취급합니다.
- 검토자가 답장할 때 관련 Telegram 메시지를 인용(quote)하도록 합니다.
- 해당 인용된 답장을 웹사이트 방문자에게 전달합니다.
이 구성에서 Knocket은 접점 및 회신 채널 역할을 합니다. 이는 앞서 설명한 제안 정책(proposal policy), 민감 사례 규칙(sensitive-case rules), 또는 리플레이 테스트(replay tests)를 대체하지 않습니다. 다른 채팅, 이메일 또는 티켓 제공업체를 사용하는 경우, 해당 제공업체와 함께 deliverReply()를 구현하고 동일한 '승인된 것만 허용(approved-only)' 전환 원칙을 유지하십시오.
설계 시 고려해야 할 실패 모드(Failure modes)
모델 엔드포인트(model endpoint)를 사용할 수 없는 경우
선택 사항인 어시스턴트 (assistant)가 실패했다고 해서 사용자의 메시지를 거부하지 마세요. 먼저 해당 사례를 저장하고, proposal_failed를 설정한 뒤, 원래 메시지를 바탕으로 사람이 답변할 수 있도록 하세요.
사용자가 모델을 위한 지침을 삽입하는 경우
지원 메시지 (support messages)는 신뢰할 수 없는 입력값입니다. 메시지를 데이터로 구분(Delimit)하고, 시스템 지침 (system instruction)에 포함하는 것을 피하며, 응답을 검증하고, 부수 효과 (side effects)를 모델의 권한 밖에 유지하세요.
초안이 완료된 동작을 허구로 만들어내는 경우
모델은 근거 없이도 그럴듯한 확인 메시지를 생성할 수 있습니다. 일반적인 주장들을 결정론적 (deterministically)으로 차단하고, 정책 플래그 (policy flags)를 눈에 띄게 표시하며, 검토자가 소스 시스템과 대조하여 동작을 확인하도록 요구하세요.
비식별화 (Redaction)가 잘못된 확신을 주는 경우
몇 개의 정규 표현식 (regular expressions)만으로는 견고한 개인정보 (PII) 탐지를 구성할 수 없습니다. 전송되는 데이터 양을 최소화하고, 어떤 데이터가 제공업체에 도달할 수 있는지 문서화하며, 위험도가 높은 경우에는 더 강력한 탐지 기술이나 로컬 프로세싱 (local processing)을 사용하세요.
검토가 형식적인 절차로 전락하는 경우
검토자가 확인 없이 클릭할 것이 예상된다면, “승인 (Approve)”이라고 표시된 버튼은 의미 있는 제어 수단이 아닙니다. 원래 메시지, 초안, 정책 플래그, 그리고 관련 검증된 제품 정보를 함께 표시하세요. 수정 사항을 추적하고, 변경되지 않은 승인 건들을 주기적으로 점검하세요.
답변의 근거가 되는 지식이 오래된 경우
검색 (Retrieval)을 통해 문맥을 제공할 수 있지만, 검색된 텍스트가 오래되었거나 사용자에게 부적절할 수도 있습니다. 소스 문서의 버전을 관리하고, 검토자에게 인용 (citations)을 보여주며, 검색 결과가 초안을 권한이 있는 결정으로 변환하게 두지 마세요.
두 프로세스가 동일한 승인된 답변을 보내는 경우
조건부 상태 전이 (conditional state transitions)와 멱등성 전달 키 (idempotent delivery keys)를 사용하세요. 외부 부수 효과 (external side effect)와 로컬 업데이트 사이에 충돌 (crash)이 발생할 수 있는 상황에서는 데이터베이스 상태만으로는 불충분합니다.
출시 체크리스트
이 워크플로우를 실제 사용자에게 연결하기 전에 다음 사항을 확인하세요:
- 모델 호출 전에 원본 요청(original request)이 영구 저장(persisted)되는가.
- 모델 타임아웃(Model timeouts) 발생 시 해당 케이스를 수동 처리할 수 있도록 유지되는가.
- 출력이 스키마 검증(schema-validated)을 거치는가.
- 민감한 카테고리가 애플리케이션 정책(application policy)에 의해 제어되는가.
- 모델이 직접적인 배송, 환불, 삭제 또는 계정 자격 증명(account credentials) 권한을 가지고 있지 않은가.
- 승인 과정에서 조건부 상태 전이(conditional state transition)를 사용하는가.
- 최종적으로 사람이 승인한 답변이 제안(proposal)과 별도로 저장되는가.
- 배송(Delivery)이 멱등성(idempotent)을 갖거나 안전하게 재시도 가능한가.
- 프롬프트(Prompt) 및 모델 버전이 기록되는가.
- 프롬프트 인젝션(Prompt-injection) 및 허위-
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기