[Feat] LangGraph 체크포인터 배선 — 대화 멀티턴 지원#66
Conversation
- common/checkpoint/saver.py: database_url 있으면 AsyncPostgresSaver(setup 포함),
없으면 InMemorySaver 폴백. SQLAlchemy식 conn string(+psycopg)도 libpq로 정규화
- builder.py: build_graph(checkpointer) 추가 — compile(checkpointer=...)로 주입.
get_graph()는 lazy 폴백 유지
- main.py: lifespan에서 checkpointer를 long-lived로 열고 그래프 주입 빌드
- solve.py: ainvoke에 config={"configurable": {"thread_id": ...}} 전달
- 테스트: 팩토리 폴백, build_graph 체크포인터 부착, InMemorySaver 멀티턴
누적/격리/대조군, /solve thread_id config 전달. test_health 라이프스팬은
checkpointer 격리 추가
범위: 대화 멀티턴만. tool box 재현(도구 메시지 영속화)은 별도 이슈.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (2)
🚧 Files skipped from review as they are similar to previous changes (1)
📝 Walkthrough걷기LangGraph 체크포인터 배선을 통해 멀티턴 대화 지원을 구현합니다. Postgres(또는 메모리 폴백) 기반 상태 영속화, user_id 네임스페이싱된 thread_id config 전달, 턴 단위 크레딧 정산, 그리고 상태 누적을 검증하는 통합 테스트를 추가합니다. 변경 사항멀티턴 체크포인터 배선
Sequence Diagram(s)sequenceDiagram
participant Client
participant solve_endpoint
participant get_graph
participant CompiledGraph
participant GraphAinvoke
participant Checkpointer
participant Database
Client->>solve_endpoint: POST /api/v1/solve (state: user_id, thread_id)
solve_endpoint->>solve_endpoint: checkpoint_thread_id = f"{user_id}:{thread_id}"
solve_endpoint->>get_graph: get_graph()
get_graph-->>CompiledGraph: CompiledStateGraph
CompiledGraph->>GraphAinvoke: ainvoke(state, config={"configurable":{"thread_id":checkpoint_thread_id}})
GraphAinvoke->>Checkpointer: load/save state using checkpoint_thread_id
Checkpointer->>Database: DB persist / retrieve
GraphAinvoke-->>solve_endpoint: SSE events
solve_endpoint-->>Client: SSE stream
🎯 3 (Moderate) | ⏱️ ~25 minutes관련 가능성 있는 PR
제안 검토자
시
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
요약
체크포인터 배선과 thread_id config 전달로 멀티턴의 핵심 누락은 잘 맞춰졌습니다. 다만 스레드 식별자가 user_id와 묶이지 않아 타 사용자 대화에 접근할 여지가 있고, 턴이 바뀔 때마다 그래프 전체가 재실행되며 credit_log가 누적될 수 있습니다. Postgres 체크포인트 역직렬화 경고도 추후 strict 모드에서 깨질 수 있습니다.
[문제 1]
- 심각도: High
- 범주: 보안
- 근거:
solve.py에서 클라이언트가 준thread_id를 그대로 LangGraphconfigurable.thread_id로 쓰고, API 레이어에user_id소유권 검증이 없습니다. 체크포인트 키가 사용자와 무관한thread_id만으로 결정되므로, UUID를 알거나 유출된 경우 다른 사용자의 대화 히스토리를 이어 받거나 메시지를 덧붙일 수 있습니다. - 수정안: (1) 서버에서
thread_id를 발급·저장할 때user_id와 함께 매핑하고, 요청 시 소유권을 검증하거나, (2) LangGraph config에thread_id=f"{user_id}:{thread_id}"처럼 사용자 네임스페이스를 포함합니다. BFF/게이트웨이에서 인증한다면 그 경계와 정책을 코드·문서에 명시하는 것이 좋습니다.
[문제 2]
- 심각도: Medium
- 범주: 정확성
- 근거: 체크포인트가 있어도
ainvoke는 매 요청마다START부터 전체 그래프를 다시 탑니다.ProovyState.credit_log는operator.add리듀서라 이전 턴 항목이 유지되고,router/planner등이 턴마다 다시 실행되며 항목이 추가됩니다.credit_settler는sum(state.credit_log)로 스레드 누적 합을 매번보냅니다. PR 설명상 “대화 멀티턴” 범위라도, 같은thread_id로 후속 질문 시 과금·정산이 의도와 다를 수 있습니다. - 수정안: 턴 단위 정산이 목표면 (a) 새 턴 시작 시
credit_log/total_credit_cost를 초기화하는 입력 전략, (b)credit_settler가 직전 턴 delta만 합산, (c) 또는Command(resume=...)등으로 재개 지점을 분리하는 방안을 검토하세요. 의도적 누적이면 API/SSE 스펙에 “스레드 누적 과금”임을 명시하는 것이 좋습니다.
[문제 3]
- 심각도: Medium
- 범주: 유지보수성
- 근거: Postgres 체크포인트에
PlanStep등 커스텀 Pydantic 타입을 저장·복원할 때Deserializing unregistered type proovy_agent.graph.state.PlanStep경고가 발생합니다. LangGraph는LANGGRAPH_STRICT_MSGPACK=true또는 향후 버전에서 미등록 타입 역직렬화를 차단할 수 있습니다. - 수정안:
StateGraph.compile()시 LangGraph 권장 방식으로 serde allowlist에PlanStep/CreditEntry모듈을 등록하거나, 체크포인트에 넣는 상태는 plain dict/TypedDict로 제한합니다. PR 머지 전.env+ Postgres로 한 번 restore smoke test를 권장합니다.
[문제 4]
- 심각도: Medium
- 범주: 성능 / 운영
- 근거:
database_url이 비어 있으면InMemorySaver로 폴백합니다. Uvicorn 다중 워커/다중 인스턴스에서는 워커·인스턴스마다 메모리가 분리되어 같은thread_id라도 요청이 다른 프로세스로 가면 멀티턴이 깨집니다. 프로덕션에서 URL 누락 시에도 기동은 되지만 데이터가 조용히 사라집니다. - 수정안: 프로덕션(
debug=False등)에서는database_url미설정 시 기동 실패(fail-fast)를 고려하거나, 폴백 시ERROR로그 + 메트릭 알람을 추가하세요. 수평 확장 환경에서는 Postgres 체크포인터가 필수임을 배포 문서에 명시하면 좋습니다.
[문제 5]
- 심각도: Low
- 범주: 정확성
- 근거:
_to_libpq는postgresql+psycopg://,postgresql+asyncpg://두 접두사만 치환합니다.postgres://,postgresql+psycopg2://등 다른 connection string 형식은 그대로AsyncPostgresSaver에 전달되어 연결 실패할 수 있습니다. - 수정안:
urllib.parse.urlparse로 scheme을 정규화하거나, 지원 형식 목록을 문서화하고 잘못된 URL에 대해 명확한 예외 메시지를 던지세요.
[문제 6]
- 심각도: Low
- 범주: 테스트
- 근거:
test_multiturn.py는 미니messages그래프로 체크포인터 메커니즘만 검증합니다. 실제ProovyState+ 전체 그래프에서 메시지 누적·plan/credit_log병합·라우팅 재실행 동작은 자동 테스트가 없습니다 (PR 본문의 수동 e2e와 일치). - 수정안: LLM/샌드박스를 mock한 통합 테스트 1건(동일
thread_id2회 호출 후messages길이·credit_log정책 assert)을 추가하면 회귀 방지에 도움이 됩니다.
Sent by Cursor Automation: Chowon Reviewer
- [보안] solve.py: 체크포인트 thread_id를 user_id로 네임스페이스
(f"{user_id}:{thread_id}") — 타 사용자 thread_id 접근 차단. user_id
인증은 상위 게이트웨이/BFF 책임으로 명시
- [운영] main.py + saver.py: 프로덕션(debug=False)에서 database_url 미설정 시
InMemory 폴백 대신 fail-fast (allow_memory_fallback=settings.debug).
조용한 멀티턴 소실 방지
- [유지보수] saver.py: AsyncPostgresSaver에 JsonPlusSerializer allowlist 주입
(PlanStep/CreditEntry) — "unregistered type" 경고 및 향후 strict 모드 차단 대비
- [정확성] saver.py: _to_libpq를 urlsplit 기반으로 강화 — postgres://,
+psycopg2 등 다양한 scheme 정규화
- 테스트: 네임스페이스 config, fail-fast, scheme 정규화 파라미터화 추가
미해결(결정 필요): credit_log 턴 누적 정산 정책, 전체 그래프 통합 테스트
- ProovyState에 settled_count(operator.add reducer) 추가. 전체 state 입력이 매 턴 LastValue 필드를 기본값으로 덮어써도 reducer라 정산 경계가 살아남음 - credit_settler: credit_log[settled_count:]만 정산해 이번 턴 비용(turn_cost)만 SSE/메시지로 보냄. total_credit_cost는 스레드 누적 합계 유지 - 통합 테스트: 실제 ProovyState + credit_settler + InMemorySaver로 동일 thread_id 2회 호출 시 turn2가 누적(10)이 아닌 턴 비용(5)만 정산하는지 검증 리뷰 #2(과금 정확성) 반영. 결정: 턴 단위 정산.
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (2)
src/proovy_agent/app/main.py (1)
19-28: ⚡ Quick win
open_checkpointer진입 실패 시 Daytona 클라이언트가 정리되지 않습니다.
init_daytona_client()는async with open_checkpointer(...)바깥에서 호출되지만,close_daytona_client()는async with내부finally에 있습니다. 따라서open_checkpointer진입 단계에서 예외가 발생하면(프로덕션 fail-fastRuntimeError또는 Postgressetup()실패 등)finally가 실행되지 않아 이미 초기화된 Daytona 클라이언트가 정리되지 않고 누수됩니다. 기동 실패가 반복되면 샌드박스 리소스가 누적될 수 있습니다.
finally를 체크포인터 컨텍스트 바깥으로 끌어올려 Daytona 초기화/정리를 짝맞추는 것을 권장합니다.♻️ 정리 순서 보장 리팩터
await init_daytona_client() - async with open_checkpointer( - settings.database_url, - allow_memory_fallback=settings.debug, - ) as checkpointer: - build_graph(checkpointer) - try: - yield - finally: - await close_daytona_client() + try: + async with open_checkpointer( + settings.database_url, + allow_memory_fallback=settings.debug, + ) as checkpointer: + build_graph(checkpointer) + yield + finally: + await close_daytona_client()🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/proovy_agent/app/main.py` around lines 19 - 28, The Daytona client is initialized by init_daytona_client() before entering async with open_checkpointer(...), but close_daytona_client() is placed inside that context's finally block so it won't run if open_checkpointer() raises; move the cleanup so initialization and cleanup are paired: call await init_daytona_client() as before, then wrap the entire open_checkpointer(...) block (including build_graph(checkpointer) and yield) in a try/finally that calls await close_daytona_client() in the finally, ensuring close_daytona_client() always runs even if open_checkpointer() fails; adjust the scope of the try/finally around open_checkpointer and reference the existing functions init_daytona_client, open_checkpointer, build_graph, and close_daytona_client.src/proovy_agent/common/checkpoint/saver.py (1)
56-61: (선택) 체크포인트 저장 데이터의 미사용 시 암호화 고려.체크포인트에는 대화
messages등 사용자 입력(잠재적 PII)이 영속화됩니다. 민감 데이터 보호가 요구되는 환경이라면EncryptedSerializer로serde를 래핑해 at-rest 암호화를 적용하는 방안을 검토할 수 있습니다. 본 PR 범위 밖일 수 있어 후속 과제로 남겨두셔도 됩니다.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/proovy_agent/common/checkpoint/saver.py` around lines 56 - 61, The checkpoint saver persists user messages which may contain PII; wrap the serde passed into AsyncPostgresSaver.from_conn_string with an at-rest encryption wrapper (e.g., EncryptedSerializer) when encryption is enabled to ensure stored checkpoints are encrypted. Modify the logic around AsyncPostgresSaver.from_conn_string (and the _to_libpq/database_url usage) to accept a wrapped serde: if an encryption flag/config is set, replace serde with EncryptedSerializer(serde, key_provider) before calling AsyncPostgresSaver.from_conn_string; otherwise pass the original serde. Ensure the EncryptedSerializer is imported/available and that key management is plumbed via configuration so AsyncPostgresSaver.setup() continues to work transparently.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/proovy_agent/app/api/v1/solve.py`:
- Around line 44-50: The current checkpoint key built as checkpoint_thread_id =
f"{state.user_id}:{state.thread_id}" can collide if user_id contains ":"; update
this by either adding a validation on SolveRequest.user_id (in
src/proovy_agent/app/schemas/solve.py) to forbid ":" via a regex/validator, or
by encoding/hashing the combined key before use (replace the f-string creation
of checkpoint_thread_id in solve.py to use a safe transform such as
base64/url-safe encode or a deterministic hash of
f"{state.user_id}:{state.thread_id}"), and then pass that safe
checkpoint_thread_id into get_graph().ainvoke config; ensure references use
state.user_id and state.thread_id so the source of the values is clear.
---
Nitpick comments:
In `@src/proovy_agent/app/main.py`:
- Around line 19-28: The Daytona client is initialized by init_daytona_client()
before entering async with open_checkpointer(...), but close_daytona_client() is
placed inside that context's finally block so it won't run if
open_checkpointer() raises; move the cleanup so initialization and cleanup are
paired: call await init_daytona_client() as before, then wrap the entire
open_checkpointer(...) block (including build_graph(checkpointer) and yield) in
a try/finally that calls await close_daytona_client() in the finally, ensuring
close_daytona_client() always runs even if open_checkpointer() fails; adjust the
scope of the try/finally around open_checkpointer and reference the existing
functions init_daytona_client, open_checkpointer, build_graph, and
close_daytona_client.
In `@src/proovy_agent/common/checkpoint/saver.py`:
- Around line 56-61: The checkpoint saver persists user messages which may
contain PII; wrap the serde passed into AsyncPostgresSaver.from_conn_string with
an at-rest encryption wrapper (e.g., EncryptedSerializer) when encryption is
enabled to ensure stored checkpoints are encrypted. Modify the logic around
AsyncPostgresSaver.from_conn_string (and the _to_libpq/database_url usage) to
accept a wrapped serde: if an encryption flag/config is set, replace serde with
EncryptedSerializer(serde, key_provider) before calling
AsyncPostgresSaver.from_conn_string; otherwise pass the original serde. Ensure
the EncryptedSerializer is imported/available and that key management is plumbed
via configuration so AsyncPostgresSaver.setup() continues to work transparently.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 010af3af-e510-46a5-838f-119af4350b46
📒 Files selected for processing (10)
src/proovy_agent/app/api/v1/solve.pysrc/proovy_agent/app/main.pysrc/proovy_agent/common/checkpoint/saver.pysrc/proovy_agent/graph/builder.pysrc/proovy_agent/graph/nodes/credit_settler.pysrc/proovy_agent/graph/state.pytests/app/test_health.pytests/app/test_solve_endpoint.pytests/common/checkpoint/test_saver.pytests/graph/test_multiturn.py
코드 리뷰 반영. 체크포인트 키 f"{user_id}:{thread_id}"에서 user_id가 ':'를
포함하면 (a:b, c)와 (a, b:c)가 동일 키로 충돌해 타 사용자 대화에 접근할 수 있다.
SolveRequest.user_id에 ':' 금지 validator를 추가해 첫 ':'가 항상 경계를 명확히
가르도록 한다 (thread_id는 ':' 포함해도 충돌 불가).
gaeunee2
left a comment
There was a problem hiding this comment.
추후에 DB 도커 방식으로 변경해주세오~


📌 관련 이슈
🏷️ PR 타입
📝 작업 내용
common/checkpoint/saver.py추가 —open_checkpointer(database_url): 값 있으면AsyncPostgresSaver(최초setup()), 없으면InMemorySaver폴백.postgresql+psycopg://같은 SQLAlchemy식 URL을 libpq 형식으로 정규화builder.py—build_graph(checkpointer)추가해compile(checkpointer=...)로 주입. 기존get_graph()는 lazy 폴백 유지main.pylifespan — checkpointer를 앱 수명 동안 열고 그래프를 주입 빌드solve.py—ainvoke(state, config={"configurable": {"thread_id": state.thread_id}})전달📸 스크린샷
✅ 체크리스트
📎 기타 참고사항
database_url은 libpq 형식(postgresql://...) 권장. Supabase 콘솔의 connection string을 그대로 넣으면 됨.Summary by CodeRabbit
새로운 기능
개선 사항
버그 수정 / 검증
테스트