따라하기 — for 루프로 에이전트 직접 만들기
한 줄 요약
run_agent() 함수를 한 줄씩 뜯어봅니다. 이 30줄이 5장에서 create_agent 한 줄로 바뀝니다. 지금 내부 동작을 확실히 이해해 두면, 앞으로 라이브러리가 무엇을 대신하는지 알고 쓰게 됩니다.
1. 전체 모습
아래는 code/ch04_web_agent.py 의 일부입니다 — 위에서 이미 붙여 넣었으니 다시 넣지 마세요.
def run_agent(question: str, verbose: bool = True) -> str:
"""ReAct 루프를 직접 돌린다: 모델이 도구를 그만 부를 때까지 반복."""
config = types.GenerateContentConfig(
tools=list(TOOLS.values()),
system_instruction=SYSTEM,
temperature=0,
automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True),
)
history = [types.Content(role="user", parts=[types.Part(text=question)])]
for step in range(1, MAX_STEPS + 1):
resp = client.models.generate_content(
model=GEMINI_MODEL, contents=history, config=config)
if not resp.function_calls:
if verbose:
print(f" [{step}단계] 도구 호출 없음 → 최종 답변")
return resp.text.strip()
history.append(resp.candidates[0].content)
for fc in resp.function_calls:
result = TOOLS[fc.name](**dict(fc.args))
if verbose:
print(f" [{step}단계] {fc.name}({dict(fc.args)})")
print(f" └ {result.splitlines()[0][:70]} ...")
history.append(types.Content(role="user", parts=[
types.Part.from_function_response(
name=fc.name, response={"result": result})]))
return "(최대 단계에 도달해 중단했습니다)"
핵심은 15줄쯤입니다. 나머지는 화면에 찍는 코드입니다.
2. 한 줄씩
① 대화의 시작점
아래는 code/ch04_web_agent.py 의 일부입니다 — 위에서 이미 붙여 넣었으니 다시 넣지 마세요.
history = [types.Content(role="user", parts=[types.Part(text=question)])]
사용자 질문 하나로 시작합니다. 2장에서 history = [] 로 시작했던 것과 같은 자리입니다.
② 반복 — 상한을 걸고 돈다
아래는 code/ch04_web_agent.py 의 일부입니다 — 위에서 이미 붙여 넣었으니 다시 넣지 마세요.
for step in range(1, MAX_STEPS + 1):
while True: 가 아니라 for + 상한입니다. 여기서 상한(上限) 은 반복 횟수의 최대값 — MAX_STEPS 번을 넘겨서는 돌지 않는다는 뜻입니다. 이유는 다음 절에서 자세히 다룹니다. 한 문장으로 말하면 — 무한 반복과 요금 폭탄을 막기 위해서입니다.
③ 생각 — 모델에게 판단을 맡긴다
아래는 code/ch04_web_agent.py 의 일부입니다 — 위에서 이미 붙여 넣었으니 다시 넣지 마세요.
resp = client.models.generate_content(
model=GEMINI_MODEL, contents=history, config=config)
contents=history 를 눈여겨보세요. 매번 지금까지 쌓인 것 전체를 보냅니다. 2장의 chat_multi 와 똑같습니다.
단계가 늘수록 보내는 양이 늘어납니다. 2장에서 본 비용 이야기가 여기서도 그대로입니다.
④ 종료 판정
아래는 code/ch04_web_agent.py 의 일부입니다 — 위에서 이미 붙여 넣었으니 다시 넣지 마세요.
if not resp.function_calls:
return resp.text.strip()
부를 도구가 없다 = 이제 답할 수 있다. 3장 6절에서 미리 봐 둔 그 판정입니다.
이 한 줄이 루프의 정상 종료입니다. 우리가 "몇 번 돌아라"라고 정하지 않습니다. 모델이 충분하다고 판단할 때까지 돕니다.
⑤ 모델의 결정을 기록
아래는 code/ch04_web_agent.py 의 일부입니다 — 위에서 이미 붙여 넣었으니 다시 넣지 마세요.
history.append(resp.candidates[0].content)
여기가 빠뜨리기 쉬운 곳입니다.
resp.candidates[0].content 는 모델이 방금 만든 발언 전체입니다 — "get_product_info 를 이 값으로 불러 줘"라는 요청서가 담겨 있습니다.
이걸 기록하지 않으면 어떻게 될까요? 다음 호출 때 모델은 자기가 방금 도구를 요청했다는 사실을 모릅니다. 그런데 실행 결과는 들어와 있죠. 앞뒤가 안 맞아 같은 도구를 또 부르거나 혼란스러워합니다.
2장에서 "③(답변 기록)을 빠뜨리면 모델은 자기가 한 말을 모른다" 고 했던 것과 정확히 같은 문제입니다. 양쪽 다 기록해야 합니다.
⑥ 행동 — 실행은 우리가
아래는 code/ch04_web_agent.py 의 일부입니다 — 위에서 이미 붙여 넣었으니 다시 넣지 마세요.
for fc in resp.function_calls:
result = TOOLS[fc.name](**dict(fc.args))
3장 2절의 그 경계선입니다. 모델은 요청서만, 실행은 우리 코드.
**dict(fc.args) — {'query': '스마트워치 시장 가격대'} 가 search_web(query='스마트워치 시장 가격대') 로 펼쳐집니다.
⑦ 관찰을 기록
아래는 code/ch04_web_agent.py 의 일부입니다 — 위에서 이미 붙여 넣었으니 다시 넣지 마세요.
history.append(types.Content(role="user", parts=[
types.Part.from_function_response(
name=fc.name, response={"result": result})]))
실행 결과를 대화에 넣습니다. 이게 없으면 모델은 도구를 부른 것까지만 알고 결과는 모릅니다.
name=fc.name 으로 어느 도구의 결과인지 밝혀 줍니다. 한 단계에서 도구를 여러 개 불렀다면 이게 있어야 짝이 맞습니다.
⑧ 비정상 종료
아래는 code/ch04_web_agent.py 의 일부입니다 — 위에서 이미 붙여 넣었으니 다시 넣지 마세요.
return "(최대 단계에 도달해 중단했습니다)"
for 문을 다 돌았는데도 모델이 계속 도구를 부르고 있으면 여기로 옵니다. 다음 절의 주제입니다.
3. 흐름 정리
history 가 계속 자라는 것이 이 구조의 전부입니다.
4. 5장에서 이 30줄이 이렇게 됩니다
아래는 개념 설명용 코드입니다 — 실습 파일에 넣지 않습니다.
from langchain.agents import create_agent
llm = get_chat(temperature=0) # common.py 가 만들어 주는 모델
agent = create_agent(llm, tools=[run_pandas], system_prompt=SYSTEM)
한 줄입니다. 그리고 history 관리, 종료 판정, 도구 실행, 결과 기록을 전부 라이브러리가 합니다.
편합니다. 그런데 감춰진 것을 알고 쓰는 것과 모르고 쓰는 것은 다릅니다.
| 여기서 손으로 한 것 | 5장에서 라이브러리가 |
|---|---|
history 만들고 append |
자동 |
if not resp.function_calls |
자동 |
TOOLS[fc.name](...) 실행 |
자동 |
Part.from_function_response |
자동 |
MAX_STEPS 상한 |
설정으로 지정 |
| 도구를 무엇으로 구성할지 | 여전히 사람의 일 |
| 각 도구의 독스트링 | 여전히 사람의 일 |
아래 셋은 라이브러리가 대신해 주지 않습니다. 그게 이 과정에서 계속 강조하는 "사람이 판단하는 자리"입니다.
스스로 해 보기
- ⑤번 줄(
history.append(resp.candidates[0].content))을 지우고 실행해 보세요. 어떻게 되나요? 몇 단계까지 가나요? 왜 그런지 설명해 보세요. - ⑦번 줄(관찰 기록)을 지우고 실행해 보세요. 이번엔 어떻게 되나요?
history의 길이를 매 단계 찍어 보세요. 단계당 몇 개씩 늘어나나요?
python print(f" [{step}단계] history 길이: {len(history)}")MAX_STEPS를1로 줄이고 실행해 보세요. 무엇이 출력되나요?
1번과 2번은 일부러 고장 내 보는 연습입니다. 정상 동작만 보면 각 줄이 왜 있는지 체감되지 않습니다. 지웠을 때 무엇이 무너지는지를 봐야 그 줄의 역할이 이해됩니다.
핵심 정리
- ReAct 루프의 실체는
for문 하나입니다. 핵심은 15줄쯤입니다. contents=history— 매번 쌓인 것 전체를 보냅니다. 2장chat_multi와 같습니다.- 종료 조건은
if not resp.function_calls— 부를 도구가 없으면 답할 때입니다. - 모델의 결정(⑤)과 실행 결과(⑦)를 둘 다 기록해야 합니다. 하나라도 빠지면 앞뒤가 안 맞습니다.
- 실행은
TOOLS[fc.name](**dict(fc.args))— 여전히 우리 코드입니다. - 5장에서 이 30줄이
create_agent한 줄이 됩니다. 감춰지는 것이 정확히 무엇인지 지금 봐 두세요. - 도구 구성과 독스트링은 라이브러리가 대신해 주지 않습니다.