runner_images.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636
  1. """Image optimization and prompt-cache support for AgentRunner."""
  2. from __future__ import annotations
  3. from typing import Any, Dict, List, Optional
  4. class RunnerImageRuntime:
  5. """Own image processing while preserving AgentRunner compatibility hooks."""
  6. def __init__(self, owner: Any) -> None:
  7. self._owner = owner
  8. @property
  9. def _image_opt_cache(self) -> Dict[str, Dict[str, Any]]:
  10. return self._owner._image_opt_cache
  11. @property
  12. def log(self):
  13. return self._owner.log
  14. @property
  15. def llm_call(self):
  16. return self._owner.llm_call
  17. async def _optimize_images(self, messages: List[Dict], model: str) -> List[Dict]:
  18. """
  19. 分级优化已处理的图片,节省 token
  20. 策略(基于图片距离最后一条 assistant 的"轮次"):
  21. 1. 最近 1-2 轮:保留原图
  22. 2. 3-5 轮:降低分辨率和压缩(节省 token 但保留视觉信息)
  23. 3. 5 轮以上:调用小模型生成文本描述 + 保留 URL
  24. 处理结果会缓存,避免重复的 PIL 解码/编码和 LLM 调用。
  25. Args:
  26. messages: 原始消息列表
  27. model: 当前使用的模型(用于选择描述生成模型)
  28. Returns:
  29. 优化后的消息列表(深拷贝)
  30. """
  31. if not messages:
  32. return messages
  33. # 找到最后一条 assistant message 的位置
  34. last_assistant_idx = -1
  35. for i in range(len(messages) - 1, -1, -1):
  36. if messages[i].get("role") == "assistant":
  37. last_assistant_idx = i
  38. break
  39. # 如果没有 assistant message,说明还没开始对话,不优化
  40. if last_assistant_idx == -1:
  41. return messages
  42. # 统计从每个位置到最后一条 assistant 之间的 assistant 数量(作为"轮次")
  43. assistant_count_after = [0] * len(messages)
  44. count = 0
  45. for i in range(len(messages) - 1, -1, -1):
  46. assistant_count_after[i] = count
  47. if messages[i].get("role") == "assistant":
  48. count += 1
  49. # 深拷贝避免修改原始数据
  50. import copy
  51. import hashlib
  52. import asyncio
  53. import base64 as b64mod
  54. import httpx
  55. import mimetypes
  56. messages = copy.deepcopy(messages)
  57. # 预处理:将所有 HTTP(S) URL 图片下载并转为 base64 data URL
  58. # Qwen API 无法访问外部签名 URL(如 BFL、火山引擎 TOS),必须在本地转换
  59. url_download_jobs = [] # [(msg_idx, block_idx, url)]
  60. for i, msg in enumerate(messages):
  61. if msg.get("role") != "tool":
  62. continue
  63. content = msg.get("content")
  64. if not isinstance(content, list):
  65. continue
  66. for block_idx, block in enumerate(content):
  67. if isinstance(block, dict) and block.get("type") == "image_url":
  68. url = block.get("image_url", {}).get("url", "")
  69. if url.startswith(("http://", "https://")):
  70. url_download_jobs.append((i, block_idx, url))
  71. if url_download_jobs:
  72. async def _download_image_to_data_url(url: str) -> str | None:
  73. try:
  74. async with httpx.AsyncClient(timeout=60, trust_env=False) as client:
  75. resp = await client.get(url)
  76. resp.raise_for_status()
  77. ct = resp.headers.get("content-type", "").split(";")[0].strip()
  78. if not ct.startswith("image/"):
  79. ct = (
  80. mimetypes.guess_type(url.split("?")[0])[0]
  81. or "image/png"
  82. )
  83. b64 = b64mod.b64encode(resp.content).decode()
  84. return f"data:{ct};base64,{b64}"
  85. except Exception:
  86. self.log.debug(
  87. "Remote image normalization failed: %s", url, exc_info=True
  88. )
  89. return None
  90. results = await asyncio.gather(
  91. *[_download_image_to_data_url(url) for _, _, url in url_download_jobs],
  92. return_exceptions=True,
  93. )
  94. converted = 0
  95. for (msg_idx, block_idx, original_url), result in zip(
  96. url_download_jobs, results
  97. ):
  98. if isinstance(result, str) and result.startswith("data:"):
  99. messages[msg_idx]["content"][block_idx]["image_url"]["url"] = result
  100. converted += 1
  101. if converted:
  102. self.log.info(
  103. f"[Image Optimization] URL→base64 预转换: {converted}/{len(url_download_jobs)} 张"
  104. )
  105. # 统计优化情况
  106. stats = {"kept": 0, "downscaled": 0, "described": 0, "cache_hit": 0}
  107. # 收集需要降分辨率或尺寸补齐的图片(用于并发处理)
  108. process_jobs = [] # [(msg_idx, block_idx, image_url, cache_key, max_size, cache_field)]
  109. # 第一遍:扫描并收集需要处理的图片
  110. for i in range(last_assistant_idx):
  111. msg = messages[i]
  112. if msg.get("role") != "tool":
  113. continue
  114. content = msg.get("content")
  115. if not isinstance(content, list):
  116. continue
  117. rounds_ago = assistant_count_after[i]
  118. for block_idx, block in enumerate(content):
  119. if isinstance(block, dict) and block.get("type") == "image_url":
  120. image_url_obj = block.get("image_url", {})
  121. image_url = image_url_obj.get("url", "")
  122. if image_url.startswith("data:"):
  123. cache_key = hashlib.md5(image_url[:200].encode()).hexdigest()
  124. else:
  125. cache_key = hashlib.md5(image_url.encode()).hexdigest()
  126. # 1-5 轮都需要检查尺寸
  127. if rounds_ago <= 5:
  128. cached = self._image_opt_cache.get(cache_key, {})
  129. cache_field = "pad_only" if rounds_ago <= 2 else "downscaled"
  130. if cache_field not in cached and image_url.startswith("data:"):
  131. max_size = None if rounds_ago <= 2 else 512
  132. process_jobs.append(
  133. (
  134. i,
  135. block_idx,
  136. image_url,
  137. cache_key,
  138. max_size,
  139. cache_field,
  140. )
  141. )
  142. # 并发处理所有尺寸任务
  143. if process_jobs:
  144. process_results = await asyncio.gather(
  145. *[
  146. self._owner._process_image_size(url, max_size=ms)
  147. for _, _, url, _, ms, _ in process_jobs
  148. ],
  149. return_exceptions=True,
  150. )
  151. for (_, _, _, cache_key, _, cache_field), result in zip(
  152. process_jobs, process_results
  153. ):
  154. if not isinstance(result, Exception) and result is not None:
  155. self._image_opt_cache.setdefault(cache_key, {})[cache_field] = (
  156. result
  157. )
  158. # 第二遍:应用处理结果
  159. for i in range(last_assistant_idx):
  160. msg = messages[i]
  161. if msg.get("role") != "tool":
  162. continue
  163. content = msg.get("content")
  164. if not isinstance(content, list):
  165. continue
  166. # 计算这条消息距离最后一条 assistant 的"轮次"
  167. rounds_ago = assistant_count_after[i]
  168. # 处理每个 content block
  169. new_content = []
  170. for block in content:
  171. if isinstance(block, dict) and block.get("type") == "image_url":
  172. image_url_obj = block.get("image_url", {})
  173. image_url = image_url_obj.get("url", "")
  174. # 生成缓存 key(URL 图片用 URL 本身,base64 用前 64 字符 hash)
  175. if image_url.startswith("data:"):
  176. cache_key = hashlib.md5(image_url[:200].encode()).hexdigest()
  177. else:
  178. cache_key = hashlib.md5(image_url.encode()).hexdigest()
  179. # 根据距离决定处理策略
  180. if rounds_ago <= 2:
  181. # 最近 1-2 轮:只补齐过小图片,保留原分辨率
  182. cached = self._image_opt_cache.get(cache_key, {})
  183. if "pad_only" in cached:
  184. new_content.append(
  185. {
  186. "type": "image_url",
  187. "image_url": {"url": cached["pad_only"]},
  188. }
  189. )
  190. stats["kept"] += 1
  191. stats["cache_hit"] += 1
  192. elif image_url.startswith("data:"):
  193. processed = await self._owner._process_image_size(
  194. image_url, max_size=None
  195. )
  196. if processed:
  197. self._image_opt_cache.setdefault(cache_key, {})[
  198. "pad_only"
  199. ] = processed
  200. new_content.append(
  201. {
  202. "type": "image_url",
  203. "image_url": {"url": processed},
  204. }
  205. )
  206. else:
  207. new_content.append(block)
  208. stats["kept"] += 1
  209. else:
  210. new_content.append(block)
  211. stats["kept"] += 1
  212. elif rounds_ago <= 5:
  213. # 3-5 轮:降低分辨率(优先从缓存取)
  214. cached = self._image_opt_cache.get(cache_key, {})
  215. if "downscaled" in cached:
  216. new_content.append(
  217. {
  218. "type": "image_url",
  219. "image_url": {"url": cached["downscaled"]},
  220. }
  221. )
  222. stats["downscaled"] += 1
  223. stats["cache_hit"] += 1
  224. elif image_url.startswith("data:"):
  225. processed = await self._owner._process_image_size(
  226. image_url, max_size=512
  227. )
  228. if processed:
  229. # 缓存结果
  230. self._image_opt_cache.setdefault(cache_key, {})[
  231. "downscaled"
  232. ] = processed
  233. new_content.append(
  234. {
  235. "type": "image_url",
  236. "image_url": {"url": processed},
  237. }
  238. )
  239. stats["downscaled"] += 1
  240. else:
  241. new_content.append(block)
  242. stats["kept"] += 1
  243. else:
  244. # URL 图片:无法直接处理,保留原图
  245. new_content.append(block)
  246. stats["kept"] += 1
  247. else:
  248. # 5 轮以上:生成文本描述(优先从缓存取)
  249. cached = self._image_opt_cache.get(cache_key, {})
  250. if "description" in cached:
  251. new_content.append(cached["description"])
  252. stats["described"] += 1
  253. stats["cache_hit"] += 1
  254. else:
  255. description = await self._owner._generate_image_description(
  256. image_url, model
  257. )
  258. url_info = (
  259. f" (URL: {image_url[:100]}...)"
  260. if not image_url.startswith("data:")
  261. else ""
  262. )
  263. desc_block = {
  264. "type": "text",
  265. "text": f"[Image description: {description}]{url_info}",
  266. }
  267. # 缓存结果
  268. self._image_opt_cache.setdefault(cache_key, {})[
  269. "description"
  270. ] = desc_block
  271. new_content.append(desc_block)
  272. stats["described"] += 1
  273. else:
  274. new_content.append(block)
  275. msg["content"] = new_content
  276. if stats["downscaled"] > 0 or stats["described"] > 0:
  277. self.log.info(
  278. f"[Image Optimization] 保留 {stats['kept']} 张,"
  279. f"降分辨率 {stats['downscaled']} 张,"
  280. f"文本描述 {stats['described']} 张,"
  281. f"缓存命中 {stats['cache_hit']} 次"
  282. )
  283. return messages
  284. async def _process_image_size(
  285. self, base64_url: str, max_size: Optional[int] = 512, min_size: int = 11
  286. ) -> Optional[str]:
  287. """
  288. 处理 base64 图片的尺寸:
  289. - 若 max_size 不为 None 且大于该值,则等比例缩放
  290. - 若任意一边小于 min_size,则补充白边 (Padding)
  291. """
  292. try:
  293. from PIL import Image
  294. import io
  295. import base64
  296. # 解析 base64 数据
  297. if not base64_url.startswith("data:"):
  298. return None
  299. _header, data = base64_url.split(",", 1)
  300. # 解码图片
  301. img_data = base64.b64decode(data)
  302. img = Image.open(io.BytesIO(img_data))
  303. width, height = img.size
  304. needs_downscale = max_size is not None and (
  305. width > max_size or height > max_size
  306. )
  307. needs_pad = width < min_size or height < min_size
  308. # 尺寸正常,无需处理
  309. if not needs_downscale and not needs_pad:
  310. return base64_url
  311. new_width, new_height = width, height
  312. # 1. 降分辨率
  313. if needs_downscale:
  314. if width > height:
  315. new_width = max_size
  316. new_height = int(height * max_size / width)
  317. else:
  318. new_height = max_size
  319. new_width = int(width * max_size / height)
  320. if (new_width, new_height) != (width, height):
  321. img_resized = img.resize(
  322. (new_width, new_height), Image.Resampling.BILINEAR
  323. )
  324. else:
  325. img_resized = img
  326. # 2. 补齐白边 (Padding)
  327. pad_width = max(new_width, min_size)
  328. pad_height = max(new_height, min_size)
  329. if pad_width > new_width or pad_height > new_height:
  330. # 创建白色背景
  331. padded_img = Image.new(
  332. "RGBA" if img_resized.mode in ("RGBA", "P") else "RGB",
  333. (pad_width, pad_height),
  334. (255, 255, 255, 255),
  335. )
  336. offset_x = (pad_width - new_width) // 2
  337. offset_y = (pad_height - new_height) // 2
  338. padded_img.paste(img_resized, (offset_x, offset_y))
  339. img_resized = padded_img
  340. # 转换为 RGB(JPEG不支持 RGBA, P 等具有透明度或索引的模式)
  341. if img_resized.mode != "RGB":
  342. if img_resized.mode == "RGBA" or img_resized.mode == "P":
  343. # Create a white background for transparent images
  344. background = Image.new("RGB", img_resized.size, (255, 255, 255))
  345. if img_resized.mode == "P" and "transparency" in img_resized.info:
  346. img_resized = img_resized.convert("RGBA")
  347. if img_resized.mode == "RGBA":
  348. background.paste(img_resized, mask=img_resized.split()[3])
  349. img_resized = background
  350. img_resized = img_resized.convert("RGB")
  351. # 重新编码为 JPEG(如果只是补齐没有缩放,可以稍微保留高点质量)
  352. buffer = io.BytesIO()
  353. quality = 60 if needs_downscale else 85
  354. img_resized.save(buffer, format="JPEG", quality=quality, optimize=False)
  355. new_data = base64.b64encode(buffer.getvalue()).decode("utf-8")
  356. return f"data:image/jpeg;base64,{new_data}"
  357. except Exception as e:
  358. self.log.warning(f"[Image Process] 处理图片尺寸失败: {e}")
  359. return None
  360. async def _generate_image_description(
  361. self, image_url: str, current_model: str
  362. ) -> str:
  363. """
  364. 使用小模型生成图片的文本描述
  365. Args:
  366. image_url: 图片 URL(base64 或 http(s))
  367. current_model: 当前使用的模型
  368. Returns:
  369. 图片描述文本
  370. """
  371. # The parameter remains part of AgentRunner's compatibility hook; the
  372. # description itself always uses the dedicated vision model below.
  373. del current_model
  374. try:
  375. # 使用 qwen-vl-max(通义千问视觉模型)生成描述
  376. # 注意:qwen-vl 系列专门支持视觉输入
  377. description_model = "qwen-vl-max"
  378. # 构建描述请求
  379. messages = [
  380. {
  381. "role": "user",
  382. "content": [
  383. {"type": "image_url", "image_url": {"url": image_url}},
  384. {
  385. "type": "text",
  386. "text": "请用 1-2 句话简洁描述这张图片的主要内容。",
  387. },
  388. ],
  389. }
  390. ]
  391. # 调用 LLM
  392. result = await self.llm_call(
  393. messages=messages,
  394. model=description_model,
  395. tools=None,
  396. temperature=0.3,
  397. )
  398. description = result.get("content", "").strip()
  399. return description if description else "图片内容"
  400. except Exception as e:
  401. self.log.warning(f"[Image Description] 生成描述失败: {e}")
  402. return "图片内容"
  403. def _add_cache_control(
  404. self, messages: List[Dict], model: str, enable: bool
  405. ) -> List[Dict]:
  406. """
  407. 为支持的模型添加 Prompt Caching 标记
  408. 策略:固定位置 + 延迟缓存
  409. 1. 如果有未处理的图片(最后一条 assistant 之后的 tool messages 中有图片),跳过缓存
  410. 2. system message 添加缓存(如果足够长)
  411. 3. 固定位置缓存点(20, 40, 60, 80),确保每个缓存点间隔 >= 1024 tokens
  412. 4. 最多使用 4 个缓存点(含 system)
  413. Args:
  414. messages: 原始消息列表
  415. model: 模型名称
  416. enable: 是否启用缓存
  417. Returns:
  418. 添加了 cache_control 的消息列表(深拷贝)
  419. """
  420. if not enable:
  421. return messages
  422. # 只对 Claude 模型启用
  423. if "claude" not in model.lower():
  424. return messages
  425. # 延迟缓存:检查是否有未处理的图片
  426. last_assistant_idx = -1
  427. for i in range(len(messages) - 1, -1, -1):
  428. if messages[i].get("role") == "assistant":
  429. last_assistant_idx = i
  430. break
  431. # 检查最后一条 assistant 之后是否有包含图片的 tool messages
  432. has_unprocessed_images = False
  433. if last_assistant_idx >= 0:
  434. for i in range(last_assistant_idx + 1, len(messages)):
  435. msg = messages[i]
  436. if msg.get("role") == "tool":
  437. content = msg.get("content")
  438. if isinstance(content, list):
  439. has_unprocessed_images = any(
  440. isinstance(block, dict) and block.get("type") == "image_url"
  441. for block in content
  442. )
  443. if has_unprocessed_images:
  444. break
  445. if has_unprocessed_images:
  446. self.log.debug("[Cache] 检测到未处理的图片,延迟缓存建立")
  447. return messages
  448. # 深拷贝避免修改原始数据
  449. import copy
  450. messages = copy.deepcopy(messages)
  451. # 策略 1: 为 system message 添加缓存
  452. system_cached = False
  453. for msg in messages:
  454. if msg.get("role") == "system":
  455. content = msg.get("content", "")
  456. if isinstance(content, str) and len(content) > 1000:
  457. msg["content"] = [
  458. {
  459. "type": "text",
  460. "text": content,
  461. "cache_control": {"type": "ephemeral"},
  462. }
  463. ]
  464. system_cached = True
  465. self.log.debug(
  466. f"[Cache] 为 system message 添加缓存标记 (len={len(content)})"
  467. )
  468. break
  469. # 策略 2: 固定位置缓存点
  470. CACHE_INTERVAL = 20
  471. MAX_POINTS = 3 if system_cached else 4
  472. MIN_TOKENS = 1024
  473. AVG_TOKENS_PER_MSG = 70
  474. total_msgs = len(messages)
  475. if total_msgs == 0:
  476. return messages
  477. cache_positions = []
  478. last_cache_pos = 0
  479. for i in range(1, MAX_POINTS + 1):
  480. target_pos = i * CACHE_INTERVAL - 1 # 19, 39, 59, 79
  481. if target_pos >= total_msgs:
  482. break
  483. # 从目标位置开始查找合适的 user/assistant 消息
  484. for j in range(target_pos, total_msgs):
  485. msg = messages[j]
  486. if msg.get("role") not in ("user", "assistant"):
  487. continue
  488. content = msg.get("content", "")
  489. if not content:
  490. continue
  491. # 检查 content 是否非空
  492. is_valid = False
  493. if isinstance(content, str):
  494. is_valid = len(content) > 0
  495. elif isinstance(content, list):
  496. is_valid = any(
  497. isinstance(block, dict)
  498. and block.get("type") == "text"
  499. and len(block.get("text", "")) > 0
  500. for block in content
  501. )
  502. if not is_valid:
  503. continue
  504. # 检查 token 距离
  505. msg_count = j - last_cache_pos
  506. estimated_tokens = msg_count * AVG_TOKENS_PER_MSG
  507. if estimated_tokens >= MIN_TOKENS:
  508. cache_positions.append(j)
  509. last_cache_pos = j
  510. self.log.debug(
  511. f"[Cache] 在位置 {j} 添加缓存点 (估算 {estimated_tokens} tokens)"
  512. )
  513. break
  514. # 应用缓存标记
  515. for idx in cache_positions:
  516. msg = messages[idx]
  517. content = msg.get("content", "")
  518. if isinstance(content, str):
  519. msg["content"] = [
  520. {
  521. "type": "text",
  522. "text": content,
  523. "cache_control": {"type": "ephemeral"},
  524. }
  525. ]
  526. self.log.debug(
  527. f"[Cache] 为 message[{idx}] ({msg.get('role')}) 添加缓存标记"
  528. )
  529. elif isinstance(content, list):
  530. # 在最后一个 text block 添加 cache_control
  531. for block in reversed(content):
  532. if isinstance(block, dict) and block.get("type") == "text":
  533. block["cache_control"] = {"type": "ephemeral"}
  534. self.log.debug(
  535. f"[Cache] 为 message[{idx}] ({msg.get('role')}) 添加缓存标记"
  536. )
  537. break
  538. self.log.debug(
  539. f"[Cache] 总消息: {total_msgs}, "
  540. f"缓存点: {len(cache_positions)} at {cache_positions}"
  541. )
  542. return messages
  543. __all__ = ["RunnerImageRuntime"]