
Thessori: Qwen Cloud 기반의 자율 문헌 검토 (Literature Review) 에이전트
요약
Thessori는 Qwen Cloud를 기반으로 연구 질문에 대해 자율적으로 문헌 검토 보고서를 작성하는 에이전트입니다. arXiv와 Semantic Scholar를 검색하여 논문을 수집하고, 사용자의 검토를 거쳐 PDF 요약 및 연구 공백을 파악하는 워크플로우를 제공합니다.
핵심 포인트
- Qwen 모델을 활용한 학술 쿼리 재작성 및 논문 검색 자동화
- 사용자가 검색 결과의 적절성을 직접 검토할 수 있는 Human-in-the-loop 구조
- PDF 다운로드, 구조화된 요약, 연구 공백 파악 및 보고서 생성 기능
- 모델 성능보다 에이전트의 신뢰성을 높이는 스캐폴딩(Scaffolding)의 중요성 강조
모든 연구 프로젝트는 동일한 방식으로 시작됩니다. 질문이 있고, 당신과 정답 사이에는 아직 읽지 않은 수백 편의 논문이 놓여 있습니다. 관련 논문을 찾고, 각각을 읽고, 아직 아무도 시도하지 않은 것이 무엇인지 파악하는 과정은 기계적인 작업입니다 — 어느 순간까지는 말이죠. 도움을 주겠다고 약속하는 대부분의 도구들은 당신이 이미 논문을 수집한 후에야 작동합니다. Thessori는 그보다 한 단계 더 일찍 시작합니다. 당신이 연구 질문을 던지면, Thessori는 실제로 누군가에게 보여줄 수 있는 수준의 문헌 검토 (Literature Review) 결과를 돌려줍니다.
저는 Qwen Cloud Hackathon Series, 특히 Autopilot Agent 트랙에 참여하기 위해 이 프로젝트를 구축했습니다. 설명은 간단합니다. 시작할 Qwen 모델로 Plus 또는 Max (최신 버전) 중 하나를 선택한 다음, 하나 이상의 연구 질문을 입력합니다. 에이전트는 arXiv와 Semantic Scholar를 병렬로 검색하며, Qwen이 찾은 결과물의 순위를 매기도록 합니다. 그 후 동작을 멈추고 선택된 항목들을 보여주어, 당신이 적절하지 않은 것들을 제외할 수 있게 합니다. 그 다음 에이전트는 실제 PDF를 다운로드하고, 이를 읽고, 각 논문의 구조화된 요약 (Structured Summary)을 작성하며, 전체 집합에서 발견되는 공백 (Gaps)을 파악한 뒤, 모든 내용을 Markdown, LaTeX 또는 PDF로 내보낼 수 있는 검토 보고서로 조립합니다. 작업이 완료되면, 당신은 앉아서 에이전트가 방금 작성한 내용에 대해 질문하거나, 발견한 특정 맥락을 더 추적하라고 지시할 수 있습니다.
흥미로운 점은, 결과적으로 모델 그 자체는 거의 문제가 되지 않았다는 것입니다. Qwen은 지시받은 대로 정확히 수행했지만, 이를 신뢰할 수 있게 만들기 위해 필요한 것은 스캐폴딩 (Scaffolding)이었습니다.
실제로 수행하는 작업
한 번의 실행은 사람을 사이에 두고 두 부분으로 나뉩니다.
첫 번째 단계에서는 질문을 제출합니다. Qwen은 무엇인가를 검색하기 전에, 사용자가 입력한 내용을 세 가지 더 정교한 학술적 쿼리 (Query)로 조용히 재작성합니다. 예를 들어 "google의 attention layers"는 "transformer self-attention mechanism (트랜스포머 자기 주의 메커니즘)", "scaled dot-product attention (스케일드 닷 프로덕트 어텐션)" 등으로 변환됩니다. 이는 선택 사항입니다. 체크박스가 있으며, 이를 해제하면 사용자의 문구가 수정 없이 그대로 전달됩니다. 어떤 방식이든, 저는 사용자의 원래 표현을 유지하면서 그것이 정확히 무엇으로 확장되었는지 보여줍니다. 왜냐하면 사용자가 요청한 것 이외의 것을 조용히 검색하는 행위는, 설령 제대로 작동하더라도 버그처럼 느껴질 수 있는 종류의 것이기 때문입니다.
각 쿼리에 대해 에이전트는 arXiv 요청과 Semantic Scholar 요청을 동시에 실행하고, 모든 결과를 병합한 뒤 제목을 기준으로 중복을 제거합니다. 그 제목들은 단 하나의 지침과 함께 Qwen에게 전달됩니다: "여기 논문들이 있고, 여기 쿼리들이 있습니다. 가장 관련성이 높은 10개의 인덱스 (Indices)를 반환하세요." 모델은 [3, 0, 7, 12, ...]와 같은 값을 반환하며, 에이전트는 이 리스트를 해당 10개로 슬라이싱 (Slicing) 합니다.
그다음 에이전트는 멈춥니다.
이 부분이 제가 올바르게 구현하고자 가장 신경 썼던 부분입니다. 많은 "자율적 (Autonomous)" 에이전트들은 모든 것을 사용자를 대신해 결정하고 사용자는 나중에 무엇이 선택되었는지를 알게 된다는 의미에서 자율적입니다. 문헌 검토 (Literature Review) 작업에서 이는 잘못된 방식입니다. 랭킹 모델 (Ranking model)은 뛰어나지만 초능력자는 아닙니다. 실제 논문 내용은 다른 것인데 제목이 쿼리와 겹친다는 이유로 가끔 논문의 순위를 높게 매기기도 합니다. 그래서 에이전트는 자신이 선택한 10개를 체크리스트 형태로 화면에 표시하고, 모든 박스에 체크를 한 뒤 기다립니다. 사용자는 적절하지 않은 항목의 체크를 해제하고 생성 (Generate) 버튼을 누릅니다. 작업이 진행되는 동안, 상단에는 fetch (가져오기), review (검토), summarize (요약), gaps (공백 탐색), report (보고서 작성) 등의 체크포인트가 왼쪽에서 오른쪽으로 채워지며, 그 아래에는 실시간 상태(예: "PDF 2/5 다운로드 중...")가 표시됩니다. 따라서 사용자는 시스템이 멈춘 것인지 궁금해하며 멈춰있는 스피너 (Spinner)만 멍하니 바라볼 필요가 없습니다.
후반부는 살아남은 내용으로 작동합니다. 제가 실제로 공들인 부분이 바로 여기인데, 스크린샷에는 나오지 않습니다. 에이전트는 승인된 논문마다 초록(abstract)을 요약하지 않습니다. 대신 arXiv에서 해당 논문의 PDF를 다운로드하고 텍스트를 추출한 다음, 본문 전체를 요약합니다. 초록은 마케팅 자료일 뿐입니다. 기여도를 과장하고 한계점(limitations)은 숨기기 때문인데, 그 한계점이 저자들이 가장 읽히길 원치 않는 부분이기 때문입니다. 논문 자체가 더 정직합니다. 그래서 Thessori는 처음 25페이지까지 내용을 읽고, 논문이 길 경우 앞부분의 서론(intro), 방법론(method)과 뒷부분의 결과(results) 및 결론(conclusion), 그리고 초록에서 건너뛴 한계점들을 잘라내기보다는 큰 덩어리로 유지합니다. 각 논문은 기여도(contribution), 방법론(method), 발견 사항(findings), 한계점(limitations)이라는 네 개의 짧고 명확하게 라벨링된 섹션으로 돌아옵니다.
그런 다음 모든 요약본이 연결되어 다시 Qwen에게 다른 프롬프트와 함께 전송됩니다. 그 내용은 '어떤 문제가 아직 열려 있는지, 어떤 방법론이 빠져 있는지, 이 분야가 어디로 나아갈 수 있는지'에 대한 것입니다. 이것이 마지막 섹션이 됩니다. 서술형 텍스트와 더불어, 모델은 세 가지 구체적인 후속 검색 쿼리(follow-up search queries)를 제공하고, 이들은 '심층 분석(Deep Dive)' 버튼에 연결됩니다. 한 번의 클릭으로 에이전트는 방금 발견한 격차(gaps)에 대해 새로운 실행을 시작하며, 이는 실제 사람이 읽는 방식과 가장 유사합니다. 논문 하나를 마치면, 구멍이 있다는 것을 알아채고 다시 찾아보게 됩니다.
모든 것이 Markdown 형식으로 엮이며, 이것은 LaTeX 및 PDF 내보내기(export)가 파생되는 단일 진실 공급원(single source of truth)입니다. 수학 공식은 KaTeX를 사용하여 브라우저에서 렌더링됩니다. 그리고 전체 보고서가 메모리에 존재하기 때문에, 구석에 채팅 비서가 있어 작성된 내용에 대해서만 질문에 답합니다.
이 과정을 수동으로 수행하는 것은 매우 번거로운 일입니다. 철저한 문헌 검토 (Literature Review)는 보통 검색, 관련 없는 제목 필터링, PDF 다운로드, 그리고 실제 기여도를 찾기 위한 훑어보기 등에 두세 시간을 소비하는 것을 의미합니다. Thessori는 쿼리 (Query)부터 구조화되고 형식이 갖춰진 LaTeX 초안에 이르기까지 전체 파이프라인 (Pipeline)을 약 3분 만에 완료합니다.
또한 비용이 저렴합니다. 논문을 제목 기준으로 먼저 순위를 매기고 사용자가 전체 텍스트를 다운로드하기 전에 목록을 필터링하게 함으로써, 실제로 중요한 논문에 대해서만 무거운 요약 (Summarization) 및 격차 분석 (Gap analysis) 단계를 실행하기 때문입니다. 수십 개의 검증되지 않은 원본 PDF를 모델에 무작정 입력할 때 수 달러가 드는 것과 비교하면, 전체 실행 비용은 API 토큰 기준으로 5센트 미만입니다.
왜 상태 머신 (State machine)인가
백엔드는 FastAPI로 감싸진 LangGraph 그래프입니다. 제가 자유롭게 움직이는 에이전트 루프 (Agent loop) 대신 명시적인 상태 머신 (State machine)을 선택한 이유는 다소 지루한 이유 때문입니다. 즉, 매 실행마다 정확히 어떤 일이 일어날지 알고 싶었기 때문입니다.
여섯 단계가 있습니다. 각 단계는 현재 상태를 받아 변경된 키 (Key)를 반환하는 단순한 비동기 함수 (Async function)입니다. Expand는 쿼리를 다시 작성합니다. Fetch는 후보군을 작성합니다. Rank는 상위 10개로 덮어씁니다. Summarize는 요약을 작성합니다. Gap analysis는 문자열과 후속 쿼리 목록을 작성합니다. Report는 마크다운 (Markdown)을 작성합니다. 도구 호출 (Tool-calling) 룰렛도 없고, 런타임에 자신을 세 번 더 호출할지 결정하는 단계도 없습니다. 무언가 고장 났을 때, 스택 트레이스 (Stack trace)는 추론 루프 (Reasoning loop) 아래에 파묻힌 플래너 (Planner)가 아니라 특정 함수를 정확히 가리켰습니다.
상태 (State) 자체는 모든 노드가 읽고 쓰는 하나의 타입 지정 사전 (Typed dictionary)입니다. 그것이 전체 계약 (Contract)입니다. 파이프라인의 어느 시점에 어떤 데이터가 존재하는지 알고 싶다면, 하나의 TypedDict만 읽으면 됩니다.
체크포인트 문제 (The checkpoint problem)
여기서부터 흥미로운 지점이 나타납니다. 인간의 승인을 위한 일시 정지(pause)는 설명하기는 쉽지만 구현하기는 까다롭습니다. 왜냐하면 그래프가 실제로 멈춰야 하고, 제어권을 웹 UI로 넘겨주어야 하며, 나중에 사용자의 선택 사항이 포함된 HTTP 요청을 다른 프로세스로부터 다시 재개해야 하기 때문입니다.
LangGraph에는 체크포인터(checkpointers)와 인터럽트(interrupts)를 이용한 이러한 기능을 위한 메커니즘이 있습니다. 하지만 저는 해커톤을 위해 체크포인트 저장소(checkpoint store)를 구축하고 싶지 않았기에, 더 간단한 트릭을 사용했습니다. 바로 두 개의 그래프를 사용하는 것입니다. 첫 번째 그래프는 확장(expand), 가져오기(fetch), 순위 매기기(rank)를 실행한 후 종료됩니다. API는 순위가 매겨진 논문들을 세션 ID와 함께 브라우저로 전달하고, 실행 상태(run's state)를 메모리에 유지합니다. 사용자가 일부 논문을 승인하면, 두 번째 요청이 해당 상태를 조회하고 승인된 논문들을 추가한 뒤, 요약(summarize), 공백(gaps), 보고서 작성(report)을 실행하는 두 번째 그래프를 호출합니다. 일시 정지는 단지 두 HTTP 호출 사이의 경계일 뿐입니다. 관리해야 할 체크포인터도, 영속성 계층(persistence layer)도 필요 없습니다.
두 번째 시도에서 이 방식이 작동했습니다. 첫 번째 시도는 한 시간을 허비했기에 설명할 가치가 있습니다.
LangGraph는 상태 스키마(state schema)를 단순한 dict로 선언할 수 있게 해줍니다. 모든 노드가 딕셔너리 키를 읽고 쓰기 때문에 저도 그렇게 했고, 괜찮아 보였습니다. 하지만 가져오기 및 순위 매기기(fetch-and-rank) 그래프가 20개의 논문을 올바르게 가져온 뒤, 순위 매기기 단계에서 KeyError: 'queries'와 함께 죽어버렸습니다. queries는 초기 상태에 분명히 존재했습니다. 단지 두 번째 노드에 도달하지 못했을 뿐입니다.
그 원인은 미묘하며, 만약 여러분이 일반 dict를 사용하여 LangGraph를 사용하려 한다면 반드시 알아둘 만한 내용입니다. 타입이 지정되지 않은 dict 스키마를 사용하면, 프레임워크는 각 키를 단계(step)를 거쳐 지속되는 채널(channel)로 취급하지 않습니다. 부분적인 업데이트를 반환하는 노드는 실행 중인 상태에 병합(merge)되지 않으며, 반환하지 않은 키들은 사라져 버립니다. 해결책은 그래프에 실제 스키마를 부여하는 것입니다. 저는 이미 구조를 문서화하기 위해 ResearchState TypedDict를 작성해 두었으므로, StateGraph(dict)를 StateGraph(ResearchState)로 교체했습니다. 타입이 지정된 스키마를 사용하면 모든 필드가 각각의 마지막 값 채널(last-value channel)이 되어 부분적인 반환이 병합되고, queries가 끝까지 살아남게 됩니다. 단 한 단어를 바꿨을 뿐인데 버그가 사라졌습니다.
스키마 (Schema)는 단순한 문서가 아닙니다. 그것은 시스템을 지탱하는 핵심 요소입니다.
class ResearchState(TypedDict):
session_id: str
queries: list[str]
...
여러 작업, 하나의 모델
이 파이프라인에서 Qwen은 여러 가지 서로 다른 일을 수행하며, 이 작업들을 분리하여 유지하는 것이 중요했습니다.
쿼리 확장 (Query expansion) 및 순위 지정 (Ranking)은 짧고 수백 개의 토큰으로 제한된 선택 작업이며, JSON으로 파싱됩니다. 요약 (Summarization)은 제약이 있는 글쓰기로, 논문 한 편당 한 번의 호출이 이루어지며, 네 개의 라벨이 붙은 섹션으로 구성되고, 명시적으로 간결하게 작성하도록 지시됩니다. 격차 분석 (Gap analysis)은 제가 모델이 길게 작성하도록 허용한 유일한 부분입니다. Qwen은 큰 컨텍스트 윈도우 (Context window)를 가지고 있기 때문에, 모든 논문 요약을 단일 프롬프트에 쏟아부을 수 있습니다. 모델은 특정 논문을 다시 진술하기보다는, 전체 집합을 가로질러 추론하여 모순점과 누락된 부분을 찾도록 요청받습니다. 어시스턴트 채팅 (Assistant chat)은 대화형이라는 별도의 작업을 수행하며, 보고서가 시스템 프롬프트 (System prompt)에 고정되어 있어 읽지 않은 내용으로 벗어나지 않도록 합니다. 동일한 모델인 qwen-model을 사용하되, 작업에 맞춰 프롬프트를 구성했습니다.
통합 과정은 진정으로 아무런 문제가 없었으며, 이는 찬사로서 하는 말입니다. Qwen은 OpenAI 호환 API를 제공하므로, 전체 클라이언트는 다른 베이스 URL (Base URL)을 가리키는 표준 OpenAI 비동기 SDK (Async SDK)를 그대로 사용합니다. 두 개의 환경 변수와 모델 이름만 있으면 됩니다. 채팅을 위한 스트리밍 (Streaming)을 켜는 것은 stream=True라는 플래그 하나면 충분했으며, 프론트엔드에서 응답의 청크 (Chunks)를 읽어오기만 하면 되었습니다. 저는 Qwen을 연결하는 데 들인 시간보다 로거 (Loggers)를 연결하는 데 더 많은 시간을 썼습니다.
지루한 문제들이 진짜 문제였다
이와 같은 프로젝트가 실제로 어디에 시간을 소비하는지 알고 싶다면, 그것은 헤드라인을 장식하는 핵심 기능이 아닙니다.
가장 먼저 망가진 것은 애플리케이션 코드를 한 줄도 실행하기 전인 pip install 단계였습니다. 머신에는 아주 최신 버전의 Python이 설치되어 있었는데, 너무 최신이라 제가 고정(pinned)해둔 의존성 버전들에 대해 미리 빌드된 휠(wheel) 파일이 없었습니다. pip는 Pydantic의 Rust 코어를 소스에서 직접 컴파일하려고 시도하다가 결국 포기했습니다. 정직한 해결책은 정확한 구버전들을 고정하는 것을 멈추고 대신 하한선(floors)을 고정하는 것이었습니다. 그런 다음 리졸버(resolver)가 해당 인터프리터에 대해 휠을 제공하는 현재의 릴리스를 가져오도록 하는 것이죠. 제가 사용하던 공개 API는 변경되지 않았으므로, 애플리케이션 코드는 함께 움직일 필요가 없었습니다. 고정된 버전은 생태계의 나머지 부분들이 지켜야 하는 약속이며, 새로운 인터프리터 상에서는 아직 아무도 그 약속을 하지 않은 상태였습니다.
그다음은 Semantic Scholar였습니다. 그들의 API는 무료이고 훌륭하지만, 키(key)가 없으면 속도 제한(rate-limit)이 매우 엄격하여 테스트하는 동안 거의 모든 호출에서 429 에러를 반환했습니다. 원래 저의 페치(fetch) 단계는 단순한 asyncio.gather를 사용하여 모든 소스 요청을 동시에 실행했는데, 이는 하나의 소스에서 에러가 발생하면 전체 페치 작업이 중단됨을 의미합니다. 데모 당일 오후, Semantic Scholar가 사용자가 충분히 요청했다고 판단하기 전까지는 괜찮았습니다. 저는 gather가 예외를 발생시키는 대신 예외를 수집(collect)하도록 변경했습니다. 이렇게 하면 속도 제한이 걸린 소스는 건너뛰고, 보통 arXiv 단독으로라도 결과가 돌아오면 실행이 계속됩니다. 이는 작동 모습을 보여주기에 충분합니다. 누군가 Semantic Scholar 키를 입력하는 날에는 코드 변경 없이도 두 번째 소스가 활성화될 것입니다.
# 하나의 실패가 전체 실행을 중단시키지 않도록 여러 소스에서 동시에 페치하기
results = await asyncio.gather(
fetch_arxiv(queries),
...
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기