LangGraph + Pi Agent -1-
LangGraph 와 Pi Agent 의 인터페이스를 엮어볼까 싶어서 과정을 정리해 본다.
mkdir lang-harness
# 현재 디렉터리를 LangGraph 하네스 프로젝트로 만들기
uv init --bare
# 필요한 패키지 추가
uv add langgraph langgraph-checkpoint-sqlite
# 설치 확인
uv run python -c "import langgraph; print('ok')"LangGraph 개념
LangGraph 는 LangGraph는 이름 그대로 Node와 Edge로 구성된 Graph 위에서 상태(State)를 관리하며 실행 흐름을 제어하는 오케스트레이션 (작업들을 어떤 순서와 조건으로 실행할지를 관리하는 역할) 런타임이다.
현재 [상태] 확인
↓ ← 엣지 (다음에 어디로 이동할지 결정하는 실행 규칙)
[노드] 실행
↓ ← 엣지
[상태] 변경
↓ ← 엣지
다음 [노드] 결정
↓ ← 엣지
[상태] 저장Graph는 전체 작업 흐름을 이야기한다
위와 같이 LangGraph는 State, Node, Edge를 조합하여 반복과 조건 분기가 있는 실행 흐름을 만든다.
LangGraph는 특히 다음과 같은 작업에 적합하다.
- 중간에 사용자 승인이 필요한 작업
- 실패하면 이전 단계로 돌아가는 작업
- 프로세스가 종료되어도 이어서 실행할 작업
- 단계마다 다른 입력을 사용해야 하는 작업위 내용을 보면 코딩 하네스를 만들어도 좋을 것 같은 생각이 든다. 실제로 LangGraph 를 기반으로 만든 하네스가 있다. (deepagents)
StateGraph
compile() 하지 않은 상태의 그래프 정의를 의미한다. 아래 코드 처럼 builder 를 선언하고 node 및 edge 를 정의할 수 있게 한다.
builder = StateGraph(CodingState)
builder.add_node("plan", plan_node)
builder.add_node("approve", approval_node)
builder.add_edge(START, "plan")
builder.add_edge("plan", "approve")
graph = builder.compile()Checkpointer와 Thread
Checkpointer는 Graph의 State를 실행 단계별로 저장하며 각 작업은 thread_id로 구분한다
task-001
→ 인증 오류 수정 작업
task-002
→ 결제 테스트 수정 작업Checkpointer를 연결하면 Graph 실행 단계마다 State 스냅샷이 저장되는 기능을 하고 thread_id는 체크포인트를 저장하고 다시 불러오는 기본 식별자로 사용할 수 있다.
Interrupt
interrupt()는 노드 실행을 중단하고 외부 입력을 기다리게 한다.
def approval_node(state):
# 1. 현재 State에서 계획을 읽는다.
plan = state["plan"]
# 2. 사용자 입력을 기다리기 위해 실행을 중단한다.
# 이 시점에 LangGraph 런타임이 checkpointer를 통해
# 현재 State와 실행 위치를 저장한다.
response = interrupt({
"type": "plan_approval",
"plan": plan,
})
# 3. 재개되면 사용자 입력이 response에 들어온다.
# 노드가 반환한 값은 기존 State에 병합된다.
return {
"approval_result": response,
}위 2번에서 현재 상태를 저장 하는 부분이 동작 하려면 이미 Graph가 체크포인터와 같이 컴파일 되어 있는 상태여야 한다.
graph = builder.compile(
checkpointer=checkpointer
)하네스와 개념 연결
재미삼아 보는 김에 코딩 하네스를 만든다고 가정하면 아래와 같이 구성 가능하겠다. 여직까지 다 나온 개념들이다.
CodingState = State
plan_node
approval_node
implement_node
verify_node
= Nodes
계획 → 승인
구현 → 검증
= Edges
승인 결과에 따른 분기
검증 결과에 따른 분기
= Conditional Edges
SQLiteSaver
= Checkpointer
작업 ID
= thread_id
계획 승인 대기
= interrupt()연습용 프로젝트 구조 생성
lang-harness/
├── .venv/ # uv가 생성하는 가상환경, Git 제외
├── src/
│ └── coding_harness/
│ └── __init__.py
├── runtime/
│ ├── checkpoints/
│ │ └── .gitignore
│ └── logs/
│ └── .gitignore
├── tests/
│ └── __init__.py
├── .gitignore
├── .python-version # 선택 사항
├── pyproject.toml
└── uv.lockCodingState 정의
State 를 정의해야 하는데 주제에 맞게 라고 정의를 할 것이고 이는 코딩 작업이 현재 어디까지 진행됐는지를 저장하는 구조이다.
각 노드는 State 를 읽고 자신이 처리한 결과만 반환하고 LangGraph 는 이를 기존 State 에 반영한다.
상태값은 다음과 같이 정의 해보자
pending: 대기approve: 승인revise: 계획 변경cancel: 작업 취소
src/coding_harness/state.py
from typing import Literal, TypedDict
ApprovalResult = Literal[
"pending",
"approve",
"revise",
"cancel",
]
class CodingState(TypedDict):
"""코딩 작업의 진행 상태."""
# 사용자가 요청한 작업
task: str
# 작업할 프로젝트 경로
repository_path: str
# 코드 수정 계획
plan: str
# 사용자의 계획 승인 결과
approval_result: ApprovalResult
# 실제로 변경된 파일 목록
changed_files: list[str]
# 구현 결과 설명
implementation_summary: str
# 검증 성공 여부
verification_passed: bool
# 검증 실패 또는 보완 내용
verification_feedback: str
# 현재 재시도 횟수
retry_count: int
# 허용할 최대 재시도 횟수
max_retries: int
def create_initial_state(
task: str,
repository_path: str,
max_retries: int = 2,
) -> CodingState:
"""새로운 코딩 작업의 초기 State를 만든다."""
task = task.strip()
repository_path = repository_path.strip()
if not task:
raise ValueError("task는 비어 있을 수 없습니다.")
if not repository_path:
raise ValueError("repository_path는 비어 있을 수 없습니다.")
if max_retries < 0:
raise ValueError("max_retries는 0 이상이어야 합니다.")
return {
"task": task,
"repository_path": repository_path,
"plan": "",
"approval_result": "pending",
"changed_files": [],
"implementation_summary": "",
"verification_passed": False,
"verification_feedback": "",
"retry_count": 0,
"max_retries": max_retries,
}State 초기화 확인
create_initial_state 함수를 통해 State 를 만들어보자
from pprint import pprint
from coding_harness.state import create_initial_state
state = create_initial_state(
task="인증 오류 처리를 수정한다.",
repository_path="/tmp/example-repository",
max_retries=2,
)
pprint(state)실행결과는 아래와 같이 나오고 이 State 를 그래프 실행시에 전달하게 된다.
graph.invoke(coding_state)아마도 이렇게.
{
'task': '인증 오류 처리를 수정한다.',
'repository_path': '/tmp/example-repository',
'plan': '',
'approval_result': 'pending',
'changed_files': [],
'implementation_summary': '',
'verification_passed': False,
'verification_feedback': '',
'retry_count': 0,
'max_retries': 2
}위 처럼 State 가 이런 저런 Node들을 통과하면서 업데이트 되고 Edge 에 따라 또 다시 어떤 Node 를 수행하게 될지가 결정된다.
연습용 Graph 작성
coding_harness/basic_graph.py 에 다음과 같은 함수를 작성해준다.
from pprint import pprint
from langgraph.graph import END, START, StateGraph
from coding_harness.state import CodingState, create_initial_state
def plan_node(state: CodingState) -> dict:
"""사용자 요청을 바탕으로 임시 계획을 작성한다."""
print("\n[plan_node 실행]")
print("받은 작업:", state["task"])
plan = (
"1. 관련 코드를 조사한다.\n"
"2. 필요한 파일을 수정한다.\n"
"3. 테스트를 실행한다."
)
return {
"plan": plan,
}
def finish_node(state: CodingState) -> dict:
"""계획 노드의 실행 결과를 확인한다."""
print("\n[finish_node 실행]")
print("전달받은 계획:")
print(state["plan"])
return {
"implementation_summary": "연습용 계획 작성이 완료되었습니다.",
}
def create_graph():
"""연습용 LangGraph를 구성한다."""
builder = StateGraph(CodingState)
builder.add_node("plan", plan_node)
builder.add_node("finish", finish_node)
builder.add_edge(START, "plan")
builder.add_edge("plan", "finish")
builder.add_edge("finish", END)
return builder.compile()
def main() -> None:
initial_state = create_initial_state(
task="인증 오류 처리를 수정한다.",
repository_path="/tmp/example-repository",
max_retries=2,
)
graph = create_graph()
final_state = graph.invoke(initial_state)
print("\n[최종 State]")
pprint(final_state)
if __name__ == "__main__":
main()Node
Node는 크게 plan, finish 두개로 구성했다. 별 내용없이 plan node는 State 로 task 를 받고 plan 을 리턴한다, finish node 도 State 로 plan 을 받고 결과를 리턴하는 식이다.
노드에서 완전한 State 를 리턴하지 않는다는 점을 확인하자. 아래처럼 구구절절 리턴할 필요가 없다.
return {
"task": state["task"],
"repository_path": state["repository_path"],
"plan": plan,
"approval_result": state["approval_result"],
"changed_files": state["changed_files"],
...
}Graph 빌더
Graph 를 함수가 추가 됐다. builder = StateGraph(CodingState) 처럼 CodingState 를 담은 빌더를 구성하고 add_node 로 node들을 추가, add_edge 로 시작부터 끝까지 node 들을 이어주는 코드를 작성했다. 그리고 나서 builder.compile() 을 해 주고 나서야 CompiledStateGraph 로 변환하게 된다. CompiledStateGraph 는 invoke(), stream() 등의 실행 메서드를 가진다.
즉, StateGraph는 설계용 빌더고 compile() 하고 나서야 실행 가능한 CompiledStateGraph 가 된다.
연습용 빌더 테스트
python -m coding_harness.basic_graph
[plan_node 실행]
받은 작업: 인증 오류 처리를 수정한다.
[finish_node 실행]
전달받은 계획:
1. 관련 코드를 조사한다.
2. 필요한 파일을 수정한다.
3. 테스트를 실행한다.
[최종 State]
{'approval_result': 'pending',
'changed_files': [],
'implementation_summary': '연습용 계획 작성이 완료되었습니다.',
'max_retries': 2,
'plan': '1. 관련 코드를 조사한다.\n2. 필요한 파일을 수정한다.\n3. 테스트를 실행한다.',
'repository_path': '/tmp/example-repository',
'retry_count': 0,
'task': '인증 오류 처리를 수정한다.',
'verification_feedback': '',
'verification_passed': False}네 개 노드와 조건 분기
위 밋밋한 Graph 에 조건 분기를 넣어보자
START
↓
plan
↓
approve
├─ approve → implement
├─ revise → plan
└─ cancel → END
↑
implement │
↓ │
verify ────────────┘
├─ 성공 → END
├─ 실패·재시도 가능 → implement
└─ 재시도 초과 → END시나리오는 다음과 같다.
계획 작성
→ 자동 승인
→ 임시 구현
→ 첫 검증 실패
→ 구현 재실행
→ 두 번째 검증 성공
→ 종료여기서는 라우팅 함수라는게 등장하는데 State 상태에 따라 분기를 도와주는 함수라고 보면 되는 데 아래와 같이 정의를 하고 다음 실행위치만 결정한다.
def route_verification(state: CodingState) -> str:
if state["verification_passed"]:
return "end"
return "implement"또한 라우팅 함수는 다음과 같이 Graph 에 정의한다.
builder.add_conditional_edges(
"verify", # 1. 어느 노드가 끝난 뒤 분기할지
route_verification, # 2. 다음 경로를 판단하는 함수
{ # 3. 판단 결과와 실제 이동할 노드의 연결표
# 예) 라우터_반환값: 실제_이동할_노드
"implement": "implement",
"end": END,
}
)분기 형태의 Graph 작성
coding_harness/harness_graph.py 에 다음과 같은 함수를 작성해준다.
from pprint import pprint
from typing import Literal
from langgraph.graph import END, START, StateGraph
from coding_harness.state import CodingState, create_initial_state
def plan_node(state: CodingState) -> dict:
"""저장소 조사 결과를 바탕으로 작업 계획을 작성한다."""
print("\n[계획 노드]")
print("작업:", state["task"])
print("저장소:", state["repository_path"])
plan = (
"1. 관련 코드와 테스트를 조사한다.\n"
"2. 오류 처리 로직을 수정한다.\n"
"3. 정적 분석과 테스트를 실행한다."
)
return {
"plan": plan,
"approval_result": "pending",
}
def approval_node(state: CodingState) -> dict:
"""계획 승인을 임시로 자동 처리한다."""
print("\n[승인 노드]")
print("승인할 계획:")
print(state["plan"])
# 이후 interrupt()를 사용한 실제 사용자 입력으로 교체한다.
temporary_result = "approve"
print("임시 승인 결과:", temporary_result)
return {
"approval_result": temporary_result,
}
def implement_node(state: CodingState) -> dict:
"""승인된 계획에 따라 임시 구현 결과를 생성한다."""
attempt_number = state["retry_count"] + 1
print("\n[구현 노드]")
print("구현 시도:", attempt_number)
print("승인된 계획:")
print(state["plan"])
if state["verification_feedback"]:
print("직전 검증 피드백:", state["verification_feedback"])
# 이후 Pi Agent 호출로 교체한다.
return {
"changed_files": [
"lib/auth/auth_service.dart",
],
"implementation_summary": (
f"{attempt_number}번째 구현 시도에서 "
"인증 오류 처리 로직을 수정함"
),
}
def verify_node(state: CodingState) -> dict:
"""구현 결과를 임시 검증한다."""
print("\n[검증 노드]")
print("변경 파일:", state["changed_files"])
# 첫 번째 검증은 의도적으로 실패시킨다.
if state["retry_count"] == 0:
print("검증 결과: 실패")
return {
"verification_passed": False,
"verification_feedback": (
"인증 취소 테스트가 실패했습니다. "
"취소 예외 처리 조건을 다시 확인하세요."
),
"retry_count": state["retry_count"] + 1,
}
# 두 번째 검증은 성공시킨다.
print("검증 결과: 성공")
return {
"verification_passed": True,
"verification_feedback": "모든 검증을 통과했습니다.",
}
def route_approval(
state: CodingState,
) -> Literal["implement", "plan", "end"]:
"""승인 결과에 따라 다음 노드를 결정한다."""
result = state["approval_result"]
if result == "approve":
return "implement"
if result == "revise":
return "plan"
return "end"
def route_verification(
state: CodingState,
) -> Literal["implement", "end"]:
"""검증 결과와 재시도 횟수에 따라 다음 노드를 결정한다."""
if state["verification_passed"]:
return "end"
if state["retry_count"] <= state["max_retries"]:
return "implement"
return "end"
def create_graph():
"""코딩 하네스 그래프를 구성한다."""
builder = StateGraph(CodingState)
# 노드 등록
builder.add_node("plan", plan_node)
builder.add_node("approve", approval_node)
builder.add_node("implement", implement_node)
builder.add_node("verify", verify_node)
# 고정 엣지
builder.add_edge(START, "plan")
builder.add_edge("plan", "approve")
builder.add_edge("implement", "verify")
# 승인 결과 조건 분기
builder.add_conditional_edges(
"approve",
route_approval,
{
"implement": "implement",
"plan": "plan",
"end": END,
},
)
# 검증 결과 조건 분기
builder.add_conditional_edges(
"verify",
route_verification,
{
"implement": "implement",
"end": END,
},
)
return builder.compile()
def main() -> None:
initial_state = create_initial_state(
task="인증 오류 처리를 수정한다.",
repository_path="/tmp/example-repository",
max_retries=2,
)
graph = create_graph()
final_state = graph.invoke(initial_state)
print("\n[최종 State]")
pprint(final_state)
if __name__ == "__main__":
main()실행 순서는 다음과 같다
approval_node 실행
↓
approval_result 업데이트
↓
route_approval 실행
↓
반환값에 해당하는 노드로 이동PYTHONPATH=src uv run python -m coding_harness.harness_graph실행결과는 아래와 같다. 의도적으로 1차 구현을 검증노드에서 실패 시키고 다시 구현 검증을 거치는 프로세스이다.
[계획 노드]
작업: 인증 오류 처리를 수정한다.
저장소: /tmp/example-repository
[승인 노드]
승인할 계획:
1. 관련 코드와 테스트를 조사한다.
2. 오류 처리 로직을 수정한다.
3. 정적 분석과 테스트를 실행한다.
임시 승인 결과: approve
[구현 노드]
구현 시도: 1
승인된 계획:
1. 관련 코드와 테스트를 조사한다.
2. 오류 처리 로직을 수정한다.
3. 정적 분석과 테스트를 실행한다.
[검증 노드]
변경 파일: ['lib/auth/auth_service.dart']
검증 결과: 실패
[구현 노드]
구현 시도: 2
승인된 계획:
1. 관련 코드와 테스트를 조사한다.
2. 오류 처리 로직을 수정한다.
3. 정적 분석과 테스트를 실행한다.
직전 검증 피드백: 인증 취소 테스트가 실패했습니다. 취소 예외 처리 조건을 다시 확인하세요.
[검증 노드]
변경 파일: ['lib/auth/auth_service.dart']
검증 결과: 성공
[최종 State]
{'approval_result': 'approve',
'changed_files': ['lib/auth/auth_service.dart'],
'implementation_summary': '2번째 구현 시도에서 인증 오류 처리 로직을 수정함',
'max_retries': 2,
'plan': '1. 관련 코드와 테스트를 조사한다.\n2. 오류 처리 로직을 수정한다.\n3. 정적 분석과 테스트를 실행한다.',
'repository_path': '/tmp/example-repository',
'retry_count': 1,
'task': '인증 오류 처리를 수정한다.',
'verification_feedback': '모든 검증을 통과했습니다.',
'verification_passed': True}글이 길어져서 다음 포스트에서 이어 나가기로 한다.