
Hindsight가 어떻게 나의 신용 흐름을 기록이 남는 체계로 바꾸었는가
요약
LLM 에이전트의 일관성 문제를 해결하기 위해 메모리 레이어인 Hindsight를 구축한 사례를 다룹니다. 상태가 없는(stateless) LLM 호출의 한계를 극복하고, 금융 결정의 신뢰도와 감사 가능성을 높이는 아키텍처를 설명합니다.
핵심 포인트
- LLM의 비결정론적 특성으로 인한 신용 결정의 불일치 문제 해결
- Hindsight 래퍼를 통한 메모리 검색, 실행, 성찰 기록 프로세스 구축
- Google Genkit을 활용한 타입 지정 스키마 기반의 워크플로우 구성
- 비용 최적화를 위한 cascadeflow 기반의 모델 라우팅 전략
내가 신용 결정 에이전트(credit decision agent)에게 동일한 질문을 두 번 던졌을 때, 에이전트는 동일한 승인에 대해 서로 다른 두 가지 이유를 제시했습니다. 소득도 같고, 부채도 같고, 상환 이력도 같았지만 — 추론 과정과 신뢰도 점수(confidence score)는 달랐습니다. 그 순간 나는 대출 결정을 내리는 데 있어 상태가 없는 (stateless) LLM 호출을 신뢰하는 것을 멈추고, 그 아래에 메모리 레이어(memory layer)를 구축하기 시작했습니다.
이것은 왜 내가 FinShield AI의 모든 금융 흐름 앞에 Hindsight를 배치했는지, 그것이 시스템의 동작 방식을 실제로 어떻게 바꾸었는지, 그리고 비용이 많이 드는 추론이 꼭 필요할 때만 발생하도록 cascadeflow를 통해 모델 호출을 어디로 라우팅하고 있는지에 대한 이야기입니다.
[

시스템이 하는 일
FinShield AI는 통합 금융 지능 플랫폼입니다. 신용 결정(credit decisioning), 사기 모니터링(fraud monitoring), AML(자금세탁방지) 리스크 탐지, 비용 최적화, 시장 정보(market intelligence), 그리고 대화형 금융 어드바이저가 모두 하나의 대시보드 뒤에 자리 잡고 있습니다.
[

내부적으로는, 이 모든 모듈이 Google Genkit 플로우(flow)로 구성되어 있습니다. 즉, 타입이 지정된 입력 스키마(input schema), 타입이 지정된 출력 스키마(output schema), 그리고 모델에게 스키마를 채우도록 요청하는 프롬프트(prompt)로 이루어져 있습니다. 이 부분은 표준적입니다. 많은 팀이 LLM을 Zod 스키마에 연결하고 작업을 마무리하곤 합니다.
표준적이지 않은 점, 적어도 내가 읽어본 대부분의 신용 점수 산정(credit-scoring) 코드에서는, 이 플로우 중 어느 것도 모델을 직접 호출하지 않는다는 점입니다. 모든 요청은 runWithHindsight라고 불리는 래퍼(wrapper)를 거치며, 이 래퍼는 실제 모델 호출 전후로 세 가지 작업을 수행합니다. 호출 전에 관련 메모리를 검색하고, 호출을 실행하며, 호출 후에 성찰(reflection)을 기록합니다. 모델은 결코 두 번 다시 백지 상태(blank slate)를 마주하지 않습니다.
핵심 문제: 상태가 없는 (stateless) 에이전트는 자신의 실수로부터 배우지 못한다
LLM을 멋진 Zod 스키마로 감싸는 것에는 한 가지 문제가 있습니다. 이는 출력을 구조화(structured)해주기는 하지만, 일관성(consistency)에 대해서는 아무것도 말해주지 않습니다. 신용 모델에게 월요일과 금요일에 동일한 차입자 프로필(borrower profile)을 물어봤을 때, 프롬프트(prompt)와 온도(temperature) 설정이 정확하게 고정되어 있지 않다면 서로 다른 위험 카테고리(risk category)를 받을 수 있습니다. 신용 맥락에서 이는 단순한 호기심의 문제가 아니라 감사(audit) 문제입니다. 규제 기관이 "왜 이 대출을 승인했습니까?"라고 묻는다면, 정직한 답변은 "그날 모델의 기분이 그랬습니다"가 될 수 없습니다.
저는 모든 신용 결정이 그 이전의 것들을 지목할 수 있기를 원했습니다. 해당 사용자에 대한 이전 결정들, 이전 카테고리들, 이전 신뢰 수준(confidence levels), 그리고 무엇보다 중요한 것은 — 이전의 결정들이 불안정했는지 여부에 대한 기록입니다. 이것이 제가 모델의 결정론(determinism)에 의존하는 대신, 시스템에 지속적이고 검색 가능한 메모리(persistent, retrievable memory)를 부여하도록 이끈 동기였습니다.
초기에 Vectorize의 에이전트 메모리(agent memory)에 관한 글을 읽었는데, 제 머릿속에 남은 프레임워크는 채팅 로그로서의 메모리와 검색(retrieval)으로서의 메모리 사이의 구분이었습니다. 모든 대화 기록을 매 프롬프트마다 다시 재생(replay)하고 싶은 것이 아니라, 현재 요청과 실제로 관련이 있는 소수의 이전 상호작용(interactions)만을 가져오고 싶은 것입니다. 그것이 제가 구축한 모델의 핵심입니다. 즉, 쏟아붓는 전사(transcript)가 아니라 쿼리(query)하는 메모리 저장소(memory store)입니다.
Hindsight가 실제로 신용 흐름(credit flow)을 사용하는 방식
신용, 사기(fraud), 자금세탁방지(AML), 자문(advisory), 비용(expenses) 등 모든 도메인 흐름은 모델에 접근하기 전에 동일한 runWithHindsight 래퍼(wrapper)를 호출합니다:
export async function creditDecisionAnalysis(
input: CreditDecisionInput
): Promise<CreditDecisionOutput> {
...
여기서 세 가지 작업이 순서대로 일어납니다:
1. Retrieve (검색). 모델이 실행되기 전, Hindsight는 이 요청과 관련이 있는 category: 'credit' 태그가 붙은 최대 4개의 이전 메모리(prior memories)를 불러옵니다.
2. Execute with context (컨텍스트와 함께 실행). 검색된 메모리들은 hindsight_context 문자열로 평탄화(flattened)되어 소득, 부채, 상환 이력과 함께 프롬프트(prompt)에 직접 주입됩니다.
3. Finalize and reflect (마무리 및 성찰). 모델이 응답한 후, 모델 스스로 보고한 confidence_score (신뢰도 점수)를 포함한 결과가 새로운 메모리로 다시 기록되며, 이를 바탕으로 성찰(reflection)이 생성됩니다.
이러한 오케스트레이션(orchestration)은 하나의 함수 내에서 이루어지며, 코드베이스의 모든 흐름(flow)이 호출하는 동일한 함수입니다:
export async function runWithHindsight<T>(
options: HindsightFlowOptions,
executor: (context: string) => Promise<T>,
...
만약 결정 결과가 0.55 미만의 신뢰도 점수(confidence score)와 함께 반환되면, Hindsight는 단순히 이를 기록하는 데 그치지 않습니다. 해당 상호작용을 이슈로 플래그(flag) 처리하고, 명시적인 개선 노트("답변하기 전에 더 많은 지원 금융 컨텍스트를 추가할 것")를 기록합니다. 응답 텍스트에 "불분명함" 또는 "불충분함"과 같은 유보적인 언어(hedge language)가 포함되어 있다면 이 또한 플래그 처리됩니다. 이 과정 중 어느 것도 사후에 신용 결정(credit decision)을 변경하지는 않습니다. 대신, 해당 카테고리의 다음 요청이 읽을 수 있는 지속적인 품질 신호(quality signal)를 구축합니다.
외부에서 보이는 모습
실제 신용 결정 화면에서는, 소득 95,000달러, 부채 12,000달러, 5년의 깨끗한 상환 이력을 가진 대출 신청자가 95/100의 리스크 점수, "낮음(Low)" 리스크 카테고리, 그리고 "승인(Approve)" 결정과 함께 나타나며, 모델이 가중치를 둔 구체적인 요인들이 바로 옆에 나열됩니다:
그 출력물은 단순히 대시보드용으로 저장되는 것이 아닙니다. 이는 Hindsight가 저장하고, 나중에 유사한 프로필을 가진 다음 신청자를 위한 컨텍스트 (context)로 다시 불러오는 것과 동일한 객체입니다. 그리고 이는 시각적으로 확인 가능합니다. Hindsight 대시보드 자체는 메모리 저장소 (memory store)에 대한 실제적이고 쿼리 가능한 (queryable) 뷰를 제공합니다: 총 메모리 수, 최근 메모리 수, 축적된 성찰 (reflections) 수, 그리고 시스템이 결정한 모든 사항에 대한 평균 신뢰도 (confidence) 등을 확인할 수 있습니다.
카테고리별로 필터링하거나 요청 텍스트로 검색할 수 있으며, 저장된 각 결정에 연결된 정확한 신뢰도 점수 (confidence score)를 볼 수 있습니다. 예를 들어 신용 메모리는 98%, 사기 메모리는 88%, 주식 감성 판단은 93%와 같은 식입니다. 이 테이블은 제가 처음부터 원했던 감사 로그 (audit log)입니다. 단순히 "승인"을 출력하는 블랙박스 (black box)가 아니라, 모든 결정이 요청, 신뢰도 수치, 카테고리와 연결되어 있으며, 모델이 답변하기 전에 무엇을 보았는지 추적할 수 있는 시스템입니다.
cascadeflow의 역할
Hindsight는 "이 시스템이 무언가를 기억하고 있는가"라는 문제를 해결합니다. 하지만 "왜 사소한 호출을 포함한 모든 호출이 사용 가능한 가장 비싼 모델을 호출하는가"라는 문제는 해결하지 못합니다. 바로 이 지점에서 cascadeflow가 등장합니다. 단순한 쿼리는 빠르고 저렴한 추론 (inference) (Groq의 무료 티어, 로컬 Ollama 모델 등)으로 라우팅 (routing)하고, 신용이나 사기 결정과 직접적으로 관련된 민감하거나 모호한 사례들만 유료 프론티어 모델 (frontier model)로 에스컬레이션 (escalating)하는 것입니다.
전체적인 아키텍처 (architecture)는 다음과 같습니다:
Genkit은 흐름 오케스트레이션 (flow orchestration) 및 스키마 검증 (schema validation)을 처리합니다. Hindsight는 한쪽 측면에 위치하여 모든 흐름 (flow)에 대해 메모리를 읽고 씁니다. Cascadeflow는 반대편에 위치하여 실제로 어떤 모델 계층 (model tier)을 실행해야 할지 결정합니다. 가벼운 질문에 대한 주식 감성 체크 (stock sentiment check)는 신용 승인 (credit approval)과 동일한 모델 예산을 사용할 필요가 없으며, 답변 비용도 동일해서는 안 됩니다. 이러한 결정을 개별 흐름마다 직접 구현하는 대신 인프라 계층 (infrastructure layer)에서 라우팅 (routing)하는 것이, 비용 제어를 각 흐름 작성자가 구현해야 할 사항이 아닌 시스템 자체의 속성으로 만드는 핵심입니다.
교훈 (Lessons learned)
재생 (replay)이 아닌 검색 (retrieval)으로서의 메모리는 프롬프트 (prompt)를 작게 유지합니다. 저는 모든 요청에 전체 상호작용 이력을 추가하지 않습니다. 대신 가장 관련성이 높은 이전 메모리 4개를 가져와 짧은 컨텍스트 문자열 (context string)로 요약합니다. 이를 통해 사용자가 시스템을 얼마나 오래 사용했는지와 관계없이 토큰 비용 (token cost)을 예측 가능하게 유지합니다.
신뢰도 점수 (confidence score)는 나중에 누군가 그것을 읽을 때만 유용합니다. 수많은 LLM 응답에는 아무도 확인하지 않는 자체 보고 신뢰도 필드가 포함되어 있습니다. 이 수치를 자동 성찰 (automatic reflection) 단계에 연결하여 임계값 미만인 항목에 플래그를 지정함으로써, 장식용 필드였던 것을 실제로 미래의 동작을 변화시키는 요소로 탈바꿈시켰습니다.
도메인별로 메모리를 분류하는 것은 예상보다 더 중요합니다. 모든 메모리에 category (credit, fraud, aml, chat) 태그를 붙이면, 검색 (retrieval)이 단순히 "유사한 텍스트"를 찾는 것을 넘어 유사한 결정 (decisions)을 찾게 됩니다. 사기 (fraud) 관련 메모리는 설령 문구가 겹치더라도 신용 (credit) 컨텍스트로 유출되어서는 안 됩니다.
지속성 (Persistence)은 인프라 장애에서도 살아남아야 합니다. Firestore 장애가 발생했을 때 시스템이 세션 중간에 모든 것을 조용히 잊어버리는 것을 원치 않았습니다. 따라서 기본 데이터베이스에 접속할 수 없을 때는 로컬 파일로 폴백 (fallback)하며, 읽기/쓰기는 어떤 경우에도 동일한 인터페이스를 통해 이루어집니다. runWithHindsight를 호출하는 흐름들은 어떤 백엔드가 실제로 요청을 처리했는지 알 필요가 없습니다.
라우팅 비용 결정을 개별 흐름 (flows)에서 분리하는 것이 더 잘 확장됩니다. 흐름이 몇 개 이상 많아지면, 모든 프롬프트 파일에 "비싼 모델을 사용하라"라고 하드코딩하는 것은 일일이 수동으로 감사해야 하는 작업이 됩니다. 해당 결정을 런타임 레이어 (runtime layer) — cascadeflow의 문서에서는 이를 비용 인식 라우팅 (cost-aware routing)이라고 설명합니다 — 로 밀어넣는다는 것은, 새로운 흐름을 추가한다고 해서 모델 예산을 처음부터 다시 결정할 필요가 없음을 의미합니다.
신용, 사기, 컴플라이언스 등 반복적이고 범주화된 결정—즉, "왜 그렇게 결정했는가"라는 질문이 누군가에게 실제로 던져질 수 있는 모든 것—을 구축하고 있다면, 여기서 얻어야 할 패턴은 "LLM을 추가하라"가 아닙니다. 그것은 "LLM이 읽고 다시 쓸 수 있는 메모리 레이어 (memory layer)를 추가하고, 모든 호출마다 수행할 수 있을 만큼 읽기/쓰기 비용을 충분히 저렴하게 만드는 것"입니다. 이것이 단순히 답을 하는 모델과 스스로를 설명할 수 있는 시스템 사이의 차이입니다.
Gautham.B & Fardeen Khan.F 작성.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기

