# 사고 과정 표시 기능 분석 보고서 **테스트 일시**: 2026-05-28 **테스트 질문**: "논문 결과가 어떻게 돼?" **앱 버전**: http://localhost:7860 --- ## 테스트 결과 요약 | 항목 | 사고 과정 OFF | 사고 과정 ON | |------|-------------|------------| | 총 소요 시간 | 200.5s | 233.7s | | 1단계 (질문 분석) | 59.8s | 77.4s | | 사고 과정 블록 표시 | 없음 | **없음 (버그)** | | 최종 답변 내용 | 6개 섹션, 동일 | 6개 섹션, 동일 | | 답변 차이 | **없음** | **없음** | **결론: ON/OFF 체크박스가 현재 아무런 시각적 차이를 만들지 않는다.** --- ## 실제 응답 (두 경우 모두 동일) ``` [LangGraph → agent: 질문 분석 중] (59.84s) 문서 검색 중... ("어머니의 반응성 상호작용이 아동의 중심축 행동과 지능 및 다중지능 발달에 미치는 영향") [LangGraph → tools: 도구 실행 중] (71.18s) [결과: 3개 문서 반환 → agent 복귀] [문서 검색: "어머니의 반응성 상호작용이 아동의 중심축 행동과 지능 및 다중지능 발달에 미치는 영향"] → [문서 1] 1, 81-99 어머니의 반응성 상호작용이 아동의 중심축 행동... → [문서 2] 김정미․정은주/ 어머니의반응성상호작용이... → [문서 3] 김정미․정은주/ 어머니의반응성상호작용이... [LangGraph → agent: 검색 결과 반영 중] (132.91s) [LangGraph → agent: 최종 답변 생성] 본 연구의 결과는 다음과 같이 요약할 수 있습니다: 1. 어머니의 반응성 상호작용과 아동의 중심축 행동 간의 관계 ... ``` 사고 과정 ON을 선택했을 때 기대되는 `[사고 과정]...[/사고 과정]` 블록이 나타나지 않음. --- ## 원인 분석 ### 구조적 문제 ``` LLM 생성 흐름: 사고 내용... → 최종 답변 텍스트 ↓ ↓ AIMessageChunk AIMessageChunk content="" content="본 연구의..." additional_kwargs= additional_kwargs={} {"thinking": "..."} ``` #### 핵심 병목: `call_model` 내부 누적 방식 `agent_service.py:111`의 `call_model` 함수는 LLM 청크를 내부에서 모두 누적한 뒤 **단일 `AIMessage`로 반환**한다: ```python async for chunk in llm_with_tools.astream(msgs, config): thinking_acc += chunk.additional_kwargs.get("thinking", "") content_acc += chunk.content or "" ... return {"messages": [AIMessage(content=content_acc, additional_kwargs={"thinking": thinking_acc})]} ``` LangGraph `stream_mode="messages"`는 내부 LLM 청크를 외부로 통과시키지만, 사고 청크(`content=""`, `additional_kwargs={"thinking":"..."}`)는 빈 content로 인해 **LangGraph 스트림에서 필터링**되거나 전달되지 않는 것으로 보인다. 결과적으로 `stream_response`가 수신하는 청크: | 수신되는 것 | 수신 안 되는 것 | |-----------|--------------| | content가 있는 `AIMessageChunk` | **thinking이 있는 `AIMessageChunk`** | | 최종 `AIMessage` (thinking 포함) | | #### 왜 최종 `AIMessage`의 thinking도 표시 안 되는가 `stream_response:221`의 조건이 이를 차단한다: ```python elif node == "agent" and isinstance(chunk, AIMessage): if not content_started and not thinking_open: # ← content_started=True면 전체 스킵 thinking = chunk.additional_kwargs.get("thinking", "") if thinking and _think_verbose: yield "\n[사고 과정]\n" ... ``` content `AIMessageChunk`들이 먼저 처리되면서 `content_started = True`가 세팅됨. 최종 `AIMessage`가 도착할 때는 이미 `content_started=True`라 전체 블록이 실행되지 않는다. --- ## 적용된 버그 수정 (2026-05-28) ### 수정 1: `agent_service.py:223` — 인스턴스 변수 참조 오류 ```diff - if thinking and self._think_verbose: # 항상 False (config 기본값) + if thinking and _think_verbose: # 체크박스 값 사용 ``` 이 수정은 엣지케이스(content 스트리밍 없이 최종 AIMessage만 도달하는 경우)에서 체크박스를 올바르게 반영한다. 그러나 정상 스트리밍 경로에서는 `content_started=True` 조건이 여전히 블록을 막는다. --- ## 제안하는 추가 수정 `stream_response`에서 최종 `AIMessage`의 thinking을 저장해두고, 스트리밍 루프 종료 후 표시하는 방식이 가장 간단하다: ```python # 루프 내 - AIMessage 처리 시 thinking 저장 elif node == "agent" and isinstance(chunk, AIMessage): if not thinking_open: deferred_thinking = chunk.additional_kwargs.get("thinking", "") if chunk.content and not content_started: ... # 루프 종료 후 if deferred_thinking and _think_verbose: yield "\n\n---\n**[사고 과정]**\n\n" yield deferred_thinking yield "\n\n**[/사고 과정]**\n" ``` > 단, thinking이 답변 뒤에 표시되는 UX 트레이드오프가 있다. > 답변 전에 표시하려면 `call_model`을 리팩토링해 thinking을 먼저 스트리밍해야 한다. --- ## 소요 시간 비교 참고 ON이 OFF보다 약 33초 더 걸린 점은 주목할 만하다. `enable_thinking=True`(config 설정)로 모델이 항상 thinking을 생성하므로, ON/OFF 간 소요 시간 차이는 모델 비결정성(temperature)에 의한 자연 편차로 보인다. 체크박스는 표시 여부만 제어하며 모델 동작 자체는 바꾸지 않는다.