Home LangChain 도입기 - LLM 에이전트 루프를 직접 짜다가 생긴 일
Post
Cancel

LangChain 도입기 - LLM 에이전트 루프를 직접 짜다가 생긴 일

서버 에러 로그를 LLM이 분석해서 Slack으로 원인과 수정 코드를 보내주는 시스템을 만들고 있었다. LLM이 단순히 로그를 받아서 답을 내는 게 아니라, 필요하면 직접 소스 파일을 검색해가며 분석하는 에이전트 구조였다. 처음엔 이 루프를 직접 구현했는데, 문제가 생겼다.

직접 구현한 에이전트 루프

LLM 에이전트의 동작 방식은 단순하다.

1
2
3
1. LLM에게 에러 로그를 준다
2. LLM이 "이 파일 검색해줘" 라고 요청하면 → 검색해서 돌려준다
3. LLM이 충분히 파악했다고 판단하면 → 최종 JSON 응답을 반환한다

이걸 처음에 직접 구현했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
_MAX_TOOL_ITERATIONS = 5

for iteration in range(_MAX_TOOL_ITERATIONS):
    response = await httpx.post(ollama_host + "/api/chat", json={
        "model": "gemma4:12b",
        "messages": messages,
        "tools": tools,
        "stream": False,
    })
    message = response.json().get("message", {})

    tool_calls = message.get("tool_calls") or []
    if not tool_calls:
        return message.get("content", "")  # 최종 응답

    for call in tool_calls:
        result = await search_files(call["function"]["arguments"]["query"])
        messages.append({"role": "tool", "content": result})

동작은 했다. 그런데 운영하면서 문제가 하나씩 드러났다.


문제 1: 5회 제한의 근거가 없다

_MAX_TOOL_ITERATIONS = 5는 그냥 “적당한 것 같다”는 감으로 정한 숫자였다. 간단한 에러는 2~3번이면 충분하지만, 복잡한 의존성을 추적해야 하는 에러는 5번으로 부족할 수 있다.

그렇다고 while True로 돌리면 안 된다. LLM이 루프에 빠질 수 있기 때문이다.

1
2
3
4
iteration 1 → search_files("TestErrorController")
iteration 2 → 파일 받고 나서 또 search_files("TestErrorController")
iteration 3 → 또 search_files("TestErrorController")
...무한반복

LLM이 같은 파일을 계속 보려고 하면 탈출 조건이 없다. 시간 제한도 없으니 Slack에는 영원히 응답이 안 온다.


문제 2: 에러 로그가 있는데 LLM이 아무것도 못 찾으면?

max_iterations에 도달했을 때 코드는 _fallback_analyze()를 호출하는 것으로 처리했다. 빈 응답을 반환하거나 에러를 던지는 것보다는 낫지만, “지금까지 수집한 정보로 최선의 답을 내줘”를 요청하는 게 더 자연스럽다. 이미 검색까지 다 해놓고 그냥 버리는 셈이었다.


문제 3: 스택 트레이스 파싱이 Java/Python 전용

에이전트가 검색을 시작하기 전에 스택 트레이스에서 파일 경로를 정규식으로 뽑아서 LLM에게 미리 전달하는 로직이 있었다.

1
2
3
java_pattern = re.compile(r"at ([\w$.]+)\.\w+\((\w+\.(?:java|kt)):\d+\)")
python_pattern = re.compile(r'File "([^"]+\.py)", line \d+')
# TypeScript, Go는 패턴 없음

TypeScript나 Go 프로젝트를 연결하면 이 단계에서 아무것도 못 가져온다. 반면 RAG 인덱싱은 .ts, .go 등 여러 언어를 지원하고 있었으니 앞뒤가 맞지 않는 구조였다. 그리고 애초에 LLM이 스택 트레이스를 보고 스스로 search_files를 호출하면 되는데, 왜 별도로 정규식까지 짜서 미리 주는 걸까 싶었다.


LangChain이란

LLM 애플리케이션을 만들기 위한 Python 프레임워크다. “LLM + 도구 + 루프 제어”라는 반복되는 패턴을 추상화해서 제공한다.

핵심 개념은 세 가지다.

Chain: LLM 호출과 후처리를 파이프라인으로 연결한다.

Tool: LLM이 호출할 수 있는 함수를 정의한다. @tool 데코레이터로 감싸면 LangChain이 LLM에게 함수 설명을 자동으로 전달한다.

Graph (create_react_agent): 에이전트 루프 자체를 담당한다. Tool 호출 → 결과 전달 → 반복의 흐름을 그래프 노드로 표현하며, recursion_limit(스텝 제한)으로 무한루프를 방지한다. LangChain 1.x부터는 내부적으로 LangGraph 위에서 동작한다.

직접 구현했던 for 루프가 하는 일을 create_react_agent가 대신한다.


도입 후 코드

기존에는 for 루프 안에서 Ollama API를 직접 호출하고, tool_calls를 수동으로 파싱하고, 메시지 배열을 직접 관리했다. 바뀐 코드는 다음과 같다.

Tool 정의

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from langchain_core.tools import tool as langchain_tool

def _make_search_tool(server_id: int, repo_path: Path):
    @langchain_tool
    async def search_files(query: str) -> str:
        """에러의 근본 원인과 관련된 소스 파일을 검색하고 내용을 반환합니다."""
        paths = await rag_service.search_relevant_files(server_id, query, n_results=3)
        results = {}
        for path in paths:
            try:
                content = (repo_path / path).read_text(encoding="utf-8", errors="replace")
                results[path] = content[:2000]
            except FileNotFoundError:
                pass
        return json.dumps(results, ensure_ascii=False)
    return search_files

@langchain_tool 데코레이터를 붙이면 함수의 docstring을 LLM에게 도구 설명으로 자동 전달한다. server_idrepo_path는 클로저로 캡처해서 매 요청마다 동적으로 생성한다.

에이전트 그래프 설정

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import asyncio
from langchain_ollama import ChatOllama
from langgraph.errors import GraphRecursionError
from langgraph.prebuilt import create_react_agent

llm = ChatOllama(
    model=settings.ollama_model,
    base_url=settings.ollama_host,
    think=False,
)

tools = [_make_search_tool(server_id, repo_path)]
graph = create_react_agent(model=llm, tools=tools, prompt=SYSTEM_PROMPT)

try:
    result = await asyncio.wait_for(
        graph.ainvoke(
            {"messages": [("user", f"다음 에러 로그를 분석해줘:\n\n```\n{raw_log}\n```")]},
            config={"recursion_limit": 30},
        ),
        timeout=120,
    )
    raw = result["messages"][-1].content
except GraphRecursionError:
    raw = await _fallback_analyze(raw_log)  # 스텝 한계 도달
except asyncio.TimeoutError:
    raw = await _fallback_analyze(raw_log)  # 시간 초과

recursion_limit=30인 이유가 있다. LangGraph는 “LLM 호출 1번 + 툴 실행 1번”을 각각 하나의 스텝으로 센다. 즉 툴 호출 1회 = 2 스텝이므로, recursion_limit=30은 실질적으로 툴 15회 호출에 해당한다.

시간 제한은 asyncio.wait_for(timeout=120)으로 감싸서 처리한다. 한계 도달 시에는 GraphRecursionError가 발생하는데, 이를 잡아서 _fallback_analyze()로 넘긴다. fallback은 툴 없이 단순 one-shot 프롬프트로 재시도한다.


전후 비교

항목직접 구현LangChain + LangGraph
횟수 제한5회 (임의)recursion_limit=30 (툴 15회)
시간 제한없음asyncio.wait_for(timeout=120)
루프 감지없음GraphRecursionError 자동 발생
한계 도달 시fallback 재시도GraphRecursionError catch → fallback
스택 트레이스 파싱Java/Python 전용 정규식제거 (LLM이 직접 검색)
Tool 관리수동 JSON 파싱@langchain_tool 자동 처리
코드 라인 수~90줄~40줄

FastAPI와 LangChain을 같이 쓰는 이유

둘 다 프레임워크인데 동시에 쓰는 게 어색하게 느껴질 수 있다. 그런데 역할이 완전히 다른 계층이라 충돌하지 않는다.

1
2
3
4
5
6
7
HTTP 요청
    ↓
FastAPI        ← 웹 프레임워크 (라우팅, 요청/응답)
    ↓
LangChain      ← LLM 오케스트레이션 프레임워크 (에이전트 루프, 툴 관리)
    ↓
Ollama         ← LLM 런타임

FastAPI는 “누가 왔고 어느 함수를 실행할지”를 담당하고, LangChain은 그 함수 안에서 LLM과 대화하는 복잡한 로직을 담당한다. analyze_log() 함수 하나로만 연결되어 있다.

실무에서 FastAPI + LangChain, FastAPI + LlamaIndex, Django + LangChain 조합은 표준적인 구성이다.


정리

에이전트 루프를 직접 구현하는 건 처음엔 단순해 보인다. 그런데 횟수 제한, 시간 제한, 루프 감지, 한계 도달 시 처리 같은 엣지 케이스들이 쌓이면 결국 프레임워크가 하는 일을 다시 만드는 셈이 된다.

LangChain의 create_react_agent는 이 반복되는 패턴을 추상화해놓은 것이다. 내부적으로 LangGraph의 그래프 실행 엔진 위에서 동작하며, 직접 구현의 유연함이 필요한 시점이 오기 전까지는 프레임워크에 맡기는 게 낫다.

한 가지 주의할 점은 LangChain의 버전 변화가 빠르다는 것이다. 처음 AgentExecutor로 구현했다가 langchain 1.x에서 해당 클래스가 제거되어 create_react_agent로 교체해야 했다. LLM 생태계는 아직 빠르게 변하고 있으니 버전 고정과 업그레이드 시점에 주의가 필요하다.

This post is licensed under CC BY 4.0 by the author.

AI 오류 자동 분석 에이전트 구축기 (4) — Reranker 모델 도입

-