133 lines
7.3 KiB
Python
133 lines
7.3 KiB
Python
"""Opt-in real-model multi-turn probe with checkpointing and accelerated summary triggers.
|
|
|
|
Run from backend: .venv/Scripts/python scripts/probe_multiturn_compression.py --live
|
|
Only synthetic project facts are sent. Config changes stay inside this process.
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import logging
|
|
import re
|
|
import time
|
|
|
|
from langchain.agents import create_agent
|
|
from langchain_core.messages import HumanMessage
|
|
from langgraph.checkpoint.memory import InMemorySaver
|
|
|
|
from deerflow.agents.lead_agent.agent import _create_summarization_middleware
|
|
from deerflow.agents.middlewares.context_window_guard_middleware import ContextWindowGuardMiddleware
|
|
from deerflow.agents.middlewares.llm_error_handling_middleware import is_llm_error_message
|
|
from deerflow.agents.middlewares.summarization_middleware import _clear_global_summary_cache
|
|
from deerflow.agents.thread_state import ThreadState
|
|
from deerflow.config.app_config import get_app_config
|
|
from deerflow.models.factory import create_chat_model
|
|
|
|
|
|
def visible_text(message):
|
|
text = re.sub(r"<think>.*?</think>", "", message.text, flags=re.S | re.I)
|
|
return re.split(r"<think>", text, flags=re.I)[0].strip()
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--live", action="store_true", required=True)
|
|
parser.add_argument("--model", default="MiniMax-M2.7")
|
|
parser.add_argument("--output", default=None)
|
|
args = parser.parse_args()
|
|
logging.disable(logging.CRITICAL)
|
|
config = get_app_config().model_copy(deep=True)
|
|
selected = config.get_model_config(args.model)
|
|
selected.max_retries = 0
|
|
selected.request_timeout = 90
|
|
selected.max_tokens = 2048
|
|
# Lower only the summary trigger for this probe; never invent an input ceiling.
|
|
from deerflow.config.summarization_config import ContextSize
|
|
|
|
config.summarization.trigger = [ContextSize(type="messages", value=8)]
|
|
config.summarization.keep = ContextSize(type="messages", value=2)
|
|
config.summarization.model_name = args.model
|
|
main_model = create_chat_model(name=args.model, app_config=config, thinking_enabled=False, force_disable_thinking=True)
|
|
prompts = [
|
|
"请记住本次项目:代号 ORBIT-42;预算35万元;截止日期2026-12-20。只回复已记住。",
|
|
"补充范围:系统需支持资料检索、问答和报告导出。只回复已记住。",
|
|
"补充约束:不能上传真实客户数据,只能用合成测试材料。只回复已记住。",
|
|
"补充验收项:检查引用来源、输出格式和接口稳定性。只回复已记住。",
|
|
"请仅用一句话复述项目代号、预算和截止日期。",
|
|
"更新项目预算为48万元,代号和其他信息保持不变。只回复已更新。",
|
|
"补充约束:报告必须用中文,先写结论再写依据。只回复已记住。",
|
|
"更新截止日期为2026-12-28,其他信息不变。只回复已更新。",
|
|
"验收补充:必须覆盖多轮问答和长材料。只回复已记住。",
|
|
"继续补充:需要留存测试报告,不能删除已有聊天记录。只回复已记住。",
|
|
"补充:测试使用合成材料,不更改刚才确认的代号、预算和日期。只回复已记住。",
|
|
"请仅输出JSON,包含code、budget、deadline,分别给出当前项目代号、最新预算(万元数字)、最新截止日期。",
|
|
]
|
|
saver = InMemorySaver()
|
|
rows = []
|
|
started = time.monotonic()
|
|
report = {"model": args.model, "trigger_messages": 8, "keep_messages": 2, "rows": rows}
|
|
_clear_global_summary_cache()
|
|
final = None
|
|
try:
|
|
for turn, prompt in enumerate(prompts, 1):
|
|
if turn == 9:
|
|
# Emulate a worker restart: only the persisted checkpoint survives.
|
|
_clear_global_summary_cache()
|
|
middleware = _create_summarization_middleware(app_config=config, force_enabled=True)
|
|
action = {"value": "passthrough"}
|
|
original_plan = middleware._plan_compression
|
|
|
|
def inspect_plan(request):
|
|
plan = original_plan(request)
|
|
action["value"] = plan[0] if plan else "passthrough"
|
|
return plan
|
|
|
|
middleware._plan_compression = inspect_plan
|
|
agent = create_agent(model=main_model, middleware=[middleware, ContextWindowGuardMiddleware()], state_schema=ThreadState, checkpointer=saver)
|
|
turn_started = time.monotonic()
|
|
previous_ids = set((final.get("context_summary_cache") or {}).get("summarized_ids", [])) if final else set()
|
|
final = agent.invoke({"messages": [HumanMessage(content=prompt)]}, {"configurable": {"thread_id": "synthetic-multiturn-probe"}})
|
|
message = final["messages"][-1]
|
|
visible = visible_text(message)
|
|
row = {
|
|
"turn": turn,
|
|
"compression": action["value"],
|
|
"history_messages": len(final["messages"]),
|
|
"has_cache": bool(final.get("context_summary_cache")),
|
|
"visible": visible[:500],
|
|
"elapsed_seconds": round(time.monotonic() - turn_started, 2),
|
|
}
|
|
row["summary_updated"] = set((final.get("context_summary_cache") or {}).get("summarized_ids", [])) != previous_ids
|
|
if row["summary_updated"]:
|
|
row["summary_text"] = final["context_summary_cache"]["summary_text"]
|
|
rows.append(row)
|
|
print(json.dumps({key: value for key, value in row.items() if key != "summary_text"}, ensure_ascii=True), flush=True)
|
|
if not visible or is_llm_error_message(message):
|
|
raise RuntimeError("empty_or_error_answer")
|
|
if len(final["messages"]) != turn * 2:
|
|
raise RuntimeError("history_was_lost")
|
|
if action["value"] == "summarize" and not row["summary_updated"]:
|
|
raise RuntimeError("summary_did_not_complete")
|
|
if turn == 5 and not all(value in visible for value in ("ORBIT-42", "35", "2026")):
|
|
raise RuntimeError("first_summary_lost_project_facts")
|
|
text = visible_text(final["messages"][-1])
|
|
parsed = json.loads(re.search(r"\{.*\}", text, flags=re.S).group())
|
|
report["fact_check"] = parsed.get("code") == "ORBIT-42" and str(parsed.get("budget")) == "48" and parsed.get("deadline") == "2026-12-28"
|
|
report["summary_rounds"] = [row["turn"] for row in rows if row["compression"] == "summarize"]
|
|
report["reuse_rounds"] = [row["turn"] for row in rows if row["compression"] == "reuse"]
|
|
report["outcome"] = "passed" if report["fact_check"] and len(report["summary_rounds"]) >= 3 and report["reuse_rounds"] else "failed"
|
|
except Exception as exc:
|
|
report["outcome"] = type(exc).__name__
|
|
if str(exc) in {"empty_or_error_answer", "history_was_lost", "summary_did_not_complete", "first_summary_lost_project_facts"}:
|
|
report["failure_reason"] = str(exc)
|
|
report["elapsed_seconds"] = round(time.monotonic() - started, 2)
|
|
if args.output:
|
|
from pathlib import Path
|
|
|
|
Path(args.output).write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
print(json.dumps({key: value for key, value in report.items() if key != "rows"}, ensure_ascii=True), flush=True)
|
|
return 0 if report["outcome"] == "passed" else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|