본문으로 건너뛰기
페이지 목차
이 페이지에서 검색

로컬 LLM

이 페이지에 묻기

로컬 LLM · 현재 차시와 목차 본문에서만 찾아 답합니다. 답을 지어내지 않고 해당하는 대목을 그대로 보여 주며, 여기에 없으면 없다고 알려 드립니다.

생성형 AI가 아니라 이 페이지 본문을 찾아 그대로 보여 주는 방식입니다. 외부로 나가는 요청이 없습니다.

LOCAL · LLM

로컬 LLM

생성 수정

로컬 모델 실행과 GPU·메모리 이해부터 서빙·RAG·도구 호출·업무형 앱 구현까지 차례로 살펴봅니다.

42개 문서 · 1일차 · 8 / 42

7차시 · Ollama API 활용

지도 위치 : 1일차 · ④API 층 맛보기 (Ollama 고유 API) | 이전: 6차시 로컬 LLM 모델 및 Prompt 활용 | 다음: 8차시 Ollama 기반 AI 앱 구현 키워드 : REST API, Chat API, Python 연동, 요청 · 응답 처리, Streaming 응답

이 차시를 다 읽으면

  1. Ollama 서버를 REST API (주소 + 메서드 + JSON)로 읽고, curl로 직접 말을 걸 수 있다.
  2. /api/generate와 /api/chat의 차이를 prompt 하나 ↔ messages 배열 로 말할 수 있다.
  3. 스트리밍 응답이 줄 단위 JSON(NDJSON) 이라는 것과, 26차시의 SSE와 무엇이 다른지 말할 수 있다.
  4. 마지막 done 줄의 통계로 TPS를 계산 할 수 있다 (단위: 나노초).
  5. 동기 ↔ 비동기 를 정의하고, "스트리밍은 빨라지는 게 아니라 먼저 보여 주는 것 ", "비동기는 처리량 을 늘리는 것"을 대비해 설명할 수 있다.

0. 이 차시의 판별 축

#축왼쪽 (0)오른쪽 (1)이 축으로 갈리는 것큰 축 (README 5장)
1다 될 때까지 기다리는가한 번에 받기 (stream: false)조각으로 받기 (stream: true)응답 전체를 기다림 ↔ 생성되는 대로 받음⑥ 동기 ↔ 비동기 (3.3에서 정의)
2입력이 어떤 모양인가글 한 덩어리 (prompt)역할별 메시지 배열 (messages)/api/generate ↔ /api/chat⑦ 무상태 ↔ 상태 (8차시 예고)
3누구의 규격인가Ollama 고유 API (/api/...)OpenAI 호환 API (/v1/...)이 엔진 전용 ↔ 엔진을 갈아 끼울 수 있음② 추상 ↔ 구상 (20차시)

5차시 1.2에서 "ollama run도 사실은 11434번 포트 의 서버에 요청을 보내는 클라이언트"라고 했습니다. 이번에는 그 요청을 우리가 직접 보냅니다. 터미널 명령 대신 프로그램이 서버와 말하는 방식, 즉 API 를 다루는 차시입니다. 8차시의 챗봇 앱도, 3일차의 vLLM 서버도 모두 이 방식 위에 섭니다.


1. Ollama 서버를 REST로 읽기

먼저 이 차시 전체에 나오는 단어들을 정의합니다.

API (Application Programming Interface) — [약속]인데, [ 프로그램이 다른 프로그램의 기능을 부르는 방법을 정해 둔다 ]는 점에서 사람이 쓰는 화면(UI)과 구별된다.

HTTP — [통신 규약]인데, [ 클라이언트가 요청을 하나 보내면 서버가 응답을 하나 돌려주는 한 번의 왕복 으로 대화한다]는 점에서 연결을 열어 두고 양쪽이 아무 때나 말하는 방식과 구별된다. 웹 브라우저가 페이지를 받아 오는 방식이 바로 이것이고, Ollama API도 같은 HTTP를 씁니다.

HTTP 요청과 응답은 각각 정해진 부품으로 이루어집니다.

요청의 부품뜻이 차시의 예
메서드어떻게 — 가져오기(GET) · 보내서 처리시키기(POST) · 지우기(DELETE) 등POST
주소 (URL)어디에, 무엇을 — http://호스트:포트/경로http://localhost:11434/api/chat
헤더부가 정보 — 본문 형식(Content-Type), 인증 키 등Content-Type: application/json
본문 (body)보내는 내용. LLM API에서는 대개 JSON{"model": ..., "messages": [...]}
응답의 부품뜻예
상태 코드결과를 숫자로 — 2xx 성공 , 4xx 요청한 쪽 잘못 , 5xx 서버 쪽 잘못200 성공, 404 그런 모델(자원) 없음, 500 서버 내부 오류
헤더응답 본문의 형식 등application/x-ndjson (3.1)
본문결과 JSON모델의 답 + 통계

JSON — 이름과 값을 {"이름": 값} 꼴로 적는 글 형식. 사람도 읽을 수 있고 거의 모든 프로그래밍 언어가 읽을 수 있어서 API의 공용어처럼 쓰입니다.

REST — [API 설계 방식]인데, [ 다룰 대상(자원)을 주소로 가리키고, 무엇을 할지는 HTTP 메서드로, 내용은 JSON 같은 표현으로 주고받는다 ]는 점에서 함수 이름을 그대로 불러 쓰는 방식과 구별된다. 한 줄로 "주소(무엇을) + 메서드(어떻게) + 표현(JSON)" 입니다.

엔드포인트 (Endpoint) — 메서드와 주소를 짝지은 API의 출입구 하나 . POST /api/chat이 엔드포인트 하나입니다.

1.1 엔드포인트 지도

메서드 · 주소하는 일5차시 명령과의 대응
POST /api/generate글 하나 → 이어서 생성—
POST /api/chat메시지 배열 → 다음 assistant 메시지ollama run의 대화
GET /api/tags디스크에 있는 모델 목록ollama list
GET /api/ps메모리에 올라간 모델ollama ps
POST /api/show모델 정보ollama show
POST /api/pull, DELETE /api/delete내려받기 · 지우기ollama pull · rm

⚠️ 마지막 줄을 보세요. 지우기 API도 같은 포트에 있습니다. 5차시 4.2에서 0.0.0.0으로 열면 안 된다고 한 이유가 이 표 한 줄로 보입니다. 인증 없이 열면 남이 우리 모델을 지울 수 있습니다.

1.2 curl로 직접 — 한 번에 받기

curl http://localhost:11434/api/chat -d '{
  "model": "qwen3:8b",
  "messages": [
    {"role": "system", "content": "당신은 쇼핑몰 고객센터 문의 분류 도우미입니다."},
    {"role": "user",   "content": "주문번호 20261128-001 신발이 일주일째 안 와요."}
  ],
  "stream": false,
  "options": {"temperature": 0, "num_ctx": 8192}
}'

🔍 읽는 포인트

부분REST의 무엇 (1절 첫머리)뜻
localhost:11434주소 · 포트내 컴퓨터의 Ollama 창구
/api/chat자원채팅 API
-d '...'메서드 POST + 표현(JSON)-d가 있으면 curl은 POST로 보냄
"stream": false—⭐ 다 만들고 한 번에 달라 (0절 축 1 왼쪽)
"options": {...}—6차시의 추론 파라미터 를 요청마다 지정

응답 (값은 예시 · 실측 아님 , 일부 필드 생략):

{
  "model": "qwen3:8b",
  "message": {"role": "assistant", "content": "분류: 배송지연\n주문번호: 20261128-001\n요약: ..."},
  "done": true,
  "done_reason": "stop",
  "total_duration": 2900000000,
  "load_duration": 15000000,
  "prompt_eval_count": 58,
  "prompt_eval_duration": 120000000,
  "eval_count": 40,
  "eval_duration": 800000000
}

🔍 읽는 포인트 : 답은 message.content에 있습니다. 나머지 숫자는 3.2절에서 읽습니다. done_reason이 "stop"이면 스스로 끝낸 것이고, "length"면 생성 상한(num_predict)에 걸려 잘린 것 입니다.


2. /api/generate ↔ /api/chat

2.1 차이

/api/generate/api/chat
입력"prompt": "글 하나" (+ 선택적으로 "system")"messages": [{role, content}, ...]
출력 필드responsemessage.content
채팅 템플릿엔진이 적용 (4차시 3.2)엔진이 적용 — 역할별로
어울리는 일한 번 묻고 끝 (요약 1건, 분류 1건)대화 , 역할 구분이 필요한 모든 것
도구 호출 (tools)없음지원 (30차시) — 구조화 출력 format은 둘 다 (29차시)

⭐ 실무 기본값은 /api/chat 입니다. 한 번짜리 요청도 system + user 두 개짜리 배열로 보내면 됩니다. 20차시의 OpenAI 호환 API도 messages 배열 모양이라, 지금 이 모양에 익숙해 두면 그대로 옮겨 갑니다.

2.2 ⚠️ messages 배열이 말해 주는 것

/api/chat에 이전 대화는 어디에도 저장되지 않습니다. 두 번째 질문을 보낼 때 첫 번째 질문과 답을 배열에 다시 넣어 보내지 않으면 모델은 모릅니다. 이 배열을 누가 들고 있느냐 가 8차시 전체의 주제입니다.


3. 스트리밍 — 조각으로 받기

3.1 NDJSON — 줄 하나에 JSON 하나

"stream": true(기본값)로 보내면 응답이 줄 단위로 흘러옵니다.

{"model":"qwen3:8b","message":{"role":"assistant","content":"분류"},"done":false}
{"model":"qwen3:8b","message":{"role":"assistant","content":":"},"done":false}
{"model":"qwen3:8b","message":{"role":"assistant","content":" 배송"},"done":false}
...
{"model":"qwen3:8b","message":{"role":"assistant","content":""},"done":true,"done_reason":"stop","total_duration":...,"eval_count":...,"eval_duration":...}

NDJSON (Newline-Delimited JSON) — [데이터 전송 형식]인데, [ 한 줄에 완결된 JSON 하나씩, 줄바꿈으로 구분해 보낸다 ]는 점에서 data: 접두어와 빈 줄로 구분하는 SSE와 구별된다.

Ollama /api/chat 스트리밍SSE (26차시, OpenAI 호환 /v1)
한 조각의 모양{...} + 줄바꿈data: {...} + 빈 줄
끝 표시마지막 JSON의 "done": truedata: [DONE]
조각 안의 글message.contentchoices[0].delta.content
Content-Typeapplication/x-ndjsontext/event-stream

⚠️ 같은 Ollama라도 주소에 따라 형식이 다릅니다. /api/chat은 NDJSON, /v1/chat/completions는 SSE입니다. 한쪽용으로 짠 파싱 코드를 다른 쪽에 붙이면 조각을 하나도 못 읽거나 JSON 에러가 납니다. AI가 만든 코드가 스트리밍에서 깨지면 가장 먼저 의심할 곳입니다.

3.2 ⭐ 마지막 줄의 통계 — TPS 계산

마지막 done: true 줄(또는 stream: false 응답)에 붙는 숫자들입니다. 단위는 전부 나노초(10⁻⁹초) 입니다.

필드뜻2차시와의 대응
total_duration요청 전체 시간—
load_duration모델을 메모리에 올리는 데 쓴 시간5차시 3.2 — 첫 요청이면 큼
prompt_eval_count입력으로 처리한 토큰 수Prefill
prompt_eval_duration입력 처리 시간Prefill
eval_count생성한 토큰 수Decode
eval_duration생성 시간Decode
생성 속도 (TPS) = eval_count ÷ eval_duration × 1,000,000,000

1.2절 예시값:  40 ÷ 800,000,000 × 10⁹ = 50 tok/s     (예시 · 실측 아님)
입력 처리 속도 = 58 ÷ 120,000,000 × 10⁹ ≈ 483 tok/s  (예시 · 실측 아님)

⚠️ 나노초를 초로 착각하면 숫자가 10억 배 틀립니다. AI가 "TPS가 0.00000005입니다"라고 보고하면 단위 변환을 빼먹은 것입니다.

⭐ 이 숫자는 AI의 "빨라졌습니다"를 검수하는 첫 번째 증거 입니다. 추정이 아니라 엔진이 직접 잰 값 이기 때문입니다.

앞으로 다시 만납니다 — 14차시에서 이 통계와 클라이언트 쪽 시계로 TTFT · TPS · Latency 를 정식으로 재고, 워밍업 · 반복 · 중앙값 규칙을 붙입니다. 8차시에서는 대화가 길어질수록 prompt_eval_count가 어떻게 변하는지 봅니다.

3.3 ⭐ 스트리밍은 빨라지는 게 아니라 먼저 보여 주는 것

이 절에서 README 5장의 큰 축 ⑥을 정식으로 정의합니다.

동기 (Synchronous) — [작업 처리 방식]인데, [ 결과가 올 때까지 그 자리에서 멈춰 기다린 뒤 다음 일로 넘어간다 ]는 점에서 비동기와 구별된다. 전화 통화처럼, 상대가 답할 때까지 다른 일을 못 합니다.

비동기 (Asynchronous) — [작업 처리 방식]인데, [ 결과를 기다리는 동안 멈추지 않고 다른 일을 하다가, 결과가 오면 이어서 처리한다 ]는 점에서 동기와 구별된다. 문자 메시지처럼, 보내 놓고 답이 올 때까지 다른 일을 합니다.

LLM 앱의 시간은 대부분 기다리는 시간 입니다. 앱 서버는 요청을 보내 놓고 GPU가 답을 만드는 몇 초 동안 할 일이 없습니다. 이렇게 계산이 아니라 바깥(네트워크 · 디스크 · 다른 서버)의 응답을 기다리느라 오래 걸리는 일 을 I/O-bound 라고 하고, 반대로 계산 자체가 오래 걸리는 일 을 CPU-bound (또는 GPU에서 계산이 병목인 일)라고 합니다. 비동기가 효과를 보는 것은 I/O-bound 쪽입니다.

⚠️ 비동기는 빨라지는 것이 아닙니다. 요청 한 건이 걸리는 시간은 똑같습니다. 바뀌는 것은 기다리는 시간을 놀리지 않는다 는 점이고, 그래서 같은 시간에 처리하는 요청 수(처리량, Throughput) 가 늘어납니다.

 동기:    [요청A ── 기다림 3초 ──][요청B ── 기다림 3초 ──]      → 6초에 2건
 비동기:  [요청A ── 기다림 3초 ──]
          [요청B ── 기다림 3초 ──]                            → 3초에 2건 (각 건은 여전히 3초)

(기다리는 상대 — 여기서는 LLM 서버 — 가 두 요청을 동시에 받아 줄 수 있을 때의 그림입니다. 엔진이 동시에 몇 건을 처리하는지는 13 · 21 · 24차시에서 다룹니다.)

스트리밍도 "기다리지 않는" 쪽처럼 보이지만, 바뀌는 것이 다릅니다. 둘을 나란히 놓습니다.

한 건의 총 시간서버 전체 처리량바뀌는 것
비동기 (앱 서버 쪽)같음늘어남기다리는 동안 다른 요청을 처리
스트리밍 (이 차시)같음같음⭐ 첫 글자가 보이는 시점 이 당겨짐
 stream: false   [────── 생성 3초 ──────]▶ 3초 뒤 답 전체가 한꺼번에
 stream: true    [▶첫 조각 ▶ ▶ ▶ ▶ ▶ ▶ ▶]  0.x초 뒤부터 글자가 흘러나옴, 끝은 똑같이 3초

사람이 느끼는 기다림은 첫 글자까지의 시간(TTFT, 2차시 4절) 이 좌우합니다. 그래서 상담원 화면에는 스트리밍이 맞고, 분류 결과를 받아 프로그램이 다음 단계로 넘기는 경우(분류 → DB 저장)에는 어차피 끝까지 받아야 하니 stream: false가 단순합니다.


4. Python 연동

4.1 공식 라이브러리 — ollama

import ollama

messages = [
    {"role": "system", "content": "당신은 쇼핑몰 고객센터 문의 분류 도우미입니다."},
    {"role": "user", "content": "주문번호 20261128-001 신발이 일주일째 안 와요."},
]
stream = ollama.chat(model="qwen3:8b", messages=messages, stream=True,
                     options={"temperature": 0, "num_ctx": 8192})
for chunk in stream:
    print(chunk["message"]["content"], end="", flush=True)
    if chunk["done"]:
        tps = chunk["eval_count"] / chunk["eval_duration"] * 1e9
        print(f"\n[생성 {chunk['eval_count']} 토큰, {tps:.1f} tok/s]")

🔍 읽는 포인트 : stream=True면 for로 조각을 하나씩 받습니다. 조각마다 message.content를 이어 붙여 출력 하고, done인 마지막 조각에서 3.2절의 계산을 합니다. pip install ollama가 필요하고, 앱 전용 환경(3차시 3.1의 llm-app)에 설치합니다.

4.2 라이브러리 없이 — requests로 NDJSON 읽기

import json, requests

body = {"model": "qwen3:8b", "messages": messages, "stream": True}
with requests.post("http://localhost:11434/api/chat", json=body, stream=True, timeout=120) as r:
    r.raise_for_status()
    for line in r.iter_lines():          # ⭐ 한 줄 = JSON 하나 (NDJSON)
        if not line:
            continue
        part = json.loads(line)
        print(part["message"]["content"], end="", flush=True)

🔍 읽는 포인트 : iter_lines()가 줄 단위로 끊어 주고, 각 줄을 json.loads로 엽니다 — 3.1절의 NDJSON을 코드로 옮긴 것이 전부입니다. SSE였다면 data: 를 떼고 [DONE]을 걸러야 합니다(26차시).

4.3 요청 · 응답 처리에서 챙길 것

상황증상처리
Ollama가 안 떠 있음연결 거부 (Connection refused)systemctl status ollama (5차시 1.2)
모델 이름 오타 · 미설치HTTP 404 + "error" 필드ollama list로 이름 확인
첫 요청이 오래 걸림타임아웃load_duration이 큼 → 타임아웃을 넉넉히, 또는 keep_alive로 상주 (5차시 3.2)
답이 중간에 끊김done_reason: "length"num_predict · num_ctx 확인 (6차시 4절)
Qwen3 답에 <think>가 섞임생각 모드버전에 따라 요청의 think 옵션 · /no_think — 공식 문서 확인 (4차시 3.3)
요청마다 모델 유지 시간을 바꾸고 싶음—요청 JSON의 "keep_alive"

5. 예고 — OpenAI 호환 /v1

Ollama는 같은 포트에 OpenAI와 같은 모양의 API 도 엽니다.

http://localhost:11434/api/chat              ← Ollama 고유 (이 차시)
http://localhost:11434/v1/chat/completions   ← OpenAI 호환 (20차시)

OpenAI 호환 API 는 OpenAI가 만든 API의 요청 · 응답 모양을 그대로 따른 것입니다. 이 모양이 사실상 업계 공용 규격이 되어, Ollama · vLLM을 비롯한 여러 엔진이 같은 모양의 /v1 주소를 엽니다. 그래서 /v1에 맞춰 짠 앱은 엔진이 무엇인지 모릅니다. 콘센트 규격(약속)만 맞으면 어느 회사 플러그(구현)든 꽂히는 것과 같습니다 — README 7장 문장 3, 큰 축 ② 추상 ↔ 구상.

고유 API는 Ollama의 기능(keep_alive, 상세 통계)을 다 쓰지만 Ollama에 묶이고 , /v1은 일부 기능을 포기하는 대신 vLLM으로 갈아 끼울 수 있습니다 (0절 축 3).

앞으로 다시 만납니다 — 20차시에서 같은 코드의 base_url만 바꿔 Ollama → vLLM으로 옮깁니다. 앱을 오래 쓸 생각이면 그쪽이 기본값이 됩니다.


6. 실습 — 맡기고 검수하기

지시

목표: Ollama API를 curl과 Python으로 직접 호출하고, 스트리밍 응답과 통계를 확인해줘.
맥락: Ollama 공식 API 문서(docs의 API 레퍼런스)와 ollama-python 저장소 README를 기준으로 해.
      필드 이름을 기억으로 쓰지 말고 실제 응답에서 확인해.
제약: - 모델 qwen3:8b, temperature 0, num_ctx 8192, 생각 모드 끔.
      - 같은 문의로 stream:false 한 번, stream:true 한 번.
      - 첫 요청(로딩 포함)은 따로 표시해.
보고 형식:
  ① curl stream:false 응답 JSON 원문
  ② curl stream:true 응답의 처음 5줄과 마지막 1줄 원문
  ③ 표 [요청 | load_duration(초) | prompt_eval_count | eval_count | eval_duration(초) | TPS]
     — 나노초를 초로 바꾼 식을 같이 적어
  ④ Python(ollama 라이브러리) 스트리밍 코드와 실행 화면
추측하지 말고 실행 출력 원문을 붙여.

받을 증거

  • 응답 원문 (특히 ②의 줄 단위 모양 — NDJSON인지 눈으로 확인)
  • ③ 표의 TPS 계산식

내가 직접 볼 것

볼 것이걸로 아는 것
②가 data: 없이 {로 시작하는 줄들인가NDJSON이다 (SSE 아님)
첫 요청과 두 번째 요청의 load_duration 차이로딩이 첫 요청에만 들어간다 (5차시 3.2)
TPS를 내 손으로 한 번 계산해 표와 맞는가단위(나노초) 변환이 맞는가
stream true/false의 total_duration이 비슷한가스트리밍은 총 시간을 줄이지 않는다 (3.3)

부록 A. 스스로 확인하기

#질문답이 있는 곳
1HTTP 요청 · 응답의 부품은? Ollama API를 REST의 세 요소로 읽으면?1절 첫머리, 1.2
2지우기 API가 같은 포트에 있다는 것이 왜 중요한가?1.1
3/api/generate와 /api/chat의 입력 · 출력 필드 차이는?2.1
4두 번째 질문을 보낼 때 첫 대화를 배열에 안 넣으면?2.2
5NDJSON과 SSE는 조각 모양 · 끝 표시가 어떻게 다른가?3.1
6같은 Ollama에서 두 형식이 갈리는 기준은?3.1
7TPS 계산식과 단위는?3.2
8동기와 비동기는 무엇으로 갈리는가? 스트리밍과 비동기는 각각 무엇을 바꾸는가?3.3
9done_reason이 "length"면 무슨 뜻인가?1.2, 4.3
10고유 API와 /v1은 무엇을 주고 무엇을 포기하는가?5

부록 B. 자주 하는 실수

실수바로잡기
"스트리밍을 켜면 응답이 빨라진다"❌ 총 시간은 같음. 첫 글자가 먼저 보일 뿐
"비동기로 바꾸면 한 건이 빨라진다"❌ 한 건의 시간은 같음. 늘어나는 것은 처리량 (3.3)
"Ollama 스트리밍도 SSE니까 data:를 떼면 된다"❌ /api/chat은 NDJSON . /v1만 SSE
"eval_duration이 800,000,000이니 엄청 느리다"❌ 단위가 나노초 . 0.8초
"서버가 대화를 기억하니 새 질문만 보내면 된다"❌ API는 기억하지 않음. 배열 전체를 다시 (8차시)
"첫 요청 속도로 성능을 판단"⚠️ load_duration 포함. 워밍업은 버림 (14차시)
"/api/generate로 대화를 만든다"⚠️ 역할 구분이 필요하면 /api/chat

부록 C. NotebookLM 복습 프롬프트

이 7차시 노트만 근거로 6문제를 내줘. 비전공자 기준으로.
"/api/generate와 /api/chat", "NDJSON과 SSE의 차이", "나노초 단위 TPS 계산", "스트리밍 ↔ 비동기"는 반드시 포함해줘.
답은 내가 답한 다음에 채점하고, 틀린 이유를 노트의 절 번호를 근거로 설명해줘.