6. 직접 만들기: 회의록 정리 Agent
회의록에서 할 일 뽑아내기
이 차시를 마치면
로컬 모델로 회의록에서 실행항목을 뽑습니다.
로컬 모델로 회의록에서 실행항목을 뽑습니다. 5번 트랙에서 확정한 연결 방식을 그대로 씁니다.
코드
def local_extractor(note_texts):
"""로컬 모델로 실행항목을 뽑는다. 요청은 이 기기 밖으로 나가지 않는다."""
from langchain_openai import ChatOpenAI
model = ChatOpenAI(
base_url=BASE_URL,
api_key="lm-studio",
model=MODEL,
temperature=0,
max_tokens=MAX_TOKENS,
).bind(
response_format={
"type": "json_schema",
"json_schema": {"name": "actions", "strict": True, "schema": ACTION_SCHEMA},
}
)
reply = model.invoke(
[
("system", "회의록에서 실행항목을 뽑아 JSON으로만 답한다."),
("user", "\n\n".join(note_texts)),
]
)
if not reply.content:
raise ValueError("모델 응답이 비어 있습니다. max_tokens 를 늘려 보세요.")
return json.loads(reply.content)["actions"]
이 차시는 로컬 모델을 부르므로 모델이 올라가 있어야 돌아갑니다. 5번 트랙에서 확정한 연결 방식을 그대로 씁니다. 형식 고정(json_schema)이 4B 모델에서도 동작하는 것은 2026-08-15 에 직접 확인했습니다. 모델 없이 그래프만 시험하는 방법은 9차시에 있습니다.
schema 로 답의 형식을 못 박습니다
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"],
}
형식을 프롬프트로 부탁하지 않고 지정합니다. 2번 트랙에서 배운 "JSON 처럼 보이게 프롬프트만 요구하는 실수" 를 피하는 방법입니다.
strict: True 가 형식을 강제합니다. 4B 모델에서도 동작함을 5번 트랙에서 확인했습니다.
MAX_TOKENS = 1200 인 이유
5번 트랙 6차시에서 계측한 그 함정 때문입니다.
max_tokens finish_reason completion reasoning 본문
80 length 79 77 0자
300 length 299 297 0자
800 stop 479 438 81자
이 모델은 답을 내기 전에 보이지 않는 추론 토큰을 씁니다. 부족하면 추론만 하다 잘려 본문이 빈 채로 돌아옵니다.
비면 예외를 던집니다
if not reply.content:
raise ValueError("모델 응답이 비어 있습니다. max_tokens 를 늘려 보세요.")
이 세 줄이 중요합니다. 없으면 어떻게 되는지 봅니다.
reply.content 가 "" → json.loads("") → JSONDecodeError
또는 운이 나쁘면 → 빈 목록이 흘러가 빈 TODO.md 를 만들고 기존 것을 덮어씀
두 번째가 더 나쁩니다. 오류 없이 조용히 잘못된 결과가 나옵니다. 그리고 그것이 백업을 만들며 기존 파일을 덮어씁니다. 분석
빈 값은 오류로 다룹니다
모델이 아무 말도 하지 않은 것은 "뽑을 항목이 없다" 가 아닙니다. "실패했다" 입니다. 둘을 구분하지 않으면 실패가 정상 결과로 흘러갑니다.
테스트에서는 가짜 함수를 넣습니다
def fake_extractor(note_texts):
return FAKE_ACTIONS
self.graph = notes_graph.build_graph(extractor=fake_extractor)
그래서 build_graph 가 extractor 를 받습니다.
def build_graph(extractor=None):
...
builder.add_node("extract", make_extract_node(extractor or local_extractor))
| 바꿔 넣는 이유 | 설명 |
|---|---|
| 빠름 | 모델 호출은 몇 초 걸립니다. 테스트 8개면 1분이 넘습니다 |
| 일정함 | 모델 출력은 매번 조금씩 다릅니다. 그러면 무엇이 실패한 것인지 알 수 없습니다 |
| 대상이 다름 | 이 테스트가 보려는 것은 그래프의 판단이지 모델의 품질이 아닙니다 |
모델 연결이 실제로 되는지는 다음 차시의 데모가 확인합니다. 확인 대상을 나눈 것입니다. 분석
요청은 어디로 가는가
BASE_URL = os.environ.get("LOCAL_LLM_BASE_URL", "http://host.docker.internal:1234/v1")
회의록 내용이 모델에 들어갑니다. 이 주소가 로컬이면 기기 밖으로 나가지 않습니다. 5번 트랙 전체가 이 한 줄을 위한 것이었습니다.