perf(tokens): cache tool schema estimates
This commit is contained in:
@@ -8,12 +8,61 @@ import time
|
|||||||
import uuid
|
import uuid
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from functools import lru_cache
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import tiktoken
|
import tiktoken
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
_TOOLS_TOKEN_CACHE_MAX_ENTRIES = 64
|
||||||
|
_TOOLS_TOKEN_CACHE: dict[int, tuple[tuple[int, ...], dict[bool, int]]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def _get_token_encoding() -> Any:
|
||||||
|
return tiktoken.get_encoding("cl100k_base")
|
||||||
|
|
||||||
|
|
||||||
|
def _cache_tools_token_count(
|
||||||
|
tools_id: int,
|
||||||
|
fingerprint: tuple[int, ...],
|
||||||
|
counts: dict[bool, int],
|
||||||
|
) -> None:
|
||||||
|
if (
|
||||||
|
tools_id not in _TOOLS_TOKEN_CACHE
|
||||||
|
and len(_TOOLS_TOKEN_CACHE) >= _TOOLS_TOKEN_CACHE_MAX_ENTRIES
|
||||||
|
):
|
||||||
|
_TOOLS_TOKEN_CACHE.pop(next(iter(_TOOLS_TOKEN_CACHE)))
|
||||||
|
_TOOLS_TOKEN_CACHE[tools_id] = (fingerprint, counts)
|
||||||
|
|
||||||
|
|
||||||
|
def _estimate_tools_tokens(
|
||||||
|
enc: Any,
|
||||||
|
tools: list[dict[str, Any]],
|
||||||
|
*,
|
||||||
|
leading_separator: bool,
|
||||||
|
) -> int:
|
||||||
|
"""Estimate stable tool definition tokens without re-encoding every loop."""
|
||||||
|
tools_id = id(tools)
|
||||||
|
fingerprint = tuple(id(tool) for tool in tools)
|
||||||
|
cached = _TOOLS_TOKEN_CACHE.get(tools_id)
|
||||||
|
if cached and cached[0] == fingerprint:
|
||||||
|
token_count = cached[1].get(leading_separator)
|
||||||
|
if token_count is not None:
|
||||||
|
return token_count
|
||||||
|
counts = cached[1]
|
||||||
|
else:
|
||||||
|
counts = {}
|
||||||
|
|
||||||
|
rendered = json.dumps(tools, ensure_ascii=False)
|
||||||
|
if leading_separator:
|
||||||
|
rendered = "\n" + rendered
|
||||||
|
token_count = len(enc.encode(rendered))
|
||||||
|
counts[leading_separator] = token_count
|
||||||
|
_cache_tools_token_count(tools_id, fingerprint, counts)
|
||||||
|
return token_count
|
||||||
|
|
||||||
|
|
||||||
def strip_think(text: str) -> str:
|
def strip_think(text: str) -> str:
|
||||||
"""Remove thinking blocks, unclosed trailing tags, and tokenizer-level
|
"""Remove thinking blocks, unclosed trailing tags, and tokenizer-level
|
||||||
@@ -249,7 +298,7 @@ def truncate_text_to_tokens(text: str, max_tokens: int) -> str:
|
|||||||
if max_tokens <= 0:
|
if max_tokens <= 0:
|
||||||
return text
|
return text
|
||||||
try:
|
try:
|
||||||
enc = tiktoken.get_encoding("cl100k_base")
|
enc = _get_token_encoding()
|
||||||
tokens = enc.encode(text)
|
tokens = enc.encode(text)
|
||||||
if len(tokens) <= max_tokens:
|
if len(tokens) <= max_tokens:
|
||||||
return text
|
return text
|
||||||
@@ -486,7 +535,7 @@ def estimate_prompt_tokens(
|
|||||||
reasoning_content, tool_call_id, name, plus per-message framing overhead.
|
reasoning_content, tool_call_id, name, plus per-message framing overhead.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
enc = tiktoken.get_encoding("cl100k_base")
|
enc = _get_token_encoding()
|
||||||
parts: list[str] = []
|
parts: list[str] = []
|
||||||
for msg in messages:
|
for msg in messages:
|
||||||
content = msg.get("content")
|
content = msg.get("content")
|
||||||
@@ -512,11 +561,13 @@ def estimate_prompt_tokens(
|
|||||||
if isinstance(value, str) and value:
|
if isinstance(value, str) and value:
|
||||||
parts.append(value)
|
parts.append(value)
|
||||||
|
|
||||||
if tools:
|
tool_tokens = (
|
||||||
parts.append(json.dumps(tools, ensure_ascii=False))
|
_estimate_tools_tokens(enc, tools, leading_separator=bool(parts)) if tools else 0
|
||||||
|
)
|
||||||
|
|
||||||
per_message_overhead = len(messages) * 4
|
per_message_overhead = len(messages) * 4
|
||||||
return len(enc.encode("\n".join(parts))) + per_message_overhead
|
message_tokens = len(enc.encode("\n".join(parts))) if parts else 0
|
||||||
|
return message_tokens + tool_tokens + per_message_overhead
|
||||||
except Exception:
|
except Exception:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
@@ -553,7 +604,7 @@ def estimate_message_tokens(message: dict[str, Any]) -> int:
|
|||||||
if not payload:
|
if not payload:
|
||||||
return 4
|
return 4
|
||||||
try:
|
try:
|
||||||
enc = tiktoken.get_encoding("cl100k_base")
|
enc = _get_token_encoding()
|
||||||
return max(4, len(enc.encode(payload)) + 4)
|
return max(4, len(enc.encode(payload)) + 4)
|
||||||
except Exception:
|
except Exception:
|
||||||
return max(4, len(payload) // 4 + 4)
|
return max(4, len(payload) // 4 + 4)
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
from nanobot.utils.helpers import estimate_prompt_tokens_chain
|
import json
|
||||||
|
|
||||||
|
from nanobot.utils import helpers
|
||||||
|
from nanobot.utils.helpers import estimate_prompt_tokens, estimate_prompt_tokens_chain
|
||||||
|
|
||||||
|
|
||||||
class _NoCounterProvider:
|
class _NoCounterProvider:
|
||||||
@@ -30,3 +33,67 @@ def test_estimate_prompt_tokens_chain_falls_back_when_provider_counter_fails() -
|
|||||||
|
|
||||||
assert tokens > 0
|
assert tokens > 0
|
||||||
assert source == "tiktoken"
|
assert source == "tiktoken"
|
||||||
|
|
||||||
|
|
||||||
|
def test_estimate_prompt_tokens_caches_tools_encoding(monkeypatch) -> None:
|
||||||
|
helpers._get_token_encoding.cache_clear()
|
||||||
|
helpers._TOOLS_TOKEN_CACHE.clear()
|
||||||
|
|
||||||
|
class FakeEncoding:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.encoded: list[str] = []
|
||||||
|
|
||||||
|
def encode(self, text: str) -> list[int]:
|
||||||
|
self.encoded.append(text)
|
||||||
|
return list(range(max(1, len(text) // 4)))
|
||||||
|
|
||||||
|
fake_encoding = FakeEncoding()
|
||||||
|
get_encoding_calls = 0
|
||||||
|
|
||||||
|
def fake_get_encoding(name: str) -> FakeEncoding:
|
||||||
|
nonlocal get_encoding_calls
|
||||||
|
assert name == "cl100k_base"
|
||||||
|
get_encoding_calls += 1
|
||||||
|
return fake_encoding
|
||||||
|
|
||||||
|
monkeypatch.setattr(helpers.tiktoken, "get_encoding", fake_get_encoding)
|
||||||
|
tools = [{"type": "function", "function": {"name": "demo", "description": "cached"}}]
|
||||||
|
messages = [{"role": "user", "content": "hello"}]
|
||||||
|
|
||||||
|
first = estimate_prompt_tokens(messages, tools)
|
||||||
|
second = estimate_prompt_tokens(messages, tools)
|
||||||
|
|
||||||
|
assert first == second
|
||||||
|
assert get_encoding_calls == 1
|
||||||
|
rendered_tools = "\n" + json.dumps(tools, ensure_ascii=False)
|
||||||
|
assert fake_encoding.encoded.count(rendered_tools) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_estimate_prompt_tokens_recomputes_when_tool_items_change(monkeypatch) -> None:
|
||||||
|
helpers._get_token_encoding.cache_clear()
|
||||||
|
helpers._TOOLS_TOKEN_CACHE.clear()
|
||||||
|
|
||||||
|
class FakeEncoding:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.encoded: list[str] = []
|
||||||
|
|
||||||
|
def encode(self, text: str) -> list[int]:
|
||||||
|
self.encoded.append(text)
|
||||||
|
return list(range(max(1, len(text) // 4)))
|
||||||
|
|
||||||
|
fake_encoding = FakeEncoding()
|
||||||
|
monkeypatch.setattr(helpers.tiktoken, "get_encoding", lambda _name: fake_encoding)
|
||||||
|
|
||||||
|
tools = [{"type": "function", "function": {"name": "before"}}]
|
||||||
|
messages = [{"role": "user", "content": "hello"}]
|
||||||
|
estimate_prompt_tokens(messages, tools)
|
||||||
|
|
||||||
|
tools[0] = {"type": "function", "function": {"name": "after"}}
|
||||||
|
estimate_prompt_tokens(messages, tools)
|
||||||
|
|
||||||
|
before_tools = "\n" + json.dumps(
|
||||||
|
[{"type": "function", "function": {"name": "before"}}], ensure_ascii=False
|
||||||
|
)
|
||||||
|
after_tools = "\n" + json.dumps(tools, ensure_ascii=False)
|
||||||
|
assert before_tools in fake_encoding.encoded
|
||||||
|
assert after_tools in fake_encoding.encoded
|
||||||
|
|||||||
Reference in New Issue
Block a user