253 lines
9.0 KiB
Python
253 lines
9.0 KiB
Python
"""ContextCompressor - 上下文压缩与 Prompt 缓存
|
||
|
||
长会话自动压缩历史消息,保持 Token 在预算内;
|
||
会话内 Prompt 不重复渲染。
|
||
"""
|
||
|
||
import hashlib
|
||
import json
|
||
import logging
|
||
from typing import Any, Protocol, runtime_checkable
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
@runtime_checkable
|
||
class CompressionStrategy(Protocol):
|
||
"""压缩策略协议 — 所有压缩器必须实现此接口"""
|
||
|
||
async def compress(self, messages: list[dict]) -> list[dict]:
|
||
"""压缩消息列表"""
|
||
...
|
||
|
||
async def compress_tool_result(self, tool_name: str, result: Any) -> str:
|
||
"""压缩单个工具输出结果,返回压缩后的字符串"""
|
||
...
|
||
|
||
def is_available(self) -> bool:
|
||
"""检查压缩器是否可用"""
|
||
...
|
||
|
||
|
||
class ContextCompressor:
|
||
"""Compress long conversation histories to stay within token budgets"""
|
||
|
||
def __init__(
|
||
self,
|
||
llm_gateway: Any = None,
|
||
max_tokens: int = 4000,
|
||
keep_recent: int = 3,
|
||
model: str = "default",
|
||
):
|
||
self._llm_gateway = llm_gateway
|
||
self._max_tokens = max_tokens
|
||
self._keep_recent = keep_recent
|
||
self._model = model
|
||
|
||
def estimate_tokens(self, messages: list[dict]) -> int:
|
||
"""Estimate total tokens in message list (rough: 4 chars = 1 token)"""
|
||
total = 0
|
||
for msg in messages:
|
||
content = msg.get("content", "")
|
||
total += len(str(content)) // 4
|
||
return total
|
||
|
||
async def compress(self, messages: list[dict], _compression_depth: int = 0) -> list[dict]:
|
||
"""Compress messages if they exceed token budget
|
||
|
||
Strategy:
|
||
1. Keep system messages unchanged
|
||
2. Keep the most recent N messages unchanged
|
||
3. Compress older messages into a summary using LLM
|
||
"""
|
||
if self.estimate_tokens(messages) <= self._max_tokens:
|
||
return messages
|
||
|
||
# Separate system messages, old messages, and recent messages
|
||
system_msgs = [m for m in messages if m.get("role") == "system"]
|
||
non_system = [m for m in messages if m.get("role") != "system"]
|
||
|
||
if len(non_system) <= self._keep_recent:
|
||
return messages # Not enough messages to compress
|
||
|
||
old_msgs = non_system[:-self._keep_recent]
|
||
recent_msgs = non_system[-self._keep_recent:]
|
||
|
||
# Compress old messages
|
||
summary = await self._summarize(old_msgs)
|
||
|
||
# Build compressed message list
|
||
compressed = list(system_msgs)
|
||
if summary:
|
||
compressed.append({
|
||
"role": "system",
|
||
"content": f"## Conversation Summary\n{summary}",
|
||
})
|
||
compressed.extend(recent_msgs)
|
||
|
||
# Recursive check: if still over budget, compress again
|
||
if self.estimate_tokens(compressed) > self._max_tokens:
|
||
if _compression_depth >= 1:
|
||
# Depth guard: force truncation instead of infinite recursion
|
||
return self._truncate(compressed)
|
||
if len(recent_msgs) > 1:
|
||
# Try keeping fewer recent messages
|
||
return await self._compress_aggressive(messages, _compression_depth=_compression_depth + 1)
|
||
# Last resort: truncate
|
||
return self._truncate(compressed)
|
||
|
||
return compressed
|
||
|
||
async def _summarize(self, messages: list[dict], max_input_tokens: int = 3200) -> str:
|
||
"""Summarize a list of messages using LLM"""
|
||
if not self._llm_gateway:
|
||
# No LLM available, do simple truncation
|
||
return self._simple_summary(messages)
|
||
|
||
# Build summary prompt
|
||
conversation_text = "\n".join(
|
||
f"[{m.get('role', 'unknown')}]: {m.get('content', '')}"
|
||
for m in messages
|
||
)
|
||
|
||
# Pre-truncate if conversation_text exceeds safe token threshold
|
||
estimated_tokens = len(conversation_text) // 4
|
||
if estimated_tokens > max_input_tokens:
|
||
max_chars = max_input_tokens * 4
|
||
conversation_text = conversation_text[:max_chars] + "\n...[truncated]"
|
||
|
||
prompt = (
|
||
"Summarize the following conversation history concisely, "
|
||
"preserving key facts, decisions, and context. "
|
||
"Focus on information that would be needed for continuing the conversation.\n\n"
|
||
f"{conversation_text}"
|
||
)
|
||
|
||
try:
|
||
response = await self._llm_gateway.chat(
|
||
messages=[{"role": "user", "content": prompt}],
|
||
model=self._model,
|
||
agent_name="compressor",
|
||
task_type="summarization",
|
||
)
|
||
return response.content
|
||
except Exception as e:
|
||
logger.warning(f"LLM summarization failed, using simple summary: {e}")
|
||
return self._simple_summary(messages)
|
||
|
||
def _simple_summary(self, messages: list[dict]) -> str:
|
||
"""Simple truncation-based summary when LLM is unavailable"""
|
||
parts = []
|
||
for msg in messages:
|
||
role = msg.get("role", "unknown")
|
||
content = str(msg.get("content", ""))[:200]
|
||
parts.append(f"[{role}]: {content}...")
|
||
return "\n".join(parts)
|
||
|
||
async def _compress_aggressive(self, messages: list[dict], _compression_depth: int = 0) -> list[dict]:
|
||
"""More aggressive compression when standard compression isn't enough"""
|
||
system_msgs = [m for m in messages if m.get("role") == "system"]
|
||
non_system = [m for m in messages if m.get("role") != "system"]
|
||
|
||
# Keep only the last message
|
||
if non_system:
|
||
summary = await self._summarize(non_system[:-1])
|
||
compressed = list(system_msgs)
|
||
if summary:
|
||
compressed.append({
|
||
"role": "system",
|
||
"content": f"## Conversation Summary\n{summary}",
|
||
})
|
||
compressed.append(non_system[-1])
|
||
return compressed
|
||
|
||
return messages
|
||
|
||
def _truncate(self, messages: list[dict]) -> list[dict]:
|
||
"""Last resort: truncate long messages"""
|
||
result = []
|
||
for msg in messages:
|
||
content = str(msg.get("content", ""))
|
||
if len(content) > self._max_tokens * 4:
|
||
msg = {**msg, "content": content[:self._max_tokens * 4] + "...[truncated]"}
|
||
result.append(msg)
|
||
return result
|
||
|
||
async def compress_tool_result(self, tool_name: str, result: Any) -> str:
|
||
"""默认实现:不做压缩,直接返回字符串表示"""
|
||
return str(result)
|
||
|
||
def is_available(self) -> bool:
|
||
"""ContextCompressor 始终可用"""
|
||
return True
|
||
|
||
|
||
def create_compressor(config: dict[str, Any] | None = None) -> CompressionStrategy | None:
|
||
"""根据配置创建压缩器实例
|
||
|
||
Args:
|
||
config: 压缩配置字典,支持以下字段:
|
||
- enabled: bool, 是否启用压缩(默认 False)
|
||
- provider: "headroom" | "summary", 压缩提供者
|
||
- max_tokens: int, token 预算(summary 模式)
|
||
- keep_recent: int, 保留最近 N 条消息(summary 模式)
|
||
- 其他 provider 特定配置
|
||
|
||
Returns:
|
||
CompressionStrategy 实例,或 None(未启用时)
|
||
"""
|
||
if not config or not config.get("enabled", False):
|
||
return None
|
||
|
||
provider = config.get("provider", "summary")
|
||
|
||
if provider == "headroom":
|
||
try:
|
||
from agentkit.core.headroom_compressor import HeadroomCompressor
|
||
compressor = HeadroomCompressor(config)
|
||
if compressor.is_available():
|
||
return compressor
|
||
logger.warning(
|
||
"HeadroomCompressor not available (headroom-ai not installed?). "
|
||
"Falling back to ContextCompressor."
|
||
)
|
||
except ImportError:
|
||
logger.warning(
|
||
"HeadroomCompressor module not available. "
|
||
"Falling back to ContextCompressor."
|
||
)
|
||
# Fallback to summary compressor
|
||
return ContextCompressor(
|
||
max_tokens=config.get("max_tokens", 4000),
|
||
keep_recent=config.get("keep_recent", 3),
|
||
)
|
||
|
||
# Default: summary-based compression
|
||
return ContextCompressor(
|
||
max_tokens=config.get("max_tokens", 4000),
|
||
keep_recent=config.get("keep_recent", 3),
|
||
)
|
||
|
||
|
||
def render_cached(template, variables: dict[str, Any] | None = None) -> list[dict[str, str]]:
|
||
"""Render PromptTemplate with caching - returns cached result for same variables"""
|
||
cache_key = hashlib.md5(
|
||
json.dumps(variables or {}, sort_keys=True).encode()
|
||
).hexdigest()
|
||
|
||
if not hasattr(template, '_render_cache'):
|
||
template._render_cache = {}
|
||
|
||
if cache_key in template._render_cache:
|
||
return template._render_cache[cache_key]
|
||
|
||
result = template.render(variables=variables)
|
||
template._render_cache[cache_key] = result
|
||
return result
|
||
|
||
|
||
def clear_cache(template) -> None:
|
||
"""Clear the render cache on a PromptTemplate instance"""
|
||
if hasattr(template, '_render_cache'):
|
||
template._render_cache.clear()
|