
도구를 쓰는 멀티턴 대화에서, 모델이 가끔 이상한 행동을 했다. 자기 thinking(추론) 채널 안에 generate_placeholder_thoughts 같은, 실제로 존재하지 않는 도구를 호출하는 흉내를 냈다. 어디에도 정의한 적 없는 도구였다.
배경 — thought signature가 뭔가
Gemini는 도구 호출(functionCall)에 thought signature라는 암호화된 토큰을 붙여 준다. 이건 직전 턴의 추론을 이어가기 위한 일종의 앵커다. 다음 턴에 이 signature를 그대로 돌려보내면, 모델이 "내가 방금 무슨 생각을 하고 있었지"를 이어받는다.
문제는 우리 쪽 코드에 있었다.
증상
대화 상태를 체크포인트에 저장하기 전에, 이 signature를 떼어내고 있었다. signature가 수 KB짜리 바이트 덩어리라 저장 공간에 불필요하다고 봤기 때문이다.
# 변경 전 — 저장 전에 signature 제거
response.additional_kwargs.pop(
"__gemini_function_call_thought_signatures__", None
)
그런데 도구 호출 후 이어지는 턴에서, 모델은 돌려받아야 할 추론 앵커가 사라진 상태가 된다. 앵커가 없으니 빈자리를 스스로 메우려고, thinking 채널에 가짜 도구 호출(placeholder)을 지어냈다. 내부 scaffold가 새어 나온 셈이다.
즉, 공간을 아끼려고 지운 것이 모델의 추론 연속성을 끊고 있었다.
정리한 방식
signature를 지우지 않고 그대로 두기로 했다. 그러면 langchain-google-genai가 다음 턴에 그대로 돌려보내(round-trip), 추론 앵커가 유지된다.
# 변경 후 — signature 보존 (체크포인트에 함께 저장)
# Gemini가 functionCall에 붙이는 암호화 thoughtSignature는
# 다음 턴에 echo back 돼야 추론 앵커가 유지된다 → pop 하지 않음
처음에 공간이 아까웠던 이유(트레이스에 큰 바이트 덩어리가 남는 것)는 별도로 해결했다. 관측(트레이스)에 남길 때만 signature를 마스킹해서 빼고, thinking 자체는 보존한다. 저장에서 지우는 것과, 트레이스에서 가리는 것은 다른 문제였다.
결과
오염된 스레드에서 100회 병렬로 재현해 비교했다.
- placeholder 환각: 24/100 → 0/100
- 빈 응답이나 Gemini 400 같은 회귀는 없음
정리
- Gemini의 thought signature는 멀티턴 추론을 잇는 앵커라 다음 턴에 돌려보내야 한다
- 공간을 아끼려고 이걸 저장 단계에서 지우면, 모델이 추론 빈자리를 환각으로 메울 수 있다
- "저장에서 빼기"와 "트레이스에서 가리기"는 별개 — 후자만으로 충분했다
- LLM이 이상 행동을 하면, 프롬프트보다 먼저 우리가 상태로 무엇을 넘기고 무엇을 떼고 있는지 본다
모델이 없는 도구를 지어내길래 프롬프트 문제인 줄 알았는데, 원인은 우리가 넘겨주던 상태에서 한 조각을 빼고 있던 것이었다.
'개발 > AI' 카테고리의 다른 글
| [메모리] 추출 중복 차단 context 주입 단일 출처 (0) | 2026.06.25 |
|---|---|
| [메모리] 문맥 전환 감지 임베딩 유사도 한계 매턴 검색 회귀 (0) | 2026.06.25 |
| [디버깅] google-genai SDK TypeError 마스킹 APIError 노출 (0) | 2026.06.25 |
| [Prompt] OOC, Temporal Blindness (0) | 2026.06.16 |
| [BM25] 사용 예제 (3) | 2024.11.05 |