AI Agents — A Graph, Not a Model
The One Call With Made-Up Arguments
한국어 원문으로 표시합니다.
목표
도구를 부르는 쪽의 규율을 코드로 적는다. 계약 표를 한 곳에 두고, 고른 인자를 부르기 전에 재고, 돌려받은 결과를 믿지 않고 재고, 실패를 예외가 아니라 값으로 다뤄 사유를 상태에 남기고, 그 사유로 다음 시도를 고치고, 같은 인자로 두 번 부르지 않게 기억한다.
왜 중요한가
도구를 붙인 에이전트가 처음 깨지는 자리는 모델이 아니라 호출 경계다. 모델이 인자 이름을 하나 지어내면 TypeError 가 그래프 밖으로 튀어나와 실행 전체가 끝나고, 상태도 지나온 길도 남지 않는다. 더 나쁜 쪽은 예외가 나지 않는 경우다 — 도구가 빈 목록을 돌려줬는데 그 위에 "재고 3곳" 이라는 문장이 만들어진다.
그래서 이 실습은 계약을 값으로 적는 것에서 시작한다. 이름·인자 스키마·돌려주는 모양·실제 함수를 표 하나에 묶어 두면, 재는 코드가 도구마다 필요 없고 부르는 문이 한 곳으로 좁아진다. 그 문 앞에서 인자를 재고, 문 뒤에서 결과를 재고, 문 앞에 기억을 둔다.
인자를 고르는 자리는 이 실습에서 규칙으로 대신합니다. 실제 에이전트는 그 자리(pick 노드)에서 모델에게 도구 이름과 인자를 묻습니다. 나머지 구조는 똑같습니다 — 오히려 모델이 고를수록 부르기 전 검증이 더 필요합니다.
채점기는 여러분이 적어 둔 설명을 믿지 않습니다. 여러분의 모듈을 실제로 불러 검증 함수를 매번 다른 값으로 두드려 보고, 도구 몸통이 몇 번 돌았는지를 세어 "부르기 전에 막았는가" 를 확인합니다. sku·지역·개수는 실행마다 바뀝니다.
단계
- /root/work/agtool/tools.py 에 자료(
REGIONS·STOCK)와MAX_ROWS·CALLS, 도구 둘(stock_lookup·legacy_stock), 계약 표TOOLS를 만드세요. validate_args(name, args)를 만들어 부르기 전에 인자를 재게 하세요. 통과하면 빈 문자열, 아니면 사유 하나를 돌려줍니다.call_tool(name, args)를 만들어 검증을 통과한 호출만 실제로 부르게 하세요. 예외는 밖으로 내지 않고{"ok", "value", "error"}로 돌려줍니다.validate_result(name, value)를 만들고call_tool이 결과도 재게 하세요. 사유는shape·empty·too_big·range입니다.CACHE와cache_key(name, args)를 만들어 같은 도구를 같은 인자로 두 번 부르지 않게 하세요. 성공만 기억합니다.State와 노드 넷(pick·fetch·reply·giveup),build_naive()·run_naive(order)를 만들어 실패를 예외가 아니라 경로로 다루세요.MAX_ATTEMPTS·REPAIRS·can_repair·repair노드와build_graph()·handle(order)를 더해 사유를 보고 고쳐 다시 시도하게 하세요.- /root/work/agtool/tool_report.json 과 /root/work/agtool/tool_report.md 에 확인한 것을 기록하세요.
참고
- 실행 계약: 채점기는
/root/work/agtool/tools.py를 파이썬 모듈로 불러REGIONS·STOCK·MAX_ROWS·CALLS·TOOLS·stock_lookup·legacy_stock·validate_args·validate_result·call_tool·CACHE·cache_key·run_naive·MAX_ATTEMPTS·REPAIRS·can_repair·handle을 직접 씁니다. 스크립트로 실행하지 않으므로if __name__ == "__main__"은 없어도 됩니다. - 이 실습의 자료는 이렇게 씁니다.
REGIONS = {"seoul": ["gasan", "guro", "mapo"], "busan": ["sasang", "haeundae"], "jeju": ["hallim"]},STOCK = {"A-1001": {"gasan": 4, "guro": 2, "mapo": 7, "sasang": 1}, "A-1002": {"guro": 5, "haeundae": 3}, "B-2001": {"gasan": 9, "guro": 1, "mapo": 9, "hallim": 2}, "B-2002": {"sasang": 6, "haeundae": 6}},MAX_ROWS = 2. - 두 도구의 서명은
(sku, region, limit)로 같고, 돌려주는 것도{"sku": 문자열, "rows": [{"warehouse": 문자열, "count": 정수}, ...]}로 같습니다.rows는 개수 내림차순, 같으면 창고 이름 오름차순입니다.stock_lookup은limit개까지만 돌려주고,legacy_stock은limit을 무시하고 그 지역의 창고를 전부 돌려줍니다(옛 판 흉내). CALLS는 도구 몸통이 돈 횟수를 이름별로 세는 딕셔너리입니다. 두 도구의 첫 줄에서 1씩 올리세요. 채점기가 이 값으로 "부르기 전에 막았는가" 를 확인합니다.TOOLS한 항목의 모양:{"fn": 함수, "args": {인자이름: {"type": "str"|"int", "required": True, "allowed": [...], "min": N, "max": N}}, "returns": {"sku": {"type": "str"}, "rows": {"type": "list", "max_len": MAX_ROWS, "item": {"warehouse": {"type": "str"}, "count": {"type": "int", "min": 0}}}}}.region의allowed는REGIONS의 열쇠들이고,limit은min1 ·maxMAX_ROWS입니다.validate_args의 사유:unknown_tool·missing:<이름>·extra:<이름>·type:<이름>·allowed:<이름>·range:<이름>. 통과하면 빈 문자열입니다. 채점기는 결함이 하나뿐인 인자만 던집니다.validate_result의 사유:shape(모양·형이 다르다) ·empty(rows가 비었다) ·too_big(rows가max_len보다 길다) ·range(count가 계약의 범위 밖이다). 통과하면 빈 문자열입니다.call_tool이 돌려주는error는 앞에 출처를 붙입니다: 인자 쪽은args:<사유>, 결과 쪽은result:<사유>, 도구가 예외를 던졌으면raised:<예외이름>. 성공이면 빈 문자열입니다.handle(order)의order는{"sku": ..., "region": ..., "limit": ..., "prefer": ...}입니다.prefer가 있으면 그 도구를, 없으면stock_lookup을 씁니다. 답은{"ok", "answer", "rows", "errors", "fixes", "attempts", "calls", "tool_runs"}이고tool_runs는 이 건을 처리하는 동안 도구 몸통이 돈 횟수입니다.REPAIRS는{"args:allowed:region": "region", "args:range:limit": "limit", "result:too_big": "tool"}입니다. 지역은FALLBACK_REGION("seoul")으로, 개수는MAX_ROWS로, 도구는stock_lookup으로 고칩니다.MAX_ATTEMPTS는 3 입니다.- 노드 이름과 상태 열쇠는 같은 이름 공간입니다. 상태에
tool열쇠를 두고 노드도tool이라고 부르면ValueError: 'tool' is already being used as a state key로 컴파일이 죽습니다. - 이 파드에는 인터넷이 없습니다.
pip install은 되지 않습니다. langgraph 0.2.60 이 이미 들어 있습니다(python3 -c "import langgraph"). - 공식 문서: Workflows and agents · Use the graph API · Graph API overview
- 흔한 실수: 부르고 나서 인자를 재기(몸통이 이미 돌았습니다),
isinstance(True, int)가 참이라True를 개수로 받기, 빈 결과를 성공으로 넘기기, 실패까지 기억해 고쳐도 다시 못 부르기, 시도 상한 없이 고치고 또 고치기.
계약을 값으로 한 곳에 적는다
/root/work/agtool/tools.py 에 REGIONS·STOCK·MAX_ROWS·CALLS 와 도구 둘(stock_lookup·legacy_stock), 계약 표 TOOLS 를 만드세요. TOOLS 한 항목은 fn·args·returns 셋을 들고, 두 도구는 첫 줄에서 CALLS 를 1 올립니다.
args 는 인자 이름마다 type·required 와 필요하면 allowed·min·max 를 적은 딕셔너리입니다. returns 에는 돌려주는 열쇠의 형과 rows 의 max_len·item 을 적습니다. stock_lookup 은 limit 개까지만, legacy_stock 은 limit 을 무시하고 전부 돌려줍니다 — 결과 검증이 필요한 이유를 뒤에서 이 도구로 봅니다. 참고 절의 자료표를 그대로 쓰세요.
부르기 전에 인자를 잰다
validate_args(name, args) 를 만드세요. 계약 표를 읽어 빠진 인자·계약에 없는 인자·형·허용 목록·범위를 재고, 통과하면 빈 문자열을, 아니면 사유 하나(missing:limit 처럼)를 돌려줍니다.
사유 이름은 참고 절에 있는 그대로 쓰세요. 파이썬에서 bool 은 int 의 하위형이라 isinstance(True, int) 가 참입니다 — 개수 자리에 True 가 들어오면 type: 으로 막아야 합니다. 모르는 도구 이름이면 unknown_tool 입니다. 결함이 여럿일 때 무엇을 먼저 돌려줄지는 자유이지만, 채점기는 결함이 하나뿐인 인자만 던집니다.
잘못된 인자로는 부르지 않는다
call_tool(name, args) 를 만드세요. 검증을 통과한 호출만 실제로 부르고, 예외는 밖으로 내지 않습니다. 답은 늘 {"ok": 참거짓, "value": 결과 또는 None, "error": 사유} 이고, 인자 쪽 사유에는 args: 를, 도구가 던진 예외에는 raised: 를 앞에 붙입니다.
실제 함수는 TOOLS[name]["fn"] 에서 꺼내 fn(**args) 로 부릅니다 — 부르는 문을 한 곳으로 좁히는 것이 요점입니다. 검증에서 걸리면 거기서 끝내세요. 채점기는 잘못된 인자를 준 뒤 CALLS 가 그대로인지 봅니다. 부르고 나서 예외를 잡는 방식은 이 검사를 통과하지 못합니다.
돌려준 것을 믿지 않는다
validate_result(name, value) 를 만들고 call_tool 이 결과도 재게 하세요. 사유는 shape·empty·too_big·range 이고, 걸리면 call_tool 은 error 에 result: 를 붙여 돌려줍니다.
계약의 returns 를 읽어 재세요 — 도구마다 손으로 적으면 도구가 늘 때 빠뜨립니다. rows 가 비었으면 empty 입니다(오류가 아니라고 성공으로 넘기면 답을 지어내게 됩니다). max_len 보다 길면 too_big 이고, legacy_stock 이 바로 그 경우를 만듭니다. 행 안의 count 가 계약의 min 보다 작으면 range 입니다.
같은 것을 두 번 묻지 않는다
CACHE 와 cache_key(name, args) 를 만들어 call_tool 이 같은 도구·같은 인자의 결과를 기억하게 하세요. 인자를 적은 순서가 달라도 같은 열쇠가 나와야 하고, 성공만 기억합니다.
json.dumps(args, sort_keys=True, ensure_ascii=False) 를 이름과 이어 붙이면 순서에 흔들리지 않는 열쇠가 됩니다. 기억은 검증을 통과한 뒤, 부르기 직전에 찾아봅니다. 실패까지 기억하면 고쳐서 다시 부를 길이 막히니 성공만 넣으세요. 채점기는 같은 인자로 두 번 불러 CALLS 가 한 번만 올라가는지, 실패한 호출은 두 번 다 몸통이 도는지 봅니다.
실패를 경로로 다룬다
State 와 노드 넷(pick·fetch·reply·giveup), build_naive()·run_naive(order) 를 만드세요. fetch 는 실패해도 예외를 내지 않고 사유를 errors 에 남기며, 실패하면 giveup 으로 갑니다. run_naive 는 {"ok", "answer", "rows", "errors", "calls"} 를 돌려줍니다.
pick 은 주문에서 도구 이름과 인자를 고릅니다 — 실제 에이전트는 이 자리에서 모델에게 묻습니다. errors·calls 에는 이어 붙이는 리듀서를, attempts 에는 더하는 리듀서를 붙이세요. 이 단계의 판은 고치지 않습니다 — 한 번 부르고 실패하면 그대로 포기합니다. reply 는 rows 의 첫 줄로 답 문장을 만들고, giveup 은 마지막 사유를 답에 적습니다.
사유를 보고 고쳐 다시 시도한다
MAX_ATTEMPTS = 3·FALLBACK_REGION·REPAIRS·can_repair(reason) 와 repair 노드, build_graph()·handle(order) 를 더하세요. 고칠 수 있는 사유면 인자나 도구를 고쳐 fetch 로 돌아가고, 고칠 수 없거나 상한에 닿으면 giveup 으로 갑니다.
repair 는 errors 의 마지막 사유를 읽어 REPAIRS 표대로 고치고 무엇을 고쳤는지 fixes 에 남깁니다. 사유를 상태에 남겨 둔 덕분에 이 자리에서 쓸 수 있습니다 — 예외로 던졌다면 '뭔가 잘못됐다' 밖에 없습니다. 빈 결과는 다시 물어도 빈 결과이니 고칠 수 없는 쪽입니다. handle 의 tool_runs 는 부르기 전후의 CALLS 합을 빼서 구하세요.
재어 본 것을 기록한다
/root/work/agtool/tool_report.json 에 tools·blocked_before_call·bad_result_reasons·memo_second_call_runs·repaired·attempts·gave_up 을, /root/work/agtool/tool_report.md 에 ## 도구 계약을 어디에 적었나 ## 부르기 전에 무엇을 막았나 ## 돌려준 결과를 어떻게 믿지 않았나 ## 실패를 어떤 경로로 다뤘나 네 절로 쓰세요.
숫자는 손으로 적지 말고 여러분의 모듈을 실제로 돌려 얻으세요. blocked_before_call 은 잘못된 인자로 불러 본 뒤 도구 몸통이 돈 횟수, bad_result_reasons 는 네 가지 나쁜 결과에 validate_result 가 낸 사유를 정렬한 것, memo_second_call_runs 는 같은 인자로 두 번째 불렀을 때 몸통이 돈 횟수입니다. repaired·attempts 는 지역이 틀린 주문을 handle 에 넘겨 얻고, gave_up 은 빈 결과가 나오는 주문의 ok 를 뒤집은 값입니다. ## 실패를 어떤 경로로 다뤘나 절에는 시도 상한을 숫자로 적으세요.