에이전트 루프 - 생각·행동·관찰, 그리고 언제 쓰지 않을까
이 장에서 배우는 것
대화형 AI에 일을 설명하고 답을 받는 것과, AI가 도구를 골라 여러 단계의 일을 이어 가게 하는 것은 다르다. 답변에 “계산을 마쳤다”라고 적혀 있다고 해서 계산기가 실행된 것은 아니다. “메모를 저장했다”라는 문장도 파일이 생겼다는 증거가 아니다. 실제 행동을 일으키고 그 결과를 다음 판단에 연결하려면, 모델 바깥에서 실행을 관리하는 프로그램이 필요하다.
이 책에서는 그 프로그램을 하네스(harness)라고 부른다. 하네스는 모델에 현재 상황을 전달하고, 모델이 요청한 행동을 실행하고, 실행 결과를 다시 전달한다. 동시에 반복 횟수와 승인 여부를 확인하고 멈출 시점을 결정한다. 이 장에서는 계산기와 메모 도구만 있는 작은 하네스를 만든다. 실제 모델 대신 규칙 기반 가짜 모델을 사용하므로 실행 결과를 직접 따라가며 구조를 이해할 수 있다.
- 대화형 AI의 답변과 에이전트의 실제 행동을 구별한다.
- 생각, 도구 호출, 관찰이 이어지는 반복 구조를 구현한다.
- 고정 절차로 충분한 일과 관찰에 따라 다음 행동을 골라야 하는 일을 구분한다.
- 사람의 승인 지점, 완료 조건, 최대 반복 수를 하네스에 둔다.
- 실행 기록과 자체 검사를 통해 코드가 의도대로 동작하는지 확인한다.
문제 상황
작은 팀에서 비용 정리 작업을 맡았다고 하자. 이번 요청은 두 금액을 더하고, 합계가 기준 이상이면 확인 필요라는 문구를 붙여 임시 메모에 남기는 일이다. 요청 자체는 짧지만, 계산과 판단과 저장이라는 서로 다른 단계가 들어 있다.
18과 24를 더한다. 합계가 40 이상이면 “확인 필요”, 그보다 작으면 “기준 미만”이라고 표시한다. 계산 결과와 표시를 메모에 남기되, 저장 전에 사람의 승인을 받는다.
대화형 AI는 이 요청에 합계와 메모 문안을 답할 수 있다. 그러나 파일에 기록하는 기능이 연결되지 않았다면 답변만으로 저장이 이루어지지는 않는다. 도구가 연결되었더라도 호출을 실행할지, 저장을 허용할지, 실패했을 때 계속할지는 별도로 정해야 한다.
여기서 처음 던질 질문은 “어떤 모델을 쓸까”가 아니다. “이 일에 다음 행동을 매번 선택하는 구조가 필요한가”다. 위 요청만 반복 처리한다면 합산, 조건 판정, 승인 확인, 저장을 차례대로 수행하는 함수로 충분하다. 에이전트를 도입하면 실행 경로를 이해하고 검사해야 할 부분이 늘어난다.
그럼에도 이 요청은 에이전트 루프를 배우기에 적합하다. 계산기 결과를 관찰한 뒤 메모 내용을 만들고, 저장 결과를 관찰한 뒤 완료를 선언하는 과정을 작게 드러낼 수 있기 때문이다. 이 장의 구현은 이 구조를 학습하기 위한 예제다. 실제로 같은 업무를 운영한다면 먼저 고정 절차로 해결할 수 있는지 살펴본다.
대화의 답변과 실행의 루프
에이전트(agent)는 목표와 현재 관찰을 바탕으로 다음 행동을 고르고, 그 행동의 결과를 받아 다시 판단하는 실행 주체다. 대화형 AI와 에이전트는 서로 다른 종류의 지능이라기보다, 답변을 사용하는 방식과 실행 구조에서 차이가 난다. 대화 결과를 사람이 읽고 직접 행동할 수도 있고, 하네스가 도구 호출 요청을 받아 행동을 이어 갈 수도 있다.
| 구분 | 대화 중심 사용 | 에이전트 루프 |
|---|---|---|
| 모델의 출력 | 사람이 읽을 답변 | 다음 행동 요청 또는 완료 제안 |
| 실제 실행 | 주로 사람이 이어서 수행 | 하네스가 연결된 도구를 실행 |
| 결과 확인 | 사람이 답변과 실행 결과를 확인 | 도구 결과를 다음 판단에 전달 |
| 멈춤 관리 | 대화를 끝내는 판단 | 완료 검사, 승인, 반복 제한 |
이 장의 루프는 생각, 행동, 관찰이라는 세 구간으로 읽는다. 생각은 현재 정보에서 다음 행동을 고르는 구간이다. 행동은 계산기나 메모 도구를 실제로 실행하는 구간이다. 관찰은 도구가 반환한 결과를 기록하는 구간이다. 관찰이 추가되면 모델은 처음과 다른 정보를 보게 된다.
여기서 생각이라는 말은 모델 내부의 긴 추론 내용을 수집한다는 뜻이 아니다. 예제에서는 “합계를 먼저 구한다”처럼 선택한 행동의 짧은 이유만 기록한다. 실행을 이해하는 데 필요한 것은 어떤 행동을 요청했고, 도구가 무엇을 반환했으며, 어떤 조건으로 멈췄는지다.
모델의 요청과 도구의 결과를 섞지 않는 것이 중요하다. 모델이 계산기 호출을 요청했다는 사실은 계산 성공을 뜻하지 않는다. 모델이 완료를 제안했다는 사실도 작업 완료를 뜻하지 않는다. 이 예제에서는 계산기와 메모 도구가 성공한 관찰을 남겼는지 하네스가 확인한 뒤 완료를 인정한다.
가짜 모델은 딕셔너리를 반환한다. 아직 계산 결과가 없으면 계산기를 요청하고, 계산 결과는 있지만 메모 결과가 없으면 메모를 요청한다. 두 결과가 모두 있으면 완료를 제안한다. 이처럼 입력과 출력의 관계가 정해져 있으면, 모델 호출 비용이나 응답 변동 없이 루프 자체를 검사할 수 있다.
다만 가짜 모델로 확인한 것은 하네스의 흐름이다. 실제 모델이 항상 같은 도구를 고르거나 올바른 입력을 만든다는 사실까지 확인한 것은 아니다. 이 경계를 기억해야 예제의 성공을 실제 서비스의 신뢰성으로 확대 해석하지 않는다.
고정 절차로 충분한 일
정해진 순서로 충분한 일에는 고정 절차를 먼저 고려한다. 두 수를 더하고 기준과 비교하는 과정처럼 다음 단계가 이미 확정된 작업은 함수 호출과 조건문으로 표현하기 쉽다. 각 단계의 입력과 출력이 명확하고 실패 처리도 일정하다면, 모델에게 매번 다음 행동을 고르게 할 이유가 줄어든다.
반대로 관찰에 따라 필요한 행동의 종류나 순서가 달라지는 작업에는 에이전트 구조를 검토할 수 있다. 자료가 부족하면 추가 자료를 찾고, 충분하면 계산하고, 결과가 모순되면 다른 근거를 확인하는 식이다. 이때도 모든 일을 자유롭게 맡기는 것이 아니라 허용된 도구와 멈춤 조건 안에서 선택하게 한다.
| 작업의 특징 | 먼저 고려할 방식 | 이유 |
|---|---|---|
| 순서와 분기가 모두 알려져 있다 | 고정 절차 | 실행 경로를 쉽게 예측하고 검사한다 |
| 관찰에 따라 필요한 단계가 달라진다 | 제한된 에이전트 루프 | 새 정보에 맞춰 다음 행동을 선택한다 |
| 결과가 다른 사람이나 시스템에 영향을 준다 | 승인 지점이 있는 실행 | 행동 전에 내용과 범위를 확인한다 |
| 성공 여부를 확인할 방법이 없다 | 완료 기준부터 정리 | 반복해도 끝났는지 판단하기 어렵다 |
우리 예제의 기준 판정은 고정 절차로도 구현할 수 있다. 아래 함수는 합계를 구한 뒤 반환할 문구를 정한다. 파일에 저장하기 전의 계산 부분만 떼어 놓으면 이렇게 짧다.
def make_note(left, right, threshold):
total = left + right
label = "확인 필요" if total >= threshold else "기준 미만"
return f"합계 {total}: {label}"
assert make_note(18, 24, 40) == "합계 42: 확인 필요"
assert make_note(10, 20, 40) == "합계 30: 기준 미만"
에이전트를 쓸지 판단할 때는 “여러 단계인가”보다 “다음 단계가 미리 정해져 있는가”를 본다. 단계가 많아도 순서가 고정되어 있다면 일반 프로그램이 잘 맞는다. 단계가 적어도 관찰에 따라 다른 도구를 골라야 한다면 루프가 유용할 수 있다.
기존 프로그램 전체를 에이전트로 바꿀 필요도 없다. 일부 구간만 모델이 선택하게 하고 나머지는 고정 절차로 처리할 수 있다. 계산처럼 정확한 규칙이 있는 일은 도구에 맡기고, 하네스는 그 결과를 확인한다. 역할을 이렇게 나누면 어떤 부분을 테스트해야 하는지도 분명해진다.
승인과 멈춤은 하네스가 관리한다
모델이 행동을 요청했더라도 하네스는 즉시 실행할 필요가 없다. 사람의 판단이 필요한 행동 앞에 승인 지점을 둘 수 있다. 승인은 실행하려는 내용을 보고 내리는 결정이어야 한다. 저장이 끝난 뒤 “저장해도 되는가”를 묻는 것은 승인 관문으로 작동하지 않는다.
이 장에서는 메모 저장 전에 승인 함수를 호출한다. 예제의 파일은 임시 폴더 안에만 만들어지지만, 승인 위치를 배우기 위해 저장을 승인 대상 행동으로 둔다. 계산은 바로 실행하고, 메모는 내용을 보여 준 뒤 승인 결과가 참일 때만 실행한다.
프로그램은 입력을 기다리지 않고 바로 끝나야 하므로, 사람이 미리 승인한 상황을 참 값으로 표현한다. 출력에 나타나는 승인 표시는 실제 사람이 실행 중에 응답했다는 뜻이 아니다. 실제 시스템에서는 같은 위치에 사람의 결정이 들어오도록 연결해야 한다. 승인 대기 화면이나 알림을 만드는 일은 이 장의 범위에 넣지 않는다.
완료 조건과 반복 제한도 구분한다. 완료 조건은 일을 끝냈다는 근거다. 여기서는 계산 성공과 메모 저장 성공이 모두 있어야 한다. 최대 반복 수는 더 진행하지 않겠다는 한도다. 반복 한도에 도달했다면 성공이 아니라 제한 때문에 중단한 것이다.
완성 코드에서는 모델을 한 번 호출할 때마다 한 회로 센다. 계산 요청이 첫 회, 메모 요청이 둘째 회, 완료 제안이 셋째 회다. 도구 호출이 두 번뿐이어도 모델의 완료 판단까지 포함하면 세 회가 필요하다. 반복 횟수를 세는 기준을 먼저 정해야 제한값을 잘못 해석하지 않는다.
승인 거절, 도구 오류, 잘못된 요청, 반복 한도 도달은 서로 다른 중단 사유다. 이 예제는 오류가 나면 바로 멈춘다. 실패할 때마다 자동으로 재시도하면 같은 행동을 되풀이할 수 있으므로, 재시도는 별도의 규칙이 있을 때 추가한다.
완성 코드
다음 코드를 main.py로 저장한다. Python 3.12 이상의 표준 라이브러리만 사용한다. 메모 파일은 프로그램이 만든 임시 폴더에 저장하고 종료할 때 정리한다. 임시 폴더의 실제 경로는 출력하지 않아 실행마다 기록이 같게 유지된다.
코드는 작업을 나타내는 딕셔너리, 가짜 모델, 두 도구, 실행 루프, 자체 검사로 구성된다. 자체 검사는 정상 완료뿐 아니라 승인 거절과 반복 제한도 확인한다. 검사를 통과하면 단계별 기록을 출력하는 정상 실행을 한 번 수행한다.
from pathlib import Path
from tempfile import TemporaryDirectory
TASK = {"left": 18, "right": 24, "threshold": 40}
def latest(observations, tool):
for item in reversed(observations):
if item["tool"] == tool:
return item
return None
def fake_model(task, observations):
calculation = latest(observations, "calculator")
if calculation is None:
return {
"kind": "tool",
"reason": "합계를 먼저 구한다.",
"name": "calculator",
"args": {"left": task["left"], "right": task["right"]},
}
memo = latest(observations, "memo")
if memo is None:
total = calculation["value"]
label = (
"확인 필요"
if total >= task["threshold"]
else "기준 미만"
)
return {
"kind": "tool",
"reason": "계산 결과를 메모로 남긴다.",
"name": "memo",
"args": {"text": f"합계 {total}: {label}"},
}
return {
"kind": "finish",
"reason": "계산과 메모 저장 결과를 확인했다.",
}
def calculator(left, right):
if type(left) is not int or type(right) is not int:
raise ValueError("계산기 입력은 정수여야 한다.")
return {"tool": "calculator", "ok": True, "value": left + right}
def memo(root, text):
if not isinstance(text, str) or not text:
raise ValueError("메모 내용은 빈 문자열일 수 없다.")
path = root / "memo.txt"
path.write_text(text, encoding="utf-8")
return {"tool": "memo", "ok": True, "value": text}
def is_complete(observations):
calculation = latest(observations, "calculator")
saved = latest(observations, "memo")
return (
calculation is not None
and calculation["ok"]
and saved is not None
and saved["ok"]
)
def run(task, root, approve, max_steps=4, emit=print):
observations = []
for step in range(1, max_steps + 1):
action = fake_model(task, observations)
emit(f"[{step}] 생각: {action['reason']}")
if action["kind"] == "finish":
if is_complete(observations):
emit(f"[{step}] 종료: 완료")
return "completed", observations
emit(f"[{step}] 종료: 완료 근거 부족")
return "incomplete", observations
if action["kind"] != "tool":
emit(f"[{step}] 종료: 잘못된 행동 종류")
return "invalid_action", observations
name = action["name"]
args = action["args"]
try:
if name == "calculator":
emit(
f"[{step}] 행동: calculator"
f"({args['left']}, {args['right']})"
)
result = calculator(**args)
elif name == "memo":
text = args["text"]
emit(f"[{step}] 요청: memo({text})")
if not approve(text):
emit(f"[{step}] 종료: 승인 거절")
return "denied", observations
emit(f"[{step}] 승인: 허용")
emit(f"[{step}] 행동: memo 저장")
result = memo(root, **args)
else:
emit(f"[{step}] 종료: 알 수 없는 도구")
return "unknown_tool", observations
except (OSError, ValueError, TypeError, KeyError) as error:
emit(f"[{step}] 종료: 도구 오류 ({type(error).__name__})")
return "tool_error", observations
observations.append(result)
emit(f"[{step}] 관찰: {name} 성공, 값={result['value']}")
emit(f"[종료] 최대 반복 수 {max_steps}회 도달")
return "limit", observations
def self_check():
quiet = lambda message: None
with TemporaryDirectory() as folder:
root = Path(folder)
status, observations = run(
TASK, root, lambda text: True, emit=quiet
)
assert status == "completed"
assert len(observations) == 2
assert (root / "memo.txt").read_text(
encoding="utf-8"
) == "합계 42: 확인 필요"
with TemporaryDirectory() as folder:
root = Path(folder)
status, observations = run(
TASK, root, lambda text: False, emit=quiet
)
assert status == "denied"
assert len(observations) == 1
assert not (root / "memo.txt").exists()
with TemporaryDirectory() as folder:
root = Path(folder)
status, observations = run(
TASK, root, lambda text: True, max_steps=1, emit=quiet
)
assert status == "limit"
assert len(observations) == 1
assert not (root / "memo.txt").exists()
first = fake_model(TASK, [])
assert first["name"] == "calculator"
result = calculator(10, 20)
second = fake_model(TASK, [result])
assert second["args"]["text"] == "합계 30: 기준 미만"
def main():
self_check()
print("자체 검사: 통과")
human_approved = True
with TemporaryDirectory() as folder:
root = Path(folder)
status, _ = run(
TASK, root, lambda text: human_approved
)
print(f"최종 상태: {status}")
if status == "completed":
content = (root / "memo.txt").read_text(encoding="utf-8")
print(f"메모 확인: {content}")
if __name__ == "__main__":
main()
줄별 해설
처음 두 줄은 파일 경로를 다루는 Path와 임시 폴더를 만드는 TemporaryDirectory를 가져온다. TASK는 이번 작업의 두 수와 기준값을 담는다. 모델에 긴 문장을 해석하게 하는 기능은 넣지 않았다. 이미 구조가 정해진 작업 입력을 사용해 실행 루프에 집중한다.
latest 함수는 관찰 목록을 뒤에서부터 읽는다. 같은 도구를 여러 번 실행했을 때 가장 최근 결과를 찾기 위한 순서다. 일치하는 항목이 없으면 None을 반환한다. 가짜 모델은 이 차이를 이용해 아직 수행하지 않은 행동을 선택한다.
fake_model의 첫 조건은 계산 관찰이 있는지 확인한다. 없으면 kind가 tool인 딕셔너리를 반환한다. name은 실행할 도구이고 args는 도구에 넘길 인수다. reason은 기록에 표시할 짧은 판단 이유다. 이 함수는 계산기를 직접 실행하지 않는다. 행동 요청을 만드는 역할만 한다.
계산 관찰이 있으면 value를 읽어 기준과 비교한다. 그 결과로 메모 내용을 만든다. 메모 관찰이 이미 있으면 kind가 finish인 완료 제안을 반환한다. “도구를 요청할 것인가, 완료를 제안할 것인가”를 출력 형태로 구별한 셈이다.
calculator는 두 정수를 더해 관찰 딕셔너리를 반환한다. 문자열 표현식을 받아 실행하는 방식은 쓰지 않는다. type으로 정수를 확인한 것은 참과 거짓이 정수처럼 계산에 들어오는 것을 피하기 위해서다. 예제에서는 숫자 두 개만 받는 계산기로 범위를 좁혔다.
memo는 전달받은 임시 폴더 아래 memo.txt에 내용을 쓴다. 저장 중 예외가 발생하면 성공 결과를 반환하는 줄에 도달하지 못한다. 따라서 하네스는 저장 요청과 저장 성공을 구별할 수 있다. 파일 이름을 관찰 값으로 사용하지 않고 내용 자체를 반환해 다음 판단에서 다루기 쉽게 했다.
is_complete는 계산과 메모의 성공 관찰이 모두 있는지 확인한다. 이 예제의 가짜 모델은 예상된 형식만 반환하고, 도구는 성공한 경우에만 관찰을 만든다. 완료 검사는 그 제한된 계약을 전제로 한다. 실제 모델과 더 많은 도구를 연결할 때에는 내용이 요청과 맞는지도 검사해야 하지만, 여기서는 두 행동의 성공을 완료 기준으로 삼는다.
run의 observations는 한 번의 실행 동안 쌓이는 도구 결과다. 반복문은 1부터 max_steps까지 진행한다. 매 회 가짜 모델에 같은 작업과 현재 관찰을 전달한다. 모델은 관찰이 늘어난 만큼 다음 행동을 바꾸지만, 관찰 목록에 직접 항목을 추가하지는 않는다.
완료 제안이 오면 is_complete를 먼저 호출한다. 성공 근거가 충분할 때만 completed를 반환한다. 완료 근거가 부족하면 incomplete로 멈춘다. 모델의 문장과 하네스의 성공 판정을 별도로 둔 부분이다.
계산기 요청은 즉시 실행한다. 메모 요청은 내용을 기록하고 approve를 호출한다. 승인 결과가 거짓이면 memo 함수를 호출하기 전에 반환한다. 따라서 거절된 실행에는 계산 관찰만 남고 파일은 생기지 않는다. 승인 함수가 메모 내용을 인수로 받는 이유는 사람이 실행 대상을 확인할 수 있어야 하기 때문이다.
도구 실행이 성공하면 observations.append가 결과를 목록에 추가한다. 이 줄 뒤에 관찰 기록을 출력한다. 다음 반복에서는 추가된 결과가 모델의 입력에 포함된다. 루프가 단순한 동일 작업 반복과 다른 이유가 이 지점에 있다.
예외 처리에서는 파일 오류와 입력 오류 등을 잡아 tool_error로 종료한다. 예외를 성공 관찰처럼 기록하지 않는다. 반복문을 끝까지 수행하면 limit를 반환한다. 마지막 회에 메모를 저장했더라도 완료 제안을 확인할 회차가 없으면 제한 종료로 표시된다. 이 예제는 완료 제안을 검사하는 회차까지 실행 계약에 포함한다.
self_check는 출력을 숨기는 함수를 emit으로 전달한다. 정상 실행에서는 상태뿐 아니라 실제 저장 내용도 읽어 확인한다. 승인 거절과 한 회 제한에서는 파일이 없다는 사실을 확인한다. 마지막 검사는 합계가 기준 미만일 때 메모 문구가 달라지는지 확인한다.
main의 human_approved는 미리 받은 사람의 결정을 흉내 내는 값이다. 정상 실행 뒤에는 파일을 다시 읽어 출력한다. 도구의 성공 기록 외에 저장된 내용을 직접 확인하는 단계다. 임시 폴더의 with 구간이 끝나면 파일도 정리된다. 따라서 이 예제의 완료는 임시 메모에 쓰고 내용을 확인했다는 뜻이며, 영구 보관을 뜻하지 않는다.
실행 결과
먼저 경고를 오류로 취급해 컴파일하고, 이어서 실행한다. 첫 명령은 성공하면 출력을 만들지 않는다. 컴파일은 문법을 확인할 뿐 실행 동작을 보장하지 않으므로 두 번째 명령도 수행한다. 기본 실행에서는 assert가 활성화되어 자체 검사가 동작한다.
python3 -W error -m py_compile main.py
python3 main.py
두 번째 명령의 예상 출력은 다음과 같다. 경로, 현재 시각, 난수를 출력하지 않으므로 같은 코드는 같은 기록을 만든다.
자체 검사: 통과
[1] 생각: 합계를 먼저 구한다.
[1] 행동: calculator(18, 24)
[1] 관찰: calculator 성공, 값=42
[2] 생각: 계산 결과를 메모로 남긴다.
[2] 요청: memo(합계 42: 확인 필요)
[2] 승인: 허용
[2] 행동: memo 저장
[2] 관찰: memo 성공, 값=합계 42: 확인 필요
[3] 생각: 계산과 메모 저장 결과를 확인했다.
[3] 종료: 완료
최종 상태: completed
메모 확인: 합계 42: 확인 필요
첫 회에는 계산 결과가 없으므로 계산기를 실행한다. 둘째 회에는 합계 42를 보고 메모 내용을 만든다. 셋째 회에는 두 도구의 성공 관찰이 있으므로 완료를 제안하고, 하네스가 이를 인정한다. 출력에서 요청, 승인, 행동, 관찰의 순서를 따라가면 저장이 어느 시점에 일어났는지 확인할 수 있다.
코드를 바꾼 뒤에는 자체 검사 통과 문구만 보지 않는다. 변경 전후의 차이도 검토한다. 특히 승인 함수 호출이 저장 뒤로 이동하지 않았는지, 관찰이 도구 실행 전에 추가되지 않았는지, 제한 종료가 성공으로 바뀌지 않았는지를 확인한다. AI가 제안한 수정도 같은 방식으로 실행과 검사와 변경 차이 검토를 거쳐 받아들인다.
실무에서 자주 틀리는 것
관찰을 다음 판단에 전달하지 않는다
다음 함수는 매번 빈 관찰 목록으로 모델을 호출한다. 여러 회 실행해도 모델은 계산이 아직 수행되지 않았다고 판단한다. 횟수만 늘고 실행 상태는 이어지지 않는 구조다. 예시 함수는 자체적으로 실행할 수 있도록 작은 선택 규칙을 함께 둔다.
def choose(observations):
return "finish" if observations else "calculator"
def wrong():
observations = []
for _ in range(2):
action = choose([])
observations.append(action)
return observations
assert wrong() == ["calculator", "calculator"]
고친 코드는 현재 관찰 목록을 전달한다. 도구 결과가 입력에 반영되어야 다음 선택이 달라진다. 실제 하네스에서는 문자열 대신 도구가 반환한 결과를 목록에 넣는다.
def choose(observations):
return "finish" if observations else "calculator"
def corrected():
observations = []
for _ in range(2):
action = choose(observations)
if action == "finish":
break
observations.append(action)
return observations
assert corrected() == ["calculator"]
저장한 다음 승인을 확인한다
승인 확인이 파일 쓰기 뒤에 있으면 거절해도 행동은 이미 끝난 상태다. 아래 코드는 임시 폴더 안에서만 실행되지만, 승인 관문의 위치가 잘못되었다는 점을 보여 준다.
from pathlib import Path
from tempfile import TemporaryDirectory
with TemporaryDirectory() as folder:
path = Path(folder) / "memo.txt"
approved = False
path.write_text("합계 42", encoding="utf-8")
if not approved:
assert path.exists()
고친 코드는 승인된 경우에만 쓰기 함수를 호출한다. 거절 경로에서는 파일이 없다는 사실을 확인한다. 승인 결과를 출력하는 것과 승인 결과로 실행을 제어하는 것은 다르다.
from pathlib import Path
from tempfile import TemporaryDirectory
with TemporaryDirectory() as folder:
path = Path(folder) / "memo.txt"
approved = False
if approved:
path.write_text("합계 42", encoding="utf-8")
assert not path.exists()
요청한 행동을 성공한 행동으로 기록한다
모델의 요청을 받자마자 성공 관찰을 만드는 실수도 있다. 아래에서는 저장 함수가 실패했는데도 성공 기록이 남는다. 예외를 조용히 넘기는 처리까지 더해져 이후 판단이 잘못된 근거를 사용하게 된다.
def save():
raise OSError("저장 실패")
observations = []
observations.append({"tool": "memo", "ok": True})
try:
save()
except OSError:
pass
assert observations[0]["ok"] is True
고친 코드는 성공한 경우에만 성공 관찰을 추가한다. 실패 시에는 실행을 실패 상태로 끝낸다. 재시도 여부를 정하지 않은 상태에서 오류를 숨기고 계속 진행하지 않는다.
def save():
raise OSError("저장 실패")
observations = []
try:
save()
except OSError:
status = "tool_error"
else:
observations.append({"tool": "memo", "ok": True})
status = "saved"
assert status == "tool_error"
assert observations == []
반복이 끝났다는 이유로 성공을 반환한다
반복 제한은 일을 끝냈다는 증거가 아니다. 다음 함수는 한 번도 완료 조건을 만족하지 않았는데 성공을 반환한다. 실행 시간을 제한했어도 결과를 잘못 보고하는 문제는 남는다.
def wrong_run(max_steps):
for _ in range(max_steps):
completed = False
if completed:
return "completed"
return "completed"
assert wrong_run(2) == "completed"
고친 함수는 완료 조건으로 빠져나온 경우와 횟수를 소진한 경우를 구별한다. 호출한 쪽도 limit를 보고 후속 작업을 성공 경로로 진행해서는 안 된다.
def corrected_run(max_steps):
for _ in range(max_steps):
completed = False
if completed:
return "completed"
return "limit"
assert corrected_run(2) == "limit"
한눈에 보기
| 부분 | 맡는 일 | 확인할 질문 |
|---|---|---|
| 작업 입력 | 목표에 필요한 값 전달 | 무엇을 끝내야 하는가 |
| 가짜 모델 | 관찰을 보고 다음 행동 선택 | 왜 이 행동이 다음인가 |
| 도구 | 계산이나 파일 쓰기 실행 | 실제 결과가 반환되었는가 |
| 관찰 목록 | 실행 결과를 다음 판단에 전달 | 요청과 결과를 구별했는가 |
| 승인 함수 | 실행 전에 사람의 결정 반영 | 거절하면 행동을 막는가 |
| 완료 검사 | 성공 근거 확인 | 필요한 행동이 성공했는가 |
| 반복 제한 | 계속 진행하는 횟수 제한 | 한도 도달을 성공과 구별하는가 |
에이전트의 핵심은 반복문 자체가 아니라 관찰을 반영하는 선택이다. 하네스의 핵심은 선택을 실제 행동으로 연결하면서 실행 경계를 지키는 것이다. 두 역할을 구별하면 모델을 바꾸더라도 승인과 종료에 관한 규칙을 유지하기 쉽다. 다음 장에서는 이번 예제의 도구를 이름, 입력, 출력, 위험도라는 관점에서 더 명확하게 정의한다.
연습 문제
- 완성 코드의 max_steps를 2로 설정해 실행한다고 하자. 어떤 도구까지 실행되는지, 최종 상태는 무엇인지 설명한다. 파일이 있다는 사실만으로 완료 상태를 반환해도 되는지 함께 생각한다.
- human_approved를 False로 바꾼다. 마지막으로 출력되는 상태와 메모 파일의 생성 여부를 예측한다. 자체 검사에는 어떤 영향이 있는지 설명한다.
- 합계가 기준값과 정확히 같을 때 “확인 필요”가 되는지 검사하는 코드를 self_check에 추가한다. 파일 쓰기 없이 가짜 모델의 메모 요청만 확인한다.
- “매일 정해진 두 수를 합산해 같은 형식으로 저장한다”와 “입력 자료의 상태에 따라 필요한 도구를 골라 결과를 만든다” 중 어느 쪽에 에이전트 루프를 먼저 검토할지 설명한다.
정답과 해설
첫 회에는 계산기를 실행하고 둘째 회에는 승인 뒤 메모를 저장한다. 다음 회에 완료 제안을 검사해야 하지만 제한이 2이므로 최종 상태는 limit다. 메모는 임시 폴더가 유지되는 동안 존재한다. 파일 존재만으로는 요청한 내용이 맞는지까지 알 수 없다. 이 예제에서는 완료 제안을 검사하는 회차까지 포함한 규칙을 따르므로 제한 종료를 완료로 바꾸지 않는다. 완료를 도구 실행 직후 판정하는 구조도 가능하지만, 그 경우에는 하네스의 판정 위치와 검사도 함께 바꿔야 한다.
정상 실행 부분은 계산을 마친 뒤 둘째 회의 메모 요청에서 승인 거절로 멈춘다. 최종 상태는 denied이며 메모 파일은 생성되지 않는다. main은 completed일 때만 파일을 읽으므로 메모 확인 줄도 출력하지 않는다. 자체 검사는 별도의 승인 함수를 사용하므로 그대로 통과한다. 실행 설정과 검사 설정이 서로 독립적이라는 점을 확인할 수 있다.
아래 코드를 self_check 안에 추가한다. 비교 연산자가 크거나 같음을 포함하는지 경계값으로 확인한다. 계산기 결과를 관찰로 전달하고, 다음 행동의 메모 내용을 검사한다.
boundary_task = {"left": 16, "right": 24, "threshold": 40} observation = calculator(16, 24) action = fake_model(boundary_task, [observation]) assert action["name"] == "memo" assert action["args"]["text"] == "합계 40: 확인 필요"이 검사는 정상 예제의 숫자만 다시 확인하지 않고, 기준과 같은 값에서 분기가 어떻게 동작하는지 확인한다. 수정 뒤에는 컴파일과 전체 실행을 다시 수행하고 비교 연산자의 변경 차이도 검토한다.
정해진 두 수를 같은 형식으로 저장하는 일에는 고정 절차를 먼저 쓴다. 자료 상태에 따라 필요한 도구가 달라지는 일에는 제한된 에이전트 루프를 검토한다. 다만 분기가 모두 알려져 있고 조건문으로 명확히 표현된다면 두 번째 일도 고정 절차가 적합할 수 있다. 단계 수보다 다음 행동을 미리 결정할 수 있는지가 판단 기준이다.