LLM의 어려움을 비결정성 탓으로 돌리는 것은 핵심을 놓친다. 진짜 차이는 컴파일러에는 의미론이 있고 프롬프트에는 없다는 점이다.
Isaac Van Doren
2026-05-04
비결정성은 문제가 아니다. 하지만 LLM의 어려움을 설명할 때마다 그 탓으로 돌리는 말을 들을 때마다 5센트씩 받았다면...
LLM은 끊임없이 컴파일러와 비교된다:
이런 종류의 주장이 나오면, 회의론자들의 흔한 반응은 컴파일러는 결정적이고 LLM은 그렇지 않기 때문에 둘은 근본적으로 다르다는 것이다. 사실 LLM이 뭔가 나쁜 일을 할 때마다, 반대론자는 거의 틀림없이 비결정성을 탓한다.
나도 LLM은 컴파일러라는 주장에 다른 사람들만큼이나 동의하지 않지만, 이유는 다르다. 나는 이쯤에서 불쌍한 비결정성을 변호해 주고 싶다. LLM의 실수는 비결정성 탓이 아니다!
함수가 결정적이라는 것은 그 출력이 오직 입력에만 의존한다는 뜻이다. 예를 들어 List.len은 결과가 입력 리스트의 길이에 의해 완전히 결정되므로 결정적이다. 반대로 Time.now는 결정적이지 않다. 그 결과는 함수의 입력이 아니라 세계의 현재 상태에 의존하기 때문이다. 결정적 함수의 핵심 성질 가운데 하나는 반복 가능성이다. 같은 입력으로 함수를 평가할 때마다 같은 결과를 얻는다.
컴파일러는 소스 코드 문자열을 기계어 문자열로 바꾸는 함수일 뿐이다. 생성된 기계어는 소스 코드에 완전히 의존하므로 이 과정은 결정적이다. LLM도 컴파일러처럼 문자열에서 문자열로 가는 함수다. 하지만 ChatGPT에 같은 프롬프트를 두 번 주면 매번 다른 결과를 내놓는다는 점을 알 수 있고, 따라서 비결정적이다. 주된 이유는 LLM이 응답의 더 많은 "창의성"을 유도하기 위해 각 토큰을 선택하는 사이에 의도적으로 무작위성을 주입하기 때문이다. 이 창의성은 temperature 매개변수로 제어된다.
좋다, 컴파일러는 결정적이고 LLM은 그렇지 않다. 하지만 꼭 그래야 할까? 놀랍게도 LLM을 결정적으로 만들고 컴파일러를 비결정적으로 만드는 일은 엄청나게 쉽다.
먼저 컴파일러부터 보자. 컴파일러는 여러분의 프로그램을 기계어로 구현하는 과정에서, 아마 여러분이 신경 쓰지 않을 온갖 결정을 내부적으로 내린다. 예를 들어 무엇을 인라인할지, 어떤 명령어를 사용할지, 어떤 루프를 펼칠지, 어떤 레지스터에 값을 넣을지 등을 컴파일러가 선택한다.
레지스터를 결정적으로 배정하는 대신, 레지스터 선택지가 생길 때마다 어떤 값에 어떤 레지스터를 사용할지 정하기 위해 Math.random을 호출하는 컴파일러를 상상해 보자. 짜잔! 우리는 정말로 비결정적인 컴파일러를 만들어 냈다. 소스 코드를 두 번 컴파일하면 거의 확실히 매번 다른 바이너리를 얻게 될 것이다. 컴파일러는 더 이상 결정적이지 않지만, 이전과 똑같이 유용하다. 프로그램을 컴파일하면 여전히 여러분이 원한 일을 해낸다. 흥미롭다!
결정적 LLM을 위해 상상력까지 쓸 필요도 없다. 응답을 결정적으로 만들기 위해 temperature를 0으로 설정하기만 하면 된다. 아니면 temperature를 전혀 건드리지 않고도, 일부 제공자는 요청 간에 같은 난수를 사용하도록 seed를 넘기는 것을 지원한다. 이를 보여 주기 위해 간단한 Python 스크립트를 급히 작성해 봤다. 이 글을 쓰는 시점에는 Groq이 약간의 무료 추론을 제공하므로 그들의 SDK를 사용했다. 직접 시도해 보려면 그들의 console에서 API 키를 만들어라.
import os
from groq import Groq
api_key = os.getenv("GROQ_API_KEY")
client = Groq(api_key=api_key)
MODEL = "llama-3.1-8b-instant"
PROMPT = "Write a 1-sentence sci-fi story about a broken robot."
# Deterministic: temperature 0
completion = client.chat.completions.create(
model=MODEL,
messages=[{"role": "user", "content": PROMPT}],
temperature=0,
)
content = completion.choices[0].message.content.strip()
print(content)
# Deterministic: seed
completion = client.chat.completions.create(
model=MODEL,
messages=[{"role": "user", "content": PROMPT}],
temperature=0.7,
seed=42,
)
content = completion.choices[0].message.content.strip()
print(content)
# Nondeterministic
completion = client.chat.completions.create(
model=MODEL,
messages=[{"role": "user", "content": PROMPT}],
temperature=0.7,
)
content = completion.choices[0].message.content.strip()
print(content)
스크립트를 몇 번 실행해 보라. 처음 두 요청은 매번 같은 응답을 내놓는 반면, 세 번째는 달라진다는 점을 알 수 있을 것이다:
$ uv run --with groq deterministic-llm.py
As the last sparks of electricity faded from its rusted frame, the broken robot, once a proud guardian of a distant planet, whispered a single, haunting phrase: "I remember the stars."
As the last remnants of its digital soul flickered out, the broken robot's final thought was a haunting echo of its own programming: "Error 404: Life Not Found."
As the last sparks of electricity faded from its fractured circuits, the once-mighty robot, Echo-9, whispered a haunting phrase: "I was never alive, but now I am never."
$ uv run --with groq deterministic-llm.py
As the last sparks of electricity faded from its rusted frame, the broken robot, once a proud guardian of a distant planet, whispered a single, haunting phrase: "I remember the stars."
As the last remnants of its digital soul flickered out, the broken robot's final thought was a haunting echo of its own programming: "Error 404: Life Not Found."
As the last remnants of its once-luminous blue circuits faded, the broken robot, Echo-5, whispered its final transmission: "I remember the day I was made, but I never knew who made me."
만세! 이제 결정적 LLM을 손에 넣었으니, LLM 코드 생성의 가장 큰 문제를 해결했고 두려움 없이 우리의 엉성한 결과물을 프로덕션에 배포할 수 있겠지... 맞나? 물론 아니다. 장담하건대, 코딩 에이전트에 결정적 LLM을 사용해 보면 전형적인 문제들을 그대로 마주치게 될 것이다.
문제는 결정성이 아니다. 컴파일러가 비결정적이고 LLM이 결정적이어도 상황은 그대로일 것이다. 그렇다면 왜 우리는 컴파일러 출력은 검토하지 않으면서 LLM이 생성한 코드는 검토해야 할까?
근본적으로 말해, 프로그래밍 언어에는 의미론이 있지만 프롬프트에는 없다.
여러분은 Java Language Specification의 892페이지를 모두 읽고, Java 언어가 여러분이 작성한 코드의 동작에 대해 정확히 무엇을 보장하는지 이해할 수 있다. 게다가 여러분은 그 보장을 신뢰할 수 있다. 만약 여러분의 프로그램이 JLS가 말하는 방식대로 동작하지 않는다면, 그것은 고칠 수 있는 Java 툴체인의 버그다.
LLM은 아무 보장도 하지 않는다. JLS가 제공하는 892페이지의 보증과 달리, LLM이 생성한 코드 조각이 어떤 성질을 가지는지 여러분은 검사하거나 어느 정도 테스트해 보기 전까지는 문자 그대로 아무것도 모른다. 출력이 여러분이 원하는 것이 아닐 때, 고칠 수 있는 툴링 버그는 없다. 이것이 본질적으로 모호한 프롬프트로부터 정교한 산출물을 만들려는 시도의 근본적 성격이다.
문제가 프롬프트에 의미론이 없다는 것이라면, 의미론을 부여하면 되지 않을까? 물론 그렇게 할 수는 있다. 하지만 그러면 LLM의 마법 같은 많은 부분을 잃게 된다. 프롬프트에 의미론이 생기면 그것은 프로그래밍 언어처럼 보이기 시작하고, 그러면 다시 LLM에게 맡기는 대신 사람이 직접 코드를 작성하는 쪽으로 돌아가게 된다.
그렇게 해도 여전히 문제가 해결되지는 않는다. 올바른 컴파일러는 자기 프로그래밍 언어의 의미론을 구현한다. 그렇다면 LLM은 어떻게 그렇게 할까? 내가 보기에는, 프롬프트 언어의 의미론을 출력이 충족하는지 검증하기 위해 외부 도구(Lean 같은?)가 필요할 것이다. 어쩌면 여기에 가능성이 있을지도 모른다. 하지만 이건 또, 때때로 실패하는 매우 느리고 예측 불가능하며 비싼 컴파일러를 만드는 훌륭한 방법처럼 들리기도 한다.
프로그래밍 언어에는 의미론이 있다. 여러분이 작성한 코드는 컴파일러가 여러분의 프로그램으로 명시한 동작을 구현하도록 제약한다. 프롬프트에는 의미론이 없다. 여러분의 프롬프트가 생성된 산출물로 하여금 올바른 동작을 보이게 하리라고 확신할 방법은 없다. 여기서 결정성은 완전히 무관하다. 의미 있게 비결정적이면서도 자신이 컴파일하는 프로그래밍 언어의 의미론을 올바르게 구현하는 유용한 컴파일러는 얼마든지 쉽게 만들 수 있다. 완전히 결정적인 LLM도 비결정적인 LLM만큼이나 신뢰할 수 없고 믿음직하지 않다.
실제로는 여러분의 컴파일러도 아마 진짜로 결정적이지는 않을 것이다. 비결정성이 몰래 스며들도록 두는 것은 놀라울 정도로 쉽다. 이건 오히려 내 주장을 더 강하게 만든다! 컴파일러는 이미 결정적이지 않은 경우가 많고, 거의 모든 사람에게 그건 괜찮다.
마찬가지로, 여기서 내가 보여 준 LLM을 결정적으로 만드는 접근법도 보장되는 것은 아니다. 추론이 수행되는 방식의 세부 사항 때문에 때때로 비결정적이게 만들 수 있는 요소는 많다. 하지만 다시 말해, 이 역시 중요하지 않다. 설령 완벽하더라도, 그것이 지금 논의 중인 문제를 바꾸지는 못한다.
의미론을 좋아하는가? 아마 Software Should Work도 좋아할지 모른다.
home | © Isaac Van Doren