现象:源码/测试版(
python -m src.main)语音播报流程流畅;PyInstaller 发行包卡在「播报文稿生成 ~38%」,进度与超时均失效。 结论:不是业务算法差异,而是冻结进程内引擎的启动方式触发了 Windows 默认ProactorEventLoop+ 日志锁竞争,并叠加 GUI 进程代理继承等问题。 文档日期:2026‑08‑19
1. 现象对照
| 维度 | 开发 / 测试版 | 打包发行版(修复前) |
|---|---|---|
| 启动入口 | python -m src.main |
桌面客户端程序.exe(sys.frozen=True) |
| 播报引擎进程模型 | 独立子进程 :engine/.venv/.../python -m uvicorn app.main:app |
同进程守护线程 :桌面壳内 threading.Thread + uvicorn |
| 事件循环 | 子进程默认策略,外网断开路径相对独立 | 线程内 asyncio.run → 默认 ProactorEventLoop |
| 典型卡住点 | 无 | 任务中心显示「播报文稿生成 · ...」约 38%,可挂死十余分钟 |
| 超时是否生效 | 正常 | 不生效(阶段 420s 超时、httpx 超时也无法打断) |
| 日志 | engine/engine.out.log 等 |
engine-data/engine.log;挂死时常停在某条 [vo.normalize] / 大模型调用前后 |
前端 38% 来自播报阶段序号映射:script_storyboard 在有效步骤序列中约对应 3/8,即「播报文稿 Agent 生成中」阶段,不是业务写死的 38 这个魔法数。
2. 架构分叉(问题入口)
桌面通过 ProduceEngine.ensure_started() 拉起引擎,分支如下:
text
ensure_started()
├── frozen == False → 子进程 uvicorn(开发路径)
└── frozen == True → _ensure_started_inprocess()(打包路径)
对应代码:src/produce_engine.py。
选择进程内线程的初衷合理:发行包无 .venv,依赖已打进 _internal,避免再附带解释器。副作用是:引擎与无控制台 GUI、WebView2、同步日志写文件共享同一进程地址空间,异步运行时行为与「独立 uvicorn」不再等价。
3. 问题定位过程
3.1 业务侧排除
- 同一数据库/缓存服务、大模型密钥配置下,开发版可完成同类型播报,说明 大模型、对象存储、库表本身可用。
- 卡住任务 DB 状态长期为
RUNNING,progress_message停在「播报文稿 Agent 生成中...」,completed_at为空。 - 阶段超时常量
STAGE_TIMEOUT_SCRIPT_STORYBOARD = 420存在,但挂死后 远超 420s 仍不失败 → 更像是 事件循环本身不再调度,而非单纯「模型慢」。
3.2 卡住热路径
播报文稿阶段主链:
text
pipeline._run_script_storyboard
→ agent_plan.generate_script_and_storyboard
→ search(httpx)
→ llm_client.chat_json(httpx)
→ finalize_voiceover_storyboard_sync(to_thread)
→ 进度心跳 _with_progress_heartbeat
日志中常见停顿点:[vo.script] step=llm_generate / [vo.normalize] dedupe begin 等。停在日志语句附近,提示 写日志与 asyncio 回调交叉,而不一定是 normalize 算法复杂度。
3.3 uvicorn 在 Windows 上的关键行为
uvicorn.Server.run() 大致为:
text
config.setup_event_loop()
asyncio.run(self.serve(...))
uvicorn.loops.asyncio.asyncio_setup 源码(uvicorn 0.34):
python
def asyncio_setup(use_subprocess: bool = False) -> None:
if sys.platform == "win32" and use_subprocess:
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
而 Config.use_subprocess 仅在 reload 或 workers > 1 时为真。 桌面进程内单 worker、无 reload:setup_event_loop() 等于空操作。
Python 3.12 on Windows 默认策略 → asyncio.run() 创建 ProactorEventLoop。
开发版:独立 python -m uvicorn 子进程,即使也是 Proactor,也 不与 GUI 主线程、同一套 RotatingFileHandler 高频争用;连接关闭异常路径的危害面更小,表现为「测试 OK」。
3.4 修复后的验证证据
新包启动后 engine-data/engine.log 出现:
text
冻结引擎 asyncio.run loop_factory -> _WindowsSelectorEventLoop
引擎事件循环: _WindowsSelectorEventLoop (selector=SelectSelector frozen=True)
说明发行路径已强制 Selector;此前仅 set_event_loop_policy 再调用 Server.run(),仍可能被 asyncio.run() 按默认工厂建出 Proactor。
4. 根本原因
4.1 主因:Proactor + 冻结 GUI 线程内引擎 → 异步死锁式挂起
因果链:
- 打包版引擎跑在桌面进程的守护线程里。
- uvicorn 未因
use_subprocess切换 Selector,asyncio.run使用 ProactorEventLoop。 - 播报阶段大量短连接(大模型对话、搜索、对象存储 HEAD/GET 等);远端关闭连接时,Proactor 管道回调
_call_connection_lost可能在回调里抛出OSError(如 WinError 10038)。 - asyncio 默认异常处理器走
logging;同时业务 worker / 心跳线程高频写engine.log(文件 handler)。 - logging handler 锁与异常再入写日志叠加,可导致 业务协程永久卡在 logging 调用上 ;事件循环无法继续调度 → httpx 超时、
asyncio.wait_for超时全部失效。 - UI 仍按 stage 显示「播报文稿生成 ~38%」,表现为「打包就卡、测试不卡」。
这与「算法在打包后变慢」无关,是 运行时模型差异。
4.2 次因:GUI 进程继承系统代理
无控制台 PyInstaller 进程常带上用户/机器级 HTTP(S)_PROXY。 httpx 默认 trust_env=True 时会走异常代理,外网调用表现为无限挂起,同样容易停在播报文稿阶段。
开发子进程环境变量集合往往不同,故不易复现。 对策(已落地):HTTPX_CLIENT_KWARGS = {trust_env: False, proxy: None},并在冻结引擎线程入口清理代理环境变量、设置 NO_PROXY=*。
4.3 伴随问题(放大「卡」的体感,但非 38% 主因)
| 问题 | 表现 | 说明 |
|---|---|---|
| 删除项目未释放内存队列 | 新任务显示「排队等待(第 2 位)」 | 只删 DB 行,未 cancel 同类型 asyncio.Lock 等待者;计数泄漏 |
发行包漏打 aiomysql |
「启动播报引擎失败: No module named 'aiomysql'」 | 壳与引擎共用冻结解释器,根 requirements.txt 未对齐引擎依赖 |
httpx 未进 collect_all |
偶发导入/运行异常 | 清单已补齐 |
5. 修复方案(已实现)
5.1 强制 Selector 事件循环(主修复)
在 _ensure_started_inprocess 中 不再依赖 Server.run() / uvicorn 的 setup_event_loop,改为:
- 设置
WindowsSelectorEventLoopPolicy; asyncio.run(serve(), loop_factory=policy.new_event_loop),保证真正创建的是 Selector 循环;- lifespan 日志打印
frozen与 loop 类型;若仍为 Proactor 则打 error,便于回归。
5.2 代理隔离
- 全局 httpx:
trust_env=False+proxy=None - 冻结线程:剥离
HTTP_PROXY/HTTPS_PROXY/ALL_PROXY等
5.3 删除与队列
delete_series前调用task_queue.abandon_series_tasks,取消内存 worker,避免假排队。
5.4 打包依赖
- 根
requirements.txt对齐引擎(含aiomysql、httpx等); package_manifest.py将相关包列入COLLECT_ALL/REQUIRED_INTERNAL。
6. 为何「测试 OK、打包就卡」可一句话概括
测试版引擎是独立进程;打包版引擎挤进 GUI 同进程线程,且误用 Windows Proactor 事件循环。连接关闭与日志争用把 asyncio 卡死,于是播报文稿阶段永远停在约 38%,超时也救不回来。
次要因素(代理、假排队、缺依赖)会叠加失败模式,但与「流畅 vs 卡死」二分最对齐的,是 进程模型 + 事件循环类型 这一条。
7. 回归检查清单
打包后启动,确认:
dist/.../engine-data/engine.log含_WindowsSelectorEventLoop且frozen=True。- 执行播报任务后,
progress_message能在数分钟内越过「播报文稿 Agent 生成中」,或在超时后变为失败而非永久 RUNNING。 - 删除进行中项目后,新建任务不应无故「第 2 位」排队。
verify_package.py通过,且_internal含aiomysql、httpx。
8. 相关代码与文档索引
| 路径 | 作用 |
|---|---|
src/produce_engine.py |
frozen 分支、Selector loop_factory、清代理、安全日志 handler |
engine/app/main.py |
启动时打印事件循环类型;Proactor 告警 |
engine/app/services/volc/http_utils.py |
httpx 禁用环境代理 |
engine/app/services/task_queue.py |
同类型串行锁、abandon_series_tasks |
engine/app/services/pipeline.py |
delete_series 先放弃队列再删库 |
scripts/package_manifest.py / requirements.txt |
发行依赖对齐 |
docs/packaging-notes.md |
打包结构与分发注意 |
9. 后续建议(可选)
- 长期:评估打包版也改为「附带嵌入式 Python / sidecar uvicorn」,与开发模型一致,降低同进程耦合。
- CI :冻结冒烟用例------启动 exe 后读
engine.log,断言 Selector +/api/health。 - 可观测性:卡住时在任务中心展示「无心跳超过 N 分钟」并允许一键失败重置(前端已部分具备「无活跃任务则推进失败」逻辑)。