LLM이 더 짧고 ‘간결한’ 코드를 쓰도록 강요하면 왜 성능이 떨어질 수 있는지, 서프라이설과 퍼플렉시티 관점에서 설명하고 q/kdb+ 예시와 소규모 실험으로 뒷받침합니다. 장황함이 오히려 정확도와 신뢰성을 높일 수 있음을 제안합니다.
제목: Don’t Force Your LLM to Write Terse Code: An Argument from Information Theory for q/kdb+ Developers
읽는 데 12분
6일 전
저는 LLM이 q/kdb+ 코드를 잘 쓰도록 돕는 코딩 도구를 만들어 왔는데, 대화를 나눈 한 퀀트가 이렇게 물었습니다. “정말 아주 간결한 코드를 쓰게 할 수 있을까요?”
q/kdb+ 커뮤니티에는 간결한 코드를 선호하는 오랜 전통이 있고, 이것이 올바른 코딩 방식이라는 신념도 강합니다. 저도 LLM 코딩 어시스턴트가 등장하기 전에는 그 입장이었습니다. 작은 화면을 쓰고 키 입력을 최소화하는 걸 좋아했거든요. 하지만 이제는 LLM이 제 코드를 작성합니다. 그래서 제가 신경 쓰는 건 오직 LLM의 성능을 잘 유지하는 것입니다. 그렇기 때문에, 미학적 차원에서의 간결함 선호는 LLM의 정확도 선호에 자리를 내줘야 한다고 생각합니다. 더 정확한 코드를 위해서는 더 장황한 코드가 필요할 수도 있으니까요. 그래서 그 퀀트의 질문에 대한 제 답은: 네, 정말 간결한 코드를 쓰게 할 수 있습니다. 하지만 저는 그렇게 하지 않는 것이 맞다고 봅니다.
예시부터 시작해 보죠. (2#x)#1,x#0는 x×x 단위행렬을 만드는 공식 q 프레이즈북의 코드입니다. 이는 대략 Python의 np.tile(np.concatenate([[1],x*[0]]),x)[:x*x].reshape(*2*[x])와 같습니다. 헷갈리신다면 걱정 마세요. 아래에서 Claude에게 설명을 부탁했습니다! (모든 대화록은 요약되었으며, 글 끝에 전체 버전을 제공합니다)
Python에서
np.tile(np.concatenate([[1],x*[0]]),x)[:x*x].reshape(*2*[x])는 무엇을 하나요? […]
np.concatenate([[1],x*[0]])— 이 리스트들을 하나의 배열로 이어 붙입니다:[1, 0, 0, 0]
np.tile(…, x)— 이 배열을 x번 반복합니다.x=3일 때:[1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0]
[:x*x]— 앞에서부터 x*x 원소만 취합니다.x=3,x*x=9일 때:[1, 0, 0, 0, 1, 0, 0, 0, 1]
*2*[x]— 이는*[x, x]로, x와 같은 인수 두 개로 언패킹됩니다
.reshape(*2*[x])— 1차원 배열을(x, x)모양의 2차원 배열로 재배열합니다.x=3일 때:[[1, 0, 0], [0, 1, 0], [0, 0, 1]]단위행렬을 만드는 독특한 방법이네요. 정석은 아니지만, 주대각선에 1, 나머지에 0을 두는 대각행렬을 영리하게 구성합니다. NumPy의 내장 함수에 비하면 우회적이지만, 창의적인 행렬 생성 방식입니다.
같은 질문을 q 코드에 대해 해보죠.
q에서
(2#x)#1,x#0는 무엇을 하나요? […]
(2#x)#1,x#0—1,x#0을2#x에서 가져온 치수로 행렬로 재배열합니다사실, 다시 생각해 보겠습니다. q에서는:
[…]
그러니까 x가
3 4 5같은 거라면, […](2#x)#1,x#0은 이것을3x4행렬로 재배열합니다 (하지만 원소가6개뿐이라 순환(cycle)될 겁니다)잠깐, 더 신중히 생각해야겠어요. x가
3 4 5라면:[…]
q에서 단위행렬을 생성하는 패턴이 보이네요. 리스트 생성과 재배열을 조합해 주대각선에 1, 나머지에 0을 두는 행렬을, q의 리스트 조작 능력을 활용해 간결하게 만들 수 있습니다.
여기서 Claude는 “사실, 다시 생각해 보겠습니다.”, “잠깐, 더 신중히 생각해야겠어요.”라며 두 번이나 말을 되짚은 뒤, 매번 모든 단계를 다시 훑습니다.
마지막 예시로, 제가 별도의 세션에서 Claude에게 # 연산자만 사용해 단위행렬을 만드는 q 코드를 작성하라고 프롬프트했을 때 Claude가 스스로 만들어낸 코드 중 일부를 설명해 달라고 요청했습니다. 그 코드가 바로 (x;x) # (x*x) # 1,x#0입니다. 새로운 세션에서 이것을 설명해 달라고 하자 다음과 같았습니다.
q에서
(x;x) # (x*x) # 1,x#0는 무엇을 하나요?[…]
그러니까 이건 x by x 크기의 단위행렬을 만듭니다.
x=3예로 추적해 보겠습니다:[…]
네, 이건 단위행렬을 만듭니다!
이번에는 “다시 생각해 보자”나 “더 신중히 생각하자” 같은 되짚기가 없었습니다. 마지막에 예를 추가한 것은 Python 경우와 비슷합니다.
일련의 공리(axioms)가 주어졌을 때 정리를 증명하라고 LLM에 프롬프트한다고 해봅시다. 오늘날 LLM이 꽤 잘하는 일입니다. 출력은 증명을 담은 문자열이고, 결정적이고 LLM이 아닌 증명 검증기(proof-checker)가 그 타당성을 판정할 수 있습니다.
이제 샤논 정보이론의 관점에서 생각해 봅시다. LLM이 답하기 전에는 우리가 모르는 것이 있습니다. 바로 그 증명입니다. 답한 뒤에는 우리의 무지가 사라집니다¹. 따라서 우리는 어떤 지식을 얻었다고 말할 수 있고, 이는 샤논 이론에 따라 놀람도(surprisal) 비트로 정량화할 수 있습니다. 샤논 이론에서 놀람도는 객관적 절대치가 아니라 어떤 확률분포에 상대적입니다. 그렇다면 우리 손에 있는 하나의 분포, 즉 LLM 자체가 제공하는 미래 토큰에 대한 분포를 사용하자고 제안합니다².
샤논이 정보 전송의 수학적 성질을 연구했지만, 무슨 정보가 유의미하고 무엇이 무의미한지를 그의 이론에 적용하지는 않았다는 점이 참 흥미롭습니다! 이는 당시 맥락에서 자연스럽습니다. LLM 이전 시대에는 정보가 보통 인간에 의해 생성되었고, 인간의 확률분포는 접근 불가능했기 때문입니다. 이제 우리는 객관적으로 유용한 문자열을 산출하면서도 그 출력 분포를 노출하는 에이전트가 등장한 역사상 특이한 시점에 와 있습니다.
이제 어떤 증명을 더 적은 단계로 다시 쓰려 한다고 합시다. 더 짧은 증명은 동일한 정보를 어떤 식으로든 압축했을 수 있습니다(무손실 압축), 혹은 더 자명한 단계를 생략했을 수 있습니다(유손 압축). 샤논 이론에 따르면, 출력 증명에 담긴 정보량은 I(proof) = -sum log P(token | previous tokens)로 정량화할 수 있고, I(short proof) <= I(long proof)입니다. 유손이면 <, 무손실이면 =가 되겠지요. 실제로는 유손과 무손실이 혼재하리라 기대하지만, 여기서는 더 흥미로운 무손실 부분에 집중해 보겠습니다…
I(short proof)=I(long proof)일 때, 토큰당 평균 놀람도는 긴 증명보다 짧은 증명이 더 높아야 합니다. 놀람도는 단일 토큰에 대해 -log P이므로, 평균적으로 짧은 증명은 더 낮은 확률의 토큰들로 구성된다는 뜻입니다. 토큰당 평균 놀람도를 지수변환한 값(단조 변환)은 [퍼플렉시티(perplexity)]로, LLM과 그 입력/출력에 흔히 쓰이는 지표입니다. 즉, 동일한 정보량이라면 짧은 증명이 더 높은 퍼플렉시티를 갖는 것이 기대됩니다.
LLM이 토큰을 출력할 때, 매 단계 우선 모든 토큰에 대한 출력 분포를 계산한 다음, Top-P 같은 가지치기 전략을 사용해 가장 가능성이 높은 다음 토큰들만 남기고 나머지를 버립니다. 어떤 특정한 짧은 증명이 특정 저확률 토큰을 필요로 하고, 그 토큰이 어떤 정리를 증명하라는 프롬프트에서 Top-P 컷오프에 들지 못한다면, LLM은 그 증명을 절대로 산출할 수 없습니다. 대신 더 장황한 증명을 내놓을 수밖에 없습니다³.
지금까지는 정리 증명에 대해 이야기했지만, 저는 주어진 과제를 충족하는 코드를 작성하는 경우도 매우 유사하다고 봅니다. 여전히 LLM에게 우리가 정답 여부를 검사할 수 있는 문자열을 출력하도록 프롬프트하는 것이니까요. LLM에게 코드를 쓰게 했다고 상상해 보세요. 처음부터 간결하게 쓰라고 하든, 장황하게 쓴 코드를 더 간결하게 다시 쓰라고 하든, 동일한 정보이론적 원리가 적용됩니다. LLM은 요청하면 간결한 코드를 ‘쓸 수’ 있습니다. 하지만 저는 그렇게 요청하지 말아야 한다고 봅니다. 간결한 코드는 퍼플렉시티가 높아지고, 그만큼 LLM이 신뢰성 있게 다루기 어려워지기 때문입니다.
코드를 덜 장황하게 만드는 일은, 증명을 짧게 만드는 것과 마찬가지로, 유손 압축(주석, 타입 힌트, 서술적 변수명 등을 없앰)일 수도 있고 무손실 압축일 수도 있습니다. 무손실 압축은 무엇을 의미할까요? 제 생각에는, 코드에서의 무손실 압축이란 여러 개의 더 단순한 연산 조합을 하나의 더 복잡한 연산으로 표현하는 것을 뜻합니다. 예를 들어 i += 1과 i = i + 1의 차이처럼요. 위의 이론은 후자가 퍼플렉시티가 더 낮을 것이라고 예측합니다.
이 주장을 시험하기 위해 Qwen 2.5 Coder 7B Instruct를 사용해 실제 퍼플렉시티를 측정해 보았습니다. “코드만으로 답하라: Python에서 i를 1만큼 증가시키는 방법은?”이라고 프롬프트하고 두 가지 유효한 출력을 비교했습니다. i += 1의 퍼플렉시티는 대략 38.68, i = i + 1의 퍼플렉시티는 대략 10.88이었습니다. 복합 대입 연산자 버전은 동일한 의미를 더 적은 토큰에 담아 거의 4배에 가까운 더 높은 퍼플렉시티를 보였습니다.
이는 이론적 예측을 확인해 줍니다. 같은 정보를 더 간결한 표현으로 압축할수록 토큰당 평균 놀람도가 증가합니다. 그리고 경험적 연구에 따르면 더 높은 입력 퍼플렉시티는 LLM 과제 수행 성능 저하와 상관관계를 가집니다. 이는 LLM에게 그것이 스스로 생성했을 법한 것보다 더 간결한 코드를 프롬프트로 주면, 디버깅, 설명, 확장 같은 작업에서 체계적으로 성능이 낮아질 것임을 시사합니다. 반대로 LLM이 자연스럽게 산출하는 더 장황한 코드를 사용할 때가 더 낫다는 뜻이죠.
이 정보이론적 관점은 더 넓은 원리를 드러냅니다. 토큰당 놀람도는 생성 중 LLM이 감당할 수 있는 인지 처리량을 나타냅니다. 단순한 작업에서는 LLM이 토큰당 놀람도를 관리 가능한 수준으로 유지할 만큼 적절한 장황함을 자연히 선택합니다. 그러나 매우 복잡한 작업에서는 필요한 토큰 수를 과소평가해, 너무 많은 정보를 너무 적은 토큰에 욱여넣으려 들고, 그 결과 토큰당 놀람도가 높아지고 오류가 늘어납니다. 이 틀은 “단계별로 생각하라”나 “자세히 풀어 써라” 같은 프롬프트 전략이 복잡한 작업에서 성능을 끌어올리는 이유를 설명합니다. 그런 전략들은 인지적 부하를 더 많은 토큰에 분산시켜 토큰당 놀람도를 범위 내에 묶어 두기 때문입니다.
q/kdb+ 개발자를 위한 실용적 시사점은 이렇습니다. LLM 시대에는 코딩 어시스턴트가 장황한 코드를 쓰게 두세요. 이제 타이핑을 하는 건 당신의 손가락이 아니고, 퍼플렉시티가 낮은 코드로 작업할 때 얻는 성능 이점이 간결함에 대한 미학적 선호를 능가할 수 있습니다.
[1] 비트겐슈타인에 의해 대중화된 주장에 따르면, 공리로부터 정리를 도출하는 것은 추가 정보를 주지 않습니다. 정리에 담긴 모든 정보는 필요한 모든 출발 공리에 이미 담겨 있으므로, 모든 정리는 우리의 지식에 아무것도 더하지 않는 동어반복이라는 것입니다(이는 정보이론의 베이즈적 해석에서 상황을 보는 한 방식이기도 합니다). 여기에 대해 저는, 우리가 추구하는 것은 정리가 참인지 거짓인지가 아니라, 증명 검증기에 의해 검증되는 어떤 증명을 나타내는 문자열이며, 그러한 문자열 자체는 공리 속에 포함되어 있지 않다고 반박하겠습니다.
[2] 이상적으로는 우리의 내부 분포(우리의 신념과 무지를 베이즈적 관점에서 보며) 대비 놀람도를 측정하고 싶지만, 주어진 정리를 증명하는 데 우리 LLM이 실제로 꽤 잘하기 때문에 두 분포가 적절히 유사하다고 가정해도 괜찮다고 생각합니다.
이 작가의 업데이트를 받으려면 Medium에 무료로 가입하세요.
[3] 완전성을 위해 덧붙이면, LLM은 초기 증명 접근이 막다른 길이라고 판단하면 되돌아가기(backtracking)를 할 수 있고 실제로 합니다. 하지만 지금까지의 출력이 이후 토큰 분포에 영향을 미치므로, 어떤 되돌리기도 “두 번째 시도”에서의 토큰 분포에 영향을 줍니다. 또한 제가 설명한 접근은 이런 경우보다는, LLM이 되돌리기 없이 증명을 내놓는 경우에 더 잘 들어맞는다는 점도 의미합니다.
Python 코드 설명:
What does np.tile(np.concatenate([[1],x*[0]]),x)[:x*x].reshape(2[x]) do in Python ?
[Thought process]
This is a question about Python code using NumPy. Let me break down what this expression does step by step.
The expression is: np.tile(np.concatenate([[1],x*[0]]),x)[:x*x].reshape(2[x])
Let me trace through this with an example value, say x=3:
x*[0] — This creates a list with x zeros. For x=3: [0, 0, 0]
[[1], x*[0]] — This is a list containing [1] and [0, 0, 0]: [[1], [0, 0, 0]]
np.concatenate([[1],x*[0]]) — This concatenates these lists into a single array: [1, 0, 0, 0]
np.tile(…, x) — This tiles/repeats the array x times. For x=3: [1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0]
[:xx] — This slices to take only the first xx elements. For x=3, x*x=9: [1, 0, 0, 0, 1, 0, 0, 0, 1]
2[x] — This is *[x, x], which unpacks to two arguments each equal to x
.reshape(2[x]) — This reshapes the 1D array into a 2D array of shape (x, x). For x=3:
[[1, 0, 0],
[0, 1, 0],
[0, 0, 1]]
I see a unique approach to generating an identity matrix through a creative NumPy manipulation. While unconventional, the code cleverly constructs a diagonal matrix with 1s along the main diagonal and 0s elsewhere. It’s a roundabout method compared to NumPy’s built-in functions, demonstrating an inventive way of matrix creation.
q 코드 설명:
What does (2#x)#1,x#0 do in q ?
[Thought process]
The user is asking about q/kdb+ syntax. This looks like a q expression. Let me break down what this means:
In q/kdb+:
is the take/reshape operator
x#y takes x elements from y (if x is a number) or reshapes y to dimensions x (if x is a list)
The expression (2#x)#1,x#0 involves multiple uses of #
Let me parse this step by step:
x#0 — takes x zeros (creates a list of x zeros)
1,x#0 — prepends 1 to the list of x zeros, so we get a list like 1 0 0 0… (x+1 elements total)
2#x — takes the first 2 elements of x
(2#x)#1,x#0 — reshapes 1,x#0 into a matrix with dimensions from 2#x
Actually, let me reconsider. In q:
2#x when x is a list takes the first 2 elements
Then (2#x)#y reshapes y into a matrix/array with dimensions specified by the first 2 elements of x
So if x is something like 3 4 5, then:
2#x gives 3 4
x#0 gives 5 zeros: 0 0 0 0 0
1,x#0 gives 1 0 0 0 0 0 (6 elements)
(2#x)#1,x#0 reshapes this into a 3x4 matrix (but we only have 6 elements, so it will cycle)
Wait, I need to think about this more carefully. If x is 3 4 5:
count x is 3
x#0 creates a list of zeros using the first element of x
Specifically, 3#0 results in 0 0 0
When prepending 1, we get 1 0 0 0
2#x extracts 3 4
The final reshape will create a matrix cycling through the limited elements
I see a pattern emerging for generating an identity matrix in q. By manipulating list creation and reshaping, we can construct a matrix with 1s on the diagonal and 0s elsewhere, using a compact technique that leverages q’s list manipulation capabilities.
Python과 달리 q에서는 x가 스칼라인지 리스트인지에 대해 LLM이 덜 확신합니다. 그래도 프롬프트에 이 추가 정보를 포함해도 Claude는 여전히 두 번 되짚습니다.
What does (2#x)#1,x#0 do in q for scalar x?
[Thought process]
This is a question about q/kdb+, which is a programming language. Let me think about what this expression does.
In q/kdb+:
is the take/reshape operator
When used as n#list, it takes/repeats elements
When used as shape#list, it reshapes
Let’s break down (2#x)#1,x#0:
x#0 — This takes x copies of 0, creating a list of x zeros
1,x#0 — This prepends 1 to that list, so we get 1 0 0 0… (with x zeros)
2#x — This takes 2 copies of the scalar x, creating x x
(2#x)#1,x#0 — This reshapes the list 1,x#0 into shape x x, creating an x by x matrix
Wait, let me reconsider. When you have a shape vector like 2 3 and use it with #, it reshapes the right argument into that shape.
So if we have (2#x)#1,x#0:
2#x creates a 2-element list where both elements are x: x x
This is used as a shape for reshaping
The data to reshape is 1,x#0 which is 1 followed by x zeros
Reshaping into shape x x means creating an x-by-x matrix
This would create an x by x identity matrix! The first element is 1, then x zeros, and when reshaped into an x-by-x matrix, you get 1s on the diagonal and 0s elsewhere.
Actually, let me verify this logic:
For x=3: 1,3#0 gives 1 0 0 0 (4 elements)
Reshaping with shape 3 3 takes these 4 elements and repeats cyclically to fill a 3x3 matrix
Reading row by row, the matrix cycles through the initial data
The reshape operation fills the matrix by repeatedly using the source data
Each row follows a cyclic pattern determined by the initial list indices
Specifically, the indices wrap around using modulo arithmetic
This creates a unique diagonal pattern resulting in an identity matrix
Claude가 스스로 작성했던 코드를 주고 물었을 때:
What does (x;x) # (x*x) # 1,x#0 do in q?
[Thought process]
This is a question about q/kdb+, which is a programming language. Let me break down this expression:
(x;x) # (x*x) # 1,x#0
In q/kdb+:
is the “take” operator (or “reshape” depending on context)
; is used to create lists
, is the join/concatenate operator
Let me parse this from right to left (since q is generally evaluated right to left):
x#0 — this creates a list of x zeros
1,x#0 — this prepends 1 to that list, so you get a list like 1 0 0 0… with x zeros
(xx) # 1,x#0 — this reshapes the list 1,x#0 to have length xx
(x;x) # (x*x) # 1,x#0 — this reshapes the result into a matrix with dimensions x by x
So this creates an identity matrix of size x by x.
Let me trace through an example with x=3:
3#0 gives 0 0 0
1,3#0 gives 1 0 0 0 (4 elements)
(3*3) # 1,0 0 0 = 9 # 1 0 0 0 gives 1 0 0 0 1 0 0 0 1 (reshapes/cycles the input to length 9)
(3;3) # 1 0 0 0 1 0 0 0 1 reshapes this into a 3x3 matrix:
1 0 0
0 1 0
0 0 1
Yes, this creates an identity matrix!