perf(host): 新建对话延迟从10s降至3s并消除后台日志刷屏
- 索引覆盖检查改与 min(needed, 文件总数) 比较,修复每次列表请求 必触发两次全量重建的逻辑错误(重建索引日志刷屏根源) - OCRClient 的 httpx.Client/OpenAI 改为首次调用 VLM 时懒加载, 构造耗时 3675ms -> 2ms(此前每个对话级 terminal 创建都要付一次) - WebTerminal 复用父类 TerminalManager 仅注入广播回调,不再二次创建; close_all 在无终端时静默,消除 GC 路径的关闭终端噪音日志 - 新建对话版本控制初始化去重:create_new_conversation 返回 versioning_initialized 标记,路由层跳过重复初始化 - _update_index 读-改-写整体纳入 _io_lock,修复并发丢索引条目 进而引发连锁全量重建的隐患
This commit is contained in:
parent
510fb8497f
commit
97e1327987
@ -115,15 +115,20 @@ class WebTerminal(MainTerminal):
|
||||
# 默认允许输出(api_client.web_mode=False 表示允许 _print),若需静默可设置 WEB_API_SILENT=1
|
||||
self.api_client.web_mode = bool(os.environ.get("WEB_API_SILENT"))
|
||||
|
||||
# 重新初始化终端管理器
|
||||
self.terminal_manager = TerminalManager(
|
||||
project_path=project_path,
|
||||
max_terminals=MAX_TERMINALS,
|
||||
terminal_buffer_size=TERMINAL_BUFFER_SIZE,
|
||||
terminal_display_size=TERMINAL_DISPLAY_SIZE,
|
||||
broadcast_callback=message_callback,
|
||||
container_session=self.container_session
|
||||
)
|
||||
# 复用父类已创建的 TerminalManager,仅注入广播回调。
|
||||
# 此前这里会新建一个实例顶掉父类的,旧实例被 GC 时触发 __del__ -> close_all(),
|
||||
# 导致每次创建对话级 terminal 都输出“关闭所有终端会话”噪音日志。
|
||||
if self.terminal_manager is not None:
|
||||
self.terminal_manager.broadcast = message_callback
|
||||
else:
|
||||
self.terminal_manager = TerminalManager(
|
||||
project_path=project_path,
|
||||
max_terminals=MAX_TERMINALS,
|
||||
terminal_buffer_size=TERMINAL_BUFFER_SIZE,
|
||||
terminal_display_size=TERMINAL_DISPLAY_SIZE,
|
||||
broadcast_callback=message_callback,
|
||||
container_session=self.container_session
|
||||
)
|
||||
# 让 run_command 与实时终端共享同一容器环境
|
||||
self.terminal_ops.attach_terminal_manager(self.terminal_manager)
|
||||
|
||||
@ -276,10 +281,12 @@ class WebTerminal(MainTerminal):
|
||||
|
||||
perf_log("create_new_conversation before default versioning", elapsed_ms=(time.perf_counter() - t0) * 1000, extra={"conv_id": conversation_id})
|
||||
# 根据个性化设置默认开启版本控制
|
||||
versioning_initialized = False
|
||||
try:
|
||||
default_versioning_enabled = bool((prefs or {}).get("versioning_enabled_by_default", True))
|
||||
if default_versioning_enabled:
|
||||
self._ensure_conversation_versioning_enabled(conversation_id)
|
||||
versioning_initialized = True
|
||||
except Exception as exc:
|
||||
logger.warning("新对话应用默认版本控制失败: %s", exc)
|
||||
perf_log("create_new_conversation after default versioning", elapsed_ms=(time.perf_counter() - t0) * 1000, extra={"conv_id": conversation_id})
|
||||
@ -290,7 +297,9 @@ class WebTerminal(MainTerminal):
|
||||
return {
|
||||
"success": True,
|
||||
"conversation_id": conversation_id,
|
||||
"message": f"已创建新对话: {conversation_id}"
|
||||
"message": f"已创建新对话: {conversation_id}",
|
||||
# 路由层据此跳过重复的版本控制初始化(此前每次新建对话初始化两遍)
|
||||
"versioning_initialized": versioning_initialized,
|
||||
}
|
||||
except Exception as e:
|
||||
perf_log("create_new_conversation error", elapsed_ms=(time.perf_counter() - t0) * 1000, extra={"error": str(e)})
|
||||
|
||||
@ -19,6 +19,22 @@ class OCRClient:
|
||||
self.project_path = Path(project_path).resolve()
|
||||
self.file_manager = file_manager
|
||||
|
||||
# 懒加载:httpx.Client() 构造会加载 CA 证书并初始化 SSL 上下文,
|
||||
# 在 Windows(Defender 实时扫描)下可耗时数秒;每个对话级 terminal
|
||||
# 都会构造 OCRClient,因此推迟到首次真正调用 VLM 时再创建。
|
||||
self.http_client = None
|
||||
self.client = None
|
||||
self._client_ready = False
|
||||
self.model = OCR_MODEL_ID
|
||||
self.max_tokens = OCR_MAX_TOKENS or 4096
|
||||
|
||||
# 默认大小上限(10MB),超出则警告并拒绝
|
||||
self.max_image_size = 10 * 1024 * 1024
|
||||
|
||||
def _ensure_client(self):
|
||||
"""首次使用时创建 httpx / OpenAI 客户端(线程安全由 GIL 保证最坏情况重复创建一次)。"""
|
||||
if self._client_ready:
|
||||
return
|
||||
# 补全 base_url,兼容是否包含 /v1
|
||||
base_url = (OCR_API_BASE_URL or "").rstrip("/")
|
||||
if base_url and not base_url.endswith("/v1"):
|
||||
@ -26,18 +42,13 @@ class OCRClient:
|
||||
|
||||
# httpx 0.28 起不再支持 proxies 参数,显式传入 http_client 以避免默认封装报错
|
||||
self.http_client = httpx.Client()
|
||||
self.client = None
|
||||
if OCR_API_KEY:
|
||||
self.client = OpenAI(
|
||||
api_key=OCR_API_KEY,
|
||||
base_url=base_url or None,
|
||||
http_client=self.http_client,
|
||||
)
|
||||
self.model = OCR_MODEL_ID
|
||||
self.max_tokens = OCR_MAX_TOKENS or 4096
|
||||
|
||||
# 默认大小上限(10MB),超出则警告并拒绝
|
||||
self.max_image_size = 10 * 1024 * 1024
|
||||
self._client_ready = True
|
||||
|
||||
def _validate_image_path(self, path: str):
|
||||
"""复用 FileManager 的路径校验,确保在项目内。"""
|
||||
@ -60,8 +71,11 @@ class OCRClient:
|
||||
|
||||
if not prompt or not str(prompt).strip():
|
||||
return {"success": False, "error": "prompt 不能为空", "warnings": warnings}
|
||||
if not self.client or not OCR_API_KEY or not OCR_API_BASE_URL or not self.model:
|
||||
if not OCR_API_KEY or not OCR_API_BASE_URL or not self.model:
|
||||
return {"success": False, "error": "VLM 配置缺失,请设置 OCR_API_BASE_URL / OCR_API_KEY / OCR_MODEL_ID", "warnings": warnings}
|
||||
self._ensure_client()
|
||||
if not self.client:
|
||||
return {"success": False, "error": "VLM 客户端初始化失败", "warnings": warnings}
|
||||
|
||||
try:
|
||||
data = full_path.read_bytes()
|
||||
|
||||
@ -744,6 +744,10 @@ class TerminalManager:
|
||||
|
||||
def close_all(self):
|
||||
"""关闭所有终端会话"""
|
||||
# 无终端时静默返回:避免 __del__/竞态重建路径对空实例输出噪音日志
|
||||
if not getattr(self, "terminals", None):
|
||||
self.active_terminal = None
|
||||
return
|
||||
print(f"{OUTPUT_FORMATS['info']} 关闭所有终端会话...")
|
||||
|
||||
for session_name in list(self.terminals.keys()):
|
||||
|
||||
@ -730,9 +730,10 @@ def create_conversation(terminal: WebTerminal, workspace: UserWorkspace, usernam
|
||||
session['thinking_mode'] = terminal.thinking_mode
|
||||
perf_log("create_conversation before default versioning", elapsed_ms=(time.perf_counter() - t0) * 1000, extra={"conv_id": result.get("conversation_id")})
|
||||
# 根据个性化设置,为新对话默认开启版本控制(安全导航路径也需要)。
|
||||
# create_new_conversation 内部已完成初始化时跳过,避免每个新对话初始化两遍。
|
||||
try:
|
||||
prefs = load_personalization_config(workspace.data_dir)
|
||||
if bool(prefs.get("versioning_enabled_by_default", True)):
|
||||
if bool(prefs.get("versioning_enabled_by_default", True)) and not result.get("versioning_initialized"):
|
||||
_ensure_conversation_versioning_enabled(terminal, workspace, result["conversation_id"])
|
||||
except Exception as exc:
|
||||
debug_log(f"[Versioning] create_conversation apply default failed: {exc}")
|
||||
|
||||
@ -273,42 +273,46 @@ class CrudMixin:
|
||||
def _update_index(self, conversation_id: str, conversation_data: Dict):
|
||||
"""更新对话索引"""
|
||||
try:
|
||||
index = self._load_index()
|
||||
|
||||
# 创建元数据
|
||||
metadata = ConversationMetadata(
|
||||
id=conversation_id,
|
||||
title=conversation_data["title"],
|
||||
created_at=conversation_data["created_at"],
|
||||
updated_at=conversation_data["updated_at"],
|
||||
project_path=conversation_data["metadata"]["project_path"],
|
||||
project_relative_path=conversation_data["metadata"].get("project_relative_path"),
|
||||
thinking_mode=conversation_data["metadata"]["thinking_mode"],
|
||||
run_mode=conversation_data["metadata"].get("run_mode", "thinking" if conversation_data["metadata"]["thinking_mode"] else "fast"),
|
||||
total_messages=conversation_data["metadata"]["total_messages"],
|
||||
total_tools=conversation_data["metadata"]["total_tools"],
|
||||
status=conversation_data["metadata"].get("status", "active")
|
||||
)
|
||||
|
||||
# 添加到索引
|
||||
index[conversation_id] = {
|
||||
"title": metadata.title,
|
||||
"created_at": metadata.created_at,
|
||||
"updated_at": metadata.updated_at,
|
||||
"project_path": metadata.project_path,
|
||||
"project_relative_path": metadata.project_relative_path,
|
||||
"thinking_mode": metadata.thinking_mode,
|
||||
"run_mode": metadata.run_mode,
|
||||
"model_key": conversation_data["metadata"].get("model_key"),
|
||||
"has_images": conversation_data["metadata"].get("has_images", False),
|
||||
"has_videos": conversation_data["metadata"].get("has_videos", False),
|
||||
"total_messages": metadata.total_messages,
|
||||
"total_tools": metadata.total_tools,
|
||||
"status": metadata.status,
|
||||
"multi_agent_mode": bool(conversation_data["metadata"].get("multi_agent_mode", False))
|
||||
}
|
||||
|
||||
self._save_index(index)
|
||||
# 读-改-写必须整体持锁:此前仅在 _save_index 内加锁,
|
||||
# 并发保存时后写者会用旧的读结果覆盖掉先写者的条目(丢失更新),
|
||||
# 进而触发“索引缺文件→全量重建”的连锁反应。_io_lock 为 RLock,可重入。
|
||||
with self._io_lock:
|
||||
index = self._load_index()
|
||||
|
||||
# 创建元数据
|
||||
metadata = ConversationMetadata(
|
||||
id=conversation_id,
|
||||
title=conversation_data["title"],
|
||||
created_at=conversation_data["created_at"],
|
||||
updated_at=conversation_data["updated_at"],
|
||||
project_path=conversation_data["metadata"]["project_path"],
|
||||
project_relative_path=conversation_data["metadata"].get("project_relative_path"),
|
||||
thinking_mode=conversation_data["metadata"]["thinking_mode"],
|
||||
run_mode=conversation_data["metadata"].get("run_mode", "thinking" if conversation_data["metadata"]["thinking_mode"] else "fast"),
|
||||
total_messages=conversation_data["metadata"]["total_messages"],
|
||||
total_tools=conversation_data["metadata"]["total_tools"],
|
||||
status=conversation_data["metadata"].get("status", "active")
|
||||
)
|
||||
|
||||
# 添加到索引
|
||||
index[conversation_id] = {
|
||||
"title": metadata.title,
|
||||
"created_at": metadata.created_at,
|
||||
"updated_at": metadata.updated_at,
|
||||
"project_path": metadata.project_path,
|
||||
"project_relative_path": metadata.project_relative_path,
|
||||
"thinking_mode": metadata.thinking_mode,
|
||||
"run_mode": metadata.run_mode,
|
||||
"model_key": conversation_data["metadata"].get("model_key"),
|
||||
"has_images": conversation_data["metadata"].get("has_images", False),
|
||||
"has_videos": conversation_data["metadata"].get("has_videos", False),
|
||||
"total_messages": metadata.total_messages,
|
||||
"total_tools": metadata.total_tools,
|
||||
"status": metadata.status,
|
||||
"multi_agent_mode": bool(conversation_data["metadata"].get("multi_agent_mode", False))
|
||||
}
|
||||
|
||||
self._save_index(index)
|
||||
except Exception as e:
|
||||
print(f"⌘ 更新对话索引失败: {e}")
|
||||
|
||||
|
||||
@ -279,10 +279,19 @@ class IndexMixin:
|
||||
self._save_index(rebuilt)
|
||||
index = rebuilt
|
||||
|
||||
if len(index) >= needed:
|
||||
# 关键:覆盖目标不应超过实际文件总数。此前只和 needed(offset+limit) 比较,
|
||||
# 当 needed 大于对话总数时会每次触发 rebuild_needed + rebuild_full 两次全量重建,
|
||||
# 日志刷屏且随对话数线性变慢。
|
||||
try:
|
||||
total_files = len(self._iter_conversation_files(sort_by_mtime=False))
|
||||
except Exception:
|
||||
total_files = None
|
||||
coverage_target = needed if total_files is None else min(needed, total_files)
|
||||
|
||||
if len(index) >= coverage_target:
|
||||
total_ms = (time.perf_counter() - t0) * 1000
|
||||
if perf_log:
|
||||
perf_log("_ensure_index_covering return", elapsed_ms=total_ms, extra={"index_size": len(index), "needed": needed})
|
||||
perf_log("_ensure_index_covering return", elapsed_ms=total_ms, extra={"index_size": len(index), "needed": needed, "total_files": total_files})
|
||||
return index
|
||||
|
||||
# 第一次尝试:扩展到需要的数量(按更新时间倒序)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user