Files
WeClone/tests/test_full_pipeline.py

902 lines
43 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import subprocess
import sys
import os
import time
import shutil
import threading # 导入 threading
from typing import Optional, Union, IO # 导入 IO
import torch
from loguru import logger
from subprocess import Popen
#TODO 放弃了改成测cli吧
# 配置 Loguru
logger.remove() # 移除默认处理器
current_time = time.strftime('%Y%m%d_%H%M%S')
log_file_path = os.path.join(os.path.dirname(__file__), f"pipeline_test_{current_time}.log") # 日志文件名包含执行时间
logger.add(log_file_path, rotation="10 MB", encoding='utf-8', level="DEBUG", enqueue=True) # 文件记录 DEBUG 级别
logger.add(sys.stdout, colorize=True, format="[test] <green>{time:YYYY-MM-DD HH:mm:ss}</green> | <level>{level.name[0]}</level> | <level>{message}</level>", level="INFO", enqueue=True) # 控制台保持 INFO 级别
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
logger.info(f"项目根目录: {project_root}")
qa_script = "weclone/data/qa_generator.py"
train_script = "weclone/train/train_sft.py"
api_service_script = "weclone/server/api_service.py"
eval_script = "weclone/eval/test_model.py"
web_demo_script = "weclone/eval/web_demo.py"
DEFAULT_TIMEOUT: Optional[Union[int, float]] = 45
API_STARTUP_WAIT = 20
API_TERMINATE_WAIT = 15
WEB_DEMO_STARTUP_WAIT = 20
WEB_DEMO_TERMINATE_WAIT = 15
STEP_QA = "QA 数据生成"
STEP_TRAIN = "SFT 训练"
STEP_COPY_CKPT = "Checkpoint 复制"
STEP_API_START = "API 服务启动"
STEP_EVAL = "模型评估"
STEP_WEB_DEMO = "Web Demo 启动"
# Mapping from identifiers (script paths or custom keys) to step names
step_identifiers = {
qa_script: STEP_QA,
train_script: STEP_TRAIN,
"copy_checkpoint": STEP_COPY_CKPT, # Custom key for non-script step
api_service_script: STEP_API_START, # Script associated with starting API
eval_script: STEP_EVAL,
web_demo_script: STEP_WEB_DEMO, # Script associated with starting Web Demo
}
# Order for fallback logic
step_order = [STEP_QA, STEP_TRAIN, STEP_COPY_CKPT, STEP_API_START, STEP_EVAL, STEP_WEB_DEMO]
#todo 需要测试前替换成测试的settings.jsonc 测试完再替换回来
class PipelineStepError(Exception):
"""自定义异常类,用于表示 Pipeline 步骤执行失败。"""
pass
# --- 辅助函数:用于在线程中读取和记录流 ---
def log_stream(stream: Optional[IO[str]], log_func):
"""读取流并使用指定的 log 函数记录每一行。"""
if stream is None:
return
try:
for line in iter(stream.readline, ''):
if line:
log_func(line.strip()) # 去除末尾换行符
except ValueError:
# 当 Popen 的 stream 在另一线程中被关闭时,readline 可能会抛出 ValueError
logger.warning("日志流在读取时似乎已被关闭。")
except Exception as e:
# 捕获其他潜在的读取错误
logger.warning(f"日志流读取时发生未预料的错误: {e}")
finally:
if stream:
try:
stream.close() # 确保流被关闭
except Exception as close_e:
logger.warning(f"关闭日志流时发生错误: {close_e}")
# --- 新增:启动日志流线程的辅助函数 ---
def _start_stream_logging_threads(process: Popen, stdout_log_func=logger.info, stderr_log_func=logger.error) -> tuple[threading.Thread, threading.Thread]:
"""为给定的进程启动 stdout 和 stderr 的日志记录线程。"""
stdout_thread = threading.Thread(
target=log_stream,
args=(process.stdout, stdout_log_func),
daemon=True
)
stderr_thread = threading.Thread(
target=log_stream,
args=(process.stderr, stderr_log_func),
daemon=True
)
stdout_thread.start()
stderr_thread.start()
return stdout_thread, stderr_thread
def run_script(script_relative_path: str, timeout: Optional[Union[int, float]] = DEFAULT_TIMEOUT, ignore_timeout_error: bool = False, env: Optional[dict] = None):
"""使用 Popen 执行脚本,通过线程实时记录 stdout/stderr 到 loguru。"""
script_full_path = os.path.join(project_root, script_relative_path)
timeout_str = '无限制' if timeout is None else f'{timeout}s'
env_str = f" (环境变量: {env})" if env else ""
logger.info(f"--- 开始执行 (流式): {script_relative_path} (超时: {timeout_str}){env_str} ---")
if not os.path.exists(script_full_path):
error_msg = f"脚本文件不存在 {script_full_path}"
logger.error(error_msg)
raise PipelineStepError(error_msg)
process: Optional[Popen] = None
stdout_thread: Optional[threading.Thread] = None
stderr_thread: Optional[threading.Thread] = None
# 准备环境变量
run_env = os.environ.copy()
if env:
run_env.update(env)
try:
process = Popen(
[sys.executable, script_full_path],
cwd=project_root,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding='utf-8',
bufsize=1, # 行缓冲
env=run_env # 传递环境变量
)
# 使用辅助函数启动日志线程
stdout_thread, stderr_thread = _start_stream_logging_threads(process, logger.debug, logger.debug) # stdout/stderr 都用 debug
# 等待子进程完成或超时
try:
return_code = process.wait(timeout=timeout)
except subprocess.TimeoutExpired:
warn_msg = f"{script_relative_path} 执行超时 ({timeout}s)。"
logger.warning(warn_msg)
# 尝试优雅地关闭流(可能已被 log_stream 关闭)
if process.stdout: process.stdout.close()
if process.stderr: process.stderr.close()
process.kill() # 强制终止超时进程
logger.warning(f"已强制终止进程 {process.pid}")
# 等待 I/O 线程完成(即使进程被 kill,也要尝试读取剩余输出)
if stdout_thread: stdout_thread.join(timeout=5)
if stderr_thread: stderr_thread.join(timeout=5)
if not ignore_timeout_error:
error_msg = f"{script_relative_path} 执行超时 ({timeout}s) 且未忽略。"
logger.error(error_msg)
raise PipelineStepError(error_msg)
else:
logger.info("--- 根据设置,超时不视为错误,继续执行后续步骤。 ---")
return # 忽略超时,函数正常返回
# 等待日志线程完成(确保所有输出都被记录)
if stdout_thread: stdout_thread.join()
if stderr_thread: stderr_thread.join()
# 检查返回码
if return_code != 0:
error_msg = f"{script_relative_path} 执行失败,返回码 {return_code}"
logger.error(error_msg)
raise PipelineStepError(error_msg)
else:
logger.success(f"--- {script_relative_path} 执行成功 ---")
except FileNotFoundError:
error_msg = f"Python 解释器 '{sys.executable}' 或脚本 '{script_full_path}' 未找到。"
logger.error(error_msg)
raise PipelineStepError(error_msg)
except Exception as e:
# 捕获其他潜在错误 (例如 Popen 本身失败)
error_msg = f"执行 {script_relative_path} 时发生意外错误: {e}"
logger.error(error_msg)
# 尝试确保进程和线程被清理
if process and process.poll() is None:
try:
if process.stdout: process.stdout.close()
if process.stderr: process.stderr.close()
process.kill()
logger.warning(f"因异常 {e},强制终止进程 {process.pid}")
except Exception as kill_e:
logger.error(f"清理过程中强制终止进程失败: {kill_e}")
if stdout_thread and stdout_thread.is_alive(): stdout_thread.join(timeout=1)
if stderr_thread and stderr_thread.is_alive(): stderr_thread.join(timeout=1)
raise PipelineStepError(error_msg)
def start_api_service_background() -> Popen:
"""在后台启动 API 服务脚本,实时记录启动日志,失败时抛出 PipelineStepError。"""
script_full_path = os.path.join(project_root, api_service_script)
logger.info(f"--- 尝试在后台启动: {api_service_script} ---")
if not os.path.exists(script_full_path):
error_msg = f"脚本文件不存在 {script_full_path}"
logger.error(error_msg)
raise PipelineStepError(error_msg)
process: Optional[Popen] = None
stdout_thread: Optional[threading.Thread] = None
stderr_thread: Optional[threading.Thread] = None
try:
logger.info(f"启动命令: {[sys.executable, script_full_path]}")
process = Popen(
[sys.executable, script_full_path],
cwd=project_root,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding='utf-8',
bufsize=1 # 行缓冲
)
# 使用辅助函数启动日志线程
stdout_thread, stderr_thread = _start_stream_logging_threads(process, logger.debug, logger.debug) # stdout/stderr 都用 debug
logger.info(f"等待 {API_STARTUP_WAIT} 秒让服务初步启动 (日志将实时显示)...")
time.sleep(API_STARTUP_WAIT)
# 检查进程是否仍在运行
if process.poll() is None:
logger.success(f"--- {api_service_script} 似乎已在后台启动 (进程 PID: {process.pid}) ---")
# 注意:不 join 日志线程,让它们继续运行
return process
else:
# 进程过早退出
logger.error(f"{api_service_script} 启动后在 {API_STARTUP_WAIT} 秒内过早退出,返回码 {process.returncode}")
# 尝试等待日志线程结束以捕获最后输出
if stdout_thread: stdout_thread.join(timeout=2)
if stderr_thread: stderr_thread.join(timeout=2)
# 读取 communicate 获取可能遗漏的最终输出 (虽然理论上线程应该读完了)
try:
# 设置短超时,因为进程已退出,communicate 应该立即返回
stdout, stderr = process.communicate(timeout=1)
except subprocess.TimeoutExpired:
logger.warning("等待 communicate 超时,可能没有更多输出了。")
stdout, stderr = "", "" # 假设没有更多输出
except Exception as comm_e:
logger.warning(f"调用 communicate 获取最后输出时出错: {comm_e}")
stdout, stderr = "", ""
error_message = f'''--- EARLY EXIT STDOUT ---
{stdout}
--- EARLY EXIT STDERR ---
{stderr}'''
logger.error(error_message)
raise PipelineStepError(f"{api_service_script} 启动失败并过早退出。")
except FileNotFoundError:
error_msg = f"Python 解释器 '{sys.executable}' 或脚本 '{script_full_path}' 未找到。"
logger.error(error_msg)
raise PipelineStepError(error_msg)
except Exception as e:
# 捕获其他启动错误
error_msg = f"启动 {api_service_script} 时发生意外错误: {e}"
logger.error(error_msg)
if process and process.poll() is None:
logger.warning("捕获到异常,尝试强制终止进程...")
try:
if process.stdout: process.stdout.close()
if process.stderr: process.stderr.close()
process.kill()
except Exception as kill_e: logger.error(f"强制终止进程时出错: {kill_e}")
# 尝试join线程
if stdout_thread and stdout_thread.is_alive(): stdout_thread.join(timeout=1)
if stderr_thread and stderr_thread.is_alive(): stderr_thread.join(timeout=1)
raise PipelineStepError(error_msg)
def stop_api_service(process: Optional[Popen]):
"""停止指定的 API 服务进程,采用更健壮的终止和清理逻辑。"""
if process and process.poll() is None:
pid = process.pid # Get PID for logging
logger.info(f"--- 尝试停止 API 服务 (PID: {pid}) ---")
try:
logger.info(f"发送 SIGTERM 信号到进程 {pid}...")
process.terminate()
try:
logger.info(f"等待最多 {API_TERMINATE_WAIT} 秒让进程 {pid} 优雅终止...")
process.wait(timeout=API_TERMINATE_WAIT)
logger.info(f"API 服务进程 {pid} 已优雅终止,返回码: {process.returncode}")
# 进程已终止,尝试获取最终输出
try:
stdout, stderr = process.communicate(timeout=2)
if stdout: logger.debug(f"进程 {pid} 最终 STDOUT:\n{stdout.strip()}")
if stderr: logger.debug(f"进程 {pid} 最终 STDERR:\n{stderr.strip()}")
except subprocess.TimeoutExpired:
logger.warning(f"获取进程 {pid} 最终输出时超时。")
except Exception as comm_e:
logger.warning(f"获取进程 {pid} 最终输出时出错: {comm_e}")
except subprocess.TimeoutExpired:
logger.warning(f"进程 {pid} 优雅终止超时 ({API_TERMINATE_WAIT}s),发送 SIGKILL 信号...")
process.kill()
logger.info(f"等待进程 {pid} 被强制终止...")
# 在 kill 后等待,应该很快返回。增加安全超时。
try:
process.wait(timeout=5)
logger.info(f"API 服务进程 {pid} 已被强制终止。")
except subprocess.TimeoutExpired:
logger.error(f"进程 {pid} 在发送 SIGKILL 后仍然没有终止!")
except Exception as wait_kill_e:
logger.error(f"等待强制终止进程 {pid} 时发生错误: {wait_kill_e}")
# 尝试在 kill 后获取输出
try:
# 在 kill 后也使用 communicate,它隐式处理等待
stdout, stderr = process.communicate(timeout=2)
if stdout: logger.warning(f"来自进程 {pid} 的 Kill 后输出 (STDOUT):\n{stdout.strip()}")
if stderr: logger.warning(f"来自进程 {pid} 的 Kill 后输出 (STDERR):\n{stderr.strip()}")
except Exception as comm_e:
logger.warning(f"获取进程 {pid} (强制终止后) 输出时出错: {comm_e}")
except Exception as e:
logger.error(f"停止 API 服务 (PID: {pid if process else '未知'}) 时发生意外错误: {e}")
# 如果进程仍然存活,尝试最后一次强制 kill
if process and process.poll() is None:
logger.warning(f"最终尝试强制终止进程 {pid}...")
try:
process.kill()
process.wait(timeout=5)
except Exception as final_kill_e:
logger.error(f"最终强制终止进程 {pid} 时出错: {final_kill_e}")
elif process:
logger.info(f"--- API 服务进程 (PID: {process.pid}) 在尝试停止前已经退出。 ---")
else:
logger.debug("--- 无需停止 API 服务 (进程不存在或已为 None) ---")
def start_web_demo_background() -> Popen:
"""在后台启动 Web Demo 脚本,实时记录启动日志,失败时抛出 PipelineStepError。"""
script_full_path = os.path.join(project_root, web_demo_script)
logger.info(f"--- 尝试在后台启动: {web_demo_script} ---")
if not os.path.exists(script_full_path):
error_msg = f"脚本文件不存在 {script_full_path}"
logger.error(error_msg)
raise PipelineStepError(error_msg)
process: Optional[Popen] = None
stdout_thread: Optional[threading.Thread] = None
stderr_thread: Optional[threading.Thread] = None
try:
logger.info(f"启动命令: {[sys.executable, script_full_path]}")
process = Popen(
[sys.executable, script_full_path],
cwd=project_root,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding='utf-8',
bufsize=1 # 行缓冲
)
# 使用辅助函数启动日志线程 (stdout/stderr 都用 info)
stdout_thread, stderr_thread = _start_stream_logging_threads(process, logger.debug, logger.debug) # stdout/stderr 都用 debug
logger.info(f"等待 {WEB_DEMO_STARTUP_WAIT} 秒让 Web Demo 初步启动 (日志将实时显示)...")
time.sleep(WEB_DEMO_STARTUP_WAIT)
# 检查进程是否仍在运行
if process.poll() is None:
logger.success(f"--- {web_demo_script} 似乎已在后台启动 (进程 PID: {process.pid}) ---")
# 注意:不 join 日志线程
return process
else:
# 进程过早退出
logger.error(f"{web_demo_script} 启动后在 {WEB_DEMO_STARTUP_WAIT} 秒内过早退出,返回码 {process.returncode}")
if stdout_thread: stdout_thread.join(timeout=2)
if stderr_thread: stderr_thread.join(timeout=2)
try:
stdout, stderr = process.communicate(timeout=1)
except subprocess.TimeoutExpired:
logger.warning("等待 communicate 超时,可能没有更多输出了。")
stdout, stderr = "", ""
except Exception as comm_e:
logger.warning(f"调用 communicate 获取最后输出时出错: {comm_e}")
stdout, stderr = "", ""
error_message = f'''--- EARLY EXIT STDOUT ---
{stdout}
--- EARLY EXIT STDERR ---
{stderr}'''
logger.error(error_message)
raise PipelineStepError(f"{web_demo_script} 启动失败并过早退出。")
except FileNotFoundError:
error_msg = f"Python 解释器 '{sys.executable}' 或脚本 '{script_full_path}' 未找到。"
logger.error(error_msg)
raise PipelineStepError(error_msg)
except Exception as e:
error_msg = f"启动 {web_demo_script} 时发生意外错误: {e}"
logger.error(error_msg)
if process and process.poll() is None:
logger.warning("捕获到异常,尝试强制终止进程...")
try:
if process.stdout: process.stdout.close()
if process.stderr: process.stderr.close()
process.kill()
except Exception as kill_e: logger.error(f"强制终止进程时出错: {kill_e}")
if stdout_thread and stdout_thread.is_alive(): stdout_thread.join(timeout=1)
if stderr_thread and stderr_thread.is_alive(): stderr_thread.join(timeout=1)
raise PipelineStepError(error_msg)
def stop_web_demo(process: Optional[Popen]):
"""停止指定的 Web Demo 进程,采用更健壮的终止和清理逻辑。"""
if process and process.poll() is None:
pid = process.pid # Get PID for logging
logger.info(f"--- 尝试停止 Web Demo 服务 (PID: {pid}) ---")
try:
logger.info(f"发送 SIGTERM 信号到进程 {pid}...")
process.terminate()
try:
logger.info(f"等待最多 {WEB_DEMO_TERMINATE_WAIT} 秒让进程 {pid} 优雅终止...")
process.wait(timeout=WEB_DEMO_TERMINATE_WAIT)
logger.info(f"Web Demo 服务进程 {pid} 已优雅终止,返回码: {process.returncode}")
# 进程已终止,尝试获取最终输出
try:
stdout, stderr = process.communicate(timeout=2)
if stdout: logger.debug(f"进程 {pid} 最终 STDOUT:\n{stdout.strip()}")
if stderr: logger.debug(f"进程 {pid} 最终 STDERR:\n{stderr.strip()}")
except subprocess.TimeoutExpired:
logger.warning(f"获取进程 {pid} 最终输出时超时。")
except Exception as comm_e:
logger.warning(f"获取进程 {pid} 最终输出时出错: {comm_e}")
except subprocess.TimeoutExpired:
logger.warning(f"进程 {pid} 优雅终止超时 ({WEB_DEMO_TERMINATE_WAIT}s),发送 SIGKILL 信号...")
process.kill()
logger.info(f"等待进程 {pid} 被强制终止...")
try:
process.wait(timeout=5)
logger.info(f"Web Demo 服务进程 {pid} 已被强制终止。")
except subprocess.TimeoutExpired:
logger.error(f"进程 {pid} 在发送 SIGKILL 后仍然没有终止!")
except Exception as wait_kill_e:
logger.error(f"等待强制终止进程 {pid} 时发生错误: {wait_kill_e}")
# 尝试在 kill 后获取输出
try:
stdout, stderr = process.communicate(timeout=2)
if stdout: logger.warning(f"来自进程 {pid} 的 Kill 后输出 (STDOUT):\n{stdout.strip()}")
if stderr: logger.warning(f"来自进程 {pid} 的 Kill 后输出 (STDERR):\n{stderr.strip()}")
except Exception as comm_e:
logger.warning(f"获取进程 {pid} (强制终止后) 输出时出错: {comm_e}")
except Exception as e:
logger.error(f"停止 Web Demo 服务 (PID: {pid if process else '未知'}) 时发生意外错误: {e}")
# 如果进程仍然存活,尝试最后一次强制 kill
if process and process.poll() is None:
logger.warning(f"最终尝试强制终止进程 {pid}...")
try:
process.kill()
process.wait(timeout=5)
except Exception as final_kill_e:
logger.error(f"最终强制终止进程 {pid} 时出错: {final_kill_e}")
elif process:
logger.info(f"--- Web Demo 服务进程 (PID: {process.pid}) 在尝试停止前已经退出。 ---")
else:
logger.debug("--- 无需停止 Web Demo 服务 (进程不存在或已为 None) ---")
# --- 新增:监控 Checkpoint 目录的函数 ---
def monitor_checkpoints(process: Popen, model_output_dir: str, stop_event: threading.Event, check_interval: float = 5.0):
"""
在后台线程中监控指定目录,如果发现 checkpoint* 目录,则尝试终止目标进程。
"""
logger.info(f"[Monitor] 开始监控目录 {model_output_dir} 的 checkpoint...")
while not stop_event.is_set():
if not os.path.isdir(model_output_dir):
# 目录可能尚未创建,等待下一个间隔
time.sleep(check_interval)
continue
try:
found_checkpoint = False
for item in os.listdir(model_output_dir):
item_path = os.path.join(model_output_dir, item)
if os.path.isdir(item_path) and item.startswith("checkpoint"):
logger.warning(f"[Monitor] 检测到 Checkpoint 目录: {item_path}。尝试停止训练进程 (PID: {process.pid})...")
found_checkpoint = True
break # 找到一个就足够了
if found_checkpoint:
# 发送终止信号
try:
logger.info(f"[Monitor] 发送 SIGTERM 到进程 {process.pid}...")
process.terminate()
# 给进程一点时间响应 SIGTERM
try:
process.wait(timeout=5)
logger.info(f"[Monitor] 进程 {process.pid} 已通过 SIGTERM 终止。")
except subprocess.TimeoutExpired:
logger.warning(f"[Monitor] 进程 {process.pid} 未在 5 秒内响应 SIGTERM,发送 SIGKILL...")
process.kill()
process.wait(timeout=5) # 等待 SIGKILL 生效
logger.info(f"[Monitor] 进程 {process.pid} 已通过 SIGKILL 终止。")
except Exception as term_err:
logger.error(f"[Monitor] 尝试终止进程 {process.pid} 时出错: {term_err}")
finally:
stop_event.set() # 通知主线程停止等待
logger.info("[Monitor] 已设置停止事件,监控结束。")
return # 找到 checkpoint 并处理后,监控任务完成
except FileNotFoundError:
# 目录可能在检查时被删除,忽略
pass
except Exception as e:
logger.error(f"[Monitor] 监控时发生错误: {e}")
# 出现错误也设置停止信号,防止无限循环或未处理的异常
stop_event.set()
return
# 如果没有找到,且进程仍在运行,则等待下一个检查周期
if process.poll() is None:
time.sleep(check_interval)
else:
# 如果进程已经结束(无论何种原因),监控也应结束
logger.info(f"[Monitor] 训练进程 {process.pid} 似乎已结束,停止监控。")
break # 进程已结束,退出循环
logger.info("[Monitor] 监控循环正常结束。")
# --- 新增:运行训练并进行监控的函数 ---
def run_train_with_checkpoint_monitoring(
script_relative_path: str,
model_output_dir: str,
timeout: Optional[Union[int, float]] = DEFAULT_TIMEOUT,
ignore_timeout_error: bool = False,
env: Optional[dict] = None
) -> str:
"""
执行训练脚本,同时启动一个后台线程监控 checkpoint 目录。
如果检测到 checkpoint,会尝试停止训练进程。
返回执行状态: "success", "stopped_by_monitor", "timeout", "failed"
"""
script_full_path = os.path.join(project_root, script_relative_path)
timeout_str = '无限制' if timeout is None else f'{timeout}s'
env_str = f" (环境变量: {env})" if env else ""
logger.info(f"--- 开始执行 (带监控): {script_relative_path} (超时: {timeout_str}){env_str} ---")
if not os.path.exists(script_full_path):
error_msg = f"脚本文件不存在 {script_full_path}"
logger.error(error_msg)
raise PipelineStepError(error_msg)
process: Optional[Popen] = None
stdout_thread: Optional[threading.Thread] = None
stderr_thread: Optional[threading.Thread] = None
monitor_thread: Optional[threading.Thread] = None
stop_event = threading.Event()
status = "failed" # 默认状态
# 准备环境变量
run_env = os.environ.copy()
if env:
run_env.update(env)
try:
process = Popen(
[sys.executable, script_full_path],
cwd=project_root,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding='utf-8',
bufsize=1, # 行缓冲
env=run_env
)
# 启动日志线程
stdout_thread, stderr_thread = _start_stream_logging_threads(process, logger.debug, logger.debug)
# 启动监控线程
monitor_thread = threading.Thread(
target=monitor_checkpoints,
args=(process, model_output_dir, stop_event),
daemon=True
)
monitor_thread.start()
# 等待进程完成、被监控停止或超时
start_time = time.time()
wait_interval = 1 # seconds to wait between checks
while True:
# 检查进程是否结束
return_code = process.poll()
if return_code is not None:
# 进程已结束
if stop_event.is_set(): # 如果是监控线程停止的
logger.warning(f"{script_relative_path} 被监控线程停止。返回码可能为 {return_code}。")
status = "stopped_by_monitor"
elif return_code == 0:
logger.success(f"{script_relative_path} 成功完成。")
status = "success"
else:
logger.error(f"{script_relative_path} 执行失败,返回码 {return_code}")
status = "failed"
stop_event.set() # 确保监控线程也会退出
break
# 检查是否被监控线程要求停止
if stop_event.is_set():
logger.warning(f"{script_relative_path} 被监控线程标记为停止。")
# 进程可能仍在运行,等待 monitor_checkpoints 中的终止逻辑生效
# 但我们这里也应该退出等待循环
status = "stopped_by_monitor"
# 不需要再次 killmonitor 线程会处理
break
# 检查是否超时
if timeout is not None and (time.time() - start_time) > timeout:
warn_msg = f"{script_relative_path} 执行超时 ({timeout}s)。"
logger.warning(warn_msg)
stop_event.set() # 通知监控线程停止
# 尝试优雅地关闭流
if process.stdout: process.stdout.close()
if process.stderr: process.stderr.close()
process.kill() # 强制终止超时进程
logger.warning(f"已强制终止进程 {process.pid}")
status = "timeout"
break
# 短暂休眠后继续检查
time.sleep(wait_interval)
# --- 等待所有线程完成 ---
logger.info("等待日志和监控线程完成...")
if stdout_thread: stdout_thread.join(timeout=5)
if stderr_thread: stderr_thread.join(timeout=5)
if monitor_thread: monitor_thread.join(timeout=5) # 监控线程也需要 join
# 处理最终状态
if status == "failed":
raise PipelineStepError(f"{script_relative_path} 执行失败。")
elif status == "timeout":
if not ignore_timeout_error:
error_msg = f"{script_relative_path} 执行超时 ({timeout}s) 且未忽略。"
logger.error(error_msg)
raise PipelineStepError(error_msg)
else:
logger.info("--- 根据设置,超时不视为错误,继续执行后续步骤。 ---")
# 即使忽略超时错误,状态仍然是 "timeout"
return status # 返回 "timeout" 状态
# 对于 success 和 stopped_by_monitor,直接返回状态
logger.info(f"--- {script_relative_path} 执行结束,状态: {status} ---")
return status
except FileNotFoundError:
error_msg = f"Python 解释器 '{sys.executable}' 或脚本 '{script_full_path}' 未找到。"
logger.error(error_msg)
raise PipelineStepError(error_msg)
except Exception as e:
# 捕获其他潜在错误 (例如 Popen 本身失败)
error_msg = f"执行 {script_relative_path} (带监控) 时发生意外错误: {e}"
logger.error(error_msg)
stop_event.set() # 确保监控线程停止
# 尝试确保进程和线程被清理
if process and process.poll() is None:
try:
if process.stdout: process.stdout.close()
if process.stderr: process.stderr.close()
process.kill()
logger.warning(f"因异常 {e},强制终止进程 {process.pid}")
except Exception as kill_e:
logger.error(f"清理过程中强制终止进程失败: {kill_e}")
if stdout_thread and stdout_thread.is_alive(): stdout_thread.join(timeout=1)
if stderr_thread and stderr_thread.is_alive(): stderr_thread.join(timeout=1)
if monitor_thread and monitor_thread.is_alive(): monitor_thread.join(timeout=1)
raise PipelineStepError(error_msg)
if __name__ == "__main__":
logger.info("="*20 + " 开始执行 WeClone Pipeline 脚本 " + "="*20)
is_cuda_available = torch.cuda.is_available()
logger.info("--- CUDA 可用性检查 ---")
if is_cuda_available:
gpu_count = torch.cuda.device_count()
logger.success(f"CUDA 可用 (找到 {gpu_count} 个 GPU)")
for i in range(gpu_count):
logger.info(f" - GPU {i}: {torch.cuda.get_device_name(i)}")
else:
logger.warning("CUDA 不可用,将使用 CPU (如果适用)。")
logger.info("-" * 25)
steps_completed = []
api_process: Optional[Popen] = None
web_demo_process: Optional[Popen] = None
# 设置哪些步骤需要运行
run_qa = True
run_train = True
run_copy_checkpoint = True # 依赖于 run_train
run_api = True
run_eval = True # 依赖于 run_api
run_web_demo = True # 不依赖于 run_api
try:
# 步骤 1: QA Generator
if run_qa:
logger.info("-" * 10 + " 步骤 1: QA 数据生成 " + "-" * 10)
run_script(qa_script)
steps_completed.append(f"{STEP_QA}: 成功")
else:
logger.info(f"{STEP_QA}: 跳过 (配置)")
steps_completed.append(f"{STEP_QA}: 跳过")
# 步骤 2: Train SFT (with monitoring)
if run_train:
logger.info("-" * 10 + " 步骤 2: SFT 训练 (带 Checkpoint 监控) " + "-" * 10)
model_output_dir = os.path.join(project_root, "model_output")
# --- 删除 model_output 目录 ---
if os.path.exists(model_output_dir):
logger.info(f"删除现有的 model_output 目录: {model_output_dir}")
try:
shutil.rmtree(model_output_dir)
logger.success("成功删除 model_output 目录")
except Exception as e:
logger.error(f"删除 model_output 目录时出错: {e}")
# Treat failure to delete as a critical error before training
raise PipelineStepError(f"删除 model_output 目录失败: {e} ###step_id:{train_script}###")
# --- 执行训练脚本并进行监控 ---
train_status = run_train_with_checkpoint_monitoring(
train_script,
model_output_dir, # Pass the directory to monitor
timeout=2000,
ignore_timeout_error=True,
env={'TQDM_DISABLE': '1'}
)
# --- 根据训练状态更新完成列表 ---
if train_status == "success":
steps_completed.append(f"{STEP_TRAIN}: 成功")
elif train_status == "stopped_by_monitor":
steps_completed.append(f"{STEP_TRAIN}: 已停止 (检测到 Checkpoint)")
elif train_status == "timeout":
steps_completed.append(f"{STEP_TRAIN}: 超时 (已忽略)")
else: # "failed" or other unexpected status handled by exception
steps_completed.append(f"{STEP_TRAIN}: 失败") # Should be caught by exception, but added for completeness
# 步骤 2.1: 复制 Checkpoint (只有在训练 *成功* 完成后才执行)
if run_copy_checkpoint:
if train_status == "success":
logger.info("-" * 10 + " 步骤 2.1: 复制 Checkpoint 到 model_output " + "-" * 10)
source_dir = os.path.join(project_root, "model_output", "checkpoint-2") # Note: Still assumes checkpoint-2 specifically.
dest_dir = os.path.join(project_root, "model_output")
if os.path.isdir(source_dir):
try:
logger.info(f"开始将 {source_dir} 的内容复制到 {dest_dir}...")
shutil.copytree(source_dir, dest_dir, dirs_exist_ok=True)
logger.success(f"--- {STEP_COPY_CKPT} 成功 ---")
steps_completed.append(f"{STEP_COPY_CKPT}: 成功")
except Exception as e:
error_msg = f"{STEP_COPY_CKPT} 时发生错误: {e}"
logger.error(error_msg)
raise PipelineStepError(f"{error_msg} ###step_id:copy_checkpoint###")
else:
logger.warning(f"训练成功后,源 Checkpoint 目录 {source_dir} 不存在或不是目录,跳过复制。")
steps_completed.append(f"{STEP_COPY_CKPT}: 跳过 (源不存在)")
# Consider if missing checkpoint-2 after successful training is an error
# raise PipelineStepError(f"训练成功但必需的源 Checkpoint 目录 {source_dir} 不存在")
else:
# If training didn't succeed (stopped, timeout, failed), skip copy
logger.info(f"{STEP_COPY_CKPT}: 跳过 (训练未成功完成,状态: {train_status})")
steps_completed.append(f"{STEP_COPY_CKPT}: 跳过 (训练未成功)")
else:
logger.info(f"{STEP_COPY_CKPT}: 跳过 (配置)")
steps_completed.append(f"{STEP_COPY_CKPT}: 跳过 (配置)")
else:
# If run_train is false
logger.info(f"{STEP_TRAIN}: 跳过 (配置)")
steps_completed.append(f"{STEP_TRAIN}: 跳过 (配置)")
logger.info(f"{STEP_COPY_CKPT}: 跳过 (训练未运行)")
steps_completed.append(f"{STEP_COPY_CKPT}: 跳过 (训练未运行)")
# 步骤 3: Start API Service
if run_api:
logger.info("-" * 10 + " 步骤 3: 启动 API 服务 " + "-" * 10)
api_process = start_api_service_background()
steps_completed.append(f"{STEP_API_START}: 成功")
else:
logger.info(f"{STEP_API_START}: 跳过 (配置)")
steps_completed.append(f"{STEP_API_START}: 跳过 (配置)")
# 步骤 4: Eval Model (依赖 API 服务)
if run_eval:
if not run_api:
logger.info("-" * 10 + " 步骤 4: 模型评估 " + "-" * 10)
logger.warning("--- 因 API 服务配置为不运行,跳过执行: weclone/eval/test_model.py ---")
steps_completed.append(f"{STEP_EVAL}: 跳过 (API未配置运行)")
elif api_process is None or api_process.poll() is not None: # 检查进程是否已退出
error_msg = "尝试运行评估,但 API 服务进程不存在或已退出。"
logger.error(error_msg)
raise PipelineStepError(error_msg)
else:
logger.info("-" * 10 + " 步骤 4: 模型评估 " + "-" * 10)
# 在调用评估脚本时禁用 tqdm
run_script(eval_script, timeout=9999, env={'TQDM_DISABLE': '1'})
steps_completed.append(f"{STEP_EVAL}: 成功")
stop_api_service(api_process) # 评估完成后停止API
api_process = None # 标记为已停止
else:
logger.info(f"{STEP_EVAL}: 跳过 (配置)")
steps_completed.append(f"{STEP_EVAL}: 跳过 (配置)")
if api_process: # 如果API在运行但评估被跳过,也停止API
logger.info("评估被跳过,停止 API 服务...")
stop_api_service(api_process)
api_process = None
# 步骤 5: Start Web Demo (不依赖 API 服务)
if run_web_demo:
logger.info("-" * 10 + " 步骤 5: 启动 Web Demo " + "-" * 10)
web_demo_process = start_web_demo_background()
steps_completed.append(f"{STEP_WEB_DEMO}: 成功")
logger.info("--- Web Demo 已启动,测试流程继续... ---")
else:
logger.info(f"{STEP_WEB_DEMO}: 跳过 (配置)")
steps_completed.append(f"{STEP_WEB_DEMO}: 跳过 (配置)")
# Pipeline 成功完成所有请求的步骤
logger.info("="*20 + " Pipeline 执行摘要 " + "="*20)
for step in steps_completed:
logger.info(f"- {step}")
logger.success("✅ 所有请求执行的 Pipeline 步骤均成功完成!")
skipped_steps = [s for s in steps_completed if "跳过" in s]
if skipped_steps:
logger.warning("注意: 以下步骤被设置为跳过,如需执行请修改脚本顶部的 run_xxx 变量:")
for skipped in skipped_steps:
logger.warning(f" - {skipped.split(':')[0]}")
except PipelineStepError as e:
logger.error("="*20 + " Pipeline 执行失败 " + "="*20)
failing_step = "未知步骤"
error_details = str(e)
cleaned_error_details = error_details # Store original/cleaned details for logging
# Attempt 1: Check for explicit marker (e.g., from copy_checkpoint)
marker_prefix = "###step_id:"
marker_suffix = "###"
marker_start = error_details.find(marker_prefix)
if marker_start != -1:
marker_end = error_details.find(marker_suffix, marker_start + len(marker_prefix))
if marker_end != -1:
step_id = error_details[marker_start + len(marker_prefix):marker_end]
failing_step = step_identifiers.get(step_id, f"未知标记 ({step_id})")
# Clean the marker from the displayed error details
cleaned_error_details = error_details[:marker_start].strip()
# Attempt 2: Check for known script paths in the error message if marker not found
if failing_step == "未知步骤":
found_script = False
# Iterate through potential script paths stored as keys in step_identifiers
for identifier, step_name in step_identifiers.items():
# Check if the identifier looks like a path and is in the error message
if isinstance(identifier, str) and ('/' in identifier or '\\\\' in identifier) and identifier in error_details:
failing_step = step_name
found_script = True
break # Found the most likely script
# Attempt 3: Fallback based on last completed step (if still unknown)
if failing_step == "未知步骤":
if steps_completed:
last_completed = steps_completed[-1].split(':')[0]
try:
last_completed_index = step_order.index(last_completed)
if last_completed_index + 1 < len(step_order):
# Assume the next step in the defined order failed
failing_step = step_order[last_completed_index + 1] + " (推断)"
else:
failing_step = "Pipeline末尾或未知 (推断)" # Error after the last known step
except ValueError:
# Last completed step name wasn't found in our defined order
failing_step = f"未知 (最后完成: {last_completed})"
else:
failing_step = "初始化期间" # No steps completed
logger.error(f"错误发生在步骤: {failing_step}")
logger.error(f"错误详情: {cleaned_error_details}") # Log the cleaned error details
logger.info("--- 已完成步骤 ---")
for step in steps_completed:
logger.info(f"- {step}")
logger.error("="*50)
sys.exit(1) # 测试失败时退出码为 1
finally:
logger.info("--- Pipeline 结束,开始清理后台服务 ---")
# 确保在 finally 块中总是尝试停止服务
stop_web_demo(web_demo_process)
stop_api_service(api_process) # 即使评估步骤停止了它,这里也尝试停止,无害
logger.info("--- 后台服务清理完成 ---")
# 如果 Pipeline 成功,确保退出码为 0
sys.exit(0)