Skip to content

Commit 5192b11

Browse files
author
Patori Patch Manager
committed
fix: context overflow - tool result truncation + multi-round halving + auto compact
Changes: 1. tool_loop_agent_runner.py: - Truncate tool results > 12000 chars to prevent single-tool context bloat - Replace single-round halving with multi-round (up to 5x) aggressive halving on token_limit errors, ensuring retry succeeds 2. astrbot_plugin_memory_system/main.py: - on_llm_request: add pre-emptive truncation when history > 200k chars (~50k tokens) truncates to 20 most recent turns before sending to LLM - /compact: enhanced command with modes: /compact [N] - LLM summary compress, keep N recent turns (default 6) /compact hard - fast truncate to 20 recent turns, no LLM cost /compact clear - clear all history
1 parent aa0c984 commit 5192b11

2 files changed

Lines changed: 156 additions & 44 deletions

File tree

astrbot/core/agent/runners/tool_loop_agent_runner.py

Lines changed: 62 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -298,36 +298,63 @@ async def _iter_llm_responses_with_fallback(
298298
candidate_id,
299299
)
300300
try:
301-
_before = len(self.run_context.messages)
302-
# Force halving truncation regardless of threshold
303301
from astrbot.core.agent.context.truncator import ContextTruncator
304302
_truncator = ContextTruncator()
305-
self.run_context.messages = _truncator.truncate_by_halving(
306-
self.run_context.messages
307-
)
308-
_after = len(self.run_context.messages)
309-
logger.info(
310-
"Forced context truncation: %d -> %d messages, retrying same provider.",
311-
_before, _after,
312-
)
313-
# Retry same candidate once after compression
314-
try:
315-
async for resp in self._iter_llm_responses(include_model=idx == 0):
316-
if resp.is_chunk:
317-
has_stream_output = True
318-
yield resp
319-
continue
320-
yield resp
321-
return
322-
if has_stream_output:
323-
return
324-
except Exception as retry_exc:
325-
last_exception = retry_exc
326-
logger.warning(
327-
"Chat Model %s retry after compression also failed: %s",
328-
candidate_id,
329-
retry_exc,
303+
_before_total = len(self.run_context.messages)
304+
# Aggressively halve until small enough (up to 5 rounds)
305+
_compression_success = False
306+
for _halve_round in range(5):
307+
_before = len(self.run_context.messages)
308+
self.run_context.messages = _truncator.truncate_by_halving(
309+
self.run_context.messages
310+
)
311+
_after = len(self.run_context.messages)
312+
logger.info(
313+
"Forced context truncation round %d: %d -> %d messages.",
314+
_halve_round + 1, _before, _after,
330315
)
316+
if _after <= 4:
317+
break # Can't shrink further
318+
# Retry same candidate
319+
try:
320+
async for resp in self._iter_llm_responses(include_model=idx == 0):
321+
if resp.is_chunk:
322+
has_stream_output = True
323+
yield resp
324+
continue
325+
yield resp
326+
logger.info(
327+
"Context truncation succeeded after %d round(s): %d -> %d messages.",
328+
_halve_round + 1, _before_total, _after,
329+
)
330+
_compression_success = True
331+
return
332+
if has_stream_output:
333+
_compression_success = True
334+
return
335+
_compression_success = True
336+
break # succeeded without stream output
337+
except Exception as retry_exc:
338+
_exc_str2 = str(retry_exc).lower()
339+
if not (
340+
"model_max_prompt_tokens_exceeded" in _exc_str2
341+
or "prompt token count" in _exc_str2
342+
or "tokens_exceeded" in _exc_str2
343+
or "context_length_exceeded" in _exc_str2
344+
or ("token" in _exc_str2 and "exceed" in _exc_str2)
345+
):
346+
last_exception = retry_exc
347+
logger.warning(
348+
"Chat Model %s retry after compression failed: %s",
349+
candidate_id, retry_exc,
350+
)
351+
break
352+
last_exception = retry_exc
353+
logger.warning(
354+
"Chat Model %s still token-exceeded after round %d, halving again...",
355+
candidate_id, _halve_round + 1,
356+
)
357+
continue
331358
except Exception as compress_exc:
332359
logger.error("Failed to compress context: %s", compress_exc)
333360
continue
@@ -760,6 +787,14 @@ async def _handle_function_tools(
760787
logger.info(f"Agent 使用工具: {llm_response.tools_call_name}")
761788

762789
def _append_tool_call_result(tool_call_id: str, content: str) -> None:
790+
# Truncate oversized tool results to prevent context bloat
791+
MAX_TOOL_RESULT_CHARS = 12000
792+
if len(content) > MAX_TOOL_RESULT_CHARS:
793+
truncated_len = len(content) - MAX_TOOL_RESULT_CHARS
794+
content = (
795+
content[:MAX_TOOL_RESULT_CHARS]
796+
+ f"\n... [TRUNCATED {truncated_len} chars to prevent context overflow]"
797+
)
763798
tool_call_result_blocks.append(
764799
ToolCallMessageSegment(
765800
role="tool",

data/plugins/astrbot_plugin_memory_system/main.py

Lines changed: 94 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -147,12 +147,35 @@ async def filter_empty_message(
147147
async def inject_memory_context(
148148
self, event: AstrMessageEvent, req: ProviderRequest
149149
) -> None:
150-
"""在每次 LLM 请求前将三层记忆注入 system_prompt。"""
150+
"""在每次 LLM 请求前将三层记忆注入 system_prompt,并在上下文过大时自动截断。"""
151+
import json as _json
152+
153+
# 1. 注入记忆上下文
151154
block = _build_context_block()
152155
if block:
153156
req.system_prompt = (req.system_prompt or "") + block
154157
logger.debug("[MemorySystem] 已注入记忆上下文(%d chars)", len(block))
155158

159+
# 2. 预防性历史截断:如果 history 消息总 chars 超过阈值,自动截断旧消息
160+
AUTO_COMPACT_CHARS = 200_000 # 约 50k tokens 时自动截断(保守阈值)
161+
KEEP_RECENT_TURNS = 20
162+
try:
163+
if req.contexts:
164+
contexts_str = _json.dumps(req.contexts, ensure_ascii=False)
165+
if len(contexts_str) > AUTO_COMPACT_CHARS:
166+
from astrbot.core.agent.context.truncator import ContextTruncator
167+
from astrbot.core.agent.message import Message as _Msg
168+
msgs = [_Msg.model_validate(m) if isinstance(m, dict) else m for m in req.contexts]
169+
_truncator = ContextTruncator()
170+
truncated = _truncator.truncate_by_turns(msgs, keep_most_recent_turns=KEEP_RECENT_TURNS)
171+
req.contexts = [m.model_dump() if hasattr(m, "model_dump") else m for m in truncated]
172+
logger.warning(
173+
"[MemorySystem] 自动截断历史上下文:%d -> %d 条消息(原始 %d chars 超过阈值 %d)",
174+
len(msgs), len(truncated), len(contexts_str), AUTO_COMPACT_CHARS,
175+
)
176+
except Exception as _e:
177+
logger.warning("[MemorySystem] 预防性截断失败(非致命): %s", _e)
178+
156179
# ════════════════════════════════════════
157180
# LLM 工具:写日记
158181
# ════════════════════════════════════════
@@ -381,16 +404,40 @@ async def cmd_write_diary(
381404
@filter.command("compact")
382405
async def cmd_compact(self, event: AstrMessageEvent) -> None:
383406
"""
384-
手动触发 LLM Summary 上下文压缩,将当前会话历史压缩为摘要。
385-
用法:/compact
407+
手动触发上下文压缩。
408+
用法:
409+
/compact — LLM 摘要压缩,保留最近 6 轮
410+
/compact 10 — LLM 摘要压缩,保留最近 10 轮
411+
/compact hard — 强制截断,只保留最近 20 轮(不消耗 LLM)
412+
/compact clear — 清空全部历史(谨慎!)
386413
"""
387414
import json
388415
from astrbot.core.agent.context.compressor import LLMSummaryCompressor
416+
from astrbot.core.agent.context.truncator import ContextTruncator
389417
from astrbot.core.agent.message import Message
390418

391419
umo = event.unified_msg_origin
392420
conv_mgr = self.context.conversation_manager
393421

422+
# 解析参数
423+
raw_msg = event.message_str.strip()
424+
args = raw_msg.split()[1:] # 去掉 /compact
425+
mode = "llm"
426+
keep_recent = 6
427+
if args:
428+
arg0 = args[0].lower()
429+
if arg0 == "hard":
430+
mode = "hard"
431+
keep_recent = 20
432+
elif arg0 == "clear":
433+
mode = "clear"
434+
else:
435+
try:
436+
keep_recent = max(2, int(arg0))
437+
except ValueError:
438+
yield event.plain_result("⚠️ 参数无效,请用 /compact [数字|hard|clear]")
439+
return
440+
394441
# 1. 获取当前 conversation
395442
cid = await conv_mgr.get_curr_conversation_id(umo)
396443
if not cid:
@@ -403,42 +450,72 @@ async def cmd_compact(self, event: AstrMessageEvent) -> None:
403450
return
404451

405452
history: list[dict] = json.loads(conv.history) if isinstance(conv.history, str) else (conv.history or [])
406-
# 过滤掉 system 消息,只保留 user/assistant 轮次
407453
non_system = [m for m in history if m.get("role") != "system"]
408-
if len(non_system) < 4:
409-
yield event.plain_result("📭 当前对话内容太少,不需要压缩(少于 4 条非系统消息)~")
454+
455+
if mode == "clear":
456+
await conv_mgr.update_conversation(
457+
unified_msg_origin=umo,
458+
conversation_id=cid,
459+
history=[],
460+
)
461+
yield event.plain_result(f"🗑️ 已清空全部历史记录(原有 {len(non_system)} 条消息)")
462+
return
463+
464+
if len(non_system) < 2:
465+
yield event.plain_result("📭 当前对话内容太少,不需要压缩~")
466+
return
467+
468+
if mode == "hard":
469+
# 仅截断,不使用 LLM
470+
msgs = [Message.model_validate(m) if isinstance(m, dict) else m for m in history]
471+
_truncator = ContextTruncator()
472+
truncated = _truncator.truncate_by_turns(msgs, keep_most_recent_turns=keep_recent)
473+
result_dicts = [m.model_dump() if hasattr(m, "model_dump") else dict(m) for m in truncated]
474+
await conv_mgr.update_conversation(
475+
unified_msg_origin=umo,
476+
conversation_id=cid,
477+
history=result_dicts,
478+
)
479+
after_cnt = len([m for m in result_dicts if m.get("role") != "system"])
480+
yield event.plain_result(
481+
f"✂️ 强制截断完成!\n"
482+
f"🔹 截断前:{len(non_system)} 条消息\n"
483+
f"🔹 截断后:{after_cnt} 条消息(保留最近 {keep_recent} 轮)"
484+
)
410485
return
411486

412-
# 2. 获取当前 provider
487+
# LLM 摘要压缩
413488
provider = self.context.get_using_provider(umo)
414489
if not provider:
415-
yield event.plain_result("⚠️ 当前没有可用的 LLM Provider,无法进行 LLM 摘要压缩~")
490+
yield event.plain_result("⚠️ 当前没有可用的 LLM Provider,尝试用 /compact hard 代替")
416491
return
417492

418-
yield event.plain_result(f"🗜️ 正在压缩上下文(共 {len(non_system)} 条消息),请稍等~")
493+
chars_before = len(json.dumps(history, ensure_ascii=False))
494+
yield event.plain_result(
495+
f"🗜️ 正在 LLM 摘要压缩({len(non_system)} 条消息,约 {chars_before//4:,} tokens),保留最近 {keep_recent} 轮,请稍等~"
496+
)
419497

420-
# 3. 构建 LLMSummaryCompressor 并执行压缩
421498
compressor = LLMSummaryCompressor(
422499
provider=provider,
423-
keep_recent=4,
500+
keep_recent=keep_recent,
424501
)
425-
messages = [Message(**m) if not isinstance(m, Message) else m for m in history]
502+
messages = [Message.model_validate(m) if isinstance(m, dict) else m for m in history]
426503
compressed = await compressor(messages)
427504

428-
# 4. 写回 conversation
429505
compressed_dicts = [m.model_dump() if hasattr(m, "model_dump") else dict(m) for m in compressed]
430506
await conv_mgr.update_conversation(
431507
unified_msg_origin=umo,
432508
conversation_id=cid,
433509
history=compressed_dicts,
434510
)
435511

436-
before_cnt = len(non_system)
512+
chars_after = len(json.dumps(compressed_dicts, ensure_ascii=False))
437513
after_cnt = len([m for m in compressed_dicts if m.get("role") != "system"])
438514
yield event.plain_result(
439-
f"✅ 上下文压缩完成!\n"
440-
f"🔹 压缩前:{before_cnt} 条消息\n"
441-
f"🔹 压缩后:{after_cnt} 条消息(保留了最近 4 轮 + 摘要)"
515+
f"✅ LLM 摘要压缩完成!\n"
516+
f"🔹 压缩前:{len(non_system)} 条消息 / ~{chars_before//4:,} tokens\n"
517+
f"🔹 压缩后:{after_cnt} 条消息 / ~{chars_after//4:,} tokens\n"
518+
f"🔹 压缩率:{(1 - chars_after/max(chars_before,1))*100:.1f}%(保留最近 {keep_recent} 轮 + 摘要)"
442519
)
443520

444521
async def terminate(self) -> None:

0 commit comments

Comments
 (0)