지난 글에서 LLM한테 관련성부터 물어보게 바꾼 뒤로, 이 프로젝트는 한 단계 더 나아갔다. 원래 리포트를 베이스로, 특정 팀 영업 타깃팅용으로 훨씬 무거운 버전(어카운트별 담당 조직, 산학연 네트워크, 팩트 100%, SW 솔루션 매핑까지 뽑는 리포트)을 만드는 별도 저장소로 갈라져 나온 상태였다. 그 리포트 파일 하나를 열어봤다가 이상한 걸 봤다.
요약 문장 끝에 각주 번호가 붙어 있었다
기사 제목 뒤에 [1], 표 셀 밑에 각주처럼 링크가 또 나열되는 게 보였다.
파일을 직접 grep 해보니 리터럴 [숫자] 패턴은 하나도 없었는데, 리포트를
조립하는 코드 안에 이미 \[\d+\]를 정규식으로 지우는 함수가 따로
있었다. 즉 어디선가 이 각주 번호가 생기고 있고, 누군가 이미 한 번은
그걸 눈치채서 다운스트림에서 지우는 처리를 넣어놨었다는 뜻이었다 —
그렇게 보였다.
프롬프트에는 그 얘기가 없었다
요약을 만드는 프롬프트를 열어봤다. LLM한테 회사 관련 여부, 담당조직, 산학연 정보, 팩트를 JSON으로 뽑아달라고는 시키고 있었는데, “각주나 인용 번호를 넣지 마라”는 지시는 어디에도 없었다. 다운스트림에서 정규식으로 지우는 건 증상 치료고, 애초에 LLM이 왜 각주를 붙이는지는 아무도 안 막고 있었던 것이다. 프롬프트 마지막에 한 줄 추가했다 — 각 필드 값에는 출처 인용 번호를 넣지 말라고.
캐시를 지우고 다시 돌려보려다, 옆에 있는 게 걸렸다
고친 게 진짜 먹히는지 확인하려면 다시 리포트를 생성해봐야 했다. 그런데
캐싱 로직을 다시 읽다가 이상한 줄을 하나 봤다. 요약이 끝나면
cache.save_article_cache(...)를 호출해서 결과를 저장하고 있었는데,
캐시 모듈에는 그런 이름의 함수가 없었다. 있는 건 set_article_cache
뿐이었다.
조용히 삼켜지고 있었다
이 호출은 try/except Exception 블록 안에 있었다. LLM 요약까지는
정상적으로 끝나고, 그 결과를 캐시에 저장하려는 순간 AttributeError가
나면서 예외 블록으로 떨어지고, 함수는 None을 반환하고 있었다. 그리고
None은 “이 회사랑 관련 없는 기사”라는 뜻으로 쓰이고 있었다. 즉 LLM이
정상적으로 요약까지 다 만들어낸 기사가, 캐시 저장 한 줄이 실패하는
바람에 “관련 없음”으로 둔갑해서 리포트에서 빠지고 있었던 것이다.
캐시 DB를 열어서 확인해보니 숫자로도 티가 났다. 전체 9857건 중 3138건만 요약이 채워져 있고, 나머지 6719건은 본문은 캐싱돼 있는데 요약 칸이 비어 있었다. 이미 캐시에 요약이 있던 기사만 우연히 이 버그를 안 밟고 지나간 것이었다.
고쳐서 다시 돌려봤다
함수 이름을 set_article_cache로 맞춘 다음, 실제 가이드로 리포트를
다시 생성했다. 결과를 두 가지로 확인했다. 새로 생성된 파일 8개 전체를
grep했을 때 각주 번호가 한 건도 없었고, 캐시 DB의 요약 채워진 행 수가
3138건에서 3186건으로 48건 늘어 있었다. 두 문제 다 잡았다고 생각했다.
눈으로 봐달라는 말에 다시 열어봤다
정말 그런지 직접 눈으로 봐달라는 요청을 받고 터미널에서 리포트 파일을
열었다. 그런데 여전히 [1]이 보였고, 테이블 밑에 링크 목록도 그대로
있었다. grep으로는 분명 없었는데, 화면에는 있었다.
vi로 보면 없는데, glow로 보면 있었다
같은 파일을 두 가지 방법으로 열어봤다. vi로 열면 원본 그대로,
각주 없이 [제목](url) 마크다운 링크만 보였다. 그런데 glow로 열면
제목[1]처럼 각주가 붙고, 문서 끝에 그 URL들을 모은 목록이 따로
생겼다. 같은 파일인데 뷰어에 따라 다르게 보이고 있었다 — 즉 지금 눈에
보이는 각주는 파일이 아니라 뷰어에 있었다.
테이블 셀 안 링크를 통째로 각주로 바꾸고 있었다
glow -w 0(줄바꿈 폭 제한 해제)로 다시 열어봐도 똑같았다. 원인은
폭이 아니라 테이블 자체였다. glow가 쓰는 렌더링 엔진(glamour)이
테이블 셀 안에 있는 마크다운 링크는 무조건 각주로 바꾸는 걸로 보였다 —
셀 폭 안에 [제목](url) 전체를 넣으면 테이블이 깨지니까, 대신 텍스트만
남기고 URL은 번호를 매겨서 문서 끝으로 빼는 방식이었다. 이 리포트는
테이블마다 기사 링크가 잔뜩 들어 있어서, 링크 하나하나가 전부 이 규칙에
걸렸다.
각주 없는 걸 찾아 mdcat을 설치했다
클릭할 일은 없고 그냥 모양만 보면 되는 상황이라, 가벼우면서 각주로
안 도망가는 렌더러를 찾았다. bat은 렌더링을 안 하고 원본에 색만
입히는 거라 테이블 모양 자체를 못 보고, frogmouth나 rich는 더
무거워서 mdcat을 골랐다 — 링크를 각주로 빼는 대신 OSC8 터미널
하이퍼링크로 숨겨서, 클릭 여부와 무관하게 애초에 각주나 URL 목록이
생기지 않는 방식이었다. GitHub 릴리스에서 바이너리 받아서
~/.local/bin에 설치했다.
mdcat도 안 예뻤다
각주는 확실히 없어졌는데, 이번엔 다른 문제가 나왔다. 이 리포트 테이블은
셀 안에 담당조직 설명이나 기사 요약처럼 문장 단위 텍스트가 들어 있는
구조라서, mdcat이 컬럼 폭을 내용 기준으로 넓게 잡아버리면서 줄바꿈이
깨지고 정렬을 알아볼 수 없어졌다. 각주로 도망가느냐(glow), 억지로
줄바꿈하다 깨지느냐(mdcat) 방식만 다를 뿐, “긴 문장이 든 넓은 테이블을
좁은 터미널에 욱여넣는다”는 근본 원인은 똑같았다.
테이블을 세로로 눕혔다
파이프라인 코드는 그대로 두고, 이미 생성된 리포트 파일 하나만 따로
변환하는 스크립트를 짰다. 어카운트별 담당조직/산학연/팩트 표는 행 하나당
컬럼을 - **컬럼명**: 값 식으로 세로로 쌓는 형태로 바꾸고, 맨 뒤에
있는 전체 기사 부록 표는 원래 테이블 그대로 남겨뒀다. 폭 제약이 없어지니
어느 렌더러로 봐도 내용이 안 깨졌다.
그래도 glow 쪽 모양이 더 나았고, 오픈소스라 소스를 봤다
세로형으로 바꾼 파일을 mdcat과 glow 양쪽으로 다시 비교해봤는데,
전체적인 렌더링 모양은 glow 쪽이 더 낫다는 평가가 나왔다. 다만 링크
처리(각주로 빼는 것) 하나만 여전히 마음에 안 들었다. glow와
glamour는 둘 다 오픈소스(MIT)라 소스를 직접 받아서 찾아봤다.
glamour 안에는 이미 WithInlineTableLinks(bool)이라는 옵션이 있었다
— 켜면 테이블 안 링크를 각주로 안 빼고 그대로 인라인 렌더링한다.
그런데 glow CLI의 main.go가 렌더러를 만드는 부분을 보니, 이 옵션은
안 쓰고 있었다. 색 프로필, 줄바꿈 폭, 베이스 URL만 넘기고 있었다 —
즉 이미 라이브러리에 있는 기능인데 CLI가 플래그로도, 설정 파일로도
노출을 안 해놓은 상태였다. 이건 지금 당장 손보기엔 커서, 나중에 볼 일로
적어두고 넘어갔다.
그런데, 애초에 각주가 진짜 있었던 적이 있었나?
여기서 한 번 멈췄다. glow 하나로 지금까지 본 각주와 링크 목록이 전부
설명됐다. 그러면 맨 처음에 프롬프트를 고치게 만들었던 그 각주는 —
정말 LLM이 붙인 게 맞았을까, 아니면 처음 그 리포트를 열어본 것도 사실
glow였던 걸까? 근거로 삼았던 건 reporter.py에 있던 \[\d+\] 제거
코드뿐이었다. 그 코드가 실제로 관찰된 문제 때문에 생긴 건지, 아니면
그것도 예전에 누군가 지금 우리처럼 glow 렌더링을 콘텐츠 문제로
오진하고 방어적으로 넣어둔 건지는 확인한 적이 없었다.
막던 걸 다 빼고 다시 돌려봤다
확인할 방법은 하나였다. 각주를 막고 있던 걸 전부 빼고, 실제로 다시
돌려서 진짜 나오는지 보는 것. 프롬프트에 넣었던 “각주 넣지 마라” 지시
한 줄과, reporter.py의 \[\d+\] 제거 정규식 둘 다 빼고, 기간을
1주일로 좁혀서(내용을 보려는 게 아니라 각주 발생 여부만 보면 되니까)
실제 가이드로 다시 돌렸다.
결과 리포트 파일 8개를 전부 grep했더니 [숫자] 패턴이 딱 1건
나왔는데, “삼성전자[005930]“처럼 주식 종목코드였다. 각주가 아니었다.
이번에 새로 캐싱된 LLM 원본 응답 38건도 전부 뒤져봤는데 각주 패턴은
0건. 기존에 쌓여 있던 캐시 3186건까지 다시 확인해보니, 거기도 각주로
보이는 건 종목코드 하나뿐이었다. 지금까지 LLM이 실제로 각주 스타일
인용 번호를 붙인 적은 한 번도 없었다.
배운 점
증상 하나([1] 각주, 하단 링크 목록)에 원인 후보가 두 개 있을 때,
그럴듯한 쪽(코드에 이미 방어 로직이 있다는 것)을 증거로 삼고 넘어가면
틀릴 수 있다. vi와 glow로 같은 파일을 나란히 열어본 것 한 번으로
파일과 렌더러를 분리할 수 있었고, 그다음엔 거기서 멈추지 않고 “고친
걸 다시 빼고 진짜 재현되는지” 직접 실험해서 확인했다. 고쳤다고 믿는
것과, 그게 실제로 원래 있던 문제였는지 확인하는 것은 다른 일이다.
그 과정에서 얻어걸린 것도 있었다. 각주를 쫓아 들어갔다가 캐시 저장
함수 이름이 애초에 틀려 있던, 완전히 별개의 버그를 만났다. 증상을
고치러 코드를 읽다 보면 바로 옆 줄에 있는 진짜 문제를 우연히 보게 될
때가 있는데, “이 한 줄만 고치면 끝”이라고 생각하지 않고 관련 코드를
한 번 더 읽어야 눈에 띈다. 그리고 넓은 except Exception은 에러를
숨기는 것에서 그치지 않는다 — 실패를 정상적인 비즈니스 로직(“관련
없음”)으로 둔갑시켜 버리면, 로그를 안 보는 이상 뭐가 새고 있는지 알아챌
방법이 아예 없어진다. 이건 각주와 달리 진짜 있던 버그였고, 그대로
고쳐진 채로 남았다.