context_meter.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294
  1. """上下文计量与压缩检测。
  2. ## 为什么换掉 1.5 的做法
  3. 1.5 用 tiktoken 逐条消息**本地估算** token,再拿估算值去做限额与压缩判断。
  4. 估算与实际用量必然有偏差,偏差还会随消息结构(工具结果、结构化 content 块)
  5. 变化——1.5 为此专门开了 ``fix/context-usage-accounting`` 分支,并留了
  6. ``CTX_USAGE_V2`` 开关切换口径。根子在于:**用量是 provider 算的,本地再怎么
  7. 估都只是近似,不该让估算值参与决策。**
  8. ## 2.0 的做法
  9. **真实 usage 优先**:从 ``AIMessage.usage_metadata``(langchain 统一后的
  10. 字段)累加 provider 返回的真实 token 数;只有 provider 完全不返回 usage 时
  11. 才回落到本地估算,并且在用量流水上标注 ``usage_source=estimate``——口径
  12. 不混,事后能分清哪些数字是可信的。
  13. 两个不同的量,不要混淆:
  14. - ``input_tokens`` 累加值 = 本轮请求**计费**口径(多次模型调用之和)
  15. - ``context_tokens`` = 最近一次调用的 input_tokens = **当前上下文占用**,
  16. 压缩判断用它
  17. ## 压缩检测
  18. 不依赖框架钩子,而是看两个客观信号:
  19. 1. 流里出现 ``lc_source == "summarization"`` 的消息(框架的摘要标注)
  20. 2. 上下文占用**断崖式下跌**(降到上一次的 60% 以下)——压缩把历史换成了摘要
  21. 同时把这个 agent 见过的消息文本留在缓冲区里,压缩发生时由上层把缓冲区
  22. 落盘存档,这样「被压缩掉的原文」还能追溯。1.5 的压缩默认关着、也没有
  23. 原文存档,出了问题无法复盘。
  24. """
  25. from __future__ import annotations
  26. from dataclasses import dataclass, field
  27. from typing import Any, Iterable
  28. from obs import get_logger
  29. logger = get_logger(__name__)
  30. # 占用跌破上次的 60% 视为发生压缩
  31. _COMPACTION_DROP_RATIO = 0.6
  32. try: # tiktoken 是可选依赖:装了就用来估算,没装走字符启发式
  33. import tiktoken
  34. _ENCODER = tiktoken.get_encoding("cl100k_base")
  35. except Exception: # pragma: no cover - 取决于环境
  36. _ENCODER = None
  37. def estimate_tokens(text: str) -> int:
  38. """估算一段文本的 token 数。仅在 provider 不返回 usage 时作为兜底。"""
  39. if not text:
  40. return 0
  41. if _ENCODER is not None:
  42. try:
  43. return len(_ENCODER.encode(text, disallowed_special=()))
  44. except Exception: # pragma: no cover
  45. pass
  46. # 无 tiktoken:中日韩字符按 1.5 token/字、其余按 4 字符/token 粗估。
  47. # 宁可高估——低估会导致该压缩时不压缩,直接撑爆模型窗口。
  48. cjk = sum(1 for ch in text if "\u4e00" <= ch <= "\u9fff" or "\u3000" <= ch <= "\u303f")
  49. other = len(text) - cjk
  50. return int(cjk * 1.5 + other / 4) + 1
  51. def extract_usage(message: Any) -> dict[str, int] | None:
  52. """从 AIMessage 里取 provider 返回的真实 usage。
  53. 依次尝试 langchain 统一的 ``usage_metadata`` 与各 provider 的
  54. ``response_metadata`` 原始字段。取不到返回 None(**不返回 0**——
  55. 「用量为 0」与「没有用量信息」必须能区分,否则估算兜底会失效)。
  56. """
  57. meta = getattr(message, "usage_metadata", None)
  58. if isinstance(meta, dict) and meta:
  59. input_tokens = meta.get("input_tokens")
  60. output_tokens = meta.get("output_tokens")
  61. if input_tokens is not None or output_tokens is not None:
  62. details = meta.get("input_token_details") or {}
  63. cache_read = 0
  64. if isinstance(details, dict):
  65. cache_read = int(details.get("cache_read") or details.get("cached_tokens") or 0)
  66. return {
  67. "input_tokens": int(input_tokens or 0),
  68. "output_tokens": int(output_tokens or 0),
  69. "cache_read_tokens": cache_read,
  70. }
  71. response_meta = getattr(message, "response_metadata", None)
  72. if isinstance(response_meta, dict):
  73. for key in ("token_usage", "usage"):
  74. usage = response_meta.get(key)
  75. if not isinstance(usage, dict):
  76. continue
  77. input_tokens = usage.get("prompt_tokens", usage.get("input_tokens"))
  78. output_tokens = usage.get("completion_tokens", usage.get("output_tokens"))
  79. if input_tokens is None and output_tokens is None:
  80. continue
  81. details = usage.get("prompt_tokens_details") or {}
  82. cache_read = 0
  83. if isinstance(details, dict):
  84. cache_read = int(details.get("cached_tokens") or 0)
  85. return {
  86. "input_tokens": int(input_tokens or 0),
  87. "output_tokens": int(output_tokens or 0),
  88. "cache_read_tokens": cache_read,
  89. }
  90. return None
  91. def is_summarization_message(message: Any) -> bool:
  92. """判断一条消息是不是摘要中间件产出的。"""
  93. for attr in ("lc_source", "name"):
  94. if getattr(message, attr, None) == "summarization":
  95. return True
  96. for attr in ("response_metadata", "additional_kwargs"):
  97. meta = getattr(message, attr, None)
  98. if isinstance(meta, dict) and meta.get("lc_source") == "summarization":
  99. return True
  100. return False
  101. @dataclass
  102. class ContextSnapshot:
  103. """一轮请求结束时的上下文状态,用于落 session_context_usage。"""
  104. input_tokens: int
  105. output_tokens: int
  106. cache_read_tokens: int
  107. context_tokens: int
  108. usage_source: str
  109. model_calls: int
  110. compacted: bool
  111. compact_count: int
  112. archive_text: str | None = None
  113. archive_tokens: int = 0
  114. @dataclass
  115. class ContextMeter:
  116. """一次请求的上下文计量器。
  117. 刻意做成纯逻辑、不做 I/O:它在流式循环里被高频调用,落盘与落库交给
  118. sse_core 的完成段统一处理,避免在事件循环里做同步文件写。
  119. """
  120. window: int = 0
  121. input_tokens: int = 0
  122. output_tokens: int = 0
  123. cache_read_tokens: int = 0
  124. context_tokens: int = 0
  125. model_calls: int = 0
  126. compacted: bool = False
  127. compact_count: int = 0
  128. _saw_provider_usage: bool = False
  129. _estimated_only: bool = False
  130. _last_context: int = 0
  131. # 压缩前原文的缓冲区:累积本 agent 见过的文本,压缩发生时交给上层落盘
  132. _transcript: list[str] = field(default_factory=list)
  133. _archive_pending: bool = False
  134. # -------- 写入 --------
  135. def note_text(self, text: str) -> None:
  136. """把一段文本纳入缓冲区(用户消息、助手正文、工具结果都算)。
  137. 缓冲区只在内存里增长,一轮请求结束后随实例释放——它唯一的用途是
  138. 「万一发生压缩,把被丢掉的内容存一份」。
  139. """
  140. if text:
  141. self._transcript.append(text)
  142. def note_message(self, message: Any) -> None:
  143. """观测一条消息:抽取真实 usage、累积文本、更新上下文占用。"""
  144. usage = extract_usage(message)
  145. if usage is not None:
  146. self._saw_provider_usage = True
  147. self.input_tokens += usage["input_tokens"]
  148. self.output_tokens += usage["output_tokens"]
  149. self.cache_read_tokens += usage["cache_read_tokens"]
  150. self.model_calls += 1
  151. # 最近一次调用的 input 就是「下一轮要带多少上下文进去」的近似
  152. self._update_context(usage["input_tokens"])
  153. if is_summarization_message(message):
  154. self._mark_compacted(reason="lc_source 标记")
  155. def note_estimated(self, input_tokens: int, output_tokens: int = 0) -> None:
  156. """在没有真实 usage 时登记估算值。
  157. 一旦走过这条路,本轮用量的 ``usage_source`` 就标为 estimate——
  158. 整条流水口径统一降级,不出现「一半真实一半估算」的混合数字。
  159. """
  160. if self._saw_provider_usage:
  161. # 已经有真实用量了,估算值只会污染口径,丢弃
  162. return
  163. self._estimated_only = True
  164. self.input_tokens += max(int(input_tokens), 0)
  165. self.output_tokens += max(int(output_tokens), 0)
  166. self._update_context(int(input_tokens))
  167. def _update_context(self, current_input: int) -> None:
  168. """更新上下文占用,并顺带检测「断崖式下跌」。"""
  169. if self._last_context > 0 and current_input > 0:
  170. if current_input < self._last_context * _COMPACTION_DROP_RATIO:
  171. self._mark_compacted(
  172. reason=f"占用从 {self._last_context} 跌到 {current_input}"
  173. )
  174. self._last_context = current_input
  175. self.context_tokens = current_input
  176. def _mark_compacted(self, reason: str) -> None:
  177. if self.compacted:
  178. # 一节请求只记一次压缩,避免同一事件被两个信号重复计数
  179. return
  180. self.compacted = True
  181. self.compact_count += 1
  182. self._archive_pending = True
  183. logger.info("检测到上下文压缩(%s),当前占用=%d", reason, self.context_tokens)
  184. # -------- 读取 --------
  185. @property
  186. def usage_source(self) -> str:
  187. """provider | estimate——整轮的口径标注。"""
  188. return "estimate" if (self._estimated_only and not self._saw_provider_usage) else "provider"
  189. @property
  190. def total_tokens(self) -> int:
  191. return self.input_tokens + self.output_tokens
  192. def fill_ratio(self) -> float:
  193. """当前上下文占窗口的比例,用于判断距离压缩还有多远。"""
  194. if self.window <= 0:
  195. return 0.0
  196. return min(self.context_tokens / self.window, 1.0)
  197. def estimate_from_buffer(self) -> int:
  198. """用缓冲区里的文本估算总用量。
  199. 仅在 provider 完全不返回 usage 时作为最后兜底。注意它估的是
  200. 「对话文本量」,不含 system prompt、工具 schema、few-shot 等
  201. 固定开销,所以**必然低估**——据此把 usage_source 标成 estimate,
  202. 让下游知道这个数字不可尽信。
  203. """
  204. return sum(estimate_tokens(text) for text in self._transcript)
  205. def snapshot(self) -> ContextSnapshot:
  206. """产出可落库的快照。"""
  207. archive_text = None
  208. archive_tokens = 0
  209. if self._archive_pending and self._transcript:
  210. archive_text = "\n\n---\n\n".join(self._transcript)
  211. archive_tokens = estimate_tokens(archive_text)
  212. self._archive_pending = False
  213. return ContextSnapshot(
  214. input_tokens=self.input_tokens,
  215. output_tokens=self.output_tokens,
  216. cache_read_tokens=self.cache_read_tokens,
  217. context_tokens=self.context_tokens,
  218. usage_source=self.usage_source,
  219. model_calls=self.model_calls,
  220. compacted=self.compacted,
  221. compact_count=self.compact_count,
  222. archive_text=archive_text,
  223. archive_tokens=archive_tokens,
  224. )
  225. def summary_fields(self) -> dict[str, Any]:
  226. """给 SSE 事件用的精简字段。"""
  227. return {
  228. "context_tokens": self.context_tokens,
  229. "context_window": self.window,
  230. "fill_ratio": round(self.fill_ratio(), 4),
  231. "compacted": self.compacted,
  232. "usage_source": self.usage_source,
  233. }
  234. def count_messages_tokens(messages: Iterable[Any]) -> int:
  235. """对一组消息做整体估算(压缩存档时标注「压缩前占用」用)。"""
  236. total = 0
  237. for message in messages:
  238. content = getattr(message, "content", None)
  239. if isinstance(content, str):
  240. total += estimate_tokens(content)
  241. elif isinstance(content, list):
  242. for block in content:
  243. if isinstance(block, dict) and isinstance(block.get("text"), str):
  244. total += estimate_tokens(block["text"])
  245. return total