AI 에이전트 — 모델이 아니라 그래프 · 도구 호출과 결과 검증 · 이론
도구는 계약이다 — 부르기 전에 재고, 받고 나서 또 잰다
한 줄 요약
에이전트 사고의 절반은 모델이 아니라 도구를 부르는 쪽에서 난다. 인자를 재지 않고 부르고, 돌아온 것을 재지 않고 믿기 때문이다.
왜 이게 필요했나
도구를 붙인 에이전트가 처음 깨지는 모습은 대개 이렇다.
TypeError: stock_lookup() got an unexpected keyword argument 'sku_id'모델이 인자 이름을 하나 바꿔 지어냈고, 그대로 함수에 넘겼고, 예외가 그래프 밖으로 튀어나왔다. 이 예외는 노드가 아니라 실행 전체를 끝낸다. 상태도, 지나온 길도, 왜 그랬는지도 남지 않는다.
그다음으로 깨지는 모습은 예외가 아니라서 더 나쁘다. 도구가 빈 목록을 돌려줬는데 그 위에 답을 만들어 버리는 것이다. 사용자는 "재고 3곳" 이라는 문장을 받고, 로그에는 아무 오류도 없다.
두 사고의 원인은 같다. 도구 호출에 계약이 없다. 어떤 인자를 어떤 형과 범위로 받는지, 무엇을 어떤 모양으로 돌려주는지를 코드가 한 곳에 적어 두지 않으면, 재는 코드가 노드마다 흩어지거나 아예 없다.
계약을 어디에 적는가
계약은 사람이 읽는 문서가 아니라 프로그램이 읽는 값이어야 한다. 이름 하나에 인자 스키마·돌려주는 모양·실제 함수를 묶어 표 하나로 둔다.
TOOLS = { "stock_lookup": { "fn": stock_lookup, "args": {"sku": {"type": "str", "required": True}, "region": {"type": "str", "required": True, "allowed": ["busan", "jeju", "seoul"]}, "limit": {"type": "int", "required": True, "min": 1, "max": 2}}, "returns": {"sku": {"type": "str"}, "rows": {"type": "list", "max_len": 2}}, },}이렇게 두면 세 가지가 한꺼번에 따라온다. 첫째, 인자를 재는 코드가 도구마다 하나씩 필요 없다 — 표를 읽는 함수 하나면 된다. 둘째, 모델에게 줄 도구 설명을 이 표에서 뽑아낼 수 있다. 셋째, 도구를 부르는 문이 한 곳으로 좁혀진다. [Workflows and agents](https://docs.langchain.com/oss/python/langgraph/workflows-agents) 가 보여 주는 도구 호출 구조도 결국 이 좁은 문 하나를 어디에 두느냐의 이야기다.
부르기 전에 잰다
인자 검증에서 가장 중요한 것은 부르기 전이라는 순서다. 부르고 나서 예외를 잡는 것과는 다르다. 잘못된 인자로 한 번 부르는 순간 돈이 나가거나, 메일이 발송되거나, 주문이 들어간다. 조회 도구라면 돌이킬 수 있지만 쓰기 도구는 그렇지 않다.
재는 것은 네 가지다. 빠진 인자, 형, 범위, 허용 목록. 여기에 계약에 없는 인자를 넘기는 것(오타·환각)까지 더하면 다섯이다.
파이썬에서 형을 잴 때 반드시 걸리는 함정이 하나 있다. bool 은 int 의 하위형이라 isinstance(True, int) 가 참이다. 개수 자리에 True 가 들어와도 "정수" 검사를 통과한다는 뜻이다. 그래서 정수를 잴 때는 isinstance(value, bool) 을 먼저 걸러야 한다.
검증이 무엇을 돌려줄지도 정해 두자. 참거짓 하나로는 부족하다. 무엇이 왜 틀렸는지가 사유 문자열로 남아야 다음에 쓸 수 있다. allowed:region 처럼 "어떤 검사가" "어느 인자에서" 걸렸는지를 한 줄에 담아 두면, 그 문자열 하나로 뒤에서 대응을 고를 수 있다.
인자를 고르는 자리는 원래 모델이다
이 글과 다음 실습은 모델을 부르지 않는다. 인자를 고르는 노드가 규칙으로 되어 있을 뿐, 실제 에이전트는 그 자리에서 모델에게 묻는다 — 어떤 도구를 부를지, 인자를 무엇으로 채울지를 모델이 정한다. 나머지 구조는 한 줄도 달라지지 않는다.
오히려 고르는 쪽이 모델일수록 이 구조가 더 필요해진다. 규칙은 틀려도 같은 방식으로 틀리지만 모델은 매번 다르게 틀린다. 인자 이름을 비슷한 것으로 바꾸고, 숫자를 문자열로 주고, 계약에 없는 지역 이름을 지어낸다. 그래서 계약 표에서 모델에게 줄 도구 설명을 뽑아내고, 모델이 돌려준 인자를 같은 계약 표로 다시 재는 두 방향이 한 곳에서 맞물리게 해 두는 것이 이 설계의 값어치다.
받고 나서 또 잰다
도구가 돌려준 것은 남이 만든 값이다. 우리 함수의 반환값처럼 다뤄서는 안 된다. 실제로 자주 만나는 네 가지가 있다.
- 모양이 다르다. 열쇠가 빠졌거나, 개수 자리에
"4개"같은 문자열이 온다. - 빈 결과다. 목록이 비어 있는데 오류는 아니다. 이것을 성공으로 넘기면 답을 지어내게 된다.
- 너무 크다.
limit=2로 물었는데 스무 줄이 온다. 옛 판 API 가 인자를 받아만 두고 지키지 않는 일은 드물지 않다. - 범위 밖이다. 음수 재고, 14일이어야 할 값이 720. 720은 대개 단위가 틀린 것이다 — 시간을 일 자리에 넣었다. 단위 오류는 이렇게 형이나 범위로 드러난다.
이 넷을 재는 함수도 계약 표를 읽어서 만든다. 결과 검증을 노드 안에 손으로 적으면, 도구가 늘 때마다 빠뜨린다.
실패는 예외가 아니라 값이다
도구를 부르는 문은 예외를 밖으로 내지 않는 함수로 만든다.
{"ok": False, "value": None, "error": "args:allowed:region"}돌려주는 모양이 늘 같으면 노드는 분기할 거리를 얻는다. 그리고 error 에 적힌 사유가 상태에 쌓이면, 다음 시도에서 무엇을 고쳐야 하는지가 코드로 정해진다. 사유가 args:allowed:region 이면 지역을 기본값으로 바꾸고, result:too_big 이면 도구를 옛 판에서 새 판으로 바꾼다. 반대로 예외로 던져 버리면 남는 것은 "뭔가 잘못됐다" 뿐이라, 고칠 수 있는 실패와 고칠 수 없는 실패를 가를 수 없다.
고칠 수 없는 사유도 분명히 있다. 빈 결과가 그렇다. 같은 질문을 다시 해도 빈 결과다. 이때 다시 시도하는 것은 예산만 태우는 짓이고, 답을 지어내는 것은 더 나쁘다. 고칠 수 있는 사유의 목록을 코드에 적어 두고, 그 밖은 바로 포기한다. 시도 상한은 그 위에 한 겹 더 두는 안전장치다.
같은 것을 두 번 묻지 않는다
루프를 도는 에이전트는 같은 도구를 같은 인자로 계속 부른다. 상태에 결과가 안 남았거나, 남았어도 다음 노드가 못 찾기 때문이다. 부르는 문이 한 곳이면 그 문 앞에 기억을 두는 것으로 끝난다.
열쇠는 이름 + 정규화한 인자 다. 인자를 적은 순서가 달라도 같은 열쇠가 나와야 하므로 json.dumps(args, sort_keys=True) 처럼 정렬해서 만든다. 그리고 성공만 기억한다. 실패까지 기억하면 고쳐서 다시 부를 길이 막힌다.
현장에서 만나는 모습
첫째, 모델이 인자 이름을 지어낸다. 계약에 없는 인자가 오는 것은 사고가 아니라 일상이다. 막고, 사유를 남기고, 다시 묻는다.
둘째, 조회는 성공했는데 알맹이가 없다. ok 만 보고 넘어가면 그 위에 문장이 만들어진다. 빈 결과를 실패로 분류하는 한 줄이 이것을 막는다.
셋째, 옛 판 API 가 인자를 무시한다. 상한을 지키지 않는 응답이 그대로 모델의 문맥에 실려 토큰을 먹는다. 결과 검증은 비용 관리이기도 하다.
넷째, 같은 조회가 한 실행에 대여섯 번 돈다. 청구서에 먼저 보인다. 상태에 결과가 안 남았거나, 남았어도 다음 노드가 그것을 찾지 못해서다.
다섯째, 예외 하나가 실행 전체를 끝낸다. 도구 함수 안쪽에서 난 KeyError 가 노드 밖으로, 그래프 밖으로 나가면 그 건은 통째로 사라진다. 중간까지 쌓아 둔 상태도 함께 사라져서, 다시 돌릴 때 처음부터 해야 한다.
실무에서 진짜 중요한 것
- 도구 계약은 값으로 한 곳에 둔다. 이름·인자·돌려주는 모양·함수를 한 표에.
- 인자 검증은 부르기 전에. 부르고 나서 잡는 것과는 되돌릴 수 있느냐가 다르다.
- 결과 검증은 모양·빈 결과·크기·범위 네 가지. 단위는 대개 범위로 드러난다.
- 실패는 값으로 돌려주고 사유를 상태에 남긴다. 그래야 다음 시도가 규칙이 된다.
- 고칠 수 있는 사유의 목록과 시도 상한을 함께 둔다.
다음 실습에서 할 것
/root/work/agtool/tools.py 를 한 단계씩 키운다. 도구 계약 표를 만들고, 인자를 부르기 전에 재는 함수와 결과를 받고 나서 재는 함수를 만들고, 예외를 밖으로 내지 않는 호출 문을 세운다. 이어 같은 인자를 두 번 부르지 않게 기억을 붙이고, 실패를 경로로 다루는 그래프를 세운 뒤, 사유를 보고 인자와 도구를 고쳐 다시 시도하는 판까지 만든다. 인자를 고르는 자리는 규칙으로 대신하지만, 실제 에이전트는 그 자리에서 모델에게 묻는다 — 나머지 구조는 똑같다. 채점기는 여러분의 모듈을 실제로 불러 매번 다른 값으로 검증 함수를 두드려 보고, 도구 몸통이 몇 번 돌았는지까지 센다.