저장소
저장소 (Storage)
"영속화(persistence)", "메모리(memory)", "세션(sessions)" — 이 이름으로 불리는 문제들은 서로 다르고, 답도 달라요. 여기서 시작해 보세요.
| 원하는 것 | 사용할 것 | 위치 |
|---|---|---|
| 대화를 저장하고 나중에 이어서 하기 — 채팅 스레드, 지원 티켓, 어제를 기억하는 어시스턴트 | 메시지 히스토리를 직렬화해서 자신의 데이터베이스 칼럼에 저장 | Core |
| 저장·로드 코드를 직접 작성하지 않고, continue-and-fork를 공짜로 얻기 | StepPersistence |
Pydantic AI Harness |
| 에이전트가 한 대화 안에서뿐 아니라 대화를 넘어서 사람에 대해 배운 것을 기억하게 하기 | Memory |
Pydantic AI Harness |
| 도구 호출 중간에 프로세스가 죽어도 실행이 살아남아 정확히 멈춘 지점에서 재개되게 하기 | 영속 실행 | Core |
처음 두 행은 "내 에이전트에 메모리를 어떻게 주지?"라는 질문에 대한 답이기도 해요. 사람들이 흔히 말하는 메모리 대부분은, 에이전트가 진행 중인 대화에 대한 기억이 곧 메시지 히스토리라는 뜻이에요. 그걸 위한 별도의 메모리 시스템을 추가할 필요가 없어요. 히스토리를 저장하고 다시 넘겨주는 것이 바로 전체 메커니즘이죠. 메모리는 스레드보다 오래 살아남아야 할 때만 그 자체의 무언가가 돼요.
행들은 서로 조합돼요. 영속 엔진은 하나의 실행을 살려두고, StepPersistence는 각 실행이 무엇을 했는지 기록하며, 직렬화된 히스토리는 다음 실행에 넘겨주는 것이고, Memory는 스레드가 끝났을 때 남는 것이에요. 채팅 스레드를 저장하고 싶어서 영속 엔진을 찾는 것은 흔한 실수예요. 그 정도는 jsonb 칼럼이면 충분하죠.
출처: 문서
본문
대화를 직접 저장하기 (Storing a conversation yourself)
Pydantic AI는 의도적으로 당신의 데이터베이스에 대해 의견을 강요하지 않아요. 완전 충실도(full-fidelity) 직렬화 경계를 제공하고 스키마는 당신에게 맡기죠. 팀마다 여기서 고르는 선택이 프레임워크가 유용하게 예측할 수 있는 범위보다 다양하기 때문이에요. 히스토리를 어느 테이블에 걸지, 어떤 tenant 칼럼이 필요한지, 얼마나 오래 보관할지 같은 것들이요.
핵심 원시 타입은 ModelMessagesTypeAdapter인데, 메시지 히스토리를 JSON으로 왕복(round-trip)시켜요. 모델에 절대 전송되지 않는 필드(파트의 애플리케이션 전용 metadata)도 포함해서요. 그 필드는 Any로 타입이 지정되어 있어서 JSON 형태가 없는 값은 통과하면서 정규화돼요. tuple은 list로, datetime은 ISO 문자열로 다시 로드되죠. "왕복에서 살아남는 것" 노트가 경계 지점들을 다뤄요. 바이트를 jsonb 칼럼이나 그에 준하는 곳에 저장하세요. Pydantic AI가 메시지 파트를 추가해도 스키마 마이그레이션이 필요 없어요. 이전 버전이 직렬화한 히스토리도 여전히 역직렬화되기 때문이에요.
메시지만이 아니라 완료된 실행을 저장하려면 — 출력, 사용량, 대화 ID를 함께 보관하는 것 — 자신의 Pydantic 모델에 AgentRunResult를 올려놓고 그것을 직렬화하세요. 완전한 실행 결과 저장을 참고하세요.
conversation_id가 저장 키이며, 전체 목록을 다시 쓰기보다 각 실행의 new_messages()를 추가하는 방식으로 쓰면 각 쓰기가 턴에 비례하게 유지돼요. 세션 영속화가 이 패턴을 안내해요.
채팅 UI가 보낸 히스토리 저장하기 (Storing a history a chat UI sent you)
Vercel AI 또는 AG-UI의 프론트엔드는 자체 메시지 목록을 유지하며, 이를 서빙하는 어댑터는 요청이 없어도 양방향으로 변환해요. load_messages는 프로토콜의 메시지를 ModelMessage로 바꾸고, dump_messages는 다시 되돌려요.
storing_ui_history.py
from pydantic_ai.ui.vercel_ai import VercelAIAdapter
from pydantic_ai.ui.vercel_ai.request_types import TextUIPart, UIMessage
sent_by_the_browser = [
UIMessage(id='1', role='user', parts=[TextUIPart(text='Tell me a joke.')])
]
history = VercelAIAdapter.load_messages(sent_by_the_browser) # (1)
for_the_browser = VercelAIAdapter.dump_messages(history) # (2)
브라우저가 보낸 것을, 에이전트가 실행할 수 있는 히스토리로 — 그리고 저장할 형태로 바꾼 것.
브라우저가 돌려받는 것을, 데이터베이스에 들어가는 길이 아니라 경계에서 변환한 것.
Pydantic AI 쪽을 저장하고 경계에서 변환하세요. 프로토콜의 형태를 저장하지 말고요. 와이어 포맷에는 히스토리가 담는 모든 것을 넣을 자리가 없어요. 각각 무엇을 유지하고 버리는지는 Vercel AI 메시지 메타데이터와 AG-UI 왕복에서 파일 보존에 명시돼 있고, 버려지는 필드가 바로 다음 모델 요청이 필요로 하는 것들이에요.
클라이언트가 제공한 히스토리는 신뢰할 수 있는 상태가 아니에요
브라우저에서 로드한 히스토리라면 에이전트에 전달하기 전에 살균(sanitize)하세요. 신뢰할 수 없는 히스토리 로드와 클라이언트 제공 히스토리의 신뢰 경계를 참고하세요.
히스토리만으로는 담지 못하는 것 (What a history alone doesn't carry)
메시지는 겉보기보다 더 많은 것을 담아요. run_id와 conversation_id가 각 메시지에 찍히므로, 저장소에서 다시 로드한 대화도 자신의 북키핑 없이 Logfire에서 상관관계가 유지돼요. 각 실행의 스팬은 어느 쪽이든 그 실행 자체의 토큰 사용량을 보고해요.
메시지 바깥에 있는 것은 RunUsage예요. 어떤 메시지도 기록하지 않는 tool_calls를 포함한 대화의 누적 합계죠. 그것을 히스토리 옆에 저장하고, UsageLimits가 각 실행이 아니라 대화 전체를 예산에 넣어야 할 때 usage=로 함께 넘겨주세요. 넘겨도 트레이스에는 아무 변화가 없어요. 각 실행의 스팬은 어느 쪽이든 그 실행 자신의 토큰을 보고하므로, 대화의 지출은 실행들의 합이에요.
히스토리는 자신의 대화를 넘어서 닿지도 못해요. 어제의 스레드를 다시 재생해 에이전트에 연속성을 주는 것은, 한계에 부딪힐 때까지는 효과가 있어요. 프롬프트가 끝없이 커지고 모든 요청이 그 값을 치르며, 컴팩션이 당신이 기대했던 부분을 버리게 되죠. 대화를 넘어 기억하기는 다른 메커니즘이에요.
그 코드를 직접 작성하지 않기 (Not writing that code yourself)
StepPersistence는 이 패턴을 에이전트에 추가하는 능력으로 포장해서, 로드·저장 호출을 직접 작성하지 않게 해줘요. 인메모리, 파일, SQLite, MongoDB 백엔드가 동봉되어 있고, 그 저장소는 당신의 데이터베이스에 구현할 수 있는 프로토콜이에요.
메시지보다 더 많은 것을 기록해요. 각 경계에서 에이전트가 한 일을 담은 추가 전용(append-only) 이벤트 로그, 대화를 재개하거나 포크할 수 있는 이어질 수 있는 스냅샷, 그리고 크래시 후 부작용(side effect)이 실제로 발생했는지 알려주는 도구 효과 원장(ledger)까지요.
ConversationSearch는 자체 저장 없이 그 위에 구축돼요. StepPersistence가 이미 쓴 히스토리에 순위를 매기고, 모델에 이른 턴을 주문형으로 컨텍스트로 다시 끌어오는 도구를 주죠. 컴팩션이 버린 턴까지 포함해서요. 둘을 하나의 저장소 인스턴스에 짝지으면, 회상에 추가 쓰기 경로가 필요 없어요.
대화를 넘어 기억하기 (Remembering across conversations)
위의 모든 것은 대화 범위로 한정돼요. 에이전트가 대화 사이에 어떤 사람에 대해 아는 것 — 그들의 선호, 지난주 결정, 반복하지 않아도 될 교정 — 은 다른 키와 다른 수명을 가져요.
Memory가 바로 그것을 위한 능력이에요. 에이전트에 Markdown 파일 노트북을 주고, 에이전트가 자기 도구를 통해 쓰고 읽고 검색하게 해요. 그리고 전체 노트북이 아니라 제한된 발췌본을 각 요청에 넣죠. conversation_id가 아니라, 당신의 의존성(dependencies)에서 해석하는 네임스페이스(보통 사용자나 tenant ID)로 키를 삼아요. 그래서 바로 스레드보다 오래 살아남을 수 있는 거예요. 저장소도 영속적인 것들로, 파일 디렉터리, SQLite, PostgreSQL, 또는 당신이 데이터베이스에 구현하는 것을 써요.
StepPersistence가 쓰는 것이 아니라, 자체 저장소를 가진 별개의 능력으로 남아요. 버전이 있는 노트북과 추가 전용 실행 로그는 공통점이 거의 없으니까요. 하지만 둘은 같은 데이터베이스를 쓰므로, 에이전트의 노트를 대화 옆에 두는 것은 하나의 연결이자 백업 하나면 충분해요.
Anthropic은 API 자기 쪽에 메모리 도구를 노출해요. MemoryTool은 모델이 제공자가 정의한 도구 계약을 통해 메모리 파일 디렉터리를 구동하게 하고, 그 뒤의 저장소는 여전히 당신이 제공해야 해요.
메모리는 모델이 쓴 콘텐츠예요
에이전트가 스스로 남긴 노트는 이후 프롬프트에 다시 들어가요. 노트는 잘못될 수 있고, 에이전트가 대화하던 상대가 심어놓을 수도 있어요. Memory는 그것을 지시가 아니라 user-role 콘텐츠로 주입해 권위를 낮추지만, 그것은 단단한 프롬프트 인젝션 경계는 아니에요. 이 능력의 보안 및 출처 노트가 무엇을 보장하고 무엇을 보장하지 않는지 다뤄요.
제공자에게 맡기기 (Letting a provider hold it)
일부 제공자는 대화 상태를 자기 쪽에 두고 그것으로부터 이른 턴을 재구성해서, 각 요청이 새로운 것만 담도록 해요. OpenAI Responses API에서는 그게 openai_conversation_id이며, 영속 대화에서 다뤄져요.
자체 저장소와 저울질해 보세요. 그것은 한 제공자의 기능이고, OpenAI는 체인 안의 이전 입력 토큰도 계속 청구된다고 문서화하며, Zero Data Retention이 활성화된 조직에서는 사용할 수 없어요.
여기에 없는 것 (What isn't here)
Pydantic AI는 그래프 실행 상태를 체크포인트하지 않으므로, 하나의 실행 안에서 "반쯤 끝난 실행의 4단계로 되감고 거기서부터 재생" 같은 것은 없어요. 스냅샷은 노드 중간이 아니라 실행 사이의 확정된 경계에서 찍혀요. 실행 중에 크래시를 견뎌야 하는 실행이면, 그것이 바로 영속 실행이 담당하는 일이에요.