---
tags: [research, pydantic-ai, langgraph, llm, structured-output, ir-analysis, closure]
---

# Pydantic AI 도입 기반 LLM 출력 안정화 방향확정 리서치

## 관련 문서
- [Pydantic AI 도입 기반 LLM 출력 안정화 아이디어](../ideas/260313_pydantic_ai_도입_기반_llm_출력_안정화_아이디어.md)
- [자가수정 에이전트 프레임워크 및 Workspace CLI 검증 리서치](./260311_자가수정_에이전트_프레임워크_및_workspace_cli_검증_리서치.md)
- [로빙 에이전트 루프, 스킬 훅, LLM 실행 구조 리서치](./orchestration_tools/260312_로빙_에이전트루프_스킬훅_LLM실행구조_리서치.md)
- [NAVER WORKS 브리핑 인사이트 서두 누출 종료 리서치](./260311_naverworks_briefing_insight_preamble_leak_closure_research.md)
- [콜드메일 IR 분석이 첫 번째 PDF를 잘못 선택하는 상태](../debug/260313_coldmail_ir_분석대상_오선택_디버그.md)

## 목적
- `Pydantic` 계열 typed validation 도입 아이디어가 실제 계획으로 넘어갈 수 있는지 검증합니다.
- 이번 문서는 `Pydantic-only 먼저, 필요 시 Pydantic AI 확장`이라는 방향을 기준으로, 어디에 붙이는 것이 맞는지와 왜 이 순서가 타당한지를 좁히는 데 집중합니다.
- 구현 순서, 배포, 세부 수정 항목은 다음 `plans` 문서에서 다룹니다.

## 조사 질문
1. `Pydantic-only`를 지금 로빙에 붙인다면 어디가 첫 도입 지점으로 가장 적절한가
2. 현재 IR Deck 평가 경로에서 `Pydantic-only`가 바로 메울 수 있는 공백은 정확히 무엇인가
3. `LangGraph`, 기존 `Pydantic`, `Pydantic AI`의 역할 경계는 어떻게 나누는 것이 맞는가
4. 다음 `plans` 문서가 바로 받아야 할 `Pydantic-only 우선 도입` 결정 포인트는 무엇인가

## 확인된 사실
1. 현재 아이디어 문서가 겨냥하는 직접 문제는 `LLM이 total_score=100, grade=B 같은 모순값을 반환해도 저장·노출될 수 있는 상태`입니다.
2. 현재 IR Deck 평가는 `LangGraph` 워크플로를 통해 `extract -> evaluate -> analyze_pages -> save` 순서로 진행됩니다.
   - [ir_deck_workflow.py](../../../rb8001/app/services/workflows/ir_deck_workflow.py)
3. 종합 평가 출력은 `IRDeckAnalyzer._evaluate_comprehensive()`에서 LLM JSON 응답을 파싱한 뒤 일부 필드만 범위 제한합니다.
   - [ir_deck_analyzer.py](../../../rb8001/app/services/ir_deck_analyzer.py#L591)
4. 현재 파싱 단계는 아래만 직접 보정합니다.
   - `total_score`를 `0~100`으로 clamp
   - `grade`가 `S/A/B/C`가 아니면 점수 기반으로 재계산
   - `story_scores`는 리스트/점수 범위만 제한
   - `investment_opinion.recommendation`은 값이 없을 때만 점수 기반 기본값 채움
5. 반대로 현재 파싱 단계에는 아래 의미 검증이 없습니다.
   - `total_score`와 `grade`의 정합성 검증
   - `story_scores` 평균/분포와 `total_score`의 허용 오차 검증
   - `recommendation`과 `grade/score` 정합성 검증
   - 검증 실패 시 저장 차단
6. 저장 레이어는 전달받은 `total_score`, `grade`, `story_scores`, `investment_opinion`을 그대로 DB에 넣습니다.
   - [ir_valuation_repository.py](../../../rb8001/app/state/ir_valuation_repository.py#L130)
7. 현재 `EvaluationResponse`도 저장값을 그대로 API 응답으로 노출하는 구조입니다.
   - [ir_deck.py](../../../rb8001/app/router/ir_deck.py#L44)
8. 현재 `rb8001` 의존성에는 `pydantic`과 `pydantic-settings`는 있지만 `pydantic-ai`는 없습니다.
   - [requirements.txt](../../../rb8001/requirements.txt)
9. 기존 로빙 리서치 문서도 `LangGraph 전면 교체`보다 `typed output validation 요구가 큰 경로만 부분 도입`이 맞다고 이미 정리했습니다.
   - [260311_자가수정_에이전트_프레임워크_및_workspace_cli_검증_리서치.md](./260311_자가수정_에이전트_프레임워크_및_workspace_cli_검증_리서치.md)
10. NAVER WORKS 브리핑 종료 리서치에서도 같은 패턴이 확인됐습니다.
   - 프롬프트만으로 닫지 못했고
   - `generate -> validate -> regenerate/fail-visible` 계약이 닫힘 조건이었습니다.

## 2026-03-13 실측 테스트

### 테스트 1. 현재 `_evaluate_comprehensive()`는 모순값을 그대로 통과시킵니다
- 방법:
  - `ir_deck_analyzer.py`를 최소 stub 의존성으로 import했습니다.
  - `call_llm()`이 아래 JSON을 반환하도록 고정했습니다.
    - `total_score=100`
    - `grade=B`
    - `investment_opinion.recommendation=투자 보류`
  - 실제 `_evaluate_comprehensive()`를 호출했습니다.
- 결과:
  - 반환값은 아래처럼 그대로 통과했습니다.
  - `{'total_score': 100, 'grade': 'B', ..., 'investment_opinion': {'recommendation': '투자 보류', ...}}`
- 해석:
  - 현재 구현에는 `total_score`와 `grade`, `recommendation`의 의미 정합성 검증이 없다는 것이 실측으로 확인됐습니다.

### 테스트 2. 순수 `Pydantic` 모델 validator는 같은 입력을 즉시 차단합니다
- 방법:
  - 임시 경로에 `pydantic==2.12.5`를 설치했습니다.
  - `Evaluation` 모델에 `grade == assign_grade(total_score)` validator를 넣었습니다.
  - 같은 입력(`100점 + B등급`)을 검증했습니다.
- 결과:
  - `VALIDATION_FAILED_AS_EXPECTED`
  - `Value error, grade mismatch: total_score=100, grade=B, expected=S`
- 해석:
  - 최소한의 typed validator만 있어도 현재 통과 중인 모순값을 막을 수 있습니다.

### 테스트 3. `Pydantic AI` Agent + output validator도 같은 입력을 차단합니다
- 방법:
  - `pydantic-ai==1.68.0`를 임시 경로에 설치했습니다.
  - `TestModel(custom_output_args=...)`로 동일한 모순 출력(`100점 + B등급`)을 주입했습니다.
  - `Agent(output_type=Evaluation)`에 output validator를 붙여 실행했습니다.
- 결과:
  - `UnexpectedModelBehavior`
  - `Exceeded maximum retries (1) for output validation`
- 해석:
  - `Pydantic AI`는 로빙 같은 경로에서 `typed output + validator + retry/fail-visible` 계약을 구현하는 실제 후보로 볼 수 있습니다.

### 테스트 4. `Pydantic AI` 도입은 경량 추가가 아닙니다
- 방법:
  - `python3 -m pip install --target /tmp/pydantic_ai_pkg pydantic-ai`를 실행했습니다.
- 결과:
  - 설치는 성공했지만 `openai`, `anthropic`, `google-genai`, `mcp`, `fastmcp`, `logfire`, `temporalio` 등 매우 많은 의존성이 함께 내려왔습니다.
- 해석:
  - `Pydantic AI`는 기능적으로는 유효하지만, 의존성/이미지 크기/빌드 시간 영향까지 고려해야 합니다.
  - 따라서 첫 도입 결정에는 `기능 적합성`뿐 아니라 `의존성 비용`도 함께 포함돼야 합니다.

## 해석
- 현재 로빙에는 `Pydantic` 계열 typed validation을 붙일 명확한 도입 이유가 있습니다.
- 그 이유는 `LangGraph`를 버려야 해서가 아니라, 현재 코드가 `형식 파싱`과 `범위 제한`까지만 하고 `제품값 정합성 검증`은 하지 않기 때문입니다.
- 따라서 1차 해법은 `현재 call_llm() 유지 + Pydantic model_validate() + semantic validator 추가`가 가장 현실적입니다.
- `IR Deck 종합 평가`는 이 경계를 가장 먼저 붙여볼 가치가 높은 경로입니다.
- `Pydantic AI`는 유효한 후보지만, 실측 결과 기준으로 의존성 무게가 작지 않으므로 첫 단계 기본값으로 두기보다 2차 확장 후보로 두는 편이 더 타당합니다.

## `Pydantic-only` vs `Pydantic AI` 비교

### 1. `Pydantic-only`
- 구조:
  - 기존 `call_llm()` 유지
  - 응답 JSON 추출 후 `Pydantic model_validate()` 수행
  - model validator에서 점수/등급 정합성 검사
- 장점:
  - 현재 구조 변경이 가장 작습니다.
  - `LangGraph`, `call_llm()`, 저장 흐름을 거의 그대로 유지할 수 있습니다.
  - 실측상 현재 문제(`100점 + B등급`)를 막는 데 충분합니다.
- 단점:
  - 재질의, retry, output validator orchestration은 직접 붙여야 합니다.
  - 생성과 검증이 분리돼 있어 호출 경계가 느슨합니다.

### 2. `Pydantic AI`
- 구조:
  - `Agent(output_type=...)`
  - output validator
  - retry / fail-visible
- 장점:
  - 생성과 검증을 한 계층으로 묶기 쉽습니다.
  - retry 정책을 더 일관되게 넣기 좋습니다.
  - 향후 tool calling, structured output, agent 경계 확장에 유리합니다.
- 단점:
  - 의존성 증가가 큽니다.
  - 현재 로빙 경로에 바로 붙이기엔 변경 범위가 더 큽니다.
  - 지금 당장 필요한 `모순값 차단`에는 과할 수 있습니다.

### 3. 현재 기준 판단
- 현재 단계의 기본값은 `Pydantic-only`가 더 적절합니다.
- 이유:
  1. 문제를 바로 막는 데 충분합니다.
  2. 기존 경계를 거의 유지할 수 있습니다.
  3. 실측상 `Pydantic` validator만으로도 현재 모순값을 차단했습니다.
- 따라서 지금 리서치의 결론은 `Pydantic AI를 버리자`가 아니라 `1차는 Pydantic-only, 2차는 Pydantic AI`입니다.

## 선택지 비교

### 선택지 A. 현재 구조 유지 + 프롬프트 보강만 추가
- 장점:
  - 가장 빠릅니다.
  - 의존성 추가가 없습니다.
- 단점:
  - 이미 출력 일탈이 한 번 발생했습니다.
  - 저장 전 계약 검증이 없어 같은 문제를 다시 막지 못합니다.
- 판단:
  - `Pydantic AI 도입 아이디어`를 검증한 결과, 이 선택지는 충분하지 않습니다.

### 선택지 B. `LangGraph`를 버리고 `Pydantic AI` 중심으로 재구성
- 장점:
  - 출력 계약을 새로 설계하기 쉽습니다.
  - `Agent` + `result_type` + validator 조합을 일관되게 가져갈 수 있습니다.
- 단점:
  - 현재 `interrupt`, checkpoint, 비동기 평가 흐름과 분리되지 않습니다.
  - 이번 문제보다 범위가 과도하게 큽니다.
  - 기존 리서치 결론과도 충돌합니다.
- 판단:
  - `Pydantic AI 도입` 아이디어와는 연결되지만, 부분 도입 아이디어의 범위를 벗어납니다.

### 선택지 C. `LangGraph`는 유지하고, 종합 평가 출력 직후에 `Pydantic-only` typed contract 계층을 추가
- 장점:
  - 현재 문제 지점을 직접 닫습니다.
  - `LangGraph`의 상태 관리 가치는 유지합니다.
  - `Pydantic-only`만으로 바로 시작할 수 있습니다.
- 단점:
  - retry와 fail-visible 일부는 직접 구현해야 합니다.
  - 서버 재계산 정책을 따로 정해야 합니다.
- 판단:
  - 지금 바로 실행할 첫 단계로 가장 적절합니다.

### 선택지 D. `LangGraph`는 유지하고, 바로 `Pydantic AI`를 붙인다
- 장점:
  - output validator, retry, typed output을 한 계층으로 묶기 쉽습니다.
  - 다음 단계 확장성은 좋습니다.
- 단점:
  - 현재 필요한 문제 크기보다 변경 범위가 큽니다.
  - 의존성 증가가 즉시 들어옵니다.
- 판단:
  - 후속 2차 후보로는 좋지만, 첫 단계 기본안으로 두기엔 무겁습니다.

## 결론
- 이번 리서치 기준으로 가장 맞는 결론은 `선택지 C`, 즉 `LangGraph 유지 + Pydantic-only 우선 도입`입니다.
- 정확한 실행 문장은 아래와 같습니다.
  - `LangGraph`는 유지합니다.
  - IR Deck 종합 평가 경로를 `Pydantic-only` 첫 도입 후보로 잡습니다.
  - 이 경로에 `LLM raw output -> Pydantic model_validate -> semantic validation -> server-derived final fields -> save` 계층을 추가하는 방향이 맞습니다.
  - `Pydantic AI`는 2차 확장 후보로 남깁니다.
  - 실측 테스트 기준으로도 현재 구현의 공백은 재현됐고, 순수 `Pydantic` validator만으로도 이 공백을 차단할 수 있다는 점이 확인됐습니다.

## 이번 도입 아이디어를 닫는 권장 계약

### 1. Raw Output과 Final Output을 분리해야 합니다
- LLM이 처음 반환한 값은 `raw_evaluation_result`로 취급합니다.
- 사용자와 DB에 저장되는 값은 `final_evaluation_result`로 분리해야 합니다.
- 이 둘을 같은 dict로 다루는 현재 구조가 문제의 핵심입니다.

### 2. Typed Contract는 구조 검증과 의미 검증을 둘 다 가져야 합니다
- 구조 검증:
  - 필수 필드 존재
  - 타입 일치
  - 리스트 길이, 문자열 길이, enum 범위
- 의미 검증:
  - `grade == assign_grade(total_score)` 이어야 함
  - `recommendation`은 `total_score/grade` 정책과 충돌하면 안 됨
  - `story_scores`가 있으면 `total_score`는 허용 오차 내에서 재계산 가능해야 함
  - 필수 설명 필드가 너무 비어 있으면 통과시키지 않아야 함

### 3. 핵심 제품값은 서버가 최종 소유해야 합니다
- `grade`는 서버가 `total_score`로 재계산하는 쪽이 더 안전합니다.
- `recommendation`도 서버 정책에서 파생시키는 쪽이 더 안전합니다.
- `total_score`는 이상적으로 `story_scores` 또는 서버 스코어링 규칙으로 재산출 가능한 구조로 옮겨야 합니다.
- 즉 LLM은 `해석값`을 내고, 서버는 `최종 노출값`을 소유해야 합니다.

### 4. 검증 실패는 저장 금지 또는 명시적 실패로 다뤄야 합니다
- 검증 실패 시 조용히 clamp 후 저장하는 현재 접근은 부족합니다.
- 최소한 아래 중 하나가 필요합니다.
  - 재질의 1회 후 실패 가시화
  - 서버 재계산 후 `raw`와 차이를 로그로 남기고 저장
  - 기준 미달이면 저장 자체를 실패 처리
- 어떤 정책을 택하든 `성공처럼 저장`은 금지돼야 합니다.

## 다음 `plans` 문서가 바로 결정해야 할 포인트
1. typed contract 구현을 어떤 `Pydantic` 모델 구조로 둘지
2. `final`로 서버가 소유할 필드를 어디까지로 할지
   - 최소 후보: `grade`, `recommendation`
   - 확장 후보: `total_score`
3. 검증 실패 정책을 무엇으로 할지
   - 재질의 직접 구현 1회
   - 서버 재계산 우선
   - 저장 차단
4. 첫 적용 범위를 어디로 자를지
   - 1순위: IR Deck 종합 평가
   - 이후: 브리핑 인사이트, 콜드메일 후보 판정, 투자 의견 출력
   - 2차: 같은 경계를 `Pydantic AI`로 올릴지 검토
5. 어떤 로그와 테스트를 닫힘 기준으로 삼을지

## 닫힘 기준
1. IR Deck 종합 평가 경로에 `raw -> validate -> finalize -> save` 경계가 코드로 존재해야 합니다.
2. `total_score=100, grade=B` 같은 모순 입력은 더 이상 그대로 저장되지 않아야 합니다.
3. 저장되는 `grade`, `recommendation`은 LLM 원문이 아니라 서버 정책을 통과한 최종값이어야 합니다.
4. 검증 실패 케이스는 성공처럼 응답하거나 DB에 남지 않아야 합니다.
5. 아래 테스트가 최소 포함돼야 합니다.
   - 점수/등급 모순
   - `story_scores` 평균과 총점 불일치
   - 잘못된 enum/누락 필드
   - summary/strengths/risks 최소 품질 미달

## 미확정 항목
- `Pydantic-only` 1차 적용 후 어느 시점에 `Pydantic AI`로 올릴지는 아직 미정입니다.
- `total_score`를 완전히 서버 재계산으로 가져갈지, LLM 점수를 허용 오차 내에서만 수용할지는 아직 미정입니다.
- 검증 실패 시 사용자에게 `평가 실패`를 직접 노출할지, 내부 재시도 후만 노출할지는 아직 미정입니다.
- `raw output`을 DB나 로그에 별도 저장할지는 아직 미정입니다.
- `Pydantic AI`의 의존성 증가를 나중에 운영 이미지에서 수용할지, 별도 실험 브랜치로 먼저 검증할지도 아직 미정입니다.

## 이번 리서치의 결론 문장
- 이 리서치는 `Pydantic` 계열 typed validation 도입 아이디어를 반박하지 않습니다.
- 오히려 `IR Deck 종합 평가`에 `Pydantic-only`를 먼저 붙이는 쪽이 현재 구조와 가장 잘 맞는다는 쪽으로 결론이 모입니다.
- 따라서 다음 단계 `plans`는 `Pydantic-only를 어떤 모델과 정책으로 붙일지`를 고정하는 문서가 되어야 합니다.
