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_graphextractor 를 받습니다.

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번 트랙 전체가 이 한 줄을 위한 것이었습니다.

이해도 확인

문항을 불러오는 중입니다.