Skip to content

Commit 05bb4c3

Browse files
committed
feat: add opt-in prompt-level cache to LlmJudge
1 parent eaf374c commit 05bb4c3

2 files changed

Lines changed: 88 additions & 3 deletions

File tree

gateframe/rules/semantic.py

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
from collections import OrderedDict
34
from collections.abc import Callable
45
from typing import Any
56

@@ -47,19 +48,53 @@ def validate(self, output: Any, **context: Any) -> FailureResult | None: # noqa
4748

4849
class LlmJudge:
4950
"""EXPENSIVE: Each call invokes the provided LLM function.
50-
The caller supplies the actual LLM call -- gateframe does not import any LLM SDK."""
51+
The caller supplies the actual LLM call -- gateframe does not import any LLM SDK.
52+
53+
Set ``cache=True`` to memoize results keyed on the rendered prompt. This avoids
54+
redundant LLM calls when the same output+context is validated more than once within
55+
the lifetime of the judge instance (e.g. retry loops, multi-step pipelines).
56+
``max_cache_size`` caps the in-memory LRU store; the oldest entry is evicted when
57+
the limit is reached.
58+
"""
5159

5260
def __init__(
5361
self,
5462
prompt_template: str,
5563
llm_call: Callable[[str], str],
5664
passing_response: str = "PASS",
65+
*,
66+
cache: bool = False,
67+
max_cache_size: int = 256,
5768
) -> None:
5869
self._prompt_template = prompt_template
5970
self._llm_call = llm_call
6071
self._passing_response = passing_response
72+
self._cache: OrderedDict[str, bool] | None = OrderedDict() if cache else None
73+
self._max_cache_size = max_cache_size
6174

6275
def __call__(self, output: Any, **context: Any) -> bool: # noqa: ANN401
6376
prompt = self._prompt_template.format(output=output, **context)
64-
response = self._llm_call(prompt)
65-
return self._passing_response.lower() in response.strip().lower()
77+
78+
if self._cache is not None:
79+
if prompt in self._cache:
80+
self._cache.move_to_end(prompt)
81+
return self._cache[prompt]
82+
83+
result = self._passing_response.lower() in self._llm_call(prompt).strip().lower()
84+
85+
if self._cache is not None:
86+
if len(self._cache) >= self._max_cache_size:
87+
self._cache.popitem(last=False)
88+
self._cache[prompt] = result
89+
90+
return result
91+
92+
def cache_clear(self) -> None:
93+
"""Evict all cached prompt results."""
94+
if self._cache is not None:
95+
self._cache.clear()
96+
97+
@property
98+
def cache_size(self) -> int:
99+
"""Number of cached entries (0 when caching is disabled)."""
100+
return len(self._cache) if self._cache is not None else 0

tests/rules/test_semantic.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,3 +140,53 @@ def test_partial_match(self) -> None:
140140
llm_call=lambda prompt: "The result is PASS based on criteria",
141141
)
142142
assert judge({}) is True
143+
144+
def test_cache_disabled_by_default(self) -> None:
145+
judge = LlmJudge(prompt_template="{output}", llm_call=lambda p: "PASS")
146+
judge("x")
147+
assert judge.cache_size == 0
148+
149+
def test_cache_stores_result(self) -> None:
150+
calls: list[str] = []
151+
152+
def mock_llm(prompt: str) -> str:
153+
calls.append(prompt)
154+
return "PASS"
155+
156+
judge = LlmJudge(prompt_template="{output}", llm_call=mock_llm, cache=True)
157+
assert judge("hello") is True
158+
assert judge("hello") is True
159+
assert len(calls) == 1 # second call served from cache
160+
assert judge.cache_size == 1
161+
162+
def test_cache_different_prompts_not_shared(self) -> None:
163+
calls: list[str] = []
164+
165+
def mock_llm(prompt: str) -> str:
166+
calls.append(prompt)
167+
return "PASS"
168+
169+
judge = LlmJudge(prompt_template="{output}", llm_call=mock_llm, cache=True)
170+
judge("a")
171+
judge("b")
172+
assert len(calls) == 2
173+
assert judge.cache_size == 2
174+
175+
def test_cache_evicts_lru_when_full(self) -> None:
176+
judge = LlmJudge(
177+
prompt_template="{output}",
178+
llm_call=lambda p: "PASS",
179+
cache=True,
180+
max_cache_size=2,
181+
)
182+
judge("x")
183+
judge("y")
184+
judge("z") # evicts "x"
185+
assert judge.cache_size == 2
186+
187+
def test_cache_clear(self) -> None:
188+
judge = LlmJudge(prompt_template="{output}", llm_call=lambda p: "PASS", cache=True)
189+
judge("a")
190+
assert judge.cache_size == 1
191+
judge.cache_clear()
192+
assert judge.cache_size == 0

0 commit comments

Comments
 (0)