AI 에이전트 — 모델이 아니라 그래프 · 분기와 순환, 그리고 멈추는 조건 · 이론
멈추지 않는 에이전트는 고리가 아니라 조건이 잘못된 것이다
한 줄 요약
에이전트가 멈추지 않는 사고는 대개 고리를 만들어서가 아니라, 고리를 끝내는 조건에 닿지 못하는 입력이 있어서 난다.
왜 이게 필요했나
답변 초안을 규칙에 맞을 때까지 고쳐 쓰는 에이전트를 띄웠다. 며칠은 잘 돌았다. 그러다 어느 날 한 건이 끝나지 않고 계속 돌았고, 로그에는 review 와 revise 가 번갈아 수백 줄 찍혀 있었다.
코드를 보면 끝내는 조건은 멀쩡히 있다. "점수가 70점 이상이면 내보낸다." 문제는 그 건의 초안이 70점에 닿을 수 없다는 것이었다. 점수를 깎은 이유가 두 가지였는데 — 금칙어와 길이 — 고치는 노드는 금칙어만 걷어 냈다. 길이 때문에 깎인 40점은 몇 번을 돌려도 그대로다.
고리는 잘못이 없었다. 잘못은 "고치면 나아진다" 는 가정이었다.
어떻게 동작하나
LangGraph 에서 갈래는 [add_conditional_edges](https://docs.langchain.com/oss/python/langgraph/use-graph-api) 로 만든다. 인자가 세 개다.
graph.add_conditional_edges("review", after_review, {"ship": "ship", "revise": "revise", "escalate": "escalate"})가운데 함수는 다음 노드 이름이 아니라 갈래 이름을 돌려준다. 어느 갈래가 어느 노드로 가는지는 세 번째 인자(경로 지도)가 정한다. 이 한 겹이 왜 있는가 — 판단하는 코드와 배선을 떼어 놓기 위해서다. 노드 이름을 함수 안에 적어 두면 그래프 모양을 바꿀 때마다 판단 코드를 고쳐야 하고, 그리는 쪽(get_graph())도 어디로 갈 수 있는지 알 수 없다.
고리는 그냥 뒤로 가는 엣지다. graph.add_edge("revise", "review") 한 줄이면 revise 를 지난 뒤 다시 review 로 돌아온다. 특별한 문법이 없다는 것이 이 모델의 좋은 점이자 무서운 점이다.
재귀 한도는 무엇을 세는가 — 직접 재어 본 숫자
끝내는 조건이 없으면 무한히 도는가. 아니다. LangGraph 는 recursion_limit 에 닿으면 [GraphRecursionError](https://docs.langchain.com/oss/python/langgraph/GRAPH_RECURSION_LIMIT) 를 던진다. 메시지는 이렇다.
Recursion limit of 25 reached without hitting a stop condition.You can increase the limit by setting the `recursion_limit` config key.세는 단위는 노드 실행 횟수가 아니라 수퍼스텝(superstep) 이다. 한 수퍼스텝에서 돌 수 있는 노드는 여러 개다. 이 실습 이미지의 langgraph 0.2.60 에서 직접 재어 보면 이렇다.
| 그래프 | 끝까지 도는 데 필요한 최소 recursion_limit |
| --- | --- |
| 노드 3개를 줄줄이 이은 것 | 4 |
| 노드 3개를 나란히 둔 것 | 2 |
| (기본값) | 25 |
줄줄이 이은 쪽이 노드 수 + 1 인 것은 시작 채널이 한 수퍼스텝을 쓰기 때문이고, 나란히 둔 쪽이 노드 수와 무관하게 2 인 것은 같은 수퍼스텝에 전부 돌기 때문이다. 이 차이를 모르면 "노드를 늘렸더니 한도에 걸렸다" 를 엉뚱하게 진단한다 — 나란히 늘린 것은 한도를 쓰지 않는다.
기본값 25 는 넉넉한 수가 아니다. 고쳐 쓰는 고리가 한 바퀴에 수퍼스텝 두 개(review + revise)를 쓰면 열두 바퀴가 못 된다.
상한에 닿은 것과 할 일을 마친 것은 다르다
여기서 실무가 자주 틀린다. GraphRecursionError 를 잡아서 "실패" 로만 적어 두면, 나중에 그 건이 왜 실패했는지 알 수 없다. 두 가지가 완전히 다른 사건인데 같은 칸에 들어가기 때문이다.
- 마쳤다: 끝내는 조건에 닿았다. 결과가
ship이든escalate든, 그래프는 자기 판단으로 끝났다. - 상한에 닿았다: 그래프가 판단할 기회를 잃었다. 한도를 올리면 끝날 수도 있고, 영원히 안 끝날 수도 있다.
그래서 잡은 예외는 {"status": "limit"} 처럼 다른 이름으로 남긴다. 그리고 상한에 닿은 건이 쌓이면 한도를 올리는 것이 아니라 끝내는 조건이 닿을 수 있는지 부터 본다.
두 겹의 상한을 둔다
끝내는 조건이 정상일 때도 상한은 필요하다. 실무에서는 두 겹으로 둔다.
- 바깥 상한 —
recursion_limit. 플랫폼이 보장하는 마지막 방어선이다. 여기에 닿으면 예외이므로 결과가 없다. - 안쪽 상한 — 상태에
rounds를 세고MAX_ROUNDS에서 사람에게 넘기는 경로. 여기에 닿으면 결과가 있다 — "사람에게 넘겼다" 라는 결과다.
안쪽 상한이 없으면 바깥 상한이 대신 걸리는데, 그때는 여태 만든 초안도 함께 사라진다. 안쪽 상한은 결과를 남기고, 바깥 상한은 결과를 버린다. 그 차이가 운영에서 크다.
현장에서 만나는 모습
첫째, 한도를 올려서 넘긴다. recursion_limit 을 25 에서 200 으로 올리면 그 건은 지나간다. 그리고 다음 달에 400 으로 올린다. 진짜 문제는 끝내는 조건에 닿지 못하는 입력이 있다는 것이고, 그건 한도와 무관하다.
둘째, 판단이 여러 곳에 흩어진다. 끝낼지 말지를 노드 안에서도 보고 조건 함수에서도 보면, 두 곳이 어긋나는 날이 온다. 판단은 조건 함수 한 곳에 모으고 노드는 일만 한다.
셋째, 갈래 이름과 노드 이름을 같게 쓴다. 편하지만 경로 지도를 생략하게 되고, 그러면 그래프를 그릴 때 어느 갈래가 있는지 보이지 않는다. 이름이 같아도 지도는 적는 편이 낫다.
넷째, 나란히 늘린 노드를 한도 탓으로 돌린다. 위의 표가 그 오해를 바로잡는다. 나란한 노드는 같은 수퍼스텝이다.
실무에서 진짜 중요한 것
- 끝내는 조건이 닿을 수 있는지 입력마다 물어본다. 닿지 못하는 입력을 먼저 가려내는 진단이 한도보다 값싸다.
- 안쪽 상한을 두어 결과를 남긴다. 바깥 상한에만 기대면 상한에 닿는 순간 여태 한 일이 사라진다.
- 상한에 닿은 것과 마친 것을 다른 이름으로 기록한다. 같은 칸에 넣으면 나중에 못 가른다.
- 재귀 한도가 세는 것은 수퍼스텝이다. 직렬로 늘린 것만 한도를 먹는다.
다음 실습에서 할 것
/root/work/agroute/route.py 를 한 단계씩 키운다. 먼저 경로 지도를 갖춘 갈래를 만들고, 고친 초안을 다시 재러 보내는 고리를 잇는다. 그다음 일부러 끝내는 조건을 뺀 판을 만들어 GraphRecursionError 를 자기 눈으로 보고, 노드를 줄줄이 이은 그래프와 나란히 둔 그래프의 최소 한도를 직접 재어 수퍼스텝이 무엇인지 확인한다. 마지막으로 상한에 닿은 것과 마친 것을 다른 결과로 남기고, 고쳐도 나아지지 않는 입력을 가려내는 진단을 만든다.