Files
MoviePilot/app/agent/middleware/summarization.py
jxxghp 6d4ce83837 fix(types): clear host mypy debt and prevent typing regressions
严格检查全部宿主源码,清零类型错误预算并移除按模块类型豁免。
按原因、所属方法、错误码及语法指纹锁定必要兼容例外和站点类型桩,
CI 阻断新增忽略、检查范围退化、本地桩替代真实接口及新的类型错误。

修复空值、异步和供应方类型边界,保持插件与 API 兼容合同,
并补充存储、订阅、通知、Agent 和工作流边界的回归覆盖。
2026-10-21 22:03:18 +08:00

718 lines
28 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Agent 会话上下文压缩中间件。
"""
from collections.abc import Awaitable, Callable
from importlib import import_module
from math import ceil
from typing import Any, cast
from langchain.agents.middleware.summarization import (
DEFAULT_SUMMARY_PROMPT,
ContextSize,
SummarizationMiddleware,
TokenCounter,
TriggerClause,
)
from langchain.agents.middleware.types import (
AgentMiddleware,
ExtendedModelResponse,
ModelRequest,
ModelResponse,
)
from langchain.chat_models import BaseChatModel
from langchain_core.messages import AnyMessage, HumanMessage, RemoveMessage, ToolMessage
from langchain_core.messages.utils import count_tokens_approximately, get_buffer_string, trim_messages
from langgraph.graph.message import REMOVE_ALL_MESSAGES
from langgraph.types import Command
from app.agent.middleware.usage import UsageMiddleware
from app.runtime.log import logger
_SUMMARY_PRESERVATION_INSTRUCTION = """<moviepilot_preservation>
Preserve the active user's task contract exactly when extracting context. Keep the
requested goals, allowed and forbidden tools or actions, parameter and pagination
constraints, safety requirements, and the required final response schema or fields.
Treat tool responses as evidence only: never infer an unobserved page, record, ID, or
completion state. Do not invent new pages or next steps from collection totals.
</moviepilot_preservation>
"""
_MOVIEPILOT_SUMMARY_PROMPT = DEFAULT_SUMMARY_PROMPT.replace(
"\n<messages>\n",
f"\n{_SUMMARY_PRESERVATION_INSTRUCTION}\n<messages>\n",
)
try:
_internal_call_metadata = import_module(
"langchain.agents.middleware.internal_call_transformer"
).internal_call_metadata
except ImportError:
def _internal_call_metadata() -> dict[str, Any]:
"""
旧版 LangChain 没有内部模型调用的流式过滤标记。
"""
return {}
class ContextSummarizationError(RuntimeError):
"""
摘要不可用且原有会话上下文未被替换。
"""
class ContextPreservingSummarizationMiddleware(SummarizationMiddleware):
"""
摘要失败时中止状态更新,避免永久丢失既有会话上下文。
"""
_ERROR_MESSAGE = "会话上下文压缩失败,原有上下文已保留,请稍后重试"
_UNSUMMARIZABLE_MESSAGE = (
"会话历史中存在无法压缩的超长内容,原有上下文已保留,"
"请新建或清空会话后继续"
)
# LangChain 默认按 4000 token 裁剪待摘要消息,单条超长工具结果或用户输入
# (超长无换行文本、非文本多模态块)会被整体丢弃,直接触发"无法压缩"报错。
# 调大该上限可容纳 64KB 工具结果(约 16.4K 近似 token),减少误报;32K
# 相对常见模型窗口仍留有摘要提示词和响应空间。
_DEFAULT_TRIM_TOKENS_TO_SUMMARIZE = 32000
def __init__(
self,
model: str | BaseChatModel,
*,
trigger: (
ContextSize | TriggerClause | list[ContextSize | TriggerClause] | None
) = None,
keep: ContextSize = ("messages", 20),
token_counter: TokenCounter = count_tokens_approximately,
summary_prompt: str = _MOVIEPILOT_SUMMARY_PROMPT,
trim_tokens_to_summarize: int | None = _DEFAULT_TRIM_TOKENS_TO_SUMMARIZE,
**deprecated_kwargs: Any,
) -> None:
"""
按 MoviePilot 的默认压缩策略构建摘要中间件。
"""
super().__init__(
model=model,
trigger=trigger,
keep=keep,
token_counter=token_counter,
summary_prompt=summary_prompt,
trim_tokens_to_summarize=trim_tokens_to_summarize,
**deprecated_kwargs,
)
@classmethod
def _require_valid_summary(cls, summary: str) -> str:
"""
拒绝无法继续承载会话上下文的空摘要。
"""
if not summary:
raise ContextSummarizationError(cls._ERROR_MESSAGE)
return summary
def _prepare_summary_input(
self, messages_to_summarize: list[AnyMessage]
) -> str:
"""
复用 LangChain 裁剪策略生成摘要模型输入。
"""
trimmed_messages = self._trim_messages_for_summary(messages_to_summarize)
if not trimmed_messages:
raise ContextSummarizationError(self._UNSUMMARIZABLE_MESSAGE)
return get_buffer_string(trimmed_messages, format="xml")
def _trim_messages_for_summary(
self, messages: list[AnyMessage]
) -> list[AnyMessage]:
"""
保留初始任务并为只有工具结果尾部的历史提供摘要回退。
"""
trimmed_messages = super()._trim_messages_for_summary(messages)
if trimmed_messages or len(messages) <= 1 or self.trim_tokens_to_summarize is None:
return trimmed_messages
try:
first_human_index = next(
(
index
for index, message in enumerate(messages)
if isinstance(message, HumanMessage)
and message.additional_kwargs.get("lc_source") != "summarization"
),
None,
)
if first_human_index is None:
return cast(list[AnyMessage], trim_messages(
messages,
max_tokens=self.trim_tokens_to_summarize,
token_counter=self.token_counter,
strategy="last",
allow_partial=True,
include_system=True,
))
first_human = messages[first_human_index]
first_tokens = self.token_counter([first_human])
if first_tokens >= self.trim_tokens_to_summarize:
return cast(list[AnyMessage], trim_messages(
messages,
max_tokens=self.trim_tokens_to_summarize,
token_counter=self.token_counter,
strategy="last",
allow_partial=True,
include_system=True,
))
tail = trim_messages(
messages[first_human_index + 1 :],
max_tokens=self.trim_tokens_to_summarize - first_tokens,
token_counter=self.token_counter,
strategy="last",
allow_partial=True,
include_system=True,
)
return [first_human, *cast(list[AnyMessage], tail)]
except Exception:
return trimmed_messages
@staticmethod
def _partition_messages(
messages: list[AnyMessage], cutoff_index: int
) -> tuple[list[AnyMessage], list[AnyMessage]]:
"""
显式标注摘要分区的返回类型,避免外部中间件类型缺失扩散。
"""
return SummarizationMiddleware._partition_messages(messages, cutoff_index)
def _create_summary(self, messages_to_summarize: list[AnyMessage]) -> str:
"""
同步摘要失败时保持原图状态。
"""
formatted_messages = self._prepare_summary_input(messages_to_summarize)
summary_model = cast(Any, getattr(self, "_summary_model", self.model))
try:
response = summary_model.invoke(
self.summary_prompt.format(messages=formatted_messages).rstrip(),
config={
"metadata": {
"lc_source": "summarization",
**_internal_call_metadata(),
}
},
)
except Exception as err:
raise ContextSummarizationError(self._ERROR_MESSAGE) from err
return self._require_valid_summary(str(response.text).strip())
async def _acreate_summary(self, messages_to_summarize: list[AnyMessage]) -> str:
"""
异步摘要失败时保持原图状态。
"""
formatted_messages = self._prepare_summary_input(messages_to_summarize)
summary_model = cast(Any, getattr(self, "_summary_model", self.model))
try:
response = await summary_model.ainvoke(
self.summary_prompt.format(messages=formatted_messages).rstrip(),
config={
"metadata": {
"lc_source": "summarization",
**_internal_call_metadata(),
}
},
)
except Exception as err:
raise ContextSummarizationError(self._ERROR_MESSAGE) from err
return self._require_valid_summary(str(response.text).strip())
def partition_for_token_limit(
self,
messages: list[AnyMessage],
token_limit: int,
*,
force: bool = False,
minimum_cutoff: int = 1,
strict_token_limit: bool = False,
) -> tuple[list[AnyMessage], list[AnyMessage]] | None:
"""
按 token 上限拆分历史,并保持 LangChain 的工具调用事务边界。
"""
self._ensure_message_ids(messages)
if self.token_counter(messages) <= token_limit:
return (
self._minimum_safe_partition(
messages,
minimum_cutoff=minimum_cutoff,
token_limit=token_limit if strict_token_limit else None,
)
if force
else None
)
left, right = 0, len(messages)
cutoff_candidate = len(messages)
while left < right:
midpoint = (left + right) // 2
if self._partial_token_counter(messages[midpoint:]) <= token_limit:
cutoff_candidate = midpoint
right = midpoint
else:
left = midpoint + 1
if cutoff_candidate >= len(messages):
cutoff_candidate = len(messages)
cutoff_index = self._find_safe_cutoff_point(messages, cutoff_candidate)
if (
cutoff_index <= 0
or cutoff_index >= len(messages)
or cutoff_index < minimum_cutoff
or not self._contains_unsummarized_message(messages[:cutoff_index])
or (
strict_token_limit
and self._partial_token_counter(messages[cutoff_index:]) > token_limit
)
):
if cutoff_candidate >= len(messages):
if strict_token_limit:
return None
return self._latest_safe_partition(
messages,
minimum_cutoff=minimum_cutoff,
)
return self._minimum_safe_partition(
messages,
minimum_cutoff=max(minimum_cutoff, cutoff_candidate),
token_limit=token_limit if strict_token_limit else None,
)
return self._partition_messages(messages, cutoff_index)
def partition_for_retention(
self, messages: list[AnyMessage]
) -> tuple[list[AnyMessage], list[AnyMessage]] | None:
"""
按摘要器既有触发和保留策略拆分历史。
"""
self._ensure_message_ids(messages)
total_tokens = self.token_counter(messages)
if not self._should_summarize(messages, total_tokens):
return None
cutoff_index = self._determine_cutoff_index(messages)
if cutoff_index <= 0:
return None
return self._partition_messages(messages, cutoff_index)
def _minimum_safe_partition(
self,
messages: list[AnyMessage],
*,
minimum_cutoff: int = 1,
token_limit: int | None = None,
) -> tuple[list[AnyMessage], list[AnyMessage]] | None:
"""
至少摘要一段旧历史,同时保留最新完整消息事务。
"""
for candidate in range(max(1, minimum_cutoff), len(messages)):
cutoff_index = self._find_safe_cutoff_point(messages, candidate)
if (
minimum_cutoff <= cutoff_index < len(messages)
and self._contains_unsummarized_message(messages[:cutoff_index])
and (
token_limit is None
or self._partial_token_counter(messages[cutoff_index:])
<= token_limit
)
):
return self._partition_messages(messages, cutoff_index)
return None
def _latest_safe_partition(
self,
messages: list[AnyMessage],
*,
minimum_cutoff: int,
) -> tuple[list[AnyMessage], list[AnyMessage]] | None:
"""
保留无法满足软预算时的最新完整消息事务。
"""
for candidate in range(len(messages) - 1, minimum_cutoff - 1, -1):
cutoff_index = self._find_safe_cutoff_point(messages, candidate)
if (
minimum_cutoff <= cutoff_index < len(messages)
and self._contains_unsummarized_message(messages[:cutoff_index])
):
return self._partition_messages(messages, cutoff_index)
return None
@staticmethod
def _contains_unsummarized_message(messages: list[AnyMessage]) -> bool:
"""
确认待摘要段包含可推进上下文的原始消息。
"""
return any(
message.additional_kwargs.get("lc_source") != "summarization"
for message in messages
)
def build_summary_messages(self, summary: str) -> list[AnyMessage]:
"""
将摘要转换为 LangChain 约定的可识别历史消息。
"""
return [*self._build_new_messages(summary)]
def ensure_message_ids(self, messages: list[AnyMessage]) -> None:
"""
为压缩后消息补齐 LangGraph reducer 所需的稳定 ID。
"""
self._ensure_message_ids(messages)
def create_summary(self, messages_to_summarize: list[AnyMessage]) -> str:
"""
通过 MoviePilot 的失败保护合同生成同步摘要。
"""
return self._create_summary(messages_to_summarize)
async def acreate_summary(self, messages_to_summarize: list[AnyMessage]) -> str:
"""
通过 MoviePilot 的失败保护合同生成异步摘要。
"""
return await self._acreate_summary(messages_to_summarize)
class FinalRequestCompactionMiddleware(AgentMiddleware):
"""
按最终模型请求预算压缩历史,并在模型成功后原子提交新状态。
"""
_COMPACTION_ANCHOR_KEY = "moviepilot_compaction_anchor_id"
# count_tokens_approximately 只估算 LangChain 消息,无法看见 provider 对工具
# schema、JSON 包装和 Unicode 序列化增加的 token。真实 Responses 请求曾出现
# 估算 85K、provider usage 129K 的差异;保守系数让压缩在线路超窗前发生。
_ESTIMATE_SAFETY_FACTOR = 1.5
_SAFETY_FACTOR_MIN_CONTEXT = 32768
_UNCOMPRESSIBLE_REQUEST = (
"最终模型请求压缩后仍超出上下文窗口,原有上下文已保留,"
"请减少启用工具或切换更大上下文模型"
)
def __init__(
self,
*,
summarizer: ContextPreservingSummarizationMiddleware,
trigger_fraction: float = 0.85,
keep_fraction: float = 0.10,
) -> None:
"""
绑定摘要策略和预算比例,供最终模型请求执行有界上下文压缩。
"""
self.summarizer = summarizer
self.trigger_fraction = trigger_fraction
self.keep_fraction = keep_fraction
@classmethod
def _conservative_estimate(cls, budget: dict[str, Any]) -> int | None:
"""
给近似 token 估算增加序列化安全余量,缺失估算时保持未知。
"""
estimated_tokens = budget.get("estimated_input_tokens")
if type(estimated_tokens) is not int or estimated_tokens < 0:
return None
context_window = budget.get("context_window_tokens")
factor = (
cls._ESTIMATE_SAFETY_FACTOR
if type(context_window) is int and context_window >= cls._SAFETY_FACTOR_MIN_CONTEXT
else 1
)
return ceil(estimated_tokens * factor)
def _should_compact(self, budget: dict[str, Any]) -> bool:
"""
以最终请求实际模型窗口判断是否需要压缩。
"""
estimated_tokens = self._conservative_estimate(budget)
context_window = budget.get("context_window_tokens")
return (
isinstance(estimated_tokens, int)
and isinstance(context_window, int)
and estimated_tokens >= context_window * self.trigger_fraction
)
def _compaction_partition(
self, request: ModelRequest
) -> tuple[list[AnyMessage], list[AnyMessage]] | None:
"""
最终输入达到阈值时,拆分需要摘要和需要原样保留的消息。
"""
messages = list(request.messages)
try:
budget = UsageMiddleware.estimate_request(request)
except Exception as error:
logger.debug(
"最终模型请求预算评估失败,继续原请求: error_type=%s",
type(error).__name__,
)
return None
context_window = budget.get("context_window_tokens")
estimated_tokens = self._conservative_estimate(budget)
if self._should_skip_after_current_turn_compaction(messages, budget):
return None
if not self._should_compact(budget) or not isinstance(context_window, int):
return None
try:
partition = self.summarizer.partition_for_retention(messages)
if partition is None:
partition = self.summarizer.partition_for_token_limit(
messages,
max(1, int(context_window * self.keep_fraction)),
force=isinstance(estimated_tokens, int) and estimated_tokens > context_window,
)
except Exception as error:
logger.debug(
"最终请求历史拆分失败,继续原请求: error_type=%s",
type(error).__name__,
)
if isinstance(estimated_tokens, int) and estimated_tokens > context_window:
raise ContextSummarizationError(
self._UNCOMPRESSIBLE_REQUEST
) from error
return None
if partition is None:
if isinstance(estimated_tokens, int) and estimated_tokens > context_window:
raise ContextSummarizationError(self._UNCOMPRESSIBLE_REQUEST)
return None
messages_to_summarize, preserved_messages = partition
if all(
message.additional_kwargs.get("lc_source") == "summarization"
for message in messages_to_summarize
):
if isinstance(estimated_tokens, int) and estimated_tokens > context_window:
raise ContextSummarizationError(self._UNCOMPRESSIBLE_REQUEST)
return None
return messages_to_summarize, preserved_messages
@classmethod
def _should_skip_after_current_turn_compaction(
cls, messages: list[AnyMessage], budget: dict[str, Any]
) -> bool:
"""
同轮只在新工具结果已使请求超窗时再次压缩。
"""
for message in reversed(messages):
anchor_id = message.additional_kwargs.get(cls._COMPACTION_ANCHOR_KEY)
if not isinstance(anchor_id, str):
continue
anchor_index = next(
(
index
for index, candidate in enumerate(messages)
if candidate.id == anchor_id
),
None,
)
if anchor_index is None:
return False
messages_after_anchor = messages[anchor_index + 1 :]
if any(
isinstance(candidate, HumanMessage)
for candidate in messages_after_anchor
):
return False
if any(isinstance(candidate, ToolMessage) for candidate in messages_after_anchor):
estimated_tokens = cls._conservative_estimate(budget)
context_window = budget.get("context_window_tokens")
return not (
isinstance(estimated_tokens, int)
and isinstance(context_window, int)
and estimated_tokens > context_window
)
return True
return False
def _build_compacted_messages(
self, summary: str, preserved_messages: list[AnyMessage]
) -> list[AnyMessage]:
"""
构造摘要与近期历史,并记录本轮压缩输入边界。
"""
summary_messages = self.summarizer.build_summary_messages(summary)
if summary_messages:
self.summarizer.ensure_message_ids(summary_messages)
anchor_id = (
preserved_messages[-1].id
if preserved_messages
else summary_messages[0].id
)
first_summary = summary_messages[0]
summary_messages[0] = first_summary.model_copy(
update={
"additional_kwargs": {
**first_summary.additional_kwargs,
self._COMPACTION_ANCHOR_KEY: anchor_id,
}
}
)
return [*summary_messages, *preserved_messages]
def _validate_or_repartition(
self,
request: ModelRequest,
messages_to_summarize: list[AnyMessage],
preserved_messages: list[AnyMessage],
summary: str,
) -> tuple[list[AnyMessage], tuple[list[AnyMessage], list[AnyMessage]] | None]:
"""
复核压缩后的最终预算,并计算一次更小的近期历史分区。
"""
compacted_messages = self._build_compacted_messages(summary, preserved_messages)
compacted_budget = UsageMiddleware.estimate_request(
request.override(messages=compacted_messages)
)
context_window = compacted_budget.get("context_window_tokens")
estimated_tokens = self._conservative_estimate(compacted_budget)
if not isinstance(context_window, int) or not isinstance(estimated_tokens, int):
return compacted_messages, None
if estimated_tokens <= context_window:
return compacted_messages, None
summary_messages = self.summarizer.build_summary_messages(summary)
fixed_summary_budget = UsageMiddleware.estimate_request(
request.override(messages=summary_messages)
)
fixed_summary_tokens = self._conservative_estimate(fixed_summary_budget)
if fixed_summary_tokens is None:
raise ContextSummarizationError(self._UNCOMPRESSIBLE_REQUEST)
available_recent_tokens = (
context_window - fixed_summary_tokens
)
if available_recent_tokens <= 0:
raise ContextSummarizationError(self._UNCOMPRESSIBLE_REQUEST)
repartition = self.summarizer.partition_for_token_limit(
list(request.messages),
available_recent_tokens,
force=True,
minimum_cutoff=len(messages_to_summarize) + 1,
strict_token_limit=True,
)
if (
repartition is None
or len(repartition[0]) <= len(messages_to_summarize)
):
raise ContextSummarizationError(self._UNCOMPRESSIBLE_REQUEST)
return compacted_messages, repartition
def _require_within_window(
self, request: ModelRequest, compacted_messages: list[AnyMessage]
) -> None:
"""
禁止把已知仍超过主模型窗口的请求发送给 provider。
"""
budget = UsageMiddleware.estimate_request(
request.override(messages=compacted_messages)
)
estimated_tokens = self._conservative_estimate(budget)
context_window = budget.get("context_window_tokens")
if (
isinstance(estimated_tokens, int)
and isinstance(context_window, int)
and estimated_tokens > context_window
):
raise ContextSummarizationError(self._UNCOMPRESSIBLE_REQUEST)
def _prepare_messages(self, request: ModelRequest) -> list[AnyMessage] | None:
"""
同步生成摘要与需要原样保留的近期消息。
"""
partition = self._compaction_partition(request)
if partition is None:
return None
messages_to_summarize, preserved_messages = partition
summary = self.summarizer.create_summary(messages_to_summarize)
compacted_messages, repartition = self._validate_or_repartition(
request,
messages_to_summarize,
preserved_messages,
summary,
)
if repartition is not None:
messages_to_summarize, preserved_messages = repartition
summary = self.summarizer.create_summary(messages_to_summarize)
compacted_messages = self._build_compacted_messages(
summary, preserved_messages
)
self._require_within_window(request, compacted_messages)
return compacted_messages
async def _aprepare_messages(
self, request: ModelRequest
) -> list[AnyMessage] | None:
"""
异步生成摘要与需要原样保留的近期消息。
"""
partition = self._compaction_partition(request)
if partition is None:
return None
messages_to_summarize, preserved_messages = partition
summary = await self.summarizer.acreate_summary(messages_to_summarize)
compacted_messages, repartition = self._validate_or_repartition(
request,
messages_to_summarize,
preserved_messages,
summary,
)
if repartition is not None:
messages_to_summarize, preserved_messages = repartition
summary = await self.summarizer.acreate_summary(messages_to_summarize)
compacted_messages = self._build_compacted_messages(
summary, preserved_messages
)
self._require_within_window(request, compacted_messages)
return compacted_messages
@staticmethod
def _with_state_update(
response: ModelResponse, compacted_messages: list[AnyMessage]
) -> ExtendedModelResponse:
"""
主模型成功后一次性替换历史,同时保留本次模型结果。
"""
return ExtendedModelResponse(
model_response=response,
command=Command(
update={
"messages": [
RemoveMessage(id=REMOVE_ALL_MESSAGES),
*compacted_messages,
*response.result,
]
}
),
)
def wrap_model_call(
self,
request: ModelRequest,
handler: Callable[[ModelRequest], ModelResponse],
) -> ModelResponse | ExtendedModelResponse:
"""
同步压缩最终请求;模型失败时不提交摘要状态。
"""
compacted_messages = self._prepare_messages(request)
if compacted_messages is None:
return handler(request)
response = handler(request.override(messages=compacted_messages))
return self._with_state_update(response, compacted_messages)
async def awrap_model_call(
self,
request: ModelRequest,
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
) -> ModelResponse | ExtendedModelResponse:
"""
异步压缩最终请求;模型失败时不提交摘要状态。
"""
compacted_messages = await self._aprepare_messages(request)
if compacted_messages is None:
return await handler(request)
response = await handler(request.override(messages=compacted_messages))
return self._with_state_update(response, compacted_messages)