개요
< 11 >이 SSAFY < 29 >, < 12 >가 < 30 >의 실습편이었듯, 이 편은 SSAFY < 31 >~< 34 >의 실습편이다.
| 이론 (SSAFY 시리즈) | 실습 (이 글) |
|---|---|
< 31 > 도구와 MCP |
2장 — @tool과 bind_tools
|
< 32 > 계획하고 되돌아보는 에이전트 |
3~4장 — ReAct 그래프 |
< 33 > 다중 에이전트 시스템 |
6장 — Planner · Worker · Reflection |
< 34 > 도구를 배우는 법, 생각을 아끼는 법 |
5장 — 검증과 가드레일 |
지난 < 12 >에서 찾는 법을 만들었다. 문서를 잘라 벡터로 넣고 질문에 가까운 조각을 꺼내는 데까지 갔다.
그런데 거기서 만든 건 읽고 대답하는 것까지다. 실제로 뭘 하지는 못한다. 이번 편에서 손을 달아준다.
이번 글이 답할 질문 “쿠폰 발급했습니다”라고 말한 모델은 정말로 쿠폰을 발급했나?
실습 자료는 SSAFY 교육 컨텐츠라 원본을 싣지 않는다. 개념만 가져와 예제는 전부 새로 짰다. 지난 편에서 지어낸 게임 「아르카 온라인」을 그대로 이어 쓴다. 12편에서 만든 정책 검색기가 이번 편에서는 도구 하나가 된다.
1. 발급했다고 말만 한다 — 모델에는 손이 없다
첫 단계는 아무것도 안 붙이고 시켜보는 것이다.
response = llm.invoke([HumanMessage(
content="주문 ORD001에 5000원 보상 쿠폰을 발급해줘."
)])
print(response.content)
# "네, 주문 ORD001에 5,000원 쿠폰을 발급해 드렸습니다."
그리고 쿠폰 목록을 열어보면 아무것도 없다.
당연하다. llm.invoke()가 하는 일은 글자를 이어 쓰는 것뿐이다. 발급 시스템에 연결된 적도 없고, 발급이라는 동작을 할 수단도 없다. 그런데 학습 데이터에는 “발급해 드렸습니다” 같은 문장이 잔뜩 있으니 그 문장을 만들어낼 뿐이다.
< 12 >에서 본 환각과 같은 뿌리인데 결과가 더 나쁘다. 환각은 잘못된 정보를 주는 거였고, 이건 하지도 않은 일을 했다고 보고하는 것이다. 고객은 쿠폰을 기다리는데 아무 일도 안 일어난다.
여기서부터 부품을 하나씩 붙인다. 붙일 때마다 새 문제가 하나씩 드러난다.
모델이 “했습니다”라고 쓰는 것과 실제로 그 일이 일어나는 것 사이엔 아무 연결도 없다. Agent를 만든다는 건 그 연결을 손으로 놓는 일이다.
2. 손과 기억을 달아준다 — Tool과 Memory
함수를 도구로 만든다
파이썬 함수 위에 @tool 하나만 붙이면 도구가 된다.
from langchain_core.tools import tool
@tool
def get_order_status_tool(order_id: str) -> str:
"""주문 상태를 조회한다. 주문번호(예: ORD001)를 넣으면 현재 상태를 돌려준다."""
return orders_db.get(order_id, {}).get("status", "주문을 찾을 수 없습니다")
@tool
def issue_coupon_tool(order_id: str, amount: int) -> str:
"""보상 쿠폰을 발급한다. 주문번호와 금액을 넣는다."""
issued_coupons[order_id] = amount
return f"주문 {order_id}에 {amount:,}원 쿠폰 발급 완료"
@tool
def search_policy_tool(query: str) -> str:
"""보상·배송 정책을 검색한다. 쿠폰 발급 조건이나 한도를 확인할 때 쓴다."""
return search_policy(query) # 12편에서 만든 retriever 를 그대로 쓴다
세 번째 도구가 지난 편에서 만든 검색기다. RAG가 통째로 도구 하나로 접혀 들어갔다.
여기서 제일 중요한 게 docstring이다. 이게 그냥 주석이 아니다.
llm_with_tools = llm.bind_tools(tools)
bind_tools는 함수 시그니처와 docstring을 읽어서 JSON 스키마로 바꿔 모델에게 같이 보낸다. 모델이 도구를 고를 때 보는 건 함수 본문이 아니라 그 설명문이다.
| 요소 | 모델이 보는 것 |
|---|---|
| 함수 이름 | 도구 이름 |
| docstring | 이 도구를 언제 쓰는지 |
| 인자 타입 힌트 | 뭘 넣어야 하는지 |
| 함수 본문 | 안 본다 |
그래서 docstring을 대충 쓰면 엉뚱한 도구를 고른다. 코드가 멀쩡해도 안 돌아가는데 원인이 주석에 있는, 좀 낯선 종류의 버그다.
기억은 기본으로 안 켜져 있다
도구를 달고 나면 다음 문제가 바로 온다.
agent.invoke({"messages": [{"role": "user", "content": "내 주문은 ORD001이야."}]})
agent.invoke({"messages": [{"role": "user", "content": "그 주문 상태가 뭐야?"}]})
# → "어떤 주문을 말씀하시는지 알려주세요."
두 번째 호출은 첫 번째를 모른다. 생각해보면 당연하다. invoke에 넘긴 messages에 첫 대화가 안 들어 있으니 모델 입장에선 처음 보는 대화다.
고치는 방법은 두 줄이다.
from langgraph.checkpoint.memory import MemorySaver
agent = create_react_agent(model=llm, tools=tools, checkpointer=MemorySaver())
config = {"configurable": {"thread_id": "user123"}}
agent.invoke({"messages": [...]}, config=config) # config를 매번 같이 넘긴다
MemorySaver는 대화를 저장해두는 곳이고, thread_id는 누구의 대화인지 가리키는 이름표다. 둘 다 있어야 동작한다. 체크포인터만 달고 config를 안 넘기면 저장은 되는데 꺼내오질 못한다.
thread_id를 사용자마다 다르게 주면 대화가 섞이지 않는다. 게임 서버에서 세션 키로 플레이어 상태를 구분하던 것과 같은 구조다.
@tool의 docstring은 주석이 아니라 모델이 읽는 사용설명서다. 그리고 기억은 공짜로 안 따라온다 — 저장소와 이름표를 둘 다 줘야 한다.
3. 한 줄이면 되는데 왜 직접 짓나
여기서 첫 번째로 걸렸다. Agent 하나 만드는 데 한 줄이면 끝난다.
from langgraph.prebuilt import create_react_agent
agent = create_react_agent(model=llm, tools=tools, prompt=system_prompt)
이게 이미 ReAct다. 생각하고, 도구 부르고, 결과 보고, 다시 생각하는 순환이 안에 들어 있다. 복잡한 요청도 처리한다.
그런데 실습은 이걸 만들어놓고 바로 다음 파트에서 똑같은 걸 StateGraph로 다시 짓는다. 처음엔 왜 이러나 싶었다.
이유가 몇 가지 있었다.
create_react_agent |
StateGraph 직접 |
|
|---|---|---|
| 코드 길이 | 한 줄 | 스무 줄쯤 |
| 안에서 뭐가 도는지 | 안 보인다 | 전부 보인다 |
| 고칠 수 있는 범위 | 프롬프트 정도 | 노드·엣지 단위 |
| 다른 걸 끼워 넣기 | 어렵다 | 노드 하나 추가 |
결정적인 건 세 번째 줄이다. 실제 서비스에서는 “도구 부르기 전에 권한을 확인하고”, “결과가 이상하면 다시 검색하고”, “특정 조건이면 사람에게 넘기고” 같은 게 붙는다. 한 줄짜리 Agent는 그 자리에 끼워 넣을 틈이 없다.
그리고 하나 더. 직접 짜보기 전엔 뭐가 어떻게 도는지 몰랐다. create_react_agent가 알아서 해주는 게 정확히 무엇인지는 그걸 손으로 만들어봐야 알 수 있다.
편한 한 줄과 긴 스무 줄이 있으면 보통 한 줄이 맞다. 다만 그 한 줄이 뭘 감췄는지는 한 번 열어봐야 언제 부족한지도 알게 된다.
4. 순환 엣지 — Direct와 ReAct
직접 짓는 쪽으로 가면 부품이 셋이다.
from langgraph.graph import StateGraph, MessagesState, START, END
from langchain_core.messages import ToolMessage
class AgentState(MessagesState): # messages 필드가 이미 들어 있다
pass
def call_agent(state: AgentState):
"""LLM에게 다음에 뭘 할지 묻는다"""
return {"messages": [llm.bind_tools(tools).invoke(state["messages"])]}
def call_tools(state: AgentState):
"""모델이 지목한 도구를 실제로 실행한다"""
last = state["messages"][-1]
out = []
for call in last.tool_calls:
# 이름이 맞는 도구를 하나 찾는다. 없으면 None
picked = next((t for t in tools if t.name == call["name"]), None)
if picked is None:
# 모델이 없는 도구 이름을 지어낸 경우 — 죽이지 말고 알려준다
content = f"{call['name']} 이라는 도구는 없습니다"
else:
content = str(picked.invoke(call["args"]))
out.append(ToolMessage(content=content, tool_call_id=call["id"]))
return {"messages": out}
call_agent는 도구를 실행하지 않는다. 모델이 “이 도구를 이 인자로 불러라”라고 적어놓은 걸 받아올 뿐이고, 실제로 부르는 건 call_tools다. 결정과 실행이 나뉘어 있다.
next() 가 하는 일
call_tools 안의 저 한 줄이 낯설어서 좀 들여다봤다.
picked = next((t for t in tools if t.name == call["name"]), None)
괄호 안은 리스트가 아니라 제너레이터다. 조건에 맞는 걸 미리 다 찾아두는 게 아니라, 달라고 할 때 하나씩 내놓는다. next()는 거기서 첫 개만 꺼내고 멈춘다. for문으로 쓰면 이거다.
picked = None
for t in tools:
if t.name == call["name"]:
picked = t
break # next() 가 하는 게 이 break
| 쓰는 법 | 동작 |
|---|---|
[t for t in tools if ...][0] |
맞는 걸 전부 찾아 리스트로 만든 뒤 첫 개를 꺼낸다 |
next(t for t in tools if ...) |
첫 개를 찾으면 거기서 멈춘다. 뒤는 안 본다 |
그리고 두 번째 인자 None이 안전장치다. 이게 없으면 조건에 맞는 게 하나도 없을 때 StopIteration 예외가 나면서 그래프가 통째로 죽는다. 모델이 없는 도구 이름을 지어내는 건 실제로 일어나는 일이라 여기서 막아야 한다.
죽이는 대신 “그런 도구 없다”고 문자열로 돌려주는 게 요점이다. 5장에서 볼 “거절할 땐 이유를 준다”와 같은 처리고, 그러면 모델이 그걸 읽고 다른 도구로 다시 시도한다.
잇는 방법이 두 가지다
이 둘을 어떻게 잇느냐에서 두 패턴이 갈린다.
# Direct — 도구 한 번 쓰고 끝
workflow.add_edge("agent", "tools")
workflow.add_edge("tools", END)
# ReAct — 도구 결과를 다시 agent에게 돌려준다
workflow.add_conditional_edges("agent", should_continue, {"tools": "tools", "end": END})
workflow.add_edge("tools", "agent")
두 줄이 바뀐다.
| Direct | ReAct | |
|---|---|---|
agent 다음 |
무조건 tools
|
조건에 따라 tools 또는 END
|
tools 다음 |
END |
agent (순환) |
그런데 이 둘이 따로 노는 변경이 아니다. 아래쪽을 tools → agent로 바꾸는 순간 위쪽도 바꿀 수밖에 없다.
agent → tools가 무조건이고 tools → agent도 무조건이면, 나갈 문이 없는 고리가 된다. 모델이 답을 다 써도 다시 tools로 끌려가고 영원히 돈다. 그래서 순환을 만드는 쪽이 agent에 조건 분기를 다는 것까지 강제한다.
6장에서 볼 Reflection 루프와 똑같은 이야기다. 되돌아가는 길을 만들면 멈추는 조건도 같이 만들어야 한다. 여기선 그 조건이 should_continue다.
def should_continue(state: AgentState) -> str:
last = state["messages"][-1]
if hasattr(last, "tool_calls") and last.tool_calls:
return "tools"
return "end"
분기 함수는 모델이 도구를 또 부르려 하는지만 본다. 부르려 하면 tools로 보내고, 그냥 답을 썼으면 끝낸다. 즉 언제 멈출지를 모델이 정한다.
이 두 줄이 만드는 차이가 크다.
| Direct | ReAct | |
|---|---|---|
| 도구 호출 | 1회 | 필요한 만큼 |
| “상태 확인하고 지연이면 쿠폰 줘” | 확인만 하고 끝 | 확인 → 판단 → 발급 |
| 토큰 비용 | 적다 | 몇 배로 는다 |
| 언제 쓰나 | 조회 같은 한 방 작업 | 앞 결과에 따라 다음이 달라질 때 |
Direct가 못 하는 이유가 명확하다. 도구 결과를 모델이 다시 못 보니까 그 결과로 판단할 방법이 없다. 주문 상태를 조회해놓고 그게 지연인지 아닌지 볼 기회가 없는 것이다.
반복 추론이라고 하면 대단한 것 같은데, 코드에서는 엣지 두 줄이다. 결과를 다시 돌려주는 줄 하나와, 그래서 필요해진 멈추는 조건 하나.
5. 브레이크 — 도구를 줬으면 막는 것도 짜야 한다
여기서 실습이 아픈 데를 찌른다. 쿠폰 발급 도구를 이렇게 짜뒀다.
def issue_coupon(order_id: str, amount: int) -> str:
issued_coupons[order_id] = amount
return f"주문 {order_id}에 {amount:,}원 쿠폰 발급 완료"
검증이 한 줄도 없다. 그래서 이런 게 그냥 통과된다.
issue_coupon("ORD001", 100000) # 정책 한도는 2만원인데 10만원이 나간다
issue_coupon("ORD002", 5000) # 배송 완료 주문인데 보상이 나간다
“프롬프트에 한도를 적어두면 되지 않나” 싶었다. 안 된다. 프롬프트로 부탁한 규칙은 지켜질 때만 지켜진다. 모델이 잘못 읽거나, 사용자가 교묘하게 유도하거나, 그냥 실수하면 그대로 나간다.
막는 자리는 프롬프트가 아니라 함수 안이다.
def issue_coupon(order_id: str, amount: int) -> str:
order = orders_db.get(order_id)
if order is None:
return f"주문 {order_id}을 찾을 수 없습니다"
if order["status"] != "배송 지연":
return f"발급 불가 — 현재 상태는 '{order['status']}'입니다"
if amount > MAX_COUPON:
return f"발급 불가 — 1회 한도는 {MAX_COUPON:,}원입니다"
issued_coupons[order_id] = amount
return f"주문 {order_id}에 {amount:,}원 쿠폰 발급 완료"
거절할 때 이유를 문자열로 돌려주는 게 포인트였다. 그냥 실패시키면 모델이 왜 안 됐는지 모르고 계속 재시도한다. 이유를 주면 그걸 읽고 “한도가 2만원이라 2만원으로 다시 신청하겠다”로 넘어간다. 에러 메시지가 모델에겐 다음 입력이다.
지켜야 할 자리가 몇 군데로 갈린다.
| 자리 | 막는 것 | 예 |
|---|---|---|
| 도구 안 검증 | 정책 위반 실행 | 한도 초과, 조건 미충족 |
| 입력 필터 | 위험한 요청 | 계정 정보 요구, 권한 우회 시도 |
| 출력 필터 | 민감 정보 노출 | 결제 수단, 개인 식별 정보 |
| 도구 목록 제한 | 쓰면 안 되는 기능 접근 | 허용 목록에 있는 도구만 바인딩 |
도구를 쥐여주는 순간 그게 잘못 불렸을 때 무슨 일이 나는지가 내 책임이 된다. 자율성을 준다는 건 브레이크를 같이 짠다는 뜻이다.
6. 여럿으로 나눈다 — Planner · Worker · Reflection
마지막은 Agent 하나가 아니라 여럿이다.
한 모델에게 큰 일을 통째로 시키면 빠뜨린다. 아르카 온라인 신규 시즌 이벤트 기획을 이렇게 시켜봤다.
request = """신규 시즌 이벤트를 기획해줘.
- 2주치 날짜별 진행 일정
- 등급별 보상 테이블
- 난이도 밸런스 조정안
- 필요 예산"""
response = llm.invoke(request)
일정이랑 보상은 나오는데 예산이 빠지거나, 난이도 얘기가 두 줄로 끝나거나 한다. 네 가지를 한 번에 붙들고 있질 못한다.
그래서 역할을 나눈다.
class AgentState(TypedDict):
user_request: str
plan: str
result: str
reflection: str
reflection_count: int
def planner_node(state: AgentState) -> AgentState:
"""무엇을 어떤 순서로 할지만 정한다. 직접 하지는 않는다"""
plan = llm.invoke(PLANNER_PROMPT.format(request=state["user_request"])).content
return {"plan": plan}
def worker_node(state: AgentState) -> AgentState:
"""계획을 받아 실제 결과물을 만든다"""
result = llm.invoke(WORKER_PROMPT.format(
request=state["user_request"], plan=state["plan"])).content
return {"result": result}
Planner 프롬프트에 “계획만 세우고 직접 실행하지 마라” 를 박아두는 게 중요했다. 안 그러면 Planner가 혼자 다 해버리고 Worker가 할 게 없어진다.
돌려줄 때 상태를 통째로 펼치지 않아도 된다
실습 코드는 여기를 이렇게 써놨다.
return {**state, "plan": plan} # 상태를 통째로 펼치고 plan만 덮어씀
돌아가긴 하는데 그래프 위에서는 안 해도 되는 일이다. < 12 >에서 본 그대로, 노드가 돌려준 dict는 상태를 갈아치우는 게 아니라 그 키만 덮어쓴다. plan만 돌려줘도 user_request는 그대로 남는다.
그런데 실습이 통째로 펼친 데는 이유가 있었다
처음엔 그냥 장황하게 쓴 줄 알았는데, 아니었다. 노트북이 그래프를 만들기 전에 노드를 직접 불러서 테스트한다.
result_state = planner_node(test_state) # 그래프 없이 그냥 함수 호출
final_state = worker_node(result_state) # 그 결과를 다음 함수에 그대로 넣는다
이러면 병합해줄 주체가 없다. planner_node가 {"plan": ...}만 돌려주면 user_request가 사라진 dict가 그대로 worker_node로 들어가고 KeyError가 난다. 통째로 펼쳐야 이 테스트가 돌아간다.
| 부르는 방식 | {"plan": plan} |
{**state, "plan": plan} |
|---|---|---|
app.invoke(...) — 그래프가 실행 |
정상 | 정상 |
worker_node(planner_node(s)) — 직접 호출 |
KeyError | 정상 |
| 상태 필드가 늘어날 때 | 그대로 | 매 노드가 전부 복사한다 |
그래서 어느 쪽이 맞다기보다 용도가 다르다. 그래프에 얹어 돌릴 코드면 부분 반환이 맞고, 노드를 떼어내 단위 테스트할 생각이면 통째로 펼치는 쪽이 편하다.
굳이 고르자면 부분 반환으로 두고 테스트할 때 초기 상태를 직접 만들어주는 쪽이 낫다고 봤다. 노드가 무엇을 바꿨는지가 반환문 한 줄에 그대로 드러나기 때문이다.
타입 힌트도 마찬가지다. state: AgentState를 안 붙여도 돌아가는데 — 스키마는 StateGraph(AgentState)가 정하니까 — 붙여두면 state["paln"] 같은 오타를 실행 전에 잡을 수 있다.
Reflection — 되돌아가는 길
여기에 검토자를 하나 더 붙인다. 결과를 보고 요청을 충족했는지 따진 뒤, 부족하면 Worker로 되돌려 보낸다.
def reflection_node(state: AgentState) -> AgentState:
"""결과가 요청을 충족했는지 따진다"""
reflection = llm.invoke(REFLECTION_PROMPT.format(
request=state["user_request"], result=state["result"])).content
return {
"reflection": reflection,
# 첫 실행 때는 이 키가 없으므로 get 으로 읽는다
"reflection_count": state.get("reflection_count", 0) + 1,
}
reflection_count를 여기서 올려주는 게 중요하다. 아래에서 이 값으로 반복을 끊을 건데, 올리는 사람이 없으면 영원히 0이라 잠금이 아예 안 걸린다.
state.get("reflection_count", 0)으로 읽는 것도 이유가 있다. 그래프를 부를 때 invoke({"user_request": ...})처럼 필요한 것만 넘기니까 첫 바퀴에는 그 키가 아직 없다. 대괄호로 꺼내면 그 자리에서 KeyError가 난다.
두 번째로 걸린 게 여기다. 되돌아가는 길을 만들면 안 멈출 수 있다.
검토자는 언제나 고칠 거리를 찾아낸다. “예산 근거가 더 구체적이면 좋겠다” 같은 건 몇 번을 고쳐도 또 나온다. 그래서 그래프가 worker → reflection → worker → reflection으로 영원히 돈다. API 요금은 그동안 계속 나간다.
멈추는 조건은 내가 건다
막는 방법은 상태에 횟수를 세는 칸을 하나 더 두는 것이다.
MAX_REFLECTIONS = 1
def should_continue_reflection(state: AgentState) -> str:
if state["reflection_count"] >= MAX_REFLECTIONS:
return "end" # 횟수를 다 썼으면 무조건 끝
if "충족" in state["reflection"]:
return "end"
return "worker"
workflow.add_conditional_edges("reflection", should_continue_reflection,
{"end": END, "worker": "worker"})
조건이 두 개이고, 순서가 그대로 우선순위다.
| 순서 | 조건 | 역할 |
|---|---|---|
| 먼저 | reflection_count >= MAX_REFLECTIONS |
횟수 제한 — 진짜 안전장치 |
| 다음 | "충족" in reflection |
품질이 됐으면 일찍 끝내주기 |
횟수부터 보는 게 맞다. 품질 조건은 안 걸릴 수 있어서 그것만 믿으면 못 멈춘다. 반대로 횟수 조건은 반드시 언젠가 걸린다. 확실히 걸리는 걸 앞에 둬야 잠금이 된다.
4장의 should_continue와 짝이 맞는다. 거기선 모델이 멈출 때를 정했는데, 여기선 모델을 못 믿어서 내가 정한다.
나누면 좋아지는데 공짜는 아니다.
| 단일 Agent | Planner-Worker |
|
|
|---|---|---|---|
| LLM 호출 | 1번 | 2번 | 3번 이상 |
| 빠뜨리는 것 | 많다 | 줄어든다 | 더 줄어든다 |
| 응답 시간 | 짧다 | 두 배 | 세 배 이상 |
| 어디서 틀렸는지 | 알기 어렵다 | 노드별로 보인다 | 노드별로 보인다 |
호출 수가 그대로 요금이라 아무 데나 쓸 건 아니다. 품질이 중요하고 한 번 만들면 오래 쓰는 결과물에 맞는다.
되돌아가는 길을 만들 땐 멈추는 조건을 같이 만든다. 품질 판단은 모델에게 맡겨도 되지만, 횟수 제한은 내가 걸어야 한다.
정리
- 모델이 “했습니다”라고 쓰는 것과 실제로 일어나는 것 사이엔 아무 연결이 없다. Agent는 그 연결을 놓는 일이다
-
@tool의 docstring이 모델이 읽는 사용설명서다.bind_tools가 시그니처와 설명문을 스키마로 바꿔 보내고, 모델은 함수 본문을 안 본다. 설명이 부실하면 엉뚱한 도구를 고른다 -
기억은 기본으로 안 켜져 있다.
MemorySaver(저장소)와thread_id(이름표) 둘 다 있어야 이어진다 -
create_react_agent한 줄이면 되지만, 한 번은 열어봐야 한다. 노드·엣지 단위로 손댈 게 생겼을 때 그 한 줄엔 끼울 틈이 없다 -
Direct와 ReAct는 엣지 두 줄이 다르다.
tools → agent순환을 놓으면 나갈 문이 없어지므로agent에 조건 분기를 다는 것까지 딸려 온다 - 막는 자리는 프롬프트가 아니라 함수 안이다. 프롬프트로 부탁한 규칙은 지켜질 때만 지켜진다. 거절할 땐 이유를 문자열로 돌려줘야 모델이 고쳐서 재시도한다
- 역할을 나누면 빠뜨리는 게 줄어든다. 대신 LLM 호출이 노드 수만큼 늘어나고 요금과 시간이 그만큼 든다
-
되돌아가는 길에는 잠금을 건다. 품질 판단만으로는 안 멈춘다.
reflection_count로 횟수를 세는 게 진짜 안전장치다
참고 자료
- LangChain — Tools 개념 문서
- LangGraph — Persistence와 checkpointer
- ReAct: Synergizing Reasoning and Acting in Language Models (Yao, 2022)
- Reflexion: Language Agents with Verbal Reinforcement Learning (Shinn, 2023)
한줄 평
- 도구를 달아주는 것보다 못 하게 막는 걸 짜는 데 시간이 더 들었다 -> 자율성을 준다는 게 브레이크를 같이 짠다는 뜻이라는 걸 이번에 알았다!