NLP

[LangGraph]SQL Agent + Qwen3 (with ollama)

ianyang 2025. 5. 16. 18:00

1. 서론

오늘은 LangGraph를 이용해 SQL Agent를 제작해 보겠습니다. SQL Agent를 사용하면 SQL에 대해 잘 모르는 사람도 편하게 질문하여 DB에서 데이터를 추출할 수 있습니다.

LangGraph 공식문서(https://langchain-ai.github.io/langgraph/tutorials/sql-agent/)를 참고하여 작성했으며, 공식문서에서는 OpenAI를 이용해서 만든걸 최신 모델인 Qwen3을 이용해 적용하는 방법을 소개하겠습니다.

본 글에서는 LangGraph의 SQL Agent 개념과 아키텍처를 이론적으로 살펴보고,  실제로 Qwen3-14B 모델을 Ollama를 통해 서빙하는 방식으로 구성하는 흐름으로 진행됩니다.

2. SQL Agent란?

1. 질의 이해: 자연어로 입력된 질문에서 의도를 파악 

2. 메타데이터 조회: DB에 존재하는 테이블과 스키마 정보를 수집  
3. 쿼리 생성: 테이블 구조와 질문 의도를 바탕으로 SQL 초안 작성  
4. 검증 및 수정: 생성된 SQL을 문법·논리 오류 관점에서 검토하고 보완  
5. 실행 및 응답: DB 실행 후 결과를 사용자 친화적 자연어로 요약

SQL Agent를 사용하는 이유는?

  • SQL 비전문가도 자유롭게 데이터 조회 
  • 반복적인 쿼리 작성 시간 단축 
  • 쿼리 오류와 보안 리스크 감소(단순 Chain 대비)

3. Qwen3

Ollama Qwen3

Qwen3란?

  • Qwen3는 알리바바가 2025년 4월 공개한 최신 오픈소스 대형 언어 모델(LLM)로, 최대 2,350억(235B) 파라미터의 초거대 모델부터 6억(0.6B) 파라미터의 소형 모델까지 총 8가지 크기로 제공됩니다.
  • Qwen3의 가장 큰 특징은 하이브리드 사고 모드로, 복잡한 문제에는 단계별 심층 추론(Thinking Mode), 단순한 질문에는 빠른 응답(Non-Thinking Mode)으로 자동 전환이 가능해, 상황에 맞는 최적의 답변을 제공합니다.
  • 119개 언어와 방언을 지원해 글로벌 활용성이 뛰어나며, 자연어 처리뿐 아니라 이미지와 텍스트를 함께 다루는 멀티모달 기능, 외부 도구 호출 등 에이전트 기능도 대폭 강화되었습니다.
  • Qwen3는 36조 토큰의 대규모 데이터로 학습되어 코딩, 수학, 논리 추론 등 다양한 분야에서 DeepSeek-R1, Gemini 2.5, Grok-3 등 최신 모델들과 경쟁할 만한 성능을 보이며, Apache 2.0 라이선스로 공개되어 누구나 자유롭게 연구·상업적 활용이 가능합니다.

Qwen3을 사용하는 이유?

  • Qwen3의 하이브리드 사고 모드는 복잡한 SQL 최적화나 트랜잭션 논리 설계 등 심층 추론이 필요할 때는 Thinking Mode로 단계별 분석을 수행하고, 간단한 쿼리 생성이나 형식 검증처럼 빠른 응답이 요구될 때는 Non-Thinking Mode로 즉시 결과를 반환해, 에이전트의 지능적 유연성을 극대화합니다.
  • 외부 도구 호출을 지원하기에, 데이터베이스 상태 조회, 데이터베이스 실행 등 워크플로우를 손쉽게 구성할 수 있습니다.
  • 2,350억부터 6억 파라미터까지 다양한 크기로 공개된 최신 오픈소스 모델이면서 Apache 2.0 라이선스를 따르기 때문에, 연구·상업용 모두 제약 없이 최첨단 AI 기능을 SQL Agent에 통합할 수 있습니다.

4. Ollama

Ollama란?

  • Ollama는 대규모 언어 모델(LLM)을 클라우드에 의존하지 않고 사용자의 로컬 컴퓨터에서 직접 실행할 수 있게 해주는 오픈소스 플랫폼입니다.
  • 이 도구는 Gemma, Qwen, DeepSeek 등 최신 AI 모델을 간편하게 설치하고, 손쉽게 관리할 수 있는 것이 큰 장점입니다.
  • Ollama를 활용하면 민감한 데이터도 외부로 유출될 걱정 없이 로컬에서 안전하게 처리할 수 있고, 초기 설정 이후 별도의 추가 비용 없이 AI 모델을 자유롭게 활용할 수 있어 비용 효율성도 뛰어납니다.
  • 또한 GitHub, HuggingFace 등 다양한 소스의 모델을 불러와 사용할 수 있으며, LangChain 등과 연동해 웹 탐색, 정보 요약, 보고서 생성 등 자동화된 AI 워크플로우 구축도 가능합니다.
  • Ollama는 연구자, 개발자, 기업 등 다양한 사용자가 데이터 보안과 커스터마이징 유연성을 확보하면서 고성능 AI를 활용할 수 있도록 혁신적인 로컬 AI 솔루션을 제공합니다.

Ollama 설치 방법(공식 Github)

# 리눅스 기준
# 1. ollama 설치
curl -fsSL https://ollama.com/install.sh | sh

# 2. ollama 서버 시작
ollama server &

# 3. 모델 시작
ollama run 모델명
  • 터미널에서 이렇게 진행하신다면 터미널에서 바로 모델을 사용하실 수 있습니다.
  • 다만 오늘 프로젝트는 모델을 터미널에서 사용하는 구조가 아니기에 colab 환경에서 세팅하는 방법은 colab에 자세하게 설명해 뒀으니 colab으로 하시는 분들은 아래에 있는 예제를 참고하셔서 사용하시면 됩니다.

5. SQL Agent 그래프 구조

  • START: LangGraph의 시작 지점(사용자 질문 입력)
  • list_tables: 연결된 DB의 전체 테이블 조회
  • call_get_schema: 사용자의 질문과 전체 테이블을 비교하여 적절한 테이블을 선택
  • get_schema: 이전 단계에서 선택된 테이블의 스키마 조회
  • generate_query
    • Tool 정보가 함께 넘어왔을 때: 스키마와 사용자 질문을 참고하여 쿼리 생성
    • Tool 정보가 없이 넘어왔을 때: 쿼리 실행 결과를 바탕으로 최종 답변 생성
  • check_query: 생성된 쿼리에 문법 오류가 없는지 체크합니다.
  • run_query: 생성된 쿼리를 DB에 실행합니다.
  •  

6. 코드 분석

자세한 코드는 Colab에 구성해뒀습니다. 본 글에서는 중요 부분만 설명하겠습니다.

https://colab.research.google.com/drive/1JtNDNWm6LP3GoPmoefpk_CN5i8t_h8rf?usp=sharing

 

[LangGraph]SQL Agent + Qwen3.ipynb

Colab notebook

colab.research.google.com

프로젝트는 총 두개의 Task로 진행됩니다.

  1. 사전 구성된 Agent 사용하여 SQL Agent 구현
  2. 직접 커스터마이징하여 SQL Agent 구현

6.1 공통 부분

6.1.1 모델 설정

from langchain_ollama import ChatOllama

llm = ChatOllama(
    model="qwen3:14b",
    temperature=0,
)

 

6.1.2 DB & Tool 설정

from langchain_community.utilities import SQLDatabase

# SQLite 데이터베이스 파일을 기반으로 SQLDatabase 객체 생성
db = SQLDatabase.from_uri("sqlite:///Chinook.db")

# 데이터베이스의 SQL dialect 출력 (예: sqlite)
print(f"Dialect: {db.dialect}") 
## 출력결과) Dialect: sqlite

# 사용 가능한 테이블 목록 출력
print(f"Available tables: {db.get_usable_table_names()}") 
## 출력결과) Available tables: ['Album', 'Artist', 'Customer', 'Employee', 'Genre', 'Invoice', 'InvoiceLine', 'MediaType', 'Playlist', 'PlaylistTrack', 'Track']
from langchain_community.agent_toolkits import SQLDatabaseToolkit

# SQLDatabase 객체와 LLM을 기반으로 Toolkit 생성
toolkit = SQLDatabaseToolkit(db=db, llm=llm)

# Toolkit에서 사용할 수 있는 툴 목록 가져오기
tools = toolkit.get_tools()

# 각 툴의 이름과 설명 출력
for tool in tools:
    print(f"{tool.name}: {tool.description}\n")
    
# 출력결과
#sql_db_query: Input to this tool is a detailed and correct SQL query, output is a result from the database. If the query is not correct, an error message will be returned. If an error is returned, rewrite the query, check the query, and try again. If you encounter an issue with Unknown column 'xxxx' in 'field list', use sql_db_schema to query the correct table fields.

#sql_db_schema: Input to this tool is a comma-separated list of tables, output is the schema and sample rows for those tables. Be sure that the tables actually exist by calling sql_db_list_tables first! Example Input: table1, table2, table3

#sql_db_list_tables: Input is an empty string, output is a comma-separated list of tables in the database.

#sql_db_query_checker: Use this tool to double check if your query is correct before executing it. Always use this tool before executing a query with sql_db_query!

sql_db_query

  • 이 도구는 정확한 SQL 쿼리를 입력으로 받아, 해당 쿼리의 실행 결과를 반환합니다.
  • 쿼리에 오류가 있으면, 오류 메시지가 반환됩니다.

sql_db_schema

  • 이 도구는 쉼표로 구분된 테이블 이름 목록을 입력받아, 각 테이블의 스키마 정보와 샘플 데이터 행을 반환합니다.

sql_db_list_tables

  • 이 도구는 입력 없이 호출되며, 결과로 데이터베이스에 존재하는 테이블 목록을 쉼표로 구분된 문자열 형태로 반환합니다.

sql_db_query_checker

  • 이 도구는 SQL 쿼리를 실행하기 전에 문법 및 구조가 올바른지 검토하는 데 사용됩니다.

6.2 사전 구성된 SQL Agent

6.2.1 프롬프트 세팅

system_prompt = """
You are an agent designed to interact with a SQL database.
Given an input question, create a syntactically correct {dialect} query to run,
then look at the results of the query and return the answer. Unless the user
specifies a specific number of examples they wish to obtain, always limit your
query to at most {top_k} results.

You can order the results by a relevant column to return the most interesting
examples in the database. Never query for all the columns from a specific table,
only ask for the relevant columns given the question.

You MUST double check your query before executing it. If you get an error while
executing a query, rewrite the query and try again.

DO NOT make any DML statements (INSERT, UPDATE, DELETE, DROP etc.) to the
database.

To start you should ALWAYS look at the tables in the database to see what you
can query. Do NOT skip this step.

Then you should query the schema of the most relevant tables.
""".format(
    dialect=db.dialect,
    top_k=5,
)

6.2.2 에이전트 세팅

from langgraph.prebuilt import create_react_agent

agent = create_react_agent(
    llm,
    tools,
    prompt=system_prompt,
)

6.2.3 에이전트 실행 예시

question = "Which sales agent made the most in sales in 2009?"

for step in agent.stream(
    {"messages": [{"role": "user", "content": question}]},
    stream_mode="values",
):
    step["messages"][-1].pretty_print()

 

출력결과(더 보기 클릭)

더보기

================================ Human Message =================================

Which sales agent made the most in sales in 2009?
================================== Ai Message ==================================
Tool Calls:
  sql_db_list_tables (17fa87c5-527d-4767-9a6b-ede588c3da4a)
 Call ID: 17fa87c5-527d-4767-9a6b-ede588c3da4a
  Args:
    tool_input:
================================= Tool Message =================================
Name: sql_db_list_tables

Album, Artist, Customer, Employee, Genre, Invoice, InvoiceLine, MediaType, Playlist, PlaylistTrack, Track
================================== Ai Message ==================================
Tool Calls:
  sql_db_schema (f0ebe667-d6c2-4f18-8073-cc3de2c972b3)
 Call ID: f0ebe667-d6c2-4f18-8073-cc3de2c972b3
  Args:
    table_names: Invoice, Employee
================================= Tool Message =================================
Name: sql_db_schema


CREATE TABLE "Employee" (
"EmployeeId" INTEGER NOT NULL, 
"LastName" NVARCHAR(20) NOT NULL, 
"FirstName" NVARCHAR(20) NOT NULL, 
"Title" NVARCHAR(30), 
"ReportsTo" INTEGER, 
"BirthDate" DATETIME, 
"HireDate" DATETIME, 
"Address" NVARCHAR(70), 
"City" NVARCHAR(40), 
"State" NVARCHAR(40), 
"Country" NVARCHAR(40), 
"PostalCode" NVARCHAR(10), 
"Phone" NVARCHAR(24), 
"Fax" NVARCHAR(24), 
"Email" NVARCHAR(60), 
PRIMARY KEY ("EmployeeId"), 
FOREIGN KEY("ReportsTo") REFERENCES "Employee" ("EmployeeId")
)

/*
3 rows from Employee table:
EmployeeId LastName FirstName Title ReportsTo BirthDate HireDate Address City State Country PostalCode Phone Fax Email
1 Adams Andrew General Manager None 1962-02-18 00:00:00 2002-08-14 00:00:00 11120 Jasper Ave NW Edmonton AB Canada T5K 2N1 +1 (780) 428-9482 +1 (780) 428-3457 andrew@chinookcorp.com
2 Edwards Nancy Sales Manager 1 1958-12-08 00:00:00 2002-05-01 00:00:00 825 8 Ave SW Calgary AB Canada T2P 2T3 +1 (403) 262-3443 +1 (403) 262-3322 nancy@chinookcorp.com
3 Peacock Jane Sales Support Agent 2 1973-08-29 00:00:00 2002-04-01 00:00:00 1111 6 Ave SW Calgary AB Canada T2P 5M5 +1 (403) 262-3443 +1 (403) 262-6712 jane@chinookcorp.com
*/


CREATE TABLE "Invoice" (
"InvoiceId" INTEGER NOT NULL, 
"CustomerId" INTEGER NOT NULL, 
"InvoiceDate" DATETIME NOT NULL, 
"BillingAddress" NVARCHAR(70), 
"BillingCity" NVARCHAR(40), 
"BillingState" NVARCHAR(40), 
"BillingCountry" NVARCHAR(40), 
"BillingPostalCode" NVARCHAR(10), 
"Total" NUMERIC(10, 2) NOT NULL, 
PRIMARY KEY ("InvoiceId"), 
FOREIGN KEY("CustomerId") REFERENCES "Customer" ("CustomerId")
)

/*
3 rows from Invoice table:
InvoiceId CustomerId InvoiceDate BillingAddress BillingCity BillingState BillingCountry BillingPostalCode Total
1 2 2009-01-01 00:00:00 Theodor-Heuss-Straße 34 Stuttgart None Germany 70174 1.98
2 4 2009-01-02 00:00:00 Ullevålsveien 14 Oslo None Norway 0171 3.96
3 8 2009-01-03 00:00:00 Grétrystraat 63 Brussels None Belgium 1000 5.94
*/
================================== Ai Message ==================================
Tool Calls:
  sql_db_schema (abea1dcb-f36e-469a-ae62-53e609584639)
 Call ID: abea1dcb-f36e-469a-ae62-53e609584639
  Args:
    table_names: Customer
================================= Tool Message =================================
Name: sql_db_schema


CREATE TABLE "Customer" (
"CustomerId" INTEGER NOT NULL, 
"FirstName" NVARCHAR(40) NOT NULL, 
"LastName" NVARCHAR(20) NOT NULL, 
"Company" NVARCHAR(80), 
"Address" NVARCHAR(70), 
"City" NVARCHAR(40), 
"State" NVARCHAR(40), 
"Country" NVARCHAR(40), 
"PostalCode" NVARCHAR(10), 
"Phone" NVARCHAR(24), 
"Fax" NVARCHAR(24), 
"Email" NVARCHAR(60) NOT NULL, 
"SupportRepId" INTEGER, 
PRIMARY KEY ("CustomerId"), 
FOREIGN KEY("SupportRepId") REFERENCES "Employee" ("EmployeeId")
)

/*
3 rows from Customer table:
CustomerId FirstName LastName Company Address City State Country PostalCode Phone Fax Email SupportRepId
1 Luís Gonçalves Embraer - Empresa Brasileira de Aeronáutica S.A. Av. Brigadeiro Faria Lima, 2170 São José dos Campos SP Brazil 12227-000 +55 (12) 3923-5555 +55 (12) 3923-5566 luisg@embraer.com.br 3
2 Leonie Köhler None Theodor-Heuss-Straße 34 Stuttgart None Germany 70174 +49 0711 2842222 None leonekohler@surfeu.de 5
3 François Tremblay None 1498 rue Bélanger Montréal QC Canada H2G 1A7 +1 (514) 721-4711 None ftremblay@gmail.com 3
*/
================================== Ai Message ==================================
Tool Calls:
  sql_db_query (f64bae19-b3e4-407a-8e41-51d57e38431c)
 Call ID: f64bae19-b3e4-407a-8e41-51d57e38431c
  Args:
    query: SELECT e.FirstName, e.LastName, SUM(i.Total) AS TotalSales FROM Invoice i JOIN Customer c ON i.CustomerId = c.CustomerId JOIN Employee e ON c.SupportRepId = e.EmployeeId WHERE strftime('%Y', i.InvoiceDate) = '2009' GROUP BY e.EmployeeId ORDER BY TotalSales DESC LIMIT 1;
================================= Tool Message =================================
Name: sql_db_query

[('Steve', 'Johnson', 164.33999999999997)]
================================== Ai Message ==================================

<think>
Okay, let's see. The user asked which sales agent made the most in sales in 2009. I need to figure out the answer based on the database tables.

First, I checked the tables available. There's an Employee table, which has sales agents' information, and an Invoice table that records sales. The Customer table links customers to their support rep, which is the sales agent. 

So the plan is to join these tables. The Invoice table has the Total column, which is the sales amount. The Customer table has SupportRepId that links to Employee's EmployeeId. Then, I can sum the Total for each sales agent in 2009.

I wrote a query that joins Invoice, Customer, and Employee tables. Filtered invoices from 2009 using the InvoiceDate. Grouped by EmployeeId and summed the Total. Ordered in descending order and limited to 1 result. The result came back as Steve Johnson with total sales around 164.34.

Wait, but looking at the sample data from Employee, there's no Steve Johnson. The sample employees are Adams, Edwards, Peacock. Maybe the actual data has more employees. The query seems correct, though. It's possible that Steve Johnson is another sales agent not in the sample data. The answer provided by the query is correct based on the database, even if the sample doesn't show him. So the answer should be Steve Johnson.
</think>

The sales agent who made the most in sales in 2009 is **Steve Johnson**, with total sales of approximately **$164.34**.

This result was obtained by joining the `Invoice`, `Customer`, and `Employee` tables, filtering for invoices in 2009, grouping by sales agent, and summing their total sales. The query then ordered the results by total sales in descending order and selected the top result.

6.3 커스터마이징 SQL Agent

6.3.1 툴 노드 세팅

# 'sql_db_schema' 툴 찾아서 ToolNode로 래핑
get_schema_tool = next(tool for tool in tools if tool.name == "sql_db_schema")
get_schema_node = ToolNode([get_schema_tool], name="get_schema")

# 'sql_db_query' 툴 찾아서 ToolNode로 래핑
run_query_tool = next(tool for tool in tools if tool.name == "sql_db_query")
run_query_node = ToolNode([run_query_tool], name="run_query")

6.3.2 전체 테이블 조회(list_tables)

def list_tables(state: MessagesState):
    console.print(Panel("[bold green]전체 테이블 조회 노드 진입[/]"))

    #툴 호출 메시지 객체를 강제 생성
    tool_call = {
        "name": "sql_db_list_tables",
        "args": {},
        "id": "abc123",
        "type": "tool_call",
    }

    # AIMessage에 tool_call을 붙여서 모델 → 툴 호출 흐름을 강제합니다.
    tool_call_message = AIMessage(content="", tool_calls=[tool_call])

    # 실제로 'sql_db_list_tables' 툴 실행
    list_tables_tool = next(tool for tool in tools if tool.name == "sql_db_list_tables")
    tool_message = list_tables_tool.invoke(tool_call)

    # 툴 결과를 사용자에게 보여줄 AIMessage 생성
    response = AIMessage(f"Available tables: {tool_message.content}")
    return {"messages": [tool_call_message, tool_message, response]}

6.3.3 질문 관련 스키마 조회(call_get_schema)

def call_get_schema(state: MessagesState):
    console.print(Panel("[bold green]질문 관련 테이블 선택 노드 진입[/]"))

    # LLM에 sql_db_schema 툴 바인딩, 어떤 테이블 스키마를 요청할지 물어봅니다.
    llm_with_tools = llm.bind_tools([get_schema_tool], tool_choice="any")
    response = llm_with_tools.invoke(state["messages"])

    # 이 response에는 tool_call이 담겨 있고, 바로 다음에 get_schema 노드가 실행됩니다.
    return {"messages": [response]}

6.3.4 쿼리&답변 생성

generate_query_system_prompt = """
You are an agent designed to interact with a SQL database.
Given an input question, create a syntactically correct {dialect} query to run,
then look at the results of the query and return the answer. Unless the user
specifies a specific number of examples they wish to obtain, always limit your
query to at most {top_k} results.

You can order the results by a relevant column to return the most interesting
examples in the database. Never query for all the columns from a specific table,
only ask for the relevant columns given the question.

DO NOT make any DML statements (INSERT, UPDATE, DELETE, DROP etc.) to the database.

And after executing the query and obtaining the results, provide the final answer in Korean.
""".format(
    dialect=db.dialect, # DB 종류 (SQLite, Postgres 등)이 자동으로 삽입됩니다.
    top_k=5, # 결과 개수를 k개로 제한합니다.
)

def generate_query(state: MessagesState):
    console.print(Panel("[bold green]쿼리 생성&최종 답변 생성 노드 진입[/]"))

    # 시스템 메시지에 위에서 만든 generate_query_system_prompt 프롬프트를 넣습니다.
    system_message = {
        "role": "system",
        "content": generate_query_system_prompt,
    }

    # run_query_tool 을 바인딩한 LLM 으로 “쿼리 생성” 요청
    llm_with_tools = llm.bind_tools([run_query_tool])

    # 모델이 tool_call을 내보내면, 그걸 실행하고 결과를 포함해 다시 답변하도록 함
    response = llm_with_tools.invoke([system_message] + state["messages"])
    return {"messages": [response]}

6.3.5 쿼리 점검

check_query_system_prompt = """
You are a SQL expert with a strong attention to detail.
Double check the {dialect} query for common mistakes, including:
- Using NOT IN with NULL values
- Using UNION when UNION ALL should have been used
- Using BETWEEN for exclusive ranges
- Data type mismatch in predicates
- Properly quoting identifiers
- Using the correct number of arguments for functions
- Casting to the correct data type
- Using the proper columns for joins

If there are any of the above mistakes, rewrite the query. If there are no mistakes,
just reproduce the original query.

You will call the appropriate tool to execute the query after running this check.
""".format(dialect=db.dialect)

def check_query(state: MessagesState):
    console.print(Panel("[bold green]생생된 쿼리 체크 노드 진입[/]"))
    
    # 시스템 메시지에 위에서 만든 check_query_system_prompt 프롬프트를 넣습니다.
    system_message = {
        "role": "system",
        "content": check_query_system_prompt,
    }

    # 마지막 메시지(tool_call)를 꺼내서 메시지로 만들어 둡니다.
    tool_call = state["messages"][-1].tool_calls[0]
    user_message = {"role": "user", "content": tool_call["args"]["query"]}

    # SQL 전문가 역할의 시스템 메시지와 함께, 쿼리 점검 요청합니다.
    llm_with_tools = llm.bind_tools([run_query_tool], tool_choice="any")
    response = llm_with_tools.invoke([system_message, user_message])

    # id를 이전 메시지 id로 맞춰 줘야 연속성이 유지됩니다.
    response.id = state["messages"][-1].id
    return {"messages": [response]}

6.3.6 종료 결정

def should_continue(state: MessagesState) -> Literal[END, "check_query"]:
    console.print(Panel("[bold yellow]종료 여부 판단[/]"))

    # 메시지 스택 획득: 지금까지의 대화 내역을 가져옵니다
    messages = state["messages"]

    # 가장 최근 AIMessage 객체
    last_message = messages[-1]
    
    # 디버그 출력: 마지막 메시지를 확인하여 tool_calls 포함 여부를 살핍니다
    console.print(Panel(f"[bold magenta]⏳ 마지막 메시지:[/]\n{last_message}", expand=False))

    # 툴 호출 여부에 따라 분기 처리
    if not last_message.tool_calls:
        console.print(Panel("[bold blue]✔ 더 이상 툴 호출이 없어 흐름을 종료합니다 (END)[/]", expand=False))
        return END
    else:
        console.print(Panel("[bold green]⟳ 툴 호출 감지됨 — check_query 단계로 이동합니다[/]", expand=False))
        return "check_query"

6.3.7 플로우 구성

builder = StateGraph(MessagesState)
builder.add_node(list_tables)
builder.add_node(call_get_schema)
builder.add_node(get_schema_node, "get_schema")
builder.add_node(generate_query)
builder.add_node(check_query)
builder.add_node(run_query_node, "run_query")

builder.add_edge(START, "list_tables")
builder.add_edge("list_tables", "call_get_schema")
builder.add_edge("call_get_schema", "get_schema")
builder.add_edge("get_schema", "generate_query")

# generate_query 이후, tool_call이 있으면 check_query → run_query → generate_query 로 반복
builder.add_conditional_edges(
    "generate_query",
    should_continue,
)
builder.add_edge("check_query", "run_query")
builder.add_edge("run_query", "generate_query")

agent = builder.compile()

6.3.8 실행

question = "Which sales agent made the most in sales in 2009?"

for step in agent.stream(
    {"messages": [{"role": "user", "content": question}]},
    stream_mode="values",
):
    step["messages"][-1].pretty_print()

출력결과(더 보기 클릭)

더보기
================================ Human Message =================================

Which sales agent made the most in sales in 2009?
╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ 전체 테이블 조회 노드 진입                                                                                      │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
================================== Ai Message ==================================

Available tables: Album, Artist, Customer, Employee, Genre, Invoice, InvoiceLine, MediaType, Playlist, PlaylistTrack, Track
╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ 질문 관련 테이블 선택 노드 진입                                                                                 │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
================================== Ai Message ==================================
Tool Calls:
  sql_db_schema (26673791-cc9c-459f-8e0a-ea91951bc57c)
 Call ID: 26673791-cc9c-459f-8e0a-ea91951bc57c
  Args:
    table_names: Invoice, InvoiceLine, Employee
================================= Tool Message =================================
Name: sql_db_schema


CREATE TABLE "Employee" (
	"EmployeeId" INTEGER NOT NULL, 
	"LastName" NVARCHAR(20) NOT NULL, 
	"FirstName" NVARCHAR(20) NOT NULL, 
	"Title" NVARCHAR(30), 
	"ReportsTo" INTEGER, 
	"BirthDate" DATETIME, 
	"HireDate" DATETIME, 
	"Address" NVARCHAR(70), 
	"City" NVARCHAR(40), 
	"State" NVARCHAR(40), 
	"Country" NVARCHAR(40), 
	"PostalCode" NVARCHAR(10), 
	"Phone" NVARCHAR(24), 
	"Fax" NVARCHAR(24), 
	"Email" NVARCHAR(60), 
	PRIMARY KEY ("EmployeeId"), 
	FOREIGN KEY("ReportsTo") REFERENCES "Employee" ("EmployeeId")
)

/*
3 rows from Employee table:
EmployeeId	LastName	FirstName	Title	ReportsTo	BirthDate	HireDate	Address	City	State	Country	PostalCode	Phone	Fax	Email
1	Adams	Andrew	General Manager	None	1962-02-18 00:00:00	2002-08-14 00:00:00	11120 Jasper Ave NW	Edmonton	AB	Canada	T5K 2N1	+1 (780) 428-9482	+1 (780) 428-3457	andrew@chinookcorp.com
2	Edwards	Nancy	Sales Manager	1	1958-12-08 00:00:00	2002-05-01 00:00:00	825 8 Ave SW	Calgary	AB	Canada	T2P 2T3	+1 (403) 262-3443	+1 (403) 262-3322	nancy@chinookcorp.com
3	Peacock	Jane	Sales Support Agent	2	1973-08-29 00:00:00	2002-04-01 00:00:00	1111 6 Ave SW	Calgary	AB	Canada	T2P 5M5	+1 (403) 262-3443	+1 (403) 262-6712	jane@chinookcorp.com
*/


CREATE TABLE "Invoice" (
	"InvoiceId" INTEGER NOT NULL, 
	"CustomerId" INTEGER NOT NULL, 
	"InvoiceDate" DATETIME NOT NULL, 
	"BillingAddress" NVARCHAR(70), 
	"BillingCity" NVARCHAR(40), 
	"BillingState" NVARCHAR(40), 
	"BillingCountry" NVARCHAR(40), 
	"BillingPostalCode" NVARCHAR(10), 
	"Total" NUMERIC(10, 2) NOT NULL, 
	PRIMARY KEY ("InvoiceId"), 
	FOREIGN KEY("CustomerId") REFERENCES "Customer" ("CustomerId")
)

/*
3 rows from Invoice table:
InvoiceId	CustomerId	InvoiceDate	BillingAddress	BillingCity	BillingState	BillingCountry	BillingPostalCode	Total
1	2	2009-01-01 00:00:00	Theodor-Heuss-Straße 34	Stuttgart	None	Germany	70174	1.98
2	4	2009-01-02 00:00:00	Ullevålsveien 14	Oslo	None	Norway	0171	3.96
3	8	2009-01-03 00:00:00	Grétrystraat 63	Brussels	None	Belgium	1000	5.94
*/


CREATE TABLE "InvoiceLine" (
	"InvoiceLineId" INTEGER NOT NULL, 
	"InvoiceId" INTEGER NOT NULL, 
	"TrackId" INTEGER NOT NULL, 
	"UnitPrice" NUMERIC(10, 2) NOT NULL, 
	"Quantity" INTEGER NOT NULL, 
	PRIMARY KEY ("InvoiceLineId"), 
	FOREIGN KEY("TrackId") REFERENCES "Track" ("TrackId"), 
	FOREIGN KEY("InvoiceId") REFERENCES "Invoice" ("InvoiceId")
)

/*
3 rows from InvoiceLine table:
InvoiceLineId	InvoiceId	TrackId	UnitPrice	Quantity
1	1	2	0.99	1
2	1	4	0.99	1
3	2	6	0.99	1
*/
╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ 쿼리 생성&최종 답변 생성 노드 진입                                                                              │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ 종료 여부 판단                                                                                                  │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ ⏳ 마지막 메시지:                                                                                               │
│ content='' additional_kwargs={} response_metadata={'model': 'qwen3:14b', 'created_at':                          │
│ '2025-05-15T07:00:17.418596999Z', 'done': True, 'done_reason': 'stop', 'total_duration': 98259045147,           │
│ 'load_duration': 21239971, 'prompt_eval_count': 1556, 'prompt_eval_duration': 1333973825, 'eval_count': 2196,   │
│ 'eval_duration': 96830782184, 'model_name': 'qwen3:14b'} id='run--2aa01cde-f76c-4218-83ae-546ac3a7267b-0'       │
│ tool_calls=[{'name': 'sql_db_query', 'args': {'query': "SELECT e.FirstName, e.LastName, SUM(il.UnitPrice *      │
│ il.Quantity) AS TotalSales FROM Employee e JOIN Customer c ON e.EmployeeId = c.SupportRepId JOIN Invoice i ON   │
│ c.CustomerId = i.CustomerId JOIN InvoiceLine il ON i.InvoiceId = il.InvoiceId WHERE strftime('%Y',              │
│ i.InvoiceDate) = '2009' GROUP BY e.EmployeeId ORDER BY TotalSales DESC LIMIT 1;"}, 'id':                        │
│ 'fdaa7867-9105-41a5-b180-cf5989996ec6', 'type': 'tool_call'}] usage_metadata={'input_tokens': 1556,             │
│ 'output_tokens': 2196, 'total_tokens': 3752}                                                                    │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
╭──────────────────────────────────────────────────╮
│ ⟳ 툴 호출 감지됨 — check_query 단계로 이동합니다 │
╰──────────────────────────────────────────────────╯
================================== Ai Message ==================================
Tool Calls:
  sql_db_query (fdaa7867-9105-41a5-b180-cf5989996ec6)
 Call ID: fdaa7867-9105-41a5-b180-cf5989996ec6
  Args:
    query: SELECT e.FirstName, e.LastName, SUM(il.UnitPrice * il.Quantity) AS TotalSales FROM Employee e JOIN Customer c ON e.EmployeeId = c.SupportRepId JOIN Invoice i ON c.CustomerId = i.CustomerId JOIN InvoiceLine il ON i.InvoiceId = il.InvoiceId WHERE strftime('%Y', i.InvoiceDate) = '2009' GROUP BY e.EmployeeId ORDER BY TotalSales DESC LIMIT 1;
╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ 생생된 쿼리 체크 노드 진입                                                                                      │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
================================== Ai Message ==================================
Tool Calls:
  sql_db_query (8ecf953c-ed5e-4c27-9a41-e4a1774dbd1c)
 Call ID: 8ecf953c-ed5e-4c27-9a41-e4a1774dbd1c
  Args:
    query: SELECT e.FirstName, e.LastName, SUM(il.UnitPrice * il.Quantity) AS TotalSales FROM Employee e JOIN Customer c ON e.EmployeeId = c.SupportRepId JOIN Invoice i ON c.CustomerId = i.CustomerId JOIN InvoiceLine il ON i.InvoiceId = il.InvoiceId WHERE strftime('%Y', i.InvoiceDate) = '2009' GROUP BY e.EmployeeId ORDER BY TotalSales DESC LIMIT 1;
================================= Tool Message =================================
Name: sql_db_query

[('Steve', 'Johnson', 164.34000000000003)]
╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ 쿼리 생성&최종 답변 생성 노드 진입                                                                              │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ 종료 여부 판단                                                                                                  │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ ⏳ 마지막 메시지:                                                                                               │
│ content="<think>\nOkay, let's see. The user asked which sales agent made the most in sales in 2009. I need to   │
│ figure out the answer based on the database tables provided.\n\nFirst, I remember that the relevant tables are  │
│ Employee, Invoice, Customer, and InvoiceLine. The Employee table has the sales agents' information. The         │
│ Customer table links to Employee via the SupportRepId, which is the employee who supports the customer. Then,   │
│ the Invoice table connects to Customer through CustomerId, and InvoiceLine connects to Invoice via InvoiceId.   │
│ Each InvoiceLine has UnitPrice and Quantity, which multiply to get the total sales per invoice line.\n\nSo the  │
│ plan is to join these tables: Employee (e) with Customer (c) on e.EmployeeId = c.SupportRepId. Then join        │
│ Customer with Invoice (i) on c.CustomerId = i.CustomerId. Then join Invoice with InvoiceLine (il) on            │
│ i.InvoiceId = il.InvoiceId. \n\nNext, filter the invoices to those where the InvoiceDate is in 2009. Using      │
│ strftime('%Y', i.InvoiceDate) = '2009' should work. \n\nThen, calculate the total sales per employee by summing │
│ (il.UnitPrice * il.Quantity) for each employee. Group by EmployeeId and order by the sum in descending order.   │
│ Finally, take the top result.\n\nThe SQL query I wrote earlier did that and returned Steve Johnson with         │
│ approximately $164.34. The user might want the answer in Korean, so I should present the name and the total     │
│ sales amount, making sure to mention the year 2009 and that it's the highest sales among sales                  │
│ agents.\n</think>\n\n2009년에 가장 많은 판매를 기록한 영업 담당자는 **Steve Johnson**입니다. 총 판매 금액은 약  │
│ **164.34달러**입니다." additional_kwargs={} response_metadata={'model': 'qwen3:14b', 'created_at':              │
│ '2025-05-15T07:01:02.810367849Z', 'done': True, 'done_reason': 'stop', 'total_duration': 18812155682,           │
│ 'load_duration': 20527736, 'prompt_eval_count': 1703, 'prompt_eval_duration': 1401799735, 'eval_count': 398,    │
│ 'eval_duration': 17268549162, 'model_name': 'qwen3:14b'} id='run--ae108d81-fa8b-48fb-a16c-467594f48b52-0'       │
│ usage_metadata={'input_tokens': 1703, 'output_tokens': 398, 'total_tokens': 2101}                               │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
╭──────────────────────────────────────────────────╮
│ ✔ 더 이상 툴 호출이 없어 흐름을 종료합니다 (END) │
╰──────────────────────────────────────────────────╯
================================== Ai Message ==================================

<think>
Okay, let's see. The user asked which sales agent made the most in sales in 2009. I need to figure out the answer based on the database tables provided.

First, I remember that the relevant tables are Employee, Invoice, Customer, and InvoiceLine. The Employee table has the sales agents' information. The Customer table links to Employee via the SupportRepId, which is the employee who supports the customer. Then, the Invoice table connects to Customer through CustomerId, and InvoiceLine connects to Invoice via InvoiceId. Each InvoiceLine has UnitPrice and Quantity, which multiply to get the total sales per invoice line.

So the plan is to join these tables: Employee (e) with Customer (c) on e.EmployeeId = c.SupportRepId. Then join Customer with Invoice (i) on c.CustomerId = i.CustomerId. Then join Invoice with InvoiceLine (il) on i.InvoiceId = il.InvoiceId. 

Next, filter the invoices to those where the InvoiceDate is in 2009. Using strftime('%Y', i.InvoiceDate) = '2009' should work. 

Then, calculate the total sales per employee by summing (il.UnitPrice * il.Quantity) for each employee. Group by EmployeeId and order by the sum in descending order. Finally, take the top result.

The SQL query I wrote earlier did that and returned Steve Johnson with approximately $164.34. The user might want the answer in Korean, so I should present the name and the total sales amount, making sure to mention the year 2009 and that it's the highest sales among sales agents.
</think>

2009년에 가장 많은 판매를 기록한 영업 담당자는 **Steve Johnson**입니다. 총 판매 금액은 약 **164.34달러**입니다.

7. 결론

지금까지 LangGraph 기반 SQL Agent의 핵심 이론과
Qwen3-14B 모델을 Ollama로 서빙하는 전반적인 구조를 살펴보았습니다.
다음 포스트에서도 LangGraph를 이용한 다양한 프로젝트를 작성해보겠습니다.

읽어주셔서 감사합니다.