| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294 |
- """上下文计量与压缩检测。
- ## 为什么换掉 1.5 的做法
- 1.5 用 tiktoken 逐条消息**本地估算** token,再拿估算值去做限额与压缩判断。
- 估算与实际用量必然有偏差,偏差还会随消息结构(工具结果、结构化 content 块)
- 变化——1.5 为此专门开了 ``fix/context-usage-accounting`` 分支,并留了
- ``CTX_USAGE_V2`` 开关切换口径。根子在于:**用量是 provider 算的,本地再怎么
- 估都只是近似,不该让估算值参与决策。**
- ## 2.0 的做法
- **真实 usage 优先**:从 ``AIMessage.usage_metadata``(langchain 统一后的
- 字段)累加 provider 返回的真实 token 数;只有 provider 完全不返回 usage 时
- 才回落到本地估算,并且在用量流水上标注 ``usage_source=estimate``——口径
- 不混,事后能分清哪些数字是可信的。
- 两个不同的量,不要混淆:
- - ``input_tokens`` 累加值 = 本轮请求**计费**口径(多次模型调用之和)
- - ``context_tokens`` = 最近一次调用的 input_tokens = **当前上下文占用**,
- 压缩判断用它
- ## 压缩检测
- 不依赖框架钩子,而是看两个客观信号:
- 1. 流里出现 ``lc_source == "summarization"`` 的消息(框架的摘要标注)
- 2. 上下文占用**断崖式下跌**(降到上一次的 60% 以下)——压缩把历史换成了摘要
- 同时把这个 agent 见过的消息文本留在缓冲区里,压缩发生时由上层把缓冲区
- 落盘存档,这样「被压缩掉的原文」还能追溯。1.5 的压缩默认关着、也没有
- 原文存档,出了问题无法复盘。
- """
- from __future__ import annotations
- from dataclasses import dataclass, field
- from typing import Any, Iterable
- from obs import get_logger
- logger = get_logger(__name__)
- # 占用跌破上次的 60% 视为发生压缩
- _COMPACTION_DROP_RATIO = 0.6
- try: # tiktoken 是可选依赖:装了就用来估算,没装走字符启发式
- import tiktoken
- _ENCODER = tiktoken.get_encoding("cl100k_base")
- except Exception: # pragma: no cover - 取决于环境
- _ENCODER = None
- def estimate_tokens(text: str) -> int:
- """估算一段文本的 token 数。仅在 provider 不返回 usage 时作为兜底。"""
- if not text:
- return 0
- if _ENCODER is not None:
- try:
- return len(_ENCODER.encode(text, disallowed_special=()))
- except Exception: # pragma: no cover
- pass
- # 无 tiktoken:中日韩字符按 1.5 token/字、其余按 4 字符/token 粗估。
- # 宁可高估——低估会导致该压缩时不压缩,直接撑爆模型窗口。
- cjk = sum(1 for ch in text if "\u4e00" <= ch <= "\u9fff" or "\u3000" <= ch <= "\u303f")
- other = len(text) - cjk
- return int(cjk * 1.5 + other / 4) + 1
- def extract_usage(message: Any) -> dict[str, int] | None:
- """从 AIMessage 里取 provider 返回的真实 usage。
- 依次尝试 langchain 统一的 ``usage_metadata`` 与各 provider 的
- ``response_metadata`` 原始字段。取不到返回 None(**不返回 0**——
- 「用量为 0」与「没有用量信息」必须能区分,否则估算兜底会失效)。
- """
- meta = getattr(message, "usage_metadata", None)
- if isinstance(meta, dict) and meta:
- input_tokens = meta.get("input_tokens")
- output_tokens = meta.get("output_tokens")
- if input_tokens is not None or output_tokens is not None:
- details = meta.get("input_token_details") or {}
- cache_read = 0
- if isinstance(details, dict):
- cache_read = int(details.get("cache_read") or details.get("cached_tokens") or 0)
- return {
- "input_tokens": int(input_tokens or 0),
- "output_tokens": int(output_tokens or 0),
- "cache_read_tokens": cache_read,
- }
- response_meta = getattr(message, "response_metadata", None)
- if isinstance(response_meta, dict):
- for key in ("token_usage", "usage"):
- usage = response_meta.get(key)
- if not isinstance(usage, dict):
- continue
- input_tokens = usage.get("prompt_tokens", usage.get("input_tokens"))
- output_tokens = usage.get("completion_tokens", usage.get("output_tokens"))
- if input_tokens is None and output_tokens is None:
- continue
- details = usage.get("prompt_tokens_details") or {}
- cache_read = 0
- if isinstance(details, dict):
- cache_read = int(details.get("cached_tokens") or 0)
- return {
- "input_tokens": int(input_tokens or 0),
- "output_tokens": int(output_tokens or 0),
- "cache_read_tokens": cache_read,
- }
- return None
- def is_summarization_message(message: Any) -> bool:
- """判断一条消息是不是摘要中间件产出的。"""
- for attr in ("lc_source", "name"):
- if getattr(message, attr, None) == "summarization":
- return True
- for attr in ("response_metadata", "additional_kwargs"):
- meta = getattr(message, attr, None)
- if isinstance(meta, dict) and meta.get("lc_source") == "summarization":
- return True
- return False
- @dataclass
- class ContextSnapshot:
- """一轮请求结束时的上下文状态,用于落 session_context_usage。"""
- input_tokens: int
- output_tokens: int
- cache_read_tokens: int
- context_tokens: int
- usage_source: str
- model_calls: int
- compacted: bool
- compact_count: int
- archive_text: str | None = None
- archive_tokens: int = 0
- @dataclass
- class ContextMeter:
- """一次请求的上下文计量器。
- 刻意做成纯逻辑、不做 I/O:它在流式循环里被高频调用,落盘与落库交给
- sse_core 的完成段统一处理,避免在事件循环里做同步文件写。
- """
- window: int = 0
- input_tokens: int = 0
- output_tokens: int = 0
- cache_read_tokens: int = 0
- context_tokens: int = 0
- model_calls: int = 0
- compacted: bool = False
- compact_count: int = 0
- _saw_provider_usage: bool = False
- _estimated_only: bool = False
- _last_context: int = 0
- # 压缩前原文的缓冲区:累积本 agent 见过的文本,压缩发生时交给上层落盘
- _transcript: list[str] = field(default_factory=list)
- _archive_pending: bool = False
- # -------- 写入 --------
- def note_text(self, text: str) -> None:
- """把一段文本纳入缓冲区(用户消息、助手正文、工具结果都算)。
- 缓冲区只在内存里增长,一轮请求结束后随实例释放——它唯一的用途是
- 「万一发生压缩,把被丢掉的内容存一份」。
- """
- if text:
- self._transcript.append(text)
- def note_message(self, message: Any) -> None:
- """观测一条消息:抽取真实 usage、累积文本、更新上下文占用。"""
- usage = extract_usage(message)
- if usage is not None:
- self._saw_provider_usage = True
- self.input_tokens += usage["input_tokens"]
- self.output_tokens += usage["output_tokens"]
- self.cache_read_tokens += usage["cache_read_tokens"]
- self.model_calls += 1
- # 最近一次调用的 input 就是「下一轮要带多少上下文进去」的近似
- self._update_context(usage["input_tokens"])
- if is_summarization_message(message):
- self._mark_compacted(reason="lc_source 标记")
- def note_estimated(self, input_tokens: int, output_tokens: int = 0) -> None:
- """在没有真实 usage 时登记估算值。
- 一旦走过这条路,本轮用量的 ``usage_source`` 就标为 estimate——
- 整条流水口径统一降级,不出现「一半真实一半估算」的混合数字。
- """
- if self._saw_provider_usage:
- # 已经有真实用量了,估算值只会污染口径,丢弃
- return
- self._estimated_only = True
- self.input_tokens += max(int(input_tokens), 0)
- self.output_tokens += max(int(output_tokens), 0)
- self._update_context(int(input_tokens))
- def _update_context(self, current_input: int) -> None:
- """更新上下文占用,并顺带检测「断崖式下跌」。"""
- if self._last_context > 0 and current_input > 0:
- if current_input < self._last_context * _COMPACTION_DROP_RATIO:
- self._mark_compacted(
- reason=f"占用从 {self._last_context} 跌到 {current_input}"
- )
- self._last_context = current_input
- self.context_tokens = current_input
- def _mark_compacted(self, reason: str) -> None:
- if self.compacted:
- # 一节请求只记一次压缩,避免同一事件被两个信号重复计数
- return
- self.compacted = True
- self.compact_count += 1
- self._archive_pending = True
- logger.info("检测到上下文压缩(%s),当前占用=%d", reason, self.context_tokens)
- # -------- 读取 --------
- @property
- def usage_source(self) -> str:
- """provider | estimate——整轮的口径标注。"""
- return "estimate" if (self._estimated_only and not self._saw_provider_usage) else "provider"
- @property
- def total_tokens(self) -> int:
- return self.input_tokens + self.output_tokens
- def fill_ratio(self) -> float:
- """当前上下文占窗口的比例,用于判断距离压缩还有多远。"""
- if self.window <= 0:
- return 0.0
- return min(self.context_tokens / self.window, 1.0)
- def estimate_from_buffer(self) -> int:
- """用缓冲区里的文本估算总用量。
- 仅在 provider 完全不返回 usage 时作为最后兜底。注意它估的是
- 「对话文本量」,不含 system prompt、工具 schema、few-shot 等
- 固定开销,所以**必然低估**——据此把 usage_source 标成 estimate,
- 让下游知道这个数字不可尽信。
- """
- return sum(estimate_tokens(text) for text in self._transcript)
- def snapshot(self) -> ContextSnapshot:
- """产出可落库的快照。"""
- archive_text = None
- archive_tokens = 0
- if self._archive_pending and self._transcript:
- archive_text = "\n\n---\n\n".join(self._transcript)
- archive_tokens = estimate_tokens(archive_text)
- self._archive_pending = False
- return ContextSnapshot(
- input_tokens=self.input_tokens,
- output_tokens=self.output_tokens,
- cache_read_tokens=self.cache_read_tokens,
- context_tokens=self.context_tokens,
- usage_source=self.usage_source,
- model_calls=self.model_calls,
- compacted=self.compacted,
- compact_count=self.compact_count,
- archive_text=archive_text,
- archive_tokens=archive_tokens,
- )
- def summary_fields(self) -> dict[str, Any]:
- """给 SSE 事件用的精简字段。"""
- return {
- "context_tokens": self.context_tokens,
- "context_window": self.window,
- "fill_ratio": round(self.fill_ratio(), 4),
- "compacted": self.compacted,
- "usage_source": self.usage_source,
- }
- def count_messages_tokens(messages: Iterable[Any]) -> int:
- """对一组消息做整体估算(压缩存档时标注「压缩前占用」用)。"""
- total = 0
- for message in messages:
- content = getattr(message, "content", None)
- if isinstance(content, str):
- total += estimate_tokens(content)
- elif isinstance(content, list):
- for block in content:
- if isinstance(block, dict) and isinstance(block.get("text"), str):
- total += estimate_tokens(block["text"])
- return total
|