- 压缩崩根因(venv 2.0.2 代码级证据,4 次实证 'important_discoveries' required):reply 每轮推理前 compress_context(_agent.py:609)→ generate_structured_output 用 SummarySchema json-schema(五字段全 required+maxLength,_config.py:11-53)强制 M3 tool_choice 产摘要 → dict 路 jsonschema.validate (model/_base.py:566-567)对缺字段/超长抛 ValidationError → 非 retryable 直接 raise(_base.py:426-428) → _compress_context_impl 非 overflow 支 raise e from None(_agent.py:461)→ 穿透 reply;ChatService.run 吞异常不发终结事件(app/_service/_chat.py:166-176)→ driver 600s 慢失败 - ctx_compress_patch:fail-open 包 Agent.compress_context(压缩=旁路优化件,失败只跳过不杀主链;连续 2 次 断路防每轮白烧压缩计费调用;钉 2.0.2 不改 venv 本体,纪律同 m3_stream_patch);Service 与 CLI 两处 apply - collector on_reply 增崩溃支:先 flush 收口采集,再 yield 合成 ReplyEndEvent(消费侧先 publish 到 bus → driver 立刻收到回合终结按盘面快速失败),原异常原样重抛不掩盖;正常路零行为变化 - 测试 9 用例:成功透传/失败吞掉/2 次断路/成功清零/按实例隔离/apply 幂等;崩溃合成终结+异常上抛+崩溃路 flush/正常路不追加/合成兜底不遮原异常 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
129 lines
8.0 KiB
Python
129 lines
8.0 KiB
Python
"""ctx_compress_patch.py — 运行时修 agentscope 2.0.2 context 压缩崩 run(fail-open:压缩失败绝不杀生成主链)。
|
|
|
|
【坐实根因(2026-07-03,便宜档 4 次线上实证 `'important_discoveries' is a required property`)】
|
|
逐层代码证据(venv agentscope 2.0.2 源码核验):
|
|
① reply 主循环每轮推理前调 `compress_context`(agent/_agent.py:609);token 超阈值时走
|
|
`_compress_context_impl` → `model.generate_structured_output(messages, structured_model=cfg.summary_schema)`
|
|
让模型产结构化摘要(_agent.py:300+,「压缩」本身是一次真实计费 LLM 调用)。
|
|
② `cfg.summary_schema` 默认 = `SummarySchema.model_json_schema()`(agent/_config.py:112-114)——是 **dict**;
|
|
SummarySchema 五字段(task_overview/current_state/important_discoveries/next_steps/context_to_preserve)
|
|
**全 required 且各带 max_length 300/200**(_config.py:11-53)。
|
|
③ dict schema 路的产出校验 = `jsonschema.validate(structured_output, structured_model)`(model/_base.py:566-567)。
|
|
MiniMax-M3 经 new-api 对「强制 tool_choice + 多字段带长度约束 schema」遵从不稳:少填字段 → ValidationError
|
|
`'important_discoveries' is a required property`(4 次实证);中文摘要超 300 字符 → `is too long`(同崩点)。
|
|
④ ValidationError 不在 `_get_retryable_exceptions()`(那是网络类)→ generate_structured_output 的 retry 循环
|
|
直接 `raise`(_base.py:426-428);`_compress_context_impl` 的 except 只救 context_overflow 分支,非 overflow
|
|
→ `raise e from None`(_agent.py:461)→ 异常穿透 reply 生成器。
|
|
⑤ Service 侧 `ChatService.run` 对 run 异常「logged and swallowed」、**不向 message bus 发任何终结事件**
|
|
(app/_service/_chat.py:166-176)→ SSE 消费方等不到 REPLY_END → driver 空等 600s 总超时慢失败。
|
|
(⑤ 的出口另由 cheap_service_app 的 collector 合成终结事件封;本补丁封 ③④ 的崩因本身。)
|
|
|
|
【修法=fail-open,为什么不是改 schema】压缩是省 context 的旁路优化件,它失败不该杀生成主链。改 schema
|
|
(放宽 required/maxLength)看似治本,但 venv 的 `summary_template.format(**res.content)`(_agent.py:463)对缺键
|
|
必抛 KeyError——schema 放宽后模板层还是崩,除非连模板一起换,改动面反而更大且更深地耦合 venv 内部。故取
|
|
最小面:包 `Agent.compress_context`(压缩唯一公共入口,middleware 链在其内),异常 → 告警 + 跳过本次压缩
|
|
(context 保持原样、生成继续);**连续失败 ≥2 次后本 agent 实例禁用压缩**——压缩调用本身计费,不设断路器
|
|
会每轮触发→每轮失败→每轮白烧一次 LLM 调用。context 若真涨到模型硬顶,模型 API 会确定性报错、熔断闸兜底,
|
|
配合 Service 壳合成终结事件仍是快失败——比「优化件杀主链」诚实。
|
|
|
|
【钉 agentscope==2.0.2,升级必须复核】运行时 monkeypatch `Agent.compress_context`,**绝不改 venv 文件本体**
|
|
(纪律同 m3_stream_patch)。依赖 2.0.2 契约:`async def compress_context(self, context_config=None) -> None`
|
|
(_agent.py:259)。升级 agentscope 后必须重跑 tests/test_ctx_compress_patch.py 并复核该签名与 _agent.py:609
|
|
调用点(版本漂移时 apply 会响亮 warning、但仍应用——不应用等于回到压缩崩 run)。
|
|
|
|
【作用面】跑模型的进程装配层调用:cheap Service(build_cheap_app)与 CLI(cheap_studio)。patch 的是
|
|
Agent 类方法,进程内全部 agent 生效(便宜档进程只有便宜档 agent);tier2 Service 是独立进程、不 import
|
|
本模块,不受影响。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import Any
|
|
|
|
logger = logging.getLogger("cheap.ctx_compress_patch")
|
|
|
|
# 钉定版本(补丁按 2.0.2 的 compress_context 契约写成,见模块 docstring)。
|
|
_PIN_VERSION = "2.0.2"
|
|
# 幂等标记:挂在补丁函数上,重复 apply 不套第二层。
|
|
_PATCH_FLAG = "_ctx_compress_failopen_patched"
|
|
# 连续失败达此次数 → 本 agent 实例禁用压缩(压缩调用计费,防「每轮触发每轮失败」白烧钱)。
|
|
FAIL_DISABLE_THRESHOLD = 2
|
|
# 失败/禁用状态挂在 agent 实例上的属性名(Agent 是普通类,实例属性安全;不入框架 state 序列化面)。
|
|
_STATE_ATTR = "_ctx_compress_failopen_state"
|
|
|
|
|
|
def make_failopen_compress(orig):
|
|
"""把原 compress_context 包成 fail-open 版(纯包装器,独立可测:orig 为任意同签名 async callable)。
|
|
|
|
行为:成功 → 透传返回值并清零连续失败计数;异常 → 告警吞掉(本次不压缩,生成继续),连续失败
|
|
≥ FAIL_DISABLE_THRESHOLD 次后置禁用标记,此后本 agent 实例直接跳过压缩(不再调 orig、不再烧压缩调用)。
|
|
"""
|
|
|
|
async def _failopen_compress(self: Any, context_config: Any = None) -> None:
|
|
state = getattr(self, _STATE_ATTR, None)
|
|
if state is None:
|
|
state = {"fails": 0, "disabled": False}
|
|
try:
|
|
setattr(self, _STATE_ATTR, state)
|
|
except Exception: # noqa: BLE001 —— 极端不可写实例:退化为无状态 fail-open(仍不崩主链)
|
|
pass
|
|
if state["disabled"]:
|
|
return # 已断路:本实例压缩停用(context 交模型上限/熔断闸兜底)
|
|
try:
|
|
result = await orig(self, context_config=context_config)
|
|
state["fails"] = 0 # 一次成功清零(断路器只数「连续」失败)
|
|
return result
|
|
except Exception as e: # noqa: BLE001 —— fail-open 核心:压缩任何异常都不杀生成主链
|
|
state["fails"] += 1
|
|
if state["fails"] >= FAIL_DISABLE_THRESHOLD:
|
|
state["disabled"] = True
|
|
# 可追溯日志(错误路径铁律):异常类型/信息 + 连续失败数 + 是否已断路。
|
|
logger.warning(
|
|
"[ctx-compress] 上下文压缩失败已 fail-open 跳过(生成继续,本次不压缩):%s: %s"
|
|
"(连续失败 %d/%d%s)",
|
|
type(e).__name__, str(e)[:300], state["fails"], FAIL_DISABLE_THRESHOLD,
|
|
";已达阈值 → 本 agent 实例禁用压缩(防每轮白烧压缩调用)" if state["disabled"] else "",
|
|
)
|
|
print(
|
|
f"[ctx-compress] 压缩失败 fail-open:{type(e).__name__}: {str(e)[:200]} "
|
|
f"(连续 {state['fails']}/{FAIL_DISABLE_THRESHOLD}"
|
|
+ (";本实例压缩已禁用" if state["disabled"] else "") + ")",
|
|
flush=True,
|
|
)
|
|
return None
|
|
|
|
return _failopen_compress
|
|
|
|
|
|
def apply_ctx_compress_patch() -> bool:
|
|
"""对已 import 的 agentscope 应用压缩 fail-open 补丁(幂等;返回是否本次新应用)。
|
|
|
|
调用时机:装配层 import agentscope 之后、建 agent/create_app 之前(cheap_studio 顶层 import 链后 /
|
|
build_cheap_app 内 import agentscope.app 之后)。patch 的是 Agent 类方法,此后构建的每个 agent 都吃到。
|
|
"""
|
|
import agentscope # noqa: PLC0415 —— 惰性 import 红线:调用方保证 agentscope 已安全可 import
|
|
from agentscope.agent import Agent # noqa: PLC0415
|
|
|
|
current = getattr(agentscope, "__version__", "?")
|
|
if current != _PIN_VERSION:
|
|
# 版本漂移:仍应用(不应用等于回到压缩崩 run),但响亮提醒复核(见模块 docstring 契约)。
|
|
logger.warning(
|
|
"[ctx-compress] agentscope 版本 %s ≠ 钉定 %s:补丁按 2.0.2 compress_context 契约写成,"
|
|
"升级必须复核并重跑 tests/test_ctx_compress_patch.py!",
|
|
current, _PIN_VERSION,
|
|
)
|
|
|
|
orig = Agent.compress_context
|
|
if getattr(orig, _PATCH_FLAG, False):
|
|
return False # 已打过(幂等,不套第二层)
|
|
|
|
patched = make_failopen_compress(orig)
|
|
setattr(patched, _PATCH_FLAG, True)
|
|
patched._ctx_compress_orig = orig # 留原引用(单测/诊断用)
|
|
Agent.compress_context = patched
|
|
print(f"[ctx-compress] 已装 agentscope {current} 上下文压缩 fail-open 补丁"
|
|
f"(失败跳过不杀主链;连续 {FAIL_DISABLE_THRESHOLD} 次失败断路;钉 {_PIN_VERSION},升级必须复核)。",
|
|
flush=True)
|
|
return True
|