dev.to

Your LLM has no memory. Your application had better have one.

LLM에는 기억이 없습니다. 애플리케이션에는 반드시 기억이 있어야 합니다

LLM API는 요청마다 독립적으로 동작하므로 대화 기록을 다시 보내는 방식만으로는 장기 실행 워크플로를 안정적으로 관리하기 어렵습니다. 글은 상태를 애플리케이션이 구조화해 저장하고, LLM은 현재 상태를 바탕으로 다음 행동을 제안하는 무상태 처리기로 다뤄야 한다고 설명합니다.

AI 요약

LLM 튜토리얼은 대개 메시지 목록을 보내고 응답을 받은 뒤, 응답을 목록에 붙여 다시 보내는 형태로 끝납니다. 짧은 데모에서는 잘 작동하지만, 이 방식은 상태 관리가 아니라 상태 관리의 부재를 대화 기록으로 포장한 것에 가깝습니다. LLM 플랫폼의 API는 무상태로 동작합니다. 요청마다 이전 요청과 독립적이며, 모델은 대화를 기억하지 않고 애플리케이션이 넘긴 내용을 매번 다시 읽습니다. 반면 고객 지원, 코드 마이그레이션, 승인 절차, 다단계 에이전트 같은 실제 작업은 중단과 재시작, 재시도와 장애를 견뎌야 하는 상태ful 프로세스입니다.

대화 기록을 다시 보내는 방식의 한계

대화가 열 번 정도 이어질 때는 전체 transcript를 매번 보내도 문제가 드러나지 않습니다. 백 턴으로 늘어나면 같은 토큰을 반복해서 지불하고 지연 시간이 증가합니다. 결국 가장 곤란한 시점에 context limit에 도달합니다.

오래된 대화를 요약해 크기를 줄이는 방법도 완전한 해법이 아닙니다. 요약문은 모델이 중요하다고 판단한 내용만 남긴 결과이기 때문입니다. 애플리케이션은 요약에서 무엇이 빠졌는지 검증하기 어렵습니다. 글은 환불 workflow를 예로 듭니다. 네 번째 턴에서 사용자가 주문 ID를 확인했는데, 서른 번째 턴의 요약에는 “사용자가 최근 주문 환불을 원한다”는 내용만 남을 수 있습니다. 주문 ID가 사라지면 모델은 그럴듯하지만 잘못된 주문을 선택합니다.

애플리케이션이 상태의 원본을 소유해야 합니다

프로세스가 의존하는 사실은 transcript가 아니라 코드가 소유하는 구조화된 객체에 넣어야 합니다. 예시로 제시한 RefundState에는 order_id, reason, amount_confirmed, step이 들어갑니다. 현재 workflow 위치도 collect_order처럼 명시합니다. 프롬프트를 만들 때는 이 상태와 최근에 필요한 여섯 개 메시지만 모델에 전달합니다. 그러면 모델이 기억을 보관하는 것이 아니라, 애플리케이션이 저장한 상태를 읽고 다음 행동을 제안합니다.

이 상태는 구조화되고 영속적이며 버전을 관리할 수 있고 장애 뒤 복구할 수 있어야 합니다. transcript는 사람이 읽거나 디버깅하는 로그로 남기되, 사실을 판단하는 원본으로 삼지 않습니다. 글은 이 관계를 “LLM workflow는 그 위에 언어 모델을 얹은 state machine”이라고 정리합니다.

중단과 재시작을 상태로 관리하기

사용자는 장시간 실행되는 작업이 끝날 때까지 기다리지 않습니다. 탭을 닫거나 마음을 바꾸고, tool call이 실행 중인 동안 “사실 취소해 달라”고 보낼 수도 있습니다. 마지막으로 모델이 말한 내용만 진행 상황이라면 어떤 단계가 끝났는지, 어떤 단계를 중단해도 되는지, 반쯤 실행된 작업을 되돌려야 하는지 판단하기 어렵습니다.

따라서 실행별로 단계를 저장하고 PENDING, RUNNING, DONE, CANCELLED 같은 상태를 코드가 갱신해야 합니다. 중단 요청이 오면 애플리케이션은 run record를 읽고 현재 위치에서 취소가 무엇을 뜻하는지 결정합니다. 모델에게 자신이 어디까지 처리했는지 추측하게 만들지 않습니다.

재시도에는 멱등성과 이벤트 로그가 필요합니다

재시도는 무상태 설계의 약점을 가장 분명하게 드러냅니다. tool이 이미 실행됐지만 응답을 받기 전에 요청이 timeout되면, 같은 요청을 다시 보내면서 카드 결제나 이메일 발송, 티켓 생성이 두 번 일어날 수 있습니다.

글은 이 문제를 기존 distributed systems의 문제로 보고 idempotency key, append-only event log, replay를 해법으로 제시합니다. 예시 구현은 run_id와 step_id를 조합해 키를 만들고, 저장소에 결과가 있으면 tool을 다시 실행하지 않습니다. 결과가 없을 때만 tool을 호출하고 실행 결과를 저장합니다.

다만 커뮤니티에서는 이 구현에도 빈틈이 있다고 지적합니다. 외부 tool 실행 직후 결과를 저장하기 전에 worker가 죽으면, 재시도 과정에서 같은 부작용이 다시 발생할 수 있습니다. 외부 API가 idempotency key를 지원한다면 run 또는 step ID를 전달해 원래 결과를 돌려받게 해야 합니다. 지원하지 않는 API라면 실행과 기록 사이의 창을 줄이거나, 사후 대조 작업으로 문제를 정리해야 합니다.

Provider의 thread 객체를 사용할 때 물어볼 질문

일부 LLM provider는 대화나 thread 객체로 history를 관리합니다. 단순 채팅이나 prototype에는 편리하지만, 업무 프로세스의 정확성을 맡기기 전에 몇 가지를 확인해야 합니다. 애플리케이션 코드가 판단할 수 있는 형태로 상태를 조회하고 내보낼 수 있는지, 장애 뒤 실행을 재개하거나 replay하고 rollback할 수 있는지, 다른 모델이나 provider로 바꿔도 프로세스를 잃지 않는지, 저장된 history와 실제 시스템 상태가 다를 때 누가 책임지는지 확인해야 합니다.

답이 불충분하다면 상태를 자체 저장소에 두고 provider가 관리하는 history는 cache 정도로만 취급해야 합니다. 댓글에서는 상태 객체를 저장하고 replay하면 workflow schema가 바뀌어도 이전 실행을 복구할 수 있도록 version 필드와 migration 함수를 둘 수 있다는 설명이 이어집니다. 상태가 오래 실행되는 workflow보다 먼저 바뀌지 않도록, 저장된 이전 상태를 로드할 때 애플리케이션 코드로 최신 schema로 변환하는 방식입니다.

상태와 context를 분리하면 테스트 방식도 달라집니다

커뮤니티에서는 prompt와 context를 애플리케이션 상태에서 파생한 view로 보자는 의견이 나왔습니다. 원본 상태가 같다면 현재 단계에 맞춰 다른 모델이나 다른 prompt 버전용 context를 다시 만들 수 있습니다. 그러면 문제가 저장된 데이터에 있는지, 모델에 잘못된 view를 전달했는지 구분하기 쉽습니다.

과거 상태를 보존하면 모델 교체나 prompt 수정 전에 historical state를 새 모델에 넣어 결과를 비교하는 offline evaluation도 가능합니다. 수백 개의 과거 상태에서 새 모델이 제안한 행동을 기존 결과와 diff하면 됩니다. 댓글에서는 이를 prompt의 regression testing 또는 backtesting으로 설명합니다. 반대로 과거 기록이 요약된 transcript뿐이면 같은 시점의 입력을 정확히 재구성하기 어렵습니다.

또 다른 반응은 요약 과정에서 최종 결정만 남고 거부한 선택지와 거부 이유가 사라진다는 점을 지적합니다. 그러면 모델이 열 턴 뒤 이미 거부된 경로를 다시 제안할 수 있습니다. 장기 개발 프로젝트에서도 코드, commit, 문서, 대화, agent session에 맥락이 흩어지는 문제가 비슷하게 나타납니다. 댓글은 Architecture Decision Record가 사람과 AI agent를 위해 결정의 이유를 중앙에 남기는 구조화된 기록이 될 수 있다고 설명합니다.

dev.to 반응

  • @max_quimby — 환불 예시는 미묘한 실패를 정확히 보여줍니다. 요약은 무엇이 중요했는지에 대한 모델의 의견으로 사실을 조용히 바꾸고, 그 아래 시스템에서는 이를 검증할 방법이 없습니다. 저희도 다단계 pipeline에서 비슷한 일을 겪었습니다. 네 번째 턴에 확인한 ID가 서른 번째 턴의 요약에서 빠졌고, 모델은 그럴듯하지만 틀린 ID를 아무렇지 않게 골랐습니다. 저희가 도달한 해법도 RefundState dataclass였습니다. 프로세스에 중요한 사실은 코드가 소유하는 typed object에 넣고, transcript는 원본이 아니라 “최근 맥락” 정도로 낮춰 취급합니다. 여기에 하나 더 권하고 싶습니다. 저장하고 replay할 대상을 메시지 목록이 아니라 상태 객체로 만드세요. 실행이 중단됐다가 재개될 때 durable state object에서 다시 만들면 결정적이지만, 다시 요약한 transcript에서 복원하면 그렇지 않습니다. “상태가 없는 루프를 기능처럼 꾸몄다”는 표현이 데모가 왜 turn 100까지는 버티다가 production에서 무너지는지 정확히 설명합니다. workflow가 자체 field 정의보다 오래 살아남는다면 state schema도 version을 관리하는지 궁금합니다.
    • @cyclopt_dimitrisk — 정확한 지적입니다. 지저분하거나 다시 요약한 transcript에서 복원하려 하지 말고 durable state object에서 다시 만드는 편이 debugging과 recovery를 훨씬 깔끔하게 만듭니다. schema versioning에 답하자면, 반드시 version을 관리해야 합니다. 보통 database migration이나 event schema를 다루는 방식과 같습니다. 상태 객체에 version field를 넣고, 예전에 저장된 상태를 로드할 때 애플리케이션 코드의 간단한 migration function으로 최신 상태로 올립니다. 처음에는 boilerplate가 조금 늘지만, 실행 중인 장기 workflow가 남아 있는 상태에서 workflow를 배포할 때 큰 도움이 됩니다.
  • @edwardsinclair — 메시지 history를 애플리케이션 상태로 취급하는 방식은 demo에서는 작동하지만 retry, failure, branching workflow, long-running agent에서는 무너집니다. 명시적인 상태 관리가 이런 시스템을 production-ready하게 만듭니다.
    • @cyclopt_dimitrisk — 정확합니다. 결국 LLM 애플리케이션을 마법처럼 다루지 않고 실제 software engineering으로 다루는 문제입니다. prompt를 신비한 black box로 보지 않고 database 위에 놓인 interface나 rendering layer로 보면, 표준 engineering practice가 자연스럽게 따라옵니다. 빠른 chat demo를 만드는 일은 재미있지만, 사용자가 실행 중에 노트북을 닫으면 어떻게 되는지 생각해야 할 때부터 진짜 작업이 시작됩니다. production에서 신뢰할 수 있는 시스템을 만들려면 명시적인 상태 관리가 유일한 방법입니다.
  • @compoundlabs — save_result 코드에는 crash window가 남아 있습니다. tool이 commit된 뒤 worker가 그 결과를 쓰기 전에 죽으면 retry가 tool을 다시 실행할 수 있습니다. 이 간격을 없애려면 side effect와 idempotency record가 하나의 durable boundary를 공유해야 합니다. 많은 API는 이를 제공하지 않습니다.
    • @cyclopt_dimitrisk — 전형적인 distributed systems 함정을 짚었습니다. side effect를 실행한 뒤 확인 결과를 저장하기 전의 간격을 닫기는 어렵습니다. 특히 외부 third-party API가 idempotency key를 지원하지 않으면 더 어렵습니다. 외부 API가 client-side idempotency key를 지원한다면 고유한 run 또는 step ID를 직접 전달합니다. 그러면 worker가 로컬 결과를 저장하기 전에 죽어도 retry된 API 호출이 새 결제를 만들지 않고 원래 결과를 안전하게 돌려줍니다. 지원하지 않는다면 그 간격을 최대한 줄이거나, 뒤에서 reconciliation job을 만들어 문제를 정리해야 합니다. 정상 경로 코드가 이런 까다로운 예외를 숨긴다는 점을 잘 보여줍니다.
  • @glenallen — durable state와 model context를 분리하는 부분이 특히 중요합니다. IT Path Solutions에서는 context를 상태가 조용히 쌓이는 또 다른 장소가 아니라 애플리케이션 상태에서 파생된 view로 다루는 편이 좋다는 사실을 확인했습니다. 같은 workflow를 다른 모델로 재개하거나 schema 변경 뒤, partial failure 뒤에 재개해야 할 때 이 구분이 유용합니다. 애플리케이션 상태가 권위 있는 원본으로 남고, prompt는 현재 단계에 필요한 형태로 다시 만들면 됩니다. debugging도 쉬워집니다. 저장된 상태가 잘못됐는지, 아니면 모델이 올바른 상태의 잘못된 view를 받았는지 물어볼 수 있기 때문입니다. 안정적인 장기 agent workflow에는 이 경계가 필수처럼 느껴집니다.
    • @cyclopt_dimitrisk — “context는 애플리케이션 상태에서 파생된 view”라는 표현이 정확합니다. prompt를 database에서 만든 여러 view 중 하나로 보면 모든 것이 훨씬 깔끔해집니다. 다른 모델로 바꾸거나 schema를 조정해야 해도 전체 state logic을 다시 만들 필요 없이 해당 view를 렌더링하는 방식만 바꾸면 됩니다. debugging에 관한 지적도 맞습니다. 거대하고 지저분한 chat transcript를 보며 변수가 어디서 잘못됐는지 찾는 것만큼 괴로운 일은 없습니다. 깨끗한 database record를 보고 데이터 자체가 잘못됐는지, 모델이 prompt를 잘못 해석했는지 바로 알면 많은 시간을 아낄 수 있습니다.
    • @glenallen — 그렇다면 state reconstruction도 아키텍처의 중요한 부분이 됩니다. context가 단순한 derived view라면 같은 권위 있는 상태에서 다시 만들고, 특정 시점에 다른 모델이나 prompt version이 무엇을 보게 되는지 비교할 수 있습니다. 원래 transcript에 전적으로 의존하지 않고 agent 동작을 재현하는 훨씬 깔끔한 방법입니다. model migration도 안전해집니다. production에 넣기 전에 과거 상태를 대상으로 새로운 context rendering을 테스트할 수 있기 때문입니다.
    • @cyclopt_dimitrisk — 훌륭한 확장입니다. 말씀하신 방식은 사실상 LLM prompt를 위한 regression testing 또는 backtesting입니다. raw application state를 저장하면 과거 상태를 새 모델이나 수정한 prompt에 넣고 제안된 행동을 비교하는 offline evaluation을 실행할 수 있습니다. 과거 기록이 납작해진 chat transcript뿐이라면 이런 작업은 불가능합니다. model migration을 추측이 아니라 일반적인 software engineering처럼 다룰 수 있습니다. production code를 한 줄도 바꾸기 전에 수백 개의 과거 상태에서 새 모델의 출력을 diff할 수 있습니다. prompt engineering이 감에 의존하는 작업에서 결정적이고 측정 가능한 작업으로 바뀝니다.
  • @hannune — 저희는 retry bug가 글의 세 번째 항목과 똑같은 일을 일으킨 뒤 자체 append-only log를 만들었습니다. order ID 사례도 남의 일 같지 않았습니다. turn 20쯤 요약에서 특정 account number가 사라졌고, 모델이 계속 잘못된 값을 고르는 이유를 한동안 debugging했습니다. provider thread object가 실패했는지 확인하는 질문은 “interrupt 뒤 step 3에서 replay할 수 있는가?”였습니다. state machine이라는 표현은 이 문제를 처음 접하는 사람에게 가장 깔끔한 설명이라고 생각합니다. conversation은 machine에 넣는 입력일 뿐, 현재 위치를 기록한 장부가 아닙니다.
    • @cyclopt_dimitrisk — 아픈 경험이지만 바로 이런 이유 때문에 이 설계가 필요합니다. turn 20쯤 account number 같은 중요한 데이터가 사라지는 일은 전형적인 summarization 함정입니다. 기본 테스트에서는 늘 완벽하게 작동하다가 실제 사용자가 자연스럽게 대화하면 production에서 바로 무너집니다. “step 3에서 replay할 수 있는가”라는 테스트가 결국 결정 기준입니다. 현실에는 network drop, timeout, 중간에 마음을 바꾸는 사용자가 늘 있습니다. 상태가 provider의 black-box thread에 갇히면 이런 예외를 제대로 처리할 수 없습니다. append-only log를 선택한 것은 이를 해결하고 시스템을 견고하게 만드는 좋은 방법입니다.
  • @suraj09 — “모델이 기억한다”와 “애플리케이션이 상태를 소유한다”를 구분한 점이 중요합니다. 특히 요약이 무엇이 중요했는지에 대한 모델의 의견이 된다는 부분이 좋았습니다. history에 결정, 제약, 의도적으로 거부한 내용이 들어가면 문제가 더 커질 것 같습니다. 상태는 올바르게 저장됐지만 유용한 engineering context가 commit, 문서, 대화, agent session에 흩어지는 장기 개발 프로젝트에서도 비슷한 문제가 생긴다고 보시나요?
    • @cyclopt_dimitrisk — 거부한 선택지가 사라지는 문제를 정확히 짚었습니다. 모델이 요약하면 대개 happy path나 최종 결정에 집중하고, 무엇을 왜 버렸는지는 지워 버립니다. 이런 부정적 맥락이 없으면 모델은 이전의 실패나 제약을 기록에서 찾지 못해 열 턴 뒤 똑같이 거부된 경로를 제안할 가능성이 큽니다. 장기 개발 프로젝트도 정확히 비슷합니다. codebase가 상태 자체라 해도 중요한 engineering context는 chat app, pull request, design doc에 흩어집니다. 그래서 human team에는 Architecture Decision Record 같은 도구가 유용합니다. ADR은 LLM의 structured state object와 같은 역할을 하며, “왜”를 한곳에 기록해 미래의 개발자와 AI agent가 과거 대화의 조각을 맞추지 않게 합니다.

원문: dev.to / 번역·요약: Trawling