5. 자료를 밖으로 보내지 않기
주소 한 줄만 바꾸면 된다
이 차시를 마치면
패키지를 바꾸지 않고 주소만 돌려 LangGraph 를 로컬 모델에 붙입니다.
LangGraph 에서 로컬 모델을 부릅니다. 이 차시의 핵심은 한 문장입니다.
핵심
패키지를 바꾸지 않습니다. langchain-openai 를 그대로 쓰고 base_url 만 로컬 서버로 돌립니다.
왜 이것이 가능한가
LM Studio 가 OpenAI 호환 API 를 내주기 때문입니다. 같은 경로(/v1/chat/completions), 같은 요청 형식, 같은 응답 형식입니다.
그래서 코드 관점에서 달라지는 것은 주소 하나입니다. 이것이 로컬 전환의 문턱을 크게 낮춥니다. 분석
| 외부 API | 로컬 | |
|---|---|---|
| 패키지 | langchain-openai | 같음 |
| 클래스 | ChatOpenAI | 같음 |
base_url | 기본값(사업자 서버) | http://localhost:1234/v1 |
api_key | 실제 키 | 아무 문자열. 검사되지 않음 |
model | gpt-... | google/gemma-4-e4b |
코드
import json
import os
from langchain_openai import ChatOpenAI
from langgraph.graph import StateGraph, START, END
from typing_extensions import TypedDict
BASE_URL = os.environ.get("LOCAL_LLM_BASE_URL", "http://host.docker.internal:1234/v1")
MODEL = "google/gemma-4-e4b"
# 추론 토큰을 쓰는 모델이므로 넉넉히 잡는다. 부족하면 본문이 빈 채로 돌아온다.
MAX_TOKENS = 1200
ACTION_SCHEMA = {
"type": "object",
"properties": {
"actions": {
"type": "array",
"items": {
"type": "object",
"properties": {
"owner": {"type": "string"},
"task": {"type": "string"},
"due": {"type": "string"},
},
"required": ["owner", "task", "due"],
},
}
},
"required": ["actions"],
}
class NoteState(TypedDict):
note: str
actions: list
def make_model():
"""로컬 모델. api_key 는 형식상 필요할 뿐 검사되지 않는다."""
return ChatOpenAI(
base_url=BASE_URL,
api_key="lm-studio",
model=MODEL,
temperature=0,
max_tokens=MAX_TOKENS,
)
def extract_actions(state: NoteState) -> dict:
"""회의록에서 실행항목을 뽑는다. schema 를 지정해 형식을 고정한다."""
model = make_model().bind(
response_format={
"type": "json_schema",
"json_schema": {"name": "actions", "strict": True, "schema": ACTION_SCHEMA},
}
)
reply = model.invoke(
[
("system", "회의록에서 실행항목을 뽑아 JSON으로만 답한다."),
("user", state["note"]),
]
)
return {"actions": json.loads(reply.content)["actions"]}
def build_graph():
builder = StateGraph(NoteState)
builder.add_node("extract", extract_actions)
builder.add_edge(START, "extract")
builder.add_edge("extract", END)
return builder.compile()
실행 결과
입력한 회의록입니다.
# 8월 2주차 회의록
참석: 김, 이, 박
- 김: 3분기 매출 자료 8월 20일까지 정리
- 이: 신규 채널 후보 3곳 조사, 8월 25일
- 박: 다음 회의 일정 공지 (8월 18일)
출력입니다.
base_url: http://host.docker.internal:1234/v1
model : google/gemma-4-e4b
추출한 실행항목 3건
- 김 | 3분기 매출 자료 정리 | 8월 20일
- 이 | 신규 채널 후보 3곳 조사 | 8월 25일
- 박 | 다음 회의 일정 공지 | 8월 18일
요청은 http://host.docker.internal:1234/v1 로만 갔습니다. 외부 사업자에게 가지 않았습니다.
2026-08-15 실행 결과입니다. 검증 환경은 Python 3.12.14 · langgraph 1.2.11 · langchain-openai 1.5.1 입니다.
Structured 출력이 4B 모델에서도 됩니다
주목할 결과입니다. 파라미터 4B 짜리 모델이 세 항목을 담당자·할 일·기한으로 정확히 나눴습니다.
비결은 response_format 의 json_schema 입니다. 형식을 프롬프트로 부탁하는 것이 아니라 지정합니다. 2번 트랙에서 배운 "JSON 처럼 보이게 프롬프트만 요구하는 실수" 를 피하는 방법이 여기서 실제로 쓰입니다.
model = make_model().bind(
response_format={
"type": "json_schema",
"json_schema": {"name": "actions", "strict": True, "schema": ACTION_SCHEMA},
}
)
strict: True 가 형식을 강제합니다. 확인 필요 모든 로컬 모델이 이 기능을 지원하는 것은 아닙니다. 쓰기 전에 그 모델로 확인해야 합니다.
주소를 어디로 쓸 것인가
어디서 실행하느냐에 따라 다릅니다. 실측 결과입니다.
| 실행 위치 | 주소 | 결과 |
|---|---|---|
| 같은 기기(호스트) | http://localhost:1234/v1 | 동작 |
| Docker 컨테이너 안 | http://host.docker.internal:1234/v1 | 동작 |
| Docker 컨테이너 안 | http://gateway.docker.internal:1234/v1 | 실패 (URLError) |
컨테이너 안에서 localhost | http://localhost:1234/v1 | 실패. 컨테이너 자신을 가리킵니다 |
컨테이너 안의 localhost 는 컨테이너 자신입니다. 호스트를 가리키려면 host.docker.internal 을 써야 합니다. 이 교재의 검증은 컨테이너에서 돌리므로 그 주소를 씁니다.
코드에서 환경변수로 바꿀 수 있게 해 두었습니다.
LOCAL_LLM_BASE_URL=http://localhost:1234/v1 python3 local/connect_langgraph.py
api_key 는 왜 필요한가
ChatOpenAI 가 형식상 요구하기 때문입니다. 로컬 서버는 이 값을 검사하지 않으므로 아무 문자열이나 넣어도 됩니다.
여기서 헷갈리지 말아야 할 것이 있습니다. 이 값이 아무거나여도 된다는 것은 인증이 없다는 뜻입니다. 서버가 loopback 에만 열려 있어서 같은 기기에서만 접근할 수 있을 뿐입니다. 서버를 외부 주소로 열면 누구나 쓸 수 있게 됩니다. 분석
max_tokens 를 넉넉히 잡은 이유
코드에 MAX_TOKENS = 1200 이 있고 주석에 "부족하면 본문이 빈 채로 돌아온다" 고 적혀 있습니다.
이것은 이 모델 계열의 중요한 함정입니다. 6차시에서 계측 결과와 함께 다룹니다.
직접 실행하기
cd webapp/verify
docker run --rm -v "$PWD":/work -w /work llmwiki-verify python local/connect_langgraph.py
LM Studio 서버가 켜져 있어야 합니다. 응답에 시간이 걸릴 수 있습니다.